Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20842b3ab | ||
|
|
8510cccb6e | ||
|
|
c2c77ac92a | ||
|
|
71b1b549c3 | ||
|
|
aa79eba06d | ||
|
|
62c68e4bc6 | ||
|
|
2be6216772 | ||
|
|
ae28f56337 | ||
|
|
10fe702749 | ||
|
|
4c4d741b1f | ||
|
|
6abae13dbd | ||
|
|
46eb41aaa5 | ||
|
|
add12b2fbf | ||
|
|
6d65d5975c | ||
|
|
b8779e5a35 | ||
|
|
000ebd388a | ||
|
|
3374eb1ec0 | ||
|
|
d7b82c0ec9 | ||
|
|
3718882f5b | ||
|
|
cb2355adcd | ||
|
|
c6e602d354 | ||
|
|
42002f8e6f |
No files matched your search
@@ -8,39 +8,75 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
|
||||
- **Backend**: NestJS + TypeORM + SQLite
|
||||
- **CMS**: Strapi (in /cms)
|
||||
|
||||
## Root-Level Commands
|
||||
```bash
|
||||
# Docker development
|
||||
npm run docker:up # Start all services
|
||||
npm run docker:down # Stop all services
|
||||
npm run docker:build # Rebuild containers
|
||||
npm run docker:logs # View logs
|
||||
npm run dev:docker # Start docker dev (alias)
|
||||
|
||||
# Local development
|
||||
npm run dev:local # Start backend & frontend locally
|
||||
npm run dev:backend:local # Backend only (local env)
|
||||
npm run dev:frontend:local # Frontend only (local env)
|
||||
|
||||
# Code quality
|
||||
npm run lint # Lint both projects
|
||||
npm run lint:fix # Auto-fix lint issues
|
||||
npm run type-check # Type check both projects
|
||||
|
||||
# Database operations
|
||||
npm run db:backup # Backup database
|
||||
npm run db:restore # Restore database
|
||||
npm run db:reset # Reset database
|
||||
|
||||
# Environment
|
||||
npm run reset:env # Reset to docker environment
|
||||
```
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Backend (NestJS)
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run start:dev # Watch mode
|
||||
npm run start:dev # Watch mode
|
||||
npm run start:debug # Debug mode
|
||||
npm run build
|
||||
npm run start:prod
|
||||
npm run lint # Lint
|
||||
npm run lint:fix # Auto-fix
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests
|
||||
npm run lint # Lint
|
||||
npm run lint:fix # Auto-fix
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests
|
||||
npm test:watch # Watch mode tests
|
||||
npm test:cov # Coverage
|
||||
npm test:e2e # E2E tests
|
||||
npm test app.service.spec.ts # Single file
|
||||
npm test -t "should" # By test name
|
||||
npm run test:cov # Coverage
|
||||
npm run test:e2e # E2E tests
|
||||
npm test -t "should return" # By test name pattern
|
||||
npm run format # Format with Prettier
|
||||
npm run dev:local # Local env with .env.local
|
||||
npm run dev:docker # Docker env with .env.docker
|
||||
```
|
||||
|
||||
### Frontend (TanStack)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite dev server
|
||||
npm run dev # Vite dev server
|
||||
npm run build
|
||||
npm run preview
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
npm run type-check
|
||||
npm test # All tests
|
||||
npm test Header.test.tsx # Single file
|
||||
npm test -t "renders" # By name
|
||||
npm run test:ui # Vitest UI
|
||||
npm run lint # Lint
|
||||
npm run lint:fix # Auto-fix
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests (Vitest)
|
||||
npm test:ui # Vitest UI
|
||||
npm test:coverage # Test coverage
|
||||
npm test Header.test.tsx # Single file
|
||||
npm test -t "renders" # By test name pattern
|
||||
npm run dev:local # Local env with .env.local
|
||||
npm run dev:docker # Docker env with .env.docker
|
||||
```
|
||||
|
||||
## Code Style
|
||||
@@ -51,6 +87,8 @@ npm run test:ui # Vitest UI
|
||||
- Explicit return types for public methods
|
||||
- Prefer interfaces over types for objects
|
||||
- Use `readonly` for immutable properties
|
||||
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
|
||||
- Frontend: Path alias `@/*` maps to `./src/*`
|
||||
|
||||
### Naming Conventions
|
||||
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
|
||||
@@ -62,6 +100,20 @@ npm run test:ui # Vitest UI
|
||||
|
||||
### Import Order
|
||||
- External libraries first, then internal modules, then relative imports
|
||||
- Group imports with blank lines between groups
|
||||
- Example:
|
||||
```typescript
|
||||
// External
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// Internal modules
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
// Relative imports
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
```
|
||||
|
||||
|
||||
### File Structure
|
||||
@@ -104,12 +156,16 @@ frontend/src/
|
||||
```
|
||||
|
||||
### Testing
|
||||
- **Backend**: Jest with ts-jest transformer
|
||||
- **Frontend**: Vitest with React Testing Library patterns
|
||||
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
- Descriptive test names that explain what is tested
|
||||
- Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`)
|
||||
- Mock external dependencies (use Jest for backend, Vitest for frontend)
|
||||
- Run `npm test` for all tests or target specific files/names
|
||||
- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend)
|
||||
- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend)
|
||||
|
||||
### Git Workflow
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
||||
@@ -147,5 +203,16 @@ frontend/src/
|
||||
- Example backend env: `DATABASE_PATH=./data.db`
|
||||
- Store sensitive config in `.env` files
|
||||
- Use `DATABASE_PATH` for SQLite location
|
||||
- Use `@nestjs/config` for backend environment management
|
||||
- Frontend env vars must be prefixed with `VITE_`
|
||||
|
||||
## Agent-Specific Instructions
|
||||
- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes
|
||||
- **NEVER** commit changes without explicit user request
|
||||
- **ALWAYS** follow existing code patterns and conventions
|
||||
- **PREFER** editing existing files over creating new ones
|
||||
- **VERIFY** tests pass before considering work complete
|
||||
- **USE** the root-level commands for common operations
|
||||
- **CHECK** both backend and frontend when making API changes
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -4,6 +4,9 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Install dependencies with better error handling
|
||||
COPY package*.json ./
|
||||
COPY package-lock.json* ./
|
||||
|
||||
Generated
+638
-342
File diff suppressed because it is too large.
Load diff
+12
-1
@@ -22,7 +22,9 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"dev:docker": "nest start --watch",
|
||||
"dev:local": "cp -f .env.local .env && nest start --watch",
|
||||
"dev:reset-env": "cp -f .env.docker .env"
|
||||
"dev:reset-env": "cp -f .env.docker .env",
|
||||
"seed:admin": "ts-node scripts/seed-admin.ts",
|
||||
"seed:categories": "ts-node scripts/seed-categories.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
@@ -30,10 +32,19 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.18.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -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();
|
||||
Executable
+12
@@ -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"
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -6,13 +6,21 @@ import { AppService } from './app.service';
|
||||
import { ArticlesModule } from './modules/articles.module';
|
||||
import { StrapiModule } from './modules/strapi.module';
|
||||
import { LiveBlogModule } from './modules/live-blog.module';
|
||||
import { UserModule } from './modules/users/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import {
|
||||
Article,
|
||||
Author,
|
||||
Category,
|
||||
LiveBlog,
|
||||
LiveBlogUpdate,
|
||||
User,
|
||||
Comment,
|
||||
Reaction,
|
||||
} from './modules/entities';
|
||||
import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,13 +34,27 @@ import {
|
||||
username: process.env.DATABASE_USERNAME || 'placebo_user',
|
||||
password: process.env.DATABASE_PASSWORD || 'placebo_password',
|
||||
database: process.env.DATABASE_NAME || 'placebo_backend_db',
|
||||
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
|
||||
synchronize: process.env.NODE_ENV !== 'production',
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
entities: [
|
||||
Article,
|
||||
Author,
|
||||
Category,
|
||||
LiveBlog,
|
||||
LiveBlogUpdate,
|
||||
User,
|
||||
Comment,
|
||||
Reaction,
|
||||
ShareEvent,
|
||||
],
|
||||
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
|
||||
logging: process.env.DATABASE_LOGGING === 'true',
|
||||
}),
|
||||
ArticlesModule,
|
||||
StrapiModule,
|
||||
LiveBlogModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
CommentModule,
|
||||
AnalyticsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
+16
-1
@@ -1,5 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@@ -16,6 +18,19 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// Apply global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Apply global authentication guard
|
||||
const reflector = app.get(Reflector);
|
||||
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||
await app.listen(port, host);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ValidationPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import {
|
||||
@@ -16,54 +16,73 @@ import {
|
||||
UpdateArticleDto,
|
||||
FindArticlesDto,
|
||||
} 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')
|
||||
export class ArticlesController {
|
||||
constructor(private readonly articlesService: ArticlesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
create(@Body() dto: CreateArticleDto) {
|
||||
return this.articlesService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
|
||||
) {
|
||||
@Public()
|
||||
findAll(@Query() dto: FindArticlesDto) {
|
||||
return this.articlesService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get('hero')
|
||||
@Public()
|
||||
findHero() {
|
||||
return this.articlesService.findHeroArticle();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Public()
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.articlesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@Public()
|
||||
findBySlug(@Param('slug') slug: string) {
|
||||
return this.articlesService.findBySlug(slug);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
|
||||
) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
|
||||
return this.articlesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.articlesService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
archive(@Param('id') id: string) {
|
||||
return this.articlesService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
publish(@Param('id') id: string, @Query('status') status?: ArticleStatus) {
|
||||
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
publish(@Param('id') id: string) {
|
||||
return this.articlesService.publish(id);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ArticleStatus,
|
||||
@@ -48,6 +48,10 @@ export class CreateArticleDto {
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHero?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
@@ -75,6 +79,30 @@ export class CreateArticleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@@ -111,6 +139,10 @@ export class UpdateArticleDto {
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHero?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
@@ -138,6 +170,30 @@ export class UpdateArticleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export class FindArticlesDto {
|
||||
@@ -169,6 +225,35 @@ export class FindArticlesDto {
|
||||
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 {
|
||||
@IsString()
|
||||
title: string;
|
||||
@@ -292,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
scheduledAt?: Date;
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
image?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogUpdateDto {
|
||||
@@ -314,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
scheduledAt?: Date;
|
||||
@IsString()
|
||||
image?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
}
|
||||
videoUrl?: 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;
|
||||
authorName?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -293,4 +293,15 @@ export class ArticlesService {
|
||||
await this.articleRepository.remove(article);
|
||||
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
|
||||
}
|
||||
|
||||
async findHeroArticle(): Promise<Article | null> {
|
||||
return this.articleRepository.findOne({
|
||||
where: {
|
||||
isHero: true,
|
||||
status: ArticleStatus.PUBLISHED,
|
||||
},
|
||||
relations: ['author', 'category'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginUserDto, CreateUserDto } from '../users/user.dto';
|
||||
import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginUserDto: LoginUserDto) {
|
||||
return this.authService.login(loginUserDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
async register(@Body() createUserDto: CreateUserDto) {
|
||||
return this.authService.register(createUserDto);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,17 @@ export enum VideoPosition {
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
CONTRIBUTOR = 'contributor',
|
||||
USER = 'user',
|
||||
}
|
||||
|
||||
export enum ReactionType {
|
||||
LIKE = 'like',
|
||||
DISLIKE = 'dislike',
|
||||
}
|
||||
|
||||
@Entity('authors')
|
||||
export class Author {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -114,6 +125,36 @@ export class Category {
|
||||
parent: Category;
|
||||
}
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
passwordHash: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'user',
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Entity('articles')
|
||||
export class Article {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -183,6 +224,39 @@ export class Article {
|
||||
@Column({ nullable: true })
|
||||
categoryId: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogTitle: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogDescription: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogImage: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterTitle: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterDescription: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterImage: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
facebookShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
twitterShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
whatsappShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
telegramShares: number;
|
||||
|
||||
@Column({ default: false })
|
||||
isHero: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@@ -196,6 +270,12 @@ export class Article {
|
||||
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'categoryId' })
|
||||
category: Category;
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.article)
|
||||
comments: Comment[];
|
||||
|
||||
@OneToMany(() => Reaction, (reaction) => reaction.article)
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
@Entity('live_blogs')
|
||||
@@ -278,6 +358,12 @@ export class LiveBlog {
|
||||
cascade: true,
|
||||
})
|
||||
updates: LiveBlogUpdate[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.liveBlog)
|
||||
comments: Comment[];
|
||||
|
||||
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
@Entity('live_blog_updates')
|
||||
@@ -314,3 +400,100 @@ export class LiveBlogUpdate {
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
}
|
||||
|
||||
@Entity('comments')
|
||||
export class Comment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
articleId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
liveBlogId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
parentId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
likeCount: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
dislikeCount: number;
|
||||
|
||||
@Column({ default: true })
|
||||
isVisible: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'articleId' })
|
||||
article: Article;
|
||||
|
||||
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
|
||||
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'parentId' })
|
||||
parent: Comment;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.parent)
|
||||
replies: Comment[];
|
||||
}
|
||||
|
||||
@Entity('reactions')
|
||||
export class Reaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
})
|
||||
type: ReactionType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
articleId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
liveBlogId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
commentId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'articleId' })
|
||||
article: Article;
|
||||
|
||||
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
|
||||
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'commentId' })
|
||||
comment: Comment;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ValidationPipe,
|
||||
Res,
|
||||
Logger,
|
||||
Headers,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response as ExpressResponse } from 'express';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
CreateLiveBlogUpdateDto,
|
||||
UpdateLiveBlogUpdateDto,
|
||||
} 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')
|
||||
export class LiveBlogController {
|
||||
@@ -32,68 +36,76 @@ export class LiveBlogController {
|
||||
|
||||
// Live Blog CRUD operations
|
||||
@Get('featured')
|
||||
@Public()
|
||||
getFeatured() {
|
||||
this.logger.log('GET /featured called');
|
||||
return this.liveBlogService.findPinned();
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@Public()
|
||||
getActive() {
|
||||
return this.liveBlogService.findActive();
|
||||
}
|
||||
|
||||
@Get('recent')
|
||||
@Public()
|
||||
getRecent() {
|
||||
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
|
||||
) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
create(@Body() dto: CreateLiveBlogDto) {
|
||||
return this.liveBlogService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
|
||||
) {
|
||||
@Public()
|
||||
findAll(@Query() dto: FindLiveBlogsDto) {
|
||||
return this.liveBlogService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Public()
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.liveBlogService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@Public()
|
||||
findBySlug(@Param('slug') slug: string) {
|
||||
return this.liveBlogService.findBySlug(slug);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
|
||||
) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
|
||||
return this.liveBlogService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.liveBlogService.remove(id);
|
||||
}
|
||||
|
||||
// Live Blog Updates CRUD operations
|
||||
@Post(':id/updates')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
createUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
|
||||
@Body() dto: CreateLiveBlogUpdateDto,
|
||||
) {
|
||||
return this.liveBlogService.createUpdate(dto, liveBlogId);
|
||||
}
|
||||
|
||||
@Get(':id/updates')
|
||||
@Public()
|
||||
findUpdates(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Query('page') page = 1,
|
||||
@@ -107,15 +119,19 @@ export class LiveBlogController {
|
||||
}
|
||||
|
||||
@Put(':id/updates/:updateId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
updateUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Param('updateId') updateId: string,
|
||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
|
||||
@Body() dto: UpdateLiveBlogUpdateDto,
|
||||
) {
|
||||
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/updates/:updateId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
removeUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Param('updateId') updateId: string,
|
||||
@@ -124,17 +140,22 @@ export class LiveBlogController {
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
archive(@Param('id') id: string) {
|
||||
return this.liveBlogService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
|
||||
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
|
||||
}
|
||||
|
||||
// SSE endpoint for real-time updates
|
||||
@Get(':id/stream')
|
||||
@Public()
|
||||
stream(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Res() response: ExpressResponse,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import { Public } from './auth/public.decorator';
|
||||
|
||||
interface WebhookBody {
|
||||
event:
|
||||
@@ -21,6 +22,7 @@ export class StrapiController {
|
||||
constructor(private readonly strapiService: StrapiService) {}
|
||||
|
||||
@Post('article')
|
||||
@Public()
|
||||
async handleArticleWebhook(@Body() body: WebhookBody) {
|
||||
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
|
||||
const { event, model, entry } = body;
|
||||
@@ -38,6 +40,7 @@ export class StrapiController {
|
||||
}
|
||||
|
||||
@Post('live-blog')
|
||||
@Public()
|
||||
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
|
||||
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
|
||||
const { event, model, entry } = body;
|
||||
@@ -55,6 +58,7 @@ export class StrapiController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Public()
|
||||
async handleGenericWebhook(@Body() body: unknown) {
|
||||
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import { StrapiController } from './strapi.controller';
|
||||
import { ArticlesModule } from './articles.module';
|
||||
import { LiveBlogModule } from './live-blog.module';
|
||||
import { Category } from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
TypeOrmModule.forFeature([Category]),
|
||||
forwardRef(() => ArticlesModule),
|
||||
forwardRef(() => LiveBlogModule),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
Category,
|
||||
} from './entities';
|
||||
|
||||
interface StrapiImage {
|
||||
@@ -45,6 +48,7 @@ interface StrapiArticle {
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface StrapiLiveBlog {
|
||||
@@ -64,6 +68,7 @@ interface StrapiLiveBlog {
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface StrapiResponse<T> {
|
||||
@@ -91,6 +96,8 @@ export class StrapiService {
|
||||
private readonly articlesService: ArticlesService,
|
||||
@Inject(forwardRef(() => LiveBlogService))
|
||||
private readonly liveBlogService: LiveBlogService,
|
||||
@InjectRepository(Category)
|
||||
private readonly categoryRepository: Repository<Category>,
|
||||
) {
|
||||
this.strapiUrl =
|
||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
||||
@@ -99,13 +106,51 @@ export class StrapiService {
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
return {
|
||||
Authorization: `Bearer ${this.strapiApiToken}`,
|
||||
};
|
||||
if (this.strapiApiToken) {
|
||||
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
|
||||
}
|
||||
|
||||
private async findOrCreateCategory(
|
||||
categorySlug: string,
|
||||
): Promise<Category | null> {
|
||||
if (!categorySlug) {
|
||||
return null;
|
||||
}
|
||||
return headers;
|
||||
|
||||
// Map CMS category slugs to Macedonian display names
|
||||
const categoryMap: Record<string, { name: string; description: string }> = {
|
||||
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
|
||||
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
|
||||
science: { name: 'Наука', description: 'Научни откритија и технологија' },
|
||||
};
|
||||
|
||||
const categoryInfo = categoryMap[categorySlug];
|
||||
if (!categoryInfo) {
|
||||
this.logger.warn(`Unknown category slug: ${categorySlug}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find existing category by slug
|
||||
let category = await this.categoryRepository.findOne({
|
||||
where: { slug: categorySlug },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
// Create new category
|
||||
category = this.categoryRepository.create({
|
||||
name: categoryInfo.name,
|
||||
slug: categorySlug,
|
||||
description: categoryInfo.description,
|
||||
order: 0,
|
||||
});
|
||||
category = await this.categoryRepository.save(category);
|
||||
this.logger.log(
|
||||
`Created new category: ${categorySlug} (${categoryInfo.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
|
||||
@@ -181,6 +226,17 @@ export class StrapiService {
|
||||
for (const strapiArticle of strapiArticles) {
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiArticle.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiArticle.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
@@ -198,6 +254,7 @@ export class StrapiService {
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
@@ -256,6 +313,17 @@ export class StrapiService {
|
||||
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiArticle.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiArticle.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
@@ -270,6 +338,7 @@ export class StrapiService {
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
@@ -333,6 +402,17 @@ export class StrapiService {
|
||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiLiveBlog.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiLiveBlog.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
@@ -346,6 +426,7 @@ export class StrapiService {
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
@@ -400,6 +481,17 @@ export class StrapiService {
|
||||
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiLiveBlog.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiLiveBlog.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
@@ -412,6 +504,7 @@ export class StrapiService {
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"category": {
|
||||
"type": "enumeration",
|
||||
"enum": ["sport", "art", "science"],
|
||||
"default": "sport",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: placebo-backend-dev
|
||||
env_file:
|
||||
- ./backend/.env # Use the .env file for configuration
|
||||
- ./backend/.env.docker # Use the .env.docker file for Docker configuration
|
||||
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
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/package.json:/app/package.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
|
||||
networks:
|
||||
- placebo-network-dev
|
||||
|
||||
+13
-15
@@ -6,7 +6,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres
|
||||
environment:
|
||||
POSTGRES_DB: placebo_db
|
||||
POSTGRES_DB: placebo_backend_db
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: placebo_password
|
||||
volumes:
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"]
|
||||
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -29,19 +29,17 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_TYPE: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: placebo_password
|
||||
DATABASE_NAME: placebo_db
|
||||
DATABASE_SYNCHRONIZE: "false"
|
||||
DATABASE_LOGGING: "false"
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
|
||||
JWT_EXPIRATION: 3600
|
||||
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
DATABASE_TYPE: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: placebo_password
|
||||
DATABASE_NAME: placebo_backend_db
|
||||
DATABASE_SYNCHRONIZE: "false"
|
||||
DATABASE_LOGGING: "false"
|
||||
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { Zap } from 'lucide-react'
|
||||
|
||||
export function ArticleTicker() {
|
||||
const { data } = useQuery({
|
||||
@@ -13,34 +14,32 @@ export function ArticleTicker() {
|
||||
if (articles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden bg-muted/50 border-y">
|
||||
<div className="container mx-auto max-w-6xl px-4">
|
||||
<div className="py-2 flex items-center gap-4">
|
||||
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
||||
Latest:
|
||||
</span>
|
||||
<div className="overflow-hidden flex-1 relative">
|
||||
<div className="flex animate-marquee whitespace-nowrap">
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`${article.id}-${index}`}
|
||||
to={`/articles/${article.id}` as any}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
))}
|
||||
{/* Duplicate for seamless scrolling */}
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`dup-${article.id}-${index}`}
|
||||
to={`/articles/${article.id}` as any}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
|
||||
<div className="py-2 flex items-center">
|
||||
<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">
|
||||
<Zap className="w-4 h-4" />
|
||||
Топ вести
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex animate-marquee whitespace-nowrap">
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
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'}
|
||||
</Link>
|
||||
))}
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`dup-${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
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'}
|
||||
</Link>
|
||||
))}
|
||||
</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 { Link } from '@tanstack/react-router';
|
||||
import { useActiveLiveBlogs } from '@/queries/live-blogs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
@@ -9,7 +8,7 @@ interface LiveBlogTickerProps {
|
||||
className?: string;
|
||||
maxItems?: number;
|
||||
autoScroll?: boolean;
|
||||
scrollSpeed?: number; // pixels per second
|
||||
scrollSpeed?: number;
|
||||
}
|
||||
|
||||
export function LiveBlogTicker({
|
||||
@@ -25,7 +24,6 @@ export function LiveBlogTicker({
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Calculate total width needed for scrolling
|
||||
const [totalWidth, setTotalWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,7 +33,6 @@ export function LiveBlogTicker({
|
||||
}
|
||||
}, [activeBlogs]);
|
||||
|
||||
// Auto-scroll animation
|
||||
useEffect(() => {
|
||||
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
|
||||
if (animationRef.current) {
|
||||
@@ -53,7 +50,6 @@ export function LiveBlogTicker({
|
||||
setScrollPosition(prev => {
|
||||
let newPos = prev + (scrollSpeed * delta) / 1000;
|
||||
|
||||
// Reset when scrolled past content
|
||||
if (newPos > totalWidth) {
|
||||
newPos = -(tickerRef.current?.offsetWidth || 0);
|
||||
}
|
||||
@@ -75,7 +71,7 @@ export function LiveBlogTicker({
|
||||
|
||||
if (isLoading) {
|
||||
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 gap-2">
|
||||
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
|
||||
@@ -87,7 +83,7 @@ export function LiveBlogTicker({
|
||||
}
|
||||
|
||||
if (!activeBlogs || activeBlogs.length === 0) {
|
||||
return null; // Don't show ticker if no active blogs
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBlogs = activeBlogs.slice(0, maxItems);
|
||||
@@ -97,56 +93,48 @@ export function LiveBlogTicker({
|
||||
};
|
||||
|
||||
const handleBlogClick = () => {
|
||||
// Reset scroll position when user interacts
|
||||
setScrollPosition(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Ticker header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-primary" />
|
||||
<span className="font-medium text-sm">Live Now</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeBlogs.length} active
|
||||
</Badge>
|
||||
<div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radio className="w-4 h-4 text-accent animate-pulse" />
|
||||
<span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
|
||||
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
|
||||
{activeBlogs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={handlePauseToggle}
|
||||
className="h-7 px-2"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-3 h-3" />
|
||||
) : (
|
||||
<Pause className="w-3 h-3" />
|
||||
)}
|
||||
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<span className="text-xs">View All</span>
|
||||
<Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
|
||||
Сите
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker content */}
|
||||
<div
|
||||
ref={tickerRef}
|
||||
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
|
||||
ref={contentRef}
|
||||
@@ -156,76 +144,63 @@ export function LiveBlogTicker({
|
||||
transition: isPaused ? 'transform 0.3s ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{displayBlogs.map((blog, index) => (
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={blog.id}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
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="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></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}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Separator (except after last item) */}
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Duplicate content for seamless looping */}
|
||||
{displayBlogs.map((blog, index) => (
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={`${blog.id}-dup`}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
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="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></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}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{autoScroll && totalWidth > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-1 bg-foreground/10">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
className="h-full bg-accent transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
|
||||
}}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
||||
|
||||
<div
|
||||
ref={updatesContainerRef}
|
||||
className="max-h-[600px] overflow-y-auto px-6 pb-6"
|
||||
className="px-6 pb-6"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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-7xl 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 { 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 { Link } from '@tanstack/react-router';
|
||||
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 { mk } from 'date-fns/locale';
|
||||
|
||||
export function AdminDashboardComponent() {
|
||||
// State for confirmation dialog and filters
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
|
||||
const [itemToDelete, setItemToDelete] = useState<{
|
||||
@@ -35,14 +32,11 @@ export function AdminDashboardComponent() {
|
||||
const archiveLiveBlogMutation = useArchiveLiveBlog();
|
||||
const publishArticleMutation = usePublishArticle();
|
||||
const publishLiveBlogMutation = usePublishLiveBlog();
|
||||
const updateArticleMutation = useUpdateArticle();
|
||||
|
||||
const liveBlogs = liveBlogsData?.data || [];
|
||||
const articles = articlesData?.data || [];
|
||||
|
||||
// No need to filter items - API already filters based on showArchived state
|
||||
const filteredLiveBlogs = liveBlogs;
|
||||
const filteredArticles = articles;
|
||||
|
||||
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('delete');
|
||||
@@ -81,7 +75,7 @@ export function AdminDashboardComponent() {
|
||||
} else {
|
||||
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
} else { // archive
|
||||
} else {
|
||||
if (itemToDelete.type === 'article') {
|
||||
await archiveArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
@@ -102,18 +96,32 @@ export function AdminDashboardComponent() {
|
||||
setItemToDelete(null);
|
||||
};
|
||||
|
||||
const handleSetHero = async (articleId: string, isHero: boolean) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await updateArticleMutation.mutateAsync({
|
||||
id: articleId,
|
||||
dto: { isHero }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update hero status:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
case 'live':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
return 'bg-green-500 text-white border-2 border-foreground';
|
||||
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 'ended':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
return 'bg-gray-400 text-white border-2 border-foreground';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
return 'bg-blue-500 text-white border-2 border-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,221 +137,236 @@ export function AdminDashboardComponent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-8 space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Управување со сите написи и live блогови
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
<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>
|
||||
<h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
|
||||
<p className="font-body text-muted-foreground mt-1">
|
||||
Управување со сите написи и live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
variant={showArchived ? 'brutal' : 'brutalOutline'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||
</Button>
|
||||
<Button asChild variant="brutalAccent">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild variant="brutalOutline">
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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">
|
||||
{/* Live Blogs Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredLiveBlogs.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сите live блогови со статус и датум на креирање
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{liveBlogs.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{loadingLiveBlogs ? (
|
||||
<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>
|
||||
) : filteredLiveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredLiveBlogs.map((blog) => (
|
||||
<div
|
||||
key={blog.id}
|
||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to="/admin/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{blog.title}
|
||||
</Link>
|
||||
<Badge variant="outline" className={getStatusColor(blog.status)}>
|
||||
{getStatusText(blog.status)}
|
||||
</Badge>
|
||||
{blog.isPinned && (
|
||||
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||||
Закачено
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Слаг: {blog.slug}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {blog.viewCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Објави
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Архивирај
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
) : liveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
liveBlogs.map((blog) => (
|
||||
<div key={blog.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">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/admin/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
Избриши
|
||||
</Button>
|
||||
{blog.title}
|
||||
</Link>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
|
||||
{getStatusText(blog.status)}
|
||||
</span>
|
||||
{blog.isPinned && (
|
||||
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
||||
Закачено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {blog.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {blog.viewCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Објави
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Архивирај
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Избриши
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Articles Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredArticles.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сите написи со статус и датум на креирање
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани написи' : 'Написи'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{articles.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{loadingArticles ? (
|
||||
<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>
|
||||
) : filteredArticles.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/">Креирај нов напис</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredArticles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||||
{getStatusText(article.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Слаг: {article.slug}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
|
||||
{getStatusText(article.status)}
|
||||
</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>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {article.views}</span>
|
||||
</div>
|
||||
{article.excerpt && (
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {article.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {article.views}</span>
|
||||
<span>•</span>
|
||||
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
|
||||
</div>
|
||||
{article.excerpt && (
|
||||
<p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 flex-col">
|
||||
<div className="flex gap-1">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
</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>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('article', article.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@@ -352,7 +375,7 @@ export function AdminDashboardComponent() {
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@@ -370,81 +393,147 @@ export function AdminDashboardComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats - Only show when not viewing archived items */}
|
||||
{!showArchived && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{!showArchived && (
|
||||
<div className="border-brutal bg-card p-6">
|
||||
<h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{dialogType === 'delete'
|
||||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||||
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-2xl font-display uppercase mb-3">
|
||||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||
</h3>
|
||||
<p className="font-body text-muted-foreground mb-6">
|
||||
{dialogType === 'delete'
|
||||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||||
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
|
||||
export function ArchiveComponent() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published' }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading articles...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading articles</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Articles</h1>
|
||||
<p className="text-muted-foreground">Latest news and articles</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data?.data.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No articles published yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function ArtComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="art"
|
||||
categoryName="Уметност"
|
||||
categoryDescription="Уметност, култура и забава"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||
import { CommentSection } from '@/components/features/comments/CommentSection'
|
||||
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
|
||||
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -67,9 +70,29 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
<span>By {data.author.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||
{/* 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' && (
|
||||
<div className={`relative mb-4 ${
|
||||
data.imagePosition === 'top'
|
||||
? 'w-full mb-8'
|
||||
@@ -163,10 +186,36 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</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">
|
||||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -181,6 +230,17 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
|
||||
<ReactionButtons articleId={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<CommentSection articleId={data.id} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
|
||||
export function ArticlesComponent() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published' }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading articles...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading articles</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Articles</h1>
|
||||
<p className="text-muted-foreground">Latest news and articles</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data?.data.map((article) => (
|
||||
<Link
|
||||
key={article.id}
|
||||
to={`/articles/${article.id}` as any}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No articles published yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { LoginForm } from '../auth/LoginForm';
|
||||
import { RegisterForm } from '../auth/RegisterForm';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
|
||||
export function AuthPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
Welcome to <span className="text-primary">Placebo.mk</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isLogin
|
||||
? 'Login to comment, react, and access admin features'
|
||||
: 'Join our community of sarcastic news enthusiasts'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLogin ? (
|
||||
<LoginForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToRegister={() => setIsLogin(false)}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToLogin={() => setIsLogin(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
|
||||
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
|
||||
and{' '}
|
||||
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Need help?{' '}
|
||||
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
|
||||
|
||||
interface CategoryPageProps {
|
||||
categorySlug: string
|
||||
categoryName: string
|
||||
categoryDescription?: string
|
||||
}
|
||||
|
||||
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
|
||||
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
|
||||
queryKey: ['category-articles', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
|
||||
})
|
||||
|
||||
const { data: heroData, isLoading: heroLoading } = useQuery({
|
||||
queryKey: ['category-hero', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
|
||||
enabled: !!categorySlug,
|
||||
})
|
||||
|
||||
const heroArticle = heroData?.data?.[0]
|
||||
const articles = articlesData?.data || []
|
||||
|
||||
if (articlesLoading || heroLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Вчитување...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (articlesError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Category Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{categoryName}</h1>
|
||||
{categoryDescription && (
|
||||
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero Section with Pinned Live Blogs Sidebar */}
|
||||
{heroArticle && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Hero Article - 2/3 width */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
{heroArticle.featuredImage ? (
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<img
|
||||
src={heroArticle.featuredImage}
|
||||
alt={heroArticle.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="p-6">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
|
||||
{heroArticle.title}
|
||||
</h2>
|
||||
{heroArticle.excerpt && (
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3">
|
||||
{heroArticle.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{heroArticle.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={heroArticle.id}
|
||||
title={heroArticle.title}
|
||||
url={`${window.location.origin}/articles/${heroArticle.id}`}
|
||||
excerpt={heroArticle.excerpt}
|
||||
image={heroArticle.featuredImage}
|
||||
tags={heroArticle.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned Live Blogs Sidebar - 1/3 width */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{articles
|
||||
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
|
||||
.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{articles.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Нема објавени статии во оваа категорија. Проверете подоцна!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,10 +37,11 @@ export function LiveBlogsComponent() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{data?.data.map((liveBlog) => (
|
||||
<Link
|
||||
key={liveBlog.id}
|
||||
to={`/live-blogs/${liveBlog.slug}` as any}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
key={liveBlog.id}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: liveBlog.slug }}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "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",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
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: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-11 px-6 py-2",
|
||||
sm: "h-9 px-4 py-1",
|
||||
lg: "h-14 px-8 text-base",
|
||||
icon: "h-12 w-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"border-brutal bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
))
|
||||
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-2xl font-display leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm font-body text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -69,7 +69,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
|
||||
export interface LiveBlogStreamEvent {
|
||||
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
|
||||
@@ -16,13 +16,13 @@ export function useLiveBlogStream(
|
||||
liveBlogId: string,
|
||||
options: LiveBlogStreamOptions = {}
|
||||
) {
|
||||
const defaultOptions: LiveBlogStreamOptions = {
|
||||
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
};
|
||||
}), []);
|
||||
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
@@ -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
@@ -1,51 +1,51 @@
|
||||
/* @tailwind base; */
|
||||
/* @tailwind components; */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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('VITE_API_URL env:', import.meta.env.VITE_API_URL);
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper
|
||||
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle 401 unauthorized
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -37,6 +73,18 @@ export interface Article {
|
||||
};
|
||||
createdAt: 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 {
|
||||
@@ -90,6 +138,7 @@ export interface UpdateArticleDto {
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
isHero?: boolean;
|
||||
}
|
||||
|
||||
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}`;
|
||||
console.log('Fetching from:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
const response = await authFetch(url);
|
||||
console.log('Response status:', response.status, 'ok:', response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -123,7 +172,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
|
||||
}
|
||||
|
||||
export async function fetchArticleBySlug(slug: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/slug/${slug}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/slug/${slug}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
@@ -131,7 +180,7 @@ export async function fetchArticleBySlug(slug: string): Promise<Article> {
|
||||
}
|
||||
|
||||
export async function fetchArticleById(id: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
@@ -139,11 +188,8 @@ export async function fetchArticleById(id: string): 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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
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> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -167,7 +210,7 @@ export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -176,7 +219,7 @@ export async function deleteArticle(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
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> {
|
||||
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',
|
||||
});
|
||||
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) {
|
||||
throw new Error('Failed to fetch live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
|
||||
export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
|
||||
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) {
|
||||
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> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
@@ -351,7 +398,7 @@ export async function fetchLiveBlogUpdates(
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<LiveBlogUpdatesResponse> {
|
||||
const response = await fetch(
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -361,15 +408,32 @@ export async function fetchLiveBlogUpdates(
|
||||
}
|
||||
|
||||
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/recent`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/recent`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recent live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchHeroArticle(): Promise<Article | null> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/hero`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch hero article');
|
||||
}
|
||||
const article = await response.json();
|
||||
return article || null;
|
||||
}
|
||||
|
||||
export async function fetchLatestArticles(limit = 12): Promise<ArticlesResponse> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles?status=published&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch latest articles');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pinned live blogs');
|
||||
}
|
||||
@@ -377,7 +441,7 @@ export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
}
|
||||
|
||||
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/active`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/active`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch active live blogs');
|
||||
}
|
||||
@@ -389,11 +453,8 @@ export async function createLiveBlogUpdate(
|
||||
liveBlogId: string,
|
||||
dto: CreateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -407,11 +468,8 @@ export async function updateLiveBlogUpdate(
|
||||
updateId: string,
|
||||
dto: UpdateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -421,7 +479,7 @@ export async function updateLiveBlogUpdate(
|
||||
}
|
||||
|
||||
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -430,11 +488,8 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
|
||||
}
|
||||
|
||||
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -444,11 +499,8 @@ export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog>
|
||||
}
|
||||
|
||||
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -458,7 +510,7 @@ export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promis
|
||||
}
|
||||
|
||||
export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -467,7 +519,7 @@ export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
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> {
|
||||
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',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -485,3 +537,293 @@ export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'en
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -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) + '...';
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,13 +3,20 @@ import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { router } from './routes'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { initializeTheme } from './lib/theme'
|
||||
|
||||
// Initialize theme before rendering
|
||||
initializeTheme();
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+216
-167
@@ -1,59 +1,79 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
|
||||
import { ArticleTicker } from './components/ArticleTicker'
|
||||
import { ArticlesComponent } from './components/routes/ArticlesComponent'
|
||||
import { ArchiveComponent } from './components/routes/ArchiveComponent'
|
||||
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
|
||||
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
|
||||
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
|
||||
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
||||
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
||||
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
|
||||
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
|
||||
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
|
||||
import { AuthPage } from './components/routes/AuthPage'
|
||||
import { SportComponent } from './components/routes/SportComponent'
|
||||
import { ArtComponent } from './components/routes/ArtComponent'
|
||||
import { ScienceComponent } from './components/routes/ScienceComponent'
|
||||
import { 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'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: 'Placebo.mk - Sarcastic News from Macedonia',
|
||||
title: 'Placebo.mk - Сатирични вести од Македонија',
|
||||
description: 'Latest news and articles from Macedonia with a sarcastic twist',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
</h1>
|
||||
<nav className="flex gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
|
||||
Admin
|
||||
</Link>
|
||||
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
||||
+ New Live Blog
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<Header />
|
||||
<ArticleTicker />
|
||||
|
||||
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
|
||||
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t mt-12">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
© 2025 Placebo.mk. Sarcastic news from Macedonia.
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12">
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -64,141 +84,72 @@ const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Article Ticker at the top */}
|
||||
<ArticleTicker />
|
||||
|
||||
{/* Live Blog Ticker below article ticker */}
|
||||
<LiveBlogTicker className="mt-4" />
|
||||
|
||||
<div>
|
||||
<div className="py-8 md:py-12">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Main content - 3 columns */}
|
||||
<div className="lg:col-span-3 space-y-8">
|
||||
{/* Hero section */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
Placebo<span className="text-primary">.mk</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
<div className="lg:col-span-2">
|
||||
<HeroArticle />
|
||||
</div>
|
||||
|
||||
{/* Features grid */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||
</p>
|
||||
</div>
|
||||
<LatestArticlesGrid />
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call to action */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
||||
Dive into our articles or follow live coverage of breaking news as it happens.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
to="/articles"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
|
||||
>
|
||||
Read Articles
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
to="/live-blogs"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
|
||||
>
|
||||
View Live Blogs
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/archive">
|
||||
<Button variant="brutalAccent" className="gap-2">
|
||||
Прелистај архива
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
|
||||
<Zap className="w-4 h-4" />
|
||||
Live Блогови
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - 1 column */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogSidebar />
|
||||
|
||||
{/* Additional sidebar content */}
|
||||
<div className="mt-6 rounded-xl border bg-card p-6">
|
||||
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Live coverage updated in real-time</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span>No ads, no sponsors, no BS</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
|
||||
<span>100% independent journalism</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-16">
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Најнови вести</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Свежо подготвена сатира за тековни настани, политика и сè помеѓу тоа.
|
||||
</p>
|
||||
</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>
|
||||
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
|
||||
</p>
|
||||
</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>
|
||||
<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>
|
||||
@@ -206,10 +157,28 @@ const indexRoute = createRoute({
|
||||
),
|
||||
})
|
||||
|
||||
const articlesRoute = createRoute({
|
||||
const archiveRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/articles',
|
||||
component: ArticlesComponent,
|
||||
path: '/archive',
|
||||
component: ArchiveComponent,
|
||||
})
|
||||
|
||||
const sportRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/sport',
|
||||
component: SportComponent,
|
||||
})
|
||||
|
||||
const artRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/art',
|
||||
component: ArtComponent,
|
||||
})
|
||||
|
||||
const scienceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/science',
|
||||
component: ScienceComponent,
|
||||
})
|
||||
|
||||
const articleDetailRoute = createRoute({
|
||||
@@ -219,6 +188,64 @@ const articleDetailRoute = createRoute({
|
||||
const { id } = articleDetailRoute.useParams()
|
||||
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({
|
||||
@@ -236,33 +263,55 @@ const liveBlogDetailRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const authRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/auth',
|
||||
component: AuthPage,
|
||||
})
|
||||
|
||||
const liveBlogAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/$slug',
|
||||
component: () => {
|
||||
const { slug } = liveBlogAdminRoute.useParams()
|
||||
return <LiveBlogAdminComponent slug={slug} />
|
||||
return (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<LiveBlogAdminComponent slug={slug} />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const createLiveBlogRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/create',
|
||||
component: CreateLiveBlogComponent,
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<CreateLiveBlogComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const adminDashboardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin',
|
||||
component: AdminDashboardComponent,
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<AdminDashboardComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
articlesRoute,
|
||||
archiveRoute,
|
||||
sportRoute,
|
||||
artRoute,
|
||||
scienceRoute,
|
||||
articleDetailRoute,
|
||||
liveBlogsRoute,
|
||||
liveBlogDetailRoute,
|
||||
authRoute,
|
||||
liveBlogAdminRoute,
|
||||
createLiveBlogRoute,
|
||||
adminDashboardRoute,
|
||||
|
||||
+318
-61
@@ -1,72 +1,162 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
@theme {
|
||||
--color-primary: oklch(0.647 0.22 0.23);
|
||||
--color-primary-foreground: oklch(0.985 0.002 0);
|
||||
--color-secondary: oklch(0.97 0.002 0);
|
||||
--color-secondary-foreground: oklch(0.205 0.02 266.5);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.97 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0.02 266.5);
|
||||
--color-primary: oklch(0.08 0 0);
|
||||
--color-primary-foreground: oklch(0.98 0 0);
|
||||
--color-secondary: oklch(0.93 0.02 50);
|
||||
--color-secondary-foreground: oklch(0.08 0 0);
|
||||
--color-muted: oklch(0.91 0.01 50);
|
||||
--color-muted-foreground: oklch(0.45 0.01 50);
|
||||
--color-accent: oklch(0.93 0.8 100);
|
||||
--color-accent-foreground: oklch(0.08 0 0);
|
||||
--color-destructive: oklch(0.55 0.22 25);
|
||||
--color-destructive-foreground: oklch(0.985 0.002 0);
|
||||
--color-border: oklch(0.9 0 0);
|
||||
--color-input: oklch(0.9 0 0);
|
||||
--color-ring: oklch(0.647 0.22 0.23);
|
||||
--radius: 0.5rem;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--color-destructive-foreground: oklch(0.98 0 0);
|
||||
--color-border: oklch(0.08 0 0);
|
||||
--color-input: oklch(0.08 0 0);
|
||||
--color-ring: oklch(0.93 0.8 100);
|
||||
--radius: 0px;
|
||||
--font-display: "Bebas Neue", sans-serif;
|
||||
--font-body: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--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 {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
}
|
||||
|
||||
* {
|
||||
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 {
|
||||
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 {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: flex;
|
||||
animation: marquee 30s linear infinite;
|
||||
width: max-content;
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { User, UserRole, LoginDto, RegisterDto, AuthResponse, ApiError } from './auth';
|
||||
@@ -13,6 +13,12 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': 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: {
|
||||
host: true, // Listen on all addresses
|
||||
|
||||
Generated
+658
-499
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for Strapi webhooks
|
||||
# Usage: ./test-webhooks.sh [article-id]
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Strapi Webhook Test Script ===${NC}\n"
|
||||
|
||||
# Default article ID (first article from Strapi)
|
||||
DEFAULT_ARTICLE_ID="r07qatlpgvx82d7337n3nz1l"
|
||||
ARTICLE_ID=${1:-$DEFAULT_ARTICLE_ID}
|
||||
|
||||
echo "Testing with article ID: $ARTICLE_ID"
|
||||
echo ""
|
||||
|
||||
# Test 1: Check backend is running
|
||||
echo -e "${YELLOW}1. Testing backend connectivity...${NC}"
|
||||
if curl -s -f "http://localhost:3000/api/v1/articles" > /dev/null; then
|
||||
echo -e "${GREEN}✓ Backend is running${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Backend is not accessible${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: Check Strapi is running
|
||||
echo -e "${YELLOW}2. Testing Strapi connectivity...${NC}"
|
||||
if curl -s -f "http://localhost:1337/_health" > /dev/null 2>&1 || curl -s -f "http://localhost:1337/admin" > /dev/null; then
|
||||
echo -e "${GREEN}✓ Strapi is running${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Strapi is not accessible${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 3: Send test webhook
|
||||
echo -e "${YELLOW}3. Sending test webhook...${NC}"
|
||||
WEBHOOK_RESPONSE=$(curl -s -w "%{http_code}" -X POST "http://localhost:3000/api/v1/webhooks/strapi/article" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"event\": \"entry.publish\",
|
||||
\"model\": \"article\",
|
||||
\"entry\": {
|
||||
\"documentId\": \"$ARTICLE_ID\"
|
||||
}
|
||||
}")
|
||||
|
||||
# Extract status code and body
|
||||
HTTP_STATUS=${WEBHOOK_RESPONSE: -3}
|
||||
RESPONSE_BODY=${WEBHOOK_RESPONSE%???}
|
||||
|
||||
if [[ "$HTTP_STATUS" =~ ^2[0-9][0-9]$ ]]; then
|
||||
echo -e "${GREEN}✓ Webhook sent successfully (HTTP $HTTP_STATUS)${NC}"
|
||||
echo "Response: $RESPONSE_BODY"
|
||||
else
|
||||
echo -e "${RED}✗ Webhook failed (HTTP $HTTP_STATUS)${NC}"
|
||||
echo "Response: $RESPONSE_BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 4: Check if article is in backend
|
||||
echo -e "${YELLOW}4. Verifying article in backend...${NC}"
|
||||
sleep 2 # Give backend time to process
|
||||
|
||||
ARTICLE_COUNT=$(curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total // 0')
|
||||
|
||||
if [ "$ARTICLE_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}✓ Backend has $ARTICLE_COUNT published article(s)${NC}"
|
||||
|
||||
# List articles
|
||||
echo -e "\n${YELLOW}Articles in backend:${NC}"
|
||||
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq -r '.data[] | "• \(.title) (ID: \(.id))"'
|
||||
else
|
||||
echo -e "${RED}✗ No articles found in backend${NC}"
|
||||
fi
|
||||
|
||||
# Test 5: Check Strapi articles
|
||||
echo -e "\n${YELLOW}5. Checking Strapi articles...${NC}"
|
||||
STRAPI_TOKEN="578d628f62df967ff95f95bedb205b5d10bbf792340519c8c467d6473208e16b3918151a97b49fa2285a53df0ec8e340a9ca555b01a654bd22152847840e6a368ee626a6f1338ce2f23790c171013b263ec80fbaf116e2b459d3663b234d08855fd0eb631991ed15bb94f7dbb0b80f190352965c72c7fd327c73629ceff38fbb"
|
||||
STRAPI_ARTICLES=$(curl -s -H "Authorization: Bearer $STRAPI_TOKEN" "http://localhost:1337/api/articles")
|
||||
|
||||
STRAPI_COUNT=$(echo "$STRAPI_ARTICLES" | jq '.data | length // 0')
|
||||
|
||||
if [ "$STRAPI_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}✓ Strapi has $STRAPI_COUNT article(s)${NC}"
|
||||
|
||||
echo -e "\n${YELLOW}Articles in Strapi:${NC}"
|
||||
echo "$STRAPI_ARTICLES" | jq -r '.data[] | "• \(.title) (Strapi ID: \(.documentId))"'
|
||||
else
|
||||
echo -e "${RED}✗ No articles found in Strapi${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${YELLOW}=== Summary ===${NC}"
|
||||
echo "Backend articles: $ARTICLE_COUNT"
|
||||
echo "Strapi articles: $STRAPI_COUNT"
|
||||
|
||||
if [ "$ARTICLE_COUNT" -eq "$STRAPI_COUNT" ]; then
|
||||
echo -e "${GREEN}✓ Sync appears to be working correctly!${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Article counts don't match. Manual sync may be needed.${NC}"
|
||||
echo "Run: curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all -H \"Authorization: Bearer YOUR_TOKEN\""
|
||||
fi
|
||||
|
||||
echo -e "\n${YELLOW}Next steps:${NC}"
|
||||
echo "1. Configure webhooks in Strapi admin: http://localhost:1337/admin"
|
||||
echo "2. Test by publishing an article in Strapi"
|
||||
echo "3. Check backend logs: docker logs placebo-backend-dev --tail 20"
|
||||
echo "4. Visit frontend: http://localhost:5173/articles"
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Article API</title>
|
||||
<script>
|
||||
async function testAPI() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/v1/articles?status=published');
|
||||
const data = await response.json();
|
||||
document.getElementById('result').innerHTML = `
|
||||
<h2>API Test Result</h2>
|
||||
<p>Status: ${response.status}</p>
|
||||
<p>Articles found: ${data.total}</p>
|
||||
<pre>${JSON.stringify(data.data[0], null, 2)}</pre>
|
||||
`;
|
||||
} catch (error) {
|
||||
document.getElementById('result').innerHTML = `
|
||||
<h2>Error</h2>
|
||||
<p>${error.message}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
window.onload = testAPI;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing Article API</h1>
|
||||
<div id="result">Loading...</div>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user