44 Commits
Author SHA1 Message Date
dimitar 5262e73a12 cms fix 2026-02-24 18:02:03 +01:00
dimitar 590a96e502 fix: correct Strapi database configuration structure
The database config had an incorrect nested structure with
connection.client.connection which caused Strapi to fail with
'Cannot destructure property client of db.config.connection'.

Fixed to use the correct flat structure:
connection.client, connection.host, etc.
2026-02-24 18:01:30 +01:00
dimitar 0ef26ba2f1 fix: add HTTP to HTTPS redirect and fix CMS Dockerfile
- Add HTTP router and redirect middleware for Let's Encrypt challenges
- Fix CMS Dockerfile to copy config from source (not dist)
- Add favicon.png to CMS container
- Enable automatic HTTPS redirect for all services
2026-02-24 17:52:51 +01:00
dimitar 2efdf20f88 refactor: use npm prune --omit=dev instead of deprecated --production flag 2026-02-24 17:41:38 +01:00
dimitar ed723bc429 fix: use 'https' entrypoint instead of 'websecure' for Traefik 2026-02-24 17:34:51 +01:00
dimitar b8dfde512c feat: add manual Traefik labels and coolify network to all services 2026-02-24 17:16:28 +01:00
dimitar 5fc424408e fix: correct health check endpoints and use IPv4 addresses 2026-02-24 16:49:17 +01:00
dimitar 749ff5659c port conf 2026-02-24 16:44:40 +01:00
dimitar a2caaab26a devops again 2026-02-24 15:13:50 +01:00
dimitar bc3e834a39 tr 2026-02-24 14:39:41 +01:00
dimitar 7a96b52cfc devops doodle 2026-02-24 14:17:29 +01:00
dimitar 5233eec96a devopse secret 2026-02-24 14:01:10 +01:00
dimitar 831da0af4d messing up deployment 2026-02-24 13:49:03 +01:00
dimitar a9528582fd strapi config 2026-02-24 13:31:31 +01:00
dimitar ee676b3916 db config 2026-02-24 13:15:47 +01:00
dimitar 9013bc51f3 docker files fix 2026-02-24 12:58:38 +01:00
dimitar 57ae0bb3b5 cool up 2026-02-23 06:21:45 +01:00
dimitar 3b827a90ac prod update 2026-02-23 05:54:35 +01:00
dimitar 3af4b92e1b legacy 2026-02-23 05:28:46 +01:00
dimitar cfc43a7119 docker fix 2026-02-23 05:18:40 +01:00
dimitar dc93a8b652 compose coolify 2026-02-23 04:51:14 +01:00
dimitar 69833d2067 prod 2026-02-22 05:30:47 +01:00
dimitar d041575600 logs removed 2026-02-22 04:49:38 +01:00
dimitar 57864e00da push notification
fix implemented
2026-02-22 04:00:20 +01:00
dimitar 0bbf2ab56f push notification implemented
need testing
2026-02-22 02:23:41 +01:00
dimitar 26a17b5a4c Merge branch 'pwa' 2026-02-22 01:27:13 +01:00
dimitar 7725674fd5 implemented 2026-02-22 01:20:59 +01:00
dimitar f1f16a80a5 pwa ready 2026-02-22 01:11:20 +01:00
dimitar 83c60ce40d ag 2026-02-22 00:44:46 +01:00
dimitar f20842b3ab xxl 2026-02-16 21:50:11 +01:00
dimitar 8510cccb6e buymeacoffe added 2026-02-16 21:20:58 +01:00
dimitar c2c77ac92a Merge branch 'redesignNo1' 2026-02-16 20:45:29 +01:00
dimitar 71b1b549c3 admin dashboard fixed
maybe we need another design for this page
2026-02-16 20:42:11 +01:00
dimitar aa79eba06d pading fixed on pages 2026-02-16 19:52:37 +01:00
dimitar 62c68e4bc6 scrollbar on every page 2026-02-16 19:47:24 +01:00
dimitar 2be6216772 date fixed
also прекршени вести Ч)
2026-02-16 19:41:00 +01:00
dimitar ae28f56337 header transparency fix 2026-02-16 19:19:27 +01:00
dimitar 10fe702749 liveblog update fix 2026-02-16 19:15:31 +01:00
dimitar 4c4d741b1f redesign looking good 2026-02-16 18:50:33 +01:00
dimitar 6abae13dbd run 2026-02-16 18:16:06 +01:00
dimitar 46eb41aaa5 category field added
implemented ctegory logic, ditional pages added
2026-02-06 03:35:28 +01:00
dimitar add12b2fbf hero section added
hero article menagment implemented in admin UI
2026-02-06 02:14:10 +01:00
dimitar 6d65d5975c mobile nav implemented
dark mode need rifinements
2026-02-06 01:04:43 +01:00
dimitar b8779e5a35 social share up and running 2026-02-05 03:04:19 +01:00
177 changed files with 35814 additions and 1495 deletions

No files matched your search

+112 -161
View File
@@ -1,218 +1,169 @@
# Agent Guidelines for placebo.mk # Agent Guidelines for placebo.mk
## Project Overview ## Project Overview
News site in Macedonia with sarcastic tone. Minimalistic design using TanStack stack. Macedonian satirical news site using TanStack stack + NestJS backend.
## Tech Stack ## Tech Stack
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS + shadcn/ui - **Frontend**: React 19 + TanStack (Query, Router) + Vite + Tailwind CSS + shadcn/ui
- **Backend**: NestJS + TypeORM + SQLite - **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi (in /cms) - **CMS**: Strapi (in /cms)
## Root-Level Commands ## Commands
### Root Level
```bash ```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:local # Start backend & frontend locally
npm run dev:backend:local # Backend only (local env) npm run dev:docker # Start via docker-compose
npm run dev:frontend:local # Frontend only (local env)
# Code quality
npm run lint # Lint both projects npm run lint # Lint both projects
npm run lint:fix # Auto-fix lint issues npm run lint:fix # Auto-fix lint issues
npm run type-check # Type check both projects 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 (cd backend)
### Backend (NestJS)
```bash ```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 lint / lint:fix # ESLint
npm run build
npm run start:prod
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests npm test # All tests
npm test:watch # Watch mode tests npm test app.service.spec # Single file (omit .ts)
npm test -t "should return" # By pattern
npm test:cov # Coverage npm test:cov # Coverage
npm test:e2e # E2E tests npm test:e2e # E2E tests
npm test app.service.spec.ts # Single file
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) ### Frontend (cd frontend)
```bash ```bash
cd frontend
npm install
npm run dev # Vite dev server npm run dev # Vite dev server
npm run build npm run lint / lint:fix # ESLint
npm run preview
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests (Vitest) npm test # All tests (Vitest)
npm test Header.test # Single file (omit .tsx)
npm test -t "renders" # By pattern
npm test:ui # Vitest UI npm test:ui # Vitest UI
npm test:coverage # Test coverage npm test:coverage # 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 ## Code Style
### Formatting & TypeScript ### TypeScript
- Use Prettier (2 spaces, single quotes, trailing commas, 100 char max) - Strict mode: `noImplicitAny`, `strictNullChecks`, `noFallthroughCasesInSwitch`
- Strict TypeScript - no implicit `any`, avoid `any`, use `unknown` - No implicit `any` - use `unknown` when type uncertain
- Explicit return types for public methods - Explicit return types for public methods
- Prefer interfaces over types for objects - Prefer `interface` over `type` for objects
- Use `readonly` for immutable properties - Use `readonly` for immutable properties
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
- Frontend: Path alias `@/*` maps to `./src/*`
### Naming Conventions ### Formatting (Prettier)
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`) - Single quotes, trailing commas, 2-space indent
- **Classes**: PascalCase (`UserService`, `AuthGuard`) - No semicolons in comments
- **Functions/Variables**: camelCase (`getUser`, `isLoading`)
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
- **Private members**: underscore prefix (`_cache`, `_validate()`)
- **Interfaces**: PascalCase, no 'I' prefix (`User`, `ApiResponse`)
### Import Order ### Naming
- External libraries first, then internal modules, then relative imports | Element | Convention | Example |
- Group imports with blank lines between groups |---------|------------|---------|
- Example: | Files | kebab-case | `user-profile.ts`, `auth.service.ts` |
```typescript | Classes | PascalCase | `UserService`, `AuthGuard` |
// External | Functions/Variables | camelCase | `getUser`, `isLoading` |
import { Module } from '@nestjs/common'; | Constants | UPPER_SNAKE_CASE | `MAX_RETRIES` |
import { TypeOrmModule } from '@nestjs/typeorm'; | Private members | underscore prefix | `_cache`, `_validate()` |
| Interfaces | PascalCase (no I prefix) | `User`, `ApiResponse` |
// Internal modules ### Imports
import { AuthModule } from '../auth/auth.module'; Group with blank lines: External → Internal → Relative
```typescript
// External
import { Injectable } from '@nestjs/common';
import { useQuery } from '@tanstack/react-query';
// Relative imports // Internal modules
import { UserService } from './user.service'; import { UserService } from '../users/user.service';
import { UserController } from './user.controller';
```
// Relative
import { AuthResponse } from './types';
```
### File Structure ### File Structure
``` ```
backend/src/ backend/src/
modules/feature-name/ modules/{feature}/
feature-name.module.ts {feature}.module.ts
feature-name.controller.ts {feature}.controller.ts
feature-name.service.ts {feature}.service.ts
feature-name.entity.ts {feature}.dto.ts
feature-name.dto.ts
common/{decorators,filters,guards,interceptors,pipes} common/{decorators,filters,guards,interceptors,pipes}
config/ entities.ts # All TypeORM entities
frontend/src/ frontend/src/
components/{ui,layout,features}/ components/{ui,layout,features,admin,routes}/
hooks/ hooks/ # Custom React hooks
lib/ queries/ # TanStack Query hooks
queries/ lib/ # Utilities, API client
routes/ routes/ # TanStack Router routes
types/ types/ # TypeScript types
utils/ ```
### Path Aliases
- Frontend: `@/*` maps to `./src/*`
- Import UI components: `import { Button } from '@/components/ui/button'`
## Patterns
### Backend Services (NestJS)
```typescript
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async login(dto: LoginUserDto): Promise<AuthResponse> {
// Implementation
}
}
```
### Frontend Hooks (TanStack Query)
```typescript
export function useArticles(params: FindArticlesParams = {}) {
return useQuery({
queryKey: ['articles', params],
queryFn: () => api.fetchArticles(params),
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createArticle,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
}
``` ```
### Error Handling ### Error Handling
- **Backend**: Use NestJS exceptions (`NotFoundException`, `BadRequestException`), custom exceptions, Logger - **Backend**: NestJS exceptions (`NotFoundException`, `BadRequestException`)
- **Frontend**: Error boundaries, try-catch with user-friendly messages - **Frontend**: Error boundaries, try-catch with user-friendly messages
### API Conventions ### API Response Format
- REST endpoints: `/api/v1/resources` ```typescript
- JSON fields: snake_case { data: T, meta: { total, page, limit }, error?: { code, message } }
- Pagination: `page`, `limit` query params ```
- Response format:
```typescript
{
data: T,
meta: { total, page, limit },
error?: { code, message }
}
```
### Testing ## Testing
- **Backend**: Jest with ts-jest transformer - **Backend**: Jest (`*.spec.ts`), AAA pattern, mock dependencies
- **Frontend**: Vitest with React Testing Library patterns - **Frontend**: Vitest + React Testing Library (`*.test.tsx`)
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert) - Descriptive names: `should return user data when valid ID provided`
- Integration tests for API endpoints
- E2E tests for critical user flows
- 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 ## Git Commits
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` - Conventional: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
- Keep commits atomic and focused - Atomic, imperative mood
- Write clear commit messages in imperative mood
### Component Guidelines ## UI Components
- Keep components small and focused (< 200 lines) - Use shadcn/ui from `components/ui/`
- Use functional components with hooks - CVA for variants, clsx + tailwind-merge for classes
- Prefer composition over inheritance
- Extract complex logic into custom hooks
- Use TanStack Query for server state management
- Use TanStack Router for routing
- Validate props with TypeScript
### Database ## Agent Instructions
- Use TypeORM for SQLite - **ALWAYS** run `npm run lint` and `npm run type-check` after changes
- Define entities with proper relationships - **NEVER** commit without explicit request
- Use migrations for schema changes
- Seed data with factory functions
- Use TypeORM repositories for data access
### UI Components
- Use shadcn/ui components from `components/ui/`
- Use class-variance-authority (CVA) for component variants
- Use clsx and tailwind-merge for class composition
- Follow shadcn patterns for new component creation
## Environment Variables
- Backend: `@nestjs/config` with `.env` files
- Frontend: Vite env vars (`VITE_API_URL`)
- Never commit secrets to the repository.
- Example frontend env: `VITE_API_URL=http://localhost:3000`
- 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 - **PREFER** editing existing files over creating new ones
- **VERIFY** tests pass before considering work complete - **VERIFY** tests pass before considering work complete
- **USE** the root-level commands for common operations - **CHECK** both backend/frontend when making API changes
- **CHECK** both backend and frontend when making API changes
+631 -371
View File
File diff suppressed because it is too large. Load diff
+7 -8
View File
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install ALL dependencies (including devDependencies for build)
RUN npm ci --only=production RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -18,6 +17,9 @@ COPY . .
# Build TypeScript # Build TypeScript
RUN npm run build RUN npm run build
# Prune devDependencies after build (suppress warning)
RUN npm prune --omit=dev
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
@@ -32,9 +34,6 @@ COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration
COPY --chown=nodejs:nodejs .env.example .env
# Switch to non-root user # Switch to non-root user
USER nodejs USER nodejs
@@ -46,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
EXPOSE 3000 EXPOSE 3000
# Start application # Start application
CMD ["node", "dist/main.js"] CMD ["node", "dist/src/main.js"]
+101 -31
View File
@@ -31,7 +31,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -43,6 +44,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -234,7 +236,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -2160,7 +2161,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2331,7 +2331,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz",
"integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"file-type": "21.3.0", "file-type": "21.3.0",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -2379,7 +2378,6 @@
"integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@nuxt/opencollective": "0.4.1", "@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1", "fast-safe-stringify": "2.1.1",
@@ -2456,7 +2454,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz",
"integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
@@ -2904,7 +2901,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "*", "@types/estree": "*",
"@types/json-schema": "*" "@types/json-schema": "*"
@@ -3030,7 +3026,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3143,6 +3138,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3205,7 +3210,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -3894,7 +3898,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3984,7 +3987,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -4199,6 +4201,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4401,6 +4415,12 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -4469,7 +4489,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4789,7 +4808,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -4846,15 +4864,13 @@
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
@@ -5662,7 +5678,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5723,7 +5738,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -6208,6 +6222,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
}, },
@@ -6744,6 +6759,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -7221,7 +7245,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -8544,6 +8567,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9292,7 +9321,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"passport-strategy": "1.x.x", "passport-strategy": "1.x.x",
"pause": "0.0.1", "pause": "0.0.1",
@@ -9421,7 +9449,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0", "pg-pool": "^3.11.0",
@@ -9705,7 +9732,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9795,7 +9821,8 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
@@ -9946,8 +9973,7 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
@@ -10092,7 +10118,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -10980,7 +11005,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11322,7 +11346,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -11495,7 +11518,6 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sqltools/formatter": "^1.2.5", "@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0", "ansis": "^4.2.0",
@@ -11702,7 +11724,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11996,6 +12017,47 @@
"defaults": "^1.0.3" "defaults": "^1.0.3"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.105.0", "version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
@@ -12090,6 +12152,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@@ -12108,6 +12171,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
}, },
@@ -12121,6 +12185,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"esrecurse": "^4.3.0", "esrecurse": "^4.3.0",
"estraverse": "^4.1.1" "estraverse": "^4.1.1"
@@ -12135,6 +12200,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
} }
@@ -12144,7 +12210,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/webpack/node_modules/mime-db": { "node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
@@ -12152,6 +12219,7 @@
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -12162,6 +12230,7 @@
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -12175,6 +12244,7 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.9", "@types/json-schema": "^7.0.9",
"ajv": "^8.9.0", "ajv": "^8.9.0",
+6 -2
View File
@@ -23,7 +23,9 @@
"dev:docker": "nest start --watch", "dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env", "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts" "seed:admin": "ts-node scripts/seed-admin.ts",
"seed:categories": "ts-node scripts/seed-categories.ts",
"generate-vapid": "ts-node scripts/generate-vapid-keys.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
@@ -48,7 +50,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -60,6 +63,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
+46
View File
@@ -0,0 +1,46 @@
import * as webpush from 'web-push';
import * as fs from 'fs';
import * as path from 'path';
const envPath = path.join(__dirname, '..', '.env');
function generateVapidKeys(): void {
console.log('Generating VAPID keys...');
const vapidKeys = webpush.generateVAPIDKeys();
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf-8');
}
const lines = envContent.split('\n');
const vapidSubject = `VAPID_SUBJECT=mailto:contact@placebo.mk`;
const vapidPublicKey = `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`;
const vapidPrivateKey = `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`;
const updatedLines = lines.filter(
(line) =>
!line.startsWith('VAPID_SUBJECT=') &&
!line.startsWith('VAPID_PUBLIC_KEY=') &&
!line.startsWith('VAPID_PRIVATE_KEY='),
);
const nonEmptyLines = updatedLines.filter((line) => line.trim() !== '');
const newEnvContent =
nonEmptyLines.join('\n') +
(nonEmptyLines.length > 0 ? '\n' : '') +
`${vapidSubject}\n${vapidPublicKey}\n${vapidPrivateKey}\n`;
fs.writeFileSync(envPath, newEnvContent);
console.log('VAPID keys generated and added to .env file:');
console.log(` Public Key: ${vapidKeys.publicKey}`);
console.log(` Private Key: ${vapidKeys.privateKey}`);
console.log('\nKeep your private key secret!');
}
generateVapidKeys();
+65
View File
@@ -0,0 +1,65 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Category } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
try {
const categoryRepository = app.get(getRepositoryToken(Category));
// Define categories with Macedonian names and English slugs
const categories = [
{
name: 'Спорт',
slug: 'sport',
description: 'Спортски вести и анализи',
order: 1,
},
{
name: 'Уметност',
slug: 'art',
description: 'Уметност, култура и забава',
order: 2,
},
{
name: 'Наука',
slug: 'science',
description: 'Научни откритија и технологија',
order: 3,
},
];
console.log('Seeding categories...');
for (const categoryData of categories) {
// Check if category already exists
const existingCategory = await categoryRepository.findOne({
where: { slug: categoryData.slug },
});
if (existingCategory) {
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
} else {
const category = categoryRepository.create(categoryData);
await categoryRepository.save(category);
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
}
}
console.log('\n✅ Category seeding completed!');
console.log('Categories available:');
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
allCategories.forEach((cat: Category) => {
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
});
} catch (error) {
console.error('Error seeding categories:', error);
} finally {
await app.close();
}
}
bootstrap();
+10
View File
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Public } from './modules/auth/public.decorator';
@Controller() @Controller()
export class AppController { export class AppController {
@@ -9,4 +10,13 @@ export class AppController {
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
@Public()
@Get('health')
healthCheck(): { status: string; timestamp: string } {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
} }
+8
View File
@@ -9,6 +9,8 @@ import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module'; import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module'; import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { PushModule } from './modules/push/push.module';
import { import {
Article, Article,
Author, Author,
@@ -19,6 +21,8 @@ import {
Comment, Comment,
Reaction, Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
@Module({ @Module({
imports: [ imports: [
@@ -41,6 +45,8 @@ import {
User, User,
Comment, Comment,
Reaction, Reaction,
ShareEvent,
PushSubscriptionEntity,
], ],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true', logging: process.env.DATABASE_LOGGING === 'true',
@@ -51,6 +57,8 @@ import {
UserModule, UserModule,
AuthModule, AuthModule,
CommentModule, CommentModule,
AnalyticsModule,
PushModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+1
View File
@@ -8,6 +8,7 @@ async function bootstrap() {
const allowedOrigins = [ const allowedOrigins = [
process.env.FRONTEND_URL ?? 'http://localhost:5173', process.env.FRONTEND_URL ?? 'http://localhost:5173',
process.env.PWA_URL ?? 'http://localhost:5174',
process.env.STRAPI_URL ?? 'http://localhost:1337', process.env.STRAPI_URL ?? 'http://localhost:1337',
]; ];
@@ -0,0 +1,76 @@
import {
Controller,
Post,
Body,
Get,
Query,
UsePipes,
ValidationPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
import { Public } from '../auth/public.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Public()
@Post('share')
@HttpCode(HttpStatus.CREATED)
@UsePipes(new ValidationPipe({ transform: true }))
async trackShare(@Body() trackShareDto: TrackShareDto) {
return await this.analyticsService.trackShare(trackShareDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares')
@UsePipes(new ValidationPipe({ transform: true }))
async getShareStats(
@Query() getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
return await this.analyticsService.getShareStats(getShareStatsDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/top')
async getTopSharedArticles(@Query('limit') limit: string) {
const limitNum = limit ? parseInt(limit) : 10;
return await this.analyticsService.getTopSharedArticles(limitNum);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/trends')
async getShareTrends(
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('interval') interval: 'day' | 'week' | 'month',
) {
return await this.analyticsService.getShareTrends(
new Date(startDate),
new Date(endDate),
interval || 'day',
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/total')
async getTotalShareStats() {
return await this.analyticsService.getTotalShareStats();
}
}
@@ -0,0 +1,47 @@
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import type { SharePlatform } from './analytics.entity';
export class TrackShareDto {
@IsUUID()
articleId: string;
@IsEnum(['facebook', 'twitter', 'whatsapp', 'telegram', 'link'])
platform: SharePlatform;
@IsOptional()
@IsString()
userAgent?: string;
@IsOptional()
@IsString()
ipAddress?: string;
}
export class GetShareStatsDto {
@IsOptional()
@IsUUID()
articleId?: string;
@IsOptional()
@IsString()
startDate?: string;
@IsOptional()
@IsString()
endDate?: string;
}
export class ShareStatsResponse {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Article } from '../entities';
export type SharePlatform =
| 'facebook'
| 'twitter'
| 'whatsapp'
| 'telegram'
| 'link';
@Entity('share_events')
export class ShareEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
articleId: string;
@Column({ type: 'text' })
platform: SharePlatform;
@Column({ nullable: true })
userId: string;
@Column({ nullable: true })
userAgent: string;
@Column({ nullable: true })
ipAddress: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
}
export interface ShareStats {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ShareEvent } from './analytics.entity';
import { Article } from '../entities';
@Module({
imports: [TypeOrmModule.forFeature([ShareEvent, Article])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -0,0 +1,266 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShareEvent, SharePlatform } from './analytics.entity';
import { Article } from '../entities';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
@InjectRepository(ShareEvent)
private readonly shareEventRepository: Repository<ShareEvent>,
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
) {}
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
const shareEvent = this.shareEventRepository.create(trackShareDto);
// Also update the article's share counters
await this.incrementArticleShareCounter(
trackShareDto.articleId,
trackShareDto.platform,
);
return await this.shareEventRepository.save(shareEvent);
}
private async incrementArticleShareCounter(
articleId: string,
platform: SharePlatform,
): Promise<void> {
const updateField = this.getShareCounterField(platform);
if (!updateField) return;
await this.articleRepository
.createQueryBuilder()
.update(Article)
.set({ [updateField]: () => `${updateField} + 1` })
.where('id = :id', { id: articleId })
.execute();
}
private getShareCounterField(platform: SharePlatform): string | null {
switch (platform) {
case 'facebook':
return 'facebookShares';
case 'twitter':
return 'twitterShares';
case 'whatsapp':
return 'whatsappShares';
case 'telegram':
return 'telegramShares';
default:
return null; // 'link' shares don't increment counters
}
}
async getShareStats(
getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
const query = this.articleRepository
.createQueryBuilder('article')
.select([
'article.id as "articleId"',
'article.title as "articleTitle"',
'article.facebookShares as "facebookShares"',
'article.twitterShares as "twitterShares"',
'article.whatsappShares as "whatsappShares"',
'article.telegramShares as "telegramShares"',
'article.views as "views"',
'article.createdAt as "createdAt"',
'article.updatedAt as "updatedAt"',
])
.addSelect(
`(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`,
)
.addSelect(
`CASE
WHEN article.views > 0
THEN ROUND(
(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares)::decimal / article.views * 100,
2
)
ELSE 0
END as "shareRate"`,
);
if (getShareStatsDto.articleId) {
query.where('article.id = :articleId', {
articleId: getShareStatsDto.articleId,
});
}
if (getShareStatsDto.startDate) {
query.andWhere('article.createdAt >= :startDate', {
startDate: getShareStatsDto.startDate,
});
}
if (getShareStatsDto.endDate) {
query.andWhere('article.createdAt <= :endDate', {
endDate: getShareStatsDto.endDate,
});
}
query.orderBy('"totalShares"', 'DESC');
const rawResults = await query.getRawMany<{
articleId: string;
articleTitle: string;
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
views: string;
createdAt: string;
updatedAt: string;
totalShares: string;
shareRate: string;
}>();
// Get link shares from share_events table
const results: ShareStatsResponse[] = [];
for (const rawResult of rawResults) {
const linkShares = await this.shareEventRepository.count({
where: {
articleId: rawResult.articleId,
platform: 'link',
},
});
const facebookShares = parseInt(rawResult.facebookShares) || 0;
const twitterShares = parseInt(rawResult.twitterShares) || 0;
const whatsappShares = parseInt(rawResult.whatsappShares) || 0;
const telegramShares = parseInt(rawResult.telegramShares) || 0;
const views = parseInt(rawResult.views) || 0;
const baseTotalShares =
facebookShares + twitterShares + whatsappShares + telegramShares;
const totalShares = baseTotalShares + linkShares;
const shareRate =
views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0;
results.push({
articleId: rawResult.articleId,
articleTitle: rawResult.articleTitle,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
totalShares,
views,
shareRate,
createdAt: new Date(rawResult.createdAt),
updatedAt: new Date(rawResult.updatedAt),
});
}
return results;
}
async getTopSharedArticles(
limit: number = 10,
): Promise<ShareStatsResponse[]> {
const stats = await this.getShareStats({});
return stats.slice(0, limit);
}
async getShareTrends(
startDate: Date,
endDate: Date,
interval: 'day' | 'week' | 'month' = 'day',
): Promise<
Array<{ period: string; platform: SharePlatform; count: number }>
> {
const dateFormat =
interval === 'day'
? 'YYYY-MM-DD'
: interval === 'week'
? 'YYYY-WW'
: 'YYYY-MM';
const query = this.shareEventRepository
.createQueryBuilder('share_event')
.select([
`TO_CHAR(share_event.createdAt, '${dateFormat}') as period`,
'share_event.platform as platform',
'COUNT(*) as count',
])
.where('share_event.createdAt >= :startDate', { startDate })
.andWhere('share_event.createdAt <= :endDate', { endDate })
.groupBy('period, platform')
.orderBy('period', 'ASC');
const rawResults = await query.getRawMany<{
period: string;
platform: string;
count: string;
}>();
// Convert platform strings to SharePlatform type
return rawResults.map((result) => ({
period: result.period,
platform: result.platform as SharePlatform,
count: parseInt(result.count) || 0,
}));
}
async getTotalShareStats(): Promise<{
totalShares: number;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
}> {
interface ArticleStatsRaw {
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
}
const articleStats = (await this.articleRepository
.createQueryBuilder('article')
.select([
'SUM(article.facebookShares) as facebookShares',
'SUM(article.twitterShares) as twitterShares',
'SUM(article.whatsappShares) as whatsappShares',
'SUM(article.telegramShares) as telegramShares',
])
.getRawOne()) as ArticleStatsRaw;
const linkShares = await this.shareEventRepository.count({
where: { platform: 'link' },
});
const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0;
const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0;
const whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0;
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
const totalShares =
facebookShares +
twitterShares +
whatsappShares +
telegramShares +
linkShares;
return {
totalShares,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
};
}
}
@@ -40,6 +40,12 @@ export class ArticlesController {
return this.articlesService.findAll(dto); return this.articlesService.findAll(dto);
} }
@Get('hero')
@Public()
findHero() {
return this.articlesService.findHeroArticle();
}
@Get(':id') @Get(':id')
@Public() @Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
+103 -35
View File
@@ -6,7 +6,7 @@ import {
IsUUID, IsUUID,
IsNumber, IsNumber,
IsBoolean, IsBoolean,
IsDate, IsDateString,
} from 'class-validator'; } from 'class-validator';
import { import {
ArticleStatus, ArticleStatus,
@@ -48,6 +48,10 @@ export class CreateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -75,6 +79,30 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; videoCaption?: string;
@IsOptional()
@IsString()
ogTitle?: string;
@IsOptional()
@IsString()
ogDescription?: string;
@IsOptional()
@IsString()
ogImage?: string;
@IsOptional()
@IsString()
twitterTitle?: string;
@IsOptional()
@IsString()
twitterDescription?: string;
@IsOptional()
@IsString()
twitterImage?: string;
} }
export class UpdateArticleDto { export class UpdateArticleDto {
@@ -111,6 +139,10 @@ export class UpdateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -138,6 +170,30 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; videoCaption?: string;
@IsOptional()
@IsString()
ogTitle?: string;
@IsOptional()
@IsString()
ogDescription?: string;
@IsOptional()
@IsString()
ogImage?: string;
@IsOptional()
@IsString()
twitterTitle?: string;
@IsOptional()
@IsString()
twitterDescription?: string;
@IsOptional()
@IsString()
twitterImage?: string;
} }
export class FindArticlesDto { export class FindArticlesDto {
@@ -169,6 +225,35 @@ export class FindArticlesDto {
limit?: number; limit?: number;
} }
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto { export class CreateLiveBlogDto {
@IsString() @IsString()
title: string; title: string;
@@ -292,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsDateString()
scheduledAt?: Date; scheduledAt?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; image?: string;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
} }
export class UpdateLiveBlogUpdateDto { export class UpdateLiveBlogUpdateDto {
@@ -314,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsString()
scheduledAt?: Date; image?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; videoUrl?: string;
}
export class FindLiveBlogsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
category?: string; authorName?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
} }
+356
View File
@@ -0,0 +1,356 @@
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
title: string;
@IsString()
content: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateArticleDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class FindArticlesDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto {
@IsString()
title: string;
@IsString()
slug: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateLiveBlogDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class CreateLiveBlogUpdateDto {
@IsString()
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class UpdateLiveBlogUpdateDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
+2
View File
@@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller'; import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities'; import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Article, Author, Category]), TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [ArticlesController], controllers: [ArticlesController],
providers: [ArticlesService], providers: [ArticlesService],
+32
View File
@@ -4,6 +4,7 @@ import {
Logger, Logger,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -14,6 +15,7 @@ import {
UpdateArticleDto, UpdateArticleDto,
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
@Injectable() @Injectable()
export class ArticlesService { export class ArticlesService {
@@ -24,6 +26,9 @@ export class ArticlesService {
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
async create(dto: CreateArticleDto): Promise<Article> { async create(dto: CreateArticleDto): Promise<Article> {
@@ -183,6 +188,7 @@ export class ArticlesService {
status: ArticleStatus = ArticleStatus.PUBLISHED, status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> { ): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
const wasDraft = article.status === ArticleStatus.DRAFT;
article.status = status; article.status = status;
const savedArticle = await this.articleRepository.save(article); const savedArticle = await this.articleRepository.save(article);
@@ -202,6 +208,21 @@ export class ArticlesService {
} }
} }
// Send push notification for newly published articles
if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) {
try {
await this.pushService.notifyNewArticle(
savedArticle.title,
savedArticle.slug,
);
this.logger.log(
`Push notification sent for article: ${savedArticle.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedArticle; return savedArticle;
} }
@@ -293,4 +314,15 @@ export class ArticlesService {
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
} }
async findHeroArticle(): Promise<Article | null> {
return this.articleRepository.findOne({
where: {
isHero: true,
status: ArticleStatus.PUBLISHED,
},
relations: ['author', 'category'],
order: { createdAt: 'DESC' },
});
}
} }
+33
View File
@@ -224,6 +224,39 @@ export class Article {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; categoryId: string;
@Column({ type: 'text', nullable: true })
ogTitle: string;
@Column({ type: 'text', nullable: true })
ogDescription: string;
@Column({ type: 'text', nullable: true })
ogImage: string;
@Column({ type: 'text', nullable: true })
twitterTitle: string;
@Column({ type: 'text', nullable: true })
twitterDescription: string;
@Column({ type: 'text', nullable: true })
twitterImage: string;
@Column({ default: 0 })
facebookShares: number;
@Column({ default: 0 })
twitterShares: number;
@Column({ default: 0 })
whatsappShares: number;
@Column({ default: 0 })
telegramShares: number;
@Column({ default: false })
isHero: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+2
View File
@@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller'; import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [LiveBlogController], controllers: [LiveBlogController],
providers: [LiveBlogService], providers: [LiveBlogService],
+21
View File
@@ -5,6 +5,7 @@ import {
OnModuleInit, OnModuleInit,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
@@ -19,6 +20,7 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
interface SseClient { interface SseClient {
id: string; id: string;
@@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
update: savedUpdate, update: savedUpdate,
}); });
// Send push notification for live blog updates (only for live blogs)
if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) {
try {
await this.pushService.notifyLiveBlogUpdate(
liveBlogEntity.title,
liveBlogEntity.slug,
savedUpdate.content,
);
this.logger.debug(
`Push notification sent for live blog update: ${liveBlogEntity.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedUpdate; return savedUpdate;
} }
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('push_subscriptions')
export class PushSubscriptionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ type: 'text', unique: true })
endpoint: string;
@Column({ type: 'text' })
p256dh: string;
@Column({ type: 'text' })
auth: string;
@Column({ type: 'varchar', nullable: true })
userId: string | null;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Delete,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { PushService } from './push.service';
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
import { Public } from '../auth/public.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { UserRole } from '../entities';
@Controller('push')
@UseGuards(RolesGuard)
export class PushController {
constructor(private readonly pushService: PushService) {}
@Public()
@Get('vapid-public-key')
getVapidPublicKey(): { publicKey: string | null } {
return { publicKey: this.pushService.getPublicKey() };
}
@Public()
@Post('subscribe')
@HttpCode(HttpStatus.CREATED)
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
await this.pushService.subscribe(dto);
return { success: true };
}
@Public()
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
await this.pushService.unsubscribe(dto);
}
@Roles(UserRole.ADMIN)
@Get('stats')
getStats(): Promise<{ totalSubscribers: number }> {
return this.pushService.getStats();
}
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Post('send')
@HttpCode(HttpStatus.OK)
async sendNotification(
@Body() dto: SendNotificationDto,
): Promise<{ sent: number; failed: number }> {
return this.pushService.sendToAll({
title: dto.title,
body: dto.body,
url: dto.url,
tag: 'admin-notification',
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class SubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
@IsString()
@IsNotEmpty()
p256dh: string;
@IsString()
@IsNotEmpty()
auth: string;
@IsOptional()
@IsString()
userId?: string;
}
export class UnsubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
}
export class SendNotificationDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
body: string;
@IsOptional()
@IsString()
url?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PushController } from './push.controller';
import { PushService } from './push.service';
import { PushSubscriptionEntity } from './push-subscription.entity';
@Module({
imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])],
controllers: [PushController],
providers: [PushService],
exports: [PushService],
})
export class PushModule {}
+178
View File
@@ -0,0 +1,178 @@
import * as webpush from 'web-push';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PushSubscriptionEntity } from './push-subscription.entity';
import { SubscribeDto, UnsubscribeDto } from './push.dto';
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
url?: string;
tag?: string;
}
@Injectable()
export class PushService implements OnModuleInit {
private readonly logger = new Logger(PushService.name);
constructor(
private configService: ConfigService,
@InjectRepository(PushSubscriptionEntity)
private subscriptionRepo: Repository<PushSubscriptionEntity>,
) {}
onModuleInit() {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
const subject = this.configService.get<string>(
'VAPID_SUBJECT',
'mailto:contact@placebo.mk',
);
if (!publicKey || !privateKey) {
this.logger.warn(
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
);
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
this.logger.log('VAPID keys configured successfully');
}
getPublicKey(): string | null {
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
}
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint },
});
if (existing) {
if (dto.userId && existing.userId !== dto.userId) {
existing.userId = dto.userId;
return this.subscriptionRepo.save(existing);
}
return existing;
}
const subscription = this.subscriptionRepo.create({
endpoint: dto.endpoint,
p256dh: dto.p256dh,
auth: dto.auth,
userId: dto.userId ?? null,
});
return this.subscriptionRepo.save(subscription);
}
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
}
async sendToAll(
payload: PushPayload,
): Promise<{ sent: number; failed: number }> {
const subscriptions = await this.subscriptionRepo.find();
if (subscriptions.length === 0) {
return { sent: 0, failed: 0 };
}
const notification = JSON.stringify({
title: payload.title,
body: payload.body,
icon: payload.icon ?? '/icons/icon-192.png',
badge: payload.badge ?? '/icons/badge-72.png',
url: payload.url ?? '/',
tag: payload.tag,
});
let sent = 0;
let failed = 0;
const results = await Promise.allSettled(
subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
notification,
{
TTL: 86400,
},
);
return { success: true, id: sub.id };
} catch (error) {
if (error instanceof Error) {
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
}
if (
error instanceof webpush.WebPushError &&
(error.statusCode === 410 || error.statusCode === 404)
) {
await this.subscriptionRepo.delete({ id: sub.id });
this.logger.log(`Removed invalid subscription: ${sub.id}`);
}
return { success: false, id: sub.id };
}
}),
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) {
sent++;
} else {
failed++;
}
}
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
return { sent, failed };
}
async notifyNewArticle(
articleTitle: string,
articleSlug: string,
): Promise<void> {
await this.sendToAll({
title: 'Нови вести! 📰',
body: articleTitle,
url: `/article/${articleSlug}`,
tag: 'new-article',
});
}
async notifyLiveBlogUpdate(
blogTitle: string,
blogSlug: string,
updatePreview: string,
): Promise<void> {
const body =
updatePreview.length > 100
? updatePreview.substring(0, 97) + '...'
: updatePreview;
await this.sendToAll({
title: `${blogTitle} 📡`,
body,
url: `/live-blog/${blogSlug}`,
tag: `live-blog-${blogSlug}`,
});
}
async getStats(): Promise<{ totalSubscribers: number }> {
const totalSubscribers = await this.subscriptionRepo.count();
return { totalSubscribers };
}
}
+3
View File
@@ -1,13 +1,16 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller'; import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module'; import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module'; import { LiveBlogModule } from './live-blog.module';
import { Category } from './entities';
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule), forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule), forwardRef(() => LiveBlogModule),
], ],
+98 -5
View File
@@ -1,6 +1,8 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -11,6 +13,7 @@ import {
ImagePosition, ImagePosition,
ImageSize, ImageSize,
VideoPosition, VideoPosition,
Category,
} from './entities'; } from './entities';
interface StrapiImage { interface StrapiImage {
@@ -45,6 +48,7 @@ interface StrapiArticle {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -64,6 +68,7 @@ interface StrapiLiveBlog {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -91,6 +96,8 @@ export class StrapiService {
private readonly articlesService: ArticlesService, private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService)) @Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService, private readonly liveBlogService: LiveBlogService,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) { ) {
this.strapiUrl = this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337'; this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -99,13 +106,51 @@ export class StrapiService {
} }
private getHeaders() { private getHeaders() {
const headers: Record<string, string> = { return {
'Content-Type': 'application/json', Authorization: `Bearer ${this.strapiApiToken}`,
}; };
if (this.strapiApiToken) { }
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
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 { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
@@ -181,6 +226,17 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -198,6 +254,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -256,6 +313,17 @@ export class StrapiService {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -270,6 +338,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -333,6 +402,17 @@ export class StrapiService {
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -346,6 +426,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -400,6 +481,17 @@ export class StrapiService {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -412,6 +504,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
+20
View File
@@ -0,0 +1,20 @@
# Server
HOST=0.0.0.0
PORT=1337
# Secrets (these should be overridden by docker env vars)
APP_KEYS=tobereplaced
API_TOKEN_SALT=tobereplaced
ADMIN_JWT_SECRET=tobereplaced
TRANSFER_TOKEN_SALT=tobereplaced
JWT_SECRET=tobereplaced
# Database (these MUST be overridden by docker env vars)
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres-cms
DATABASE_PORT=5432
DATABASE_NAME=placebo_cms_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_pass
DATABASE_SSL=false
+12 -14
View File
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci --only=production RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -18,6 +17,9 @@ COPY . .
# Build Strapi # Build Strapi
RUN npm run build RUN npm run build
# Prune devDependencies after build (suppress warning)
RUN npm prune --omit=dev
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
@@ -27,28 +29,24 @@ WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \ RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 adduser -S nodejs -u 1001
# Install SQLite for development (will use PostgreSQL in production)
RUN apk add --no-cache sqlite
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/dist/config ./config
COPY --from=builder --chown=nodejs:nodejs /app/public ./public COPY --from=builder --chown=nodejs:nodejs /app/public ./public
COPY --from=builder --chown=nodejs:nodejs /app/favicon.png ./favicon.png
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration # Create data and database directories with proper permissions
COPY --chown=nodejs:nodejs .env.example .env RUN mkdir -p /app/.tmp /app/database /app/uploads /app/database/migrations && \
chown -R nodejs:nodejs /app
# Create data directory for SQLite
RUN mkdir -p /app/.tmp && \
chown -R nodejs:nodejs /app/.tmp
# Switch to non-root user # Switch to non-root user
USER nodejs USER nodejs
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})" CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"
# Expose port # Expose port
EXPOSE 1337 EXPOSE 1337
+15 -42
View File
@@ -3,58 +3,31 @@ import path from 'path';
export default ({ env }) => { export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite'); const client = env('DATABASE_CLIENT', 'sqlite');
const connections = { console.log('DATABASE_CLIENT:', client);
mysql: { console.log('DATABASE_HOST:', env('DATABASE_HOST', 'not-set'));
console.log('DATABASE_USERNAME:', env('DATABASE_USERNAME', 'not-set'));
console.log('DATABASE_NAME:', env('DATABASE_NAME', 'not-set'));
if (client === 'postgres') {
return {
connection: { connection: {
host: env('DATABASE_HOST', 'localhost'), client: 'postgres',
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'), host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432), port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'), database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'), user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'), password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && { ssl: env.bool('DATABASE_SSL', false),
key: env('DATABASE_SSL_KEY', undefined), pool: { min: 2, max: 10 },
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
}, },
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, };
}, }
sqlite: {
connection: {
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return { return {
connection: { connection: {
client, client: 'sqlite',
...connections[client], filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000), useNullAsDefault: true,
}, },
}; };
}; };
@@ -63,6 +63,12 @@
"videoCaption": { "videoCaption": {
"type": "string", "type": "string",
"default": "" "default": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
} }
} }
} }
+268
View File
@@ -0,0 +1,268 @@
# Docker Compose for Coolify Deployment
# Deploy all services in one run
#
# Usage in Coolify:
# 1. Create new Docker Compose service
# 2. Point to this file
# 3. Set environment variables in Coolify UI
# 4. Deploy
services:
# ===========================================
# DATABASES
# ===========================================
postgres-backend:
image: postgres:16-alpine
container_name: placebo-postgres-backend
restart: unless-stopped
environment:
POSTGRES_DB: placebo_backend_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- placebo-postgres-backend-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready']
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
networks:
- placebo-internal
postgres-cms:
image: postgres:16-alpine
container_name: placebo-postgres-cms
restart: unless-stopped
environment:
POSTGRES_DB: placebo_cms_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- placebo-postgres-cms-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready']
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
networks:
- placebo-internal
# ===========================================
# BACKEND (NestJS API)
# ===========================================
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
DATABASE_TYPE: postgres
DATABASE_HOST: postgres-backend
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: 'false'
DATABASE_LOGGING: 'false'
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION: '86400'
CORS_ORIGIN: https://placebo.mk,https://www.placebo.mk,https://app.placebo.mk
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
depends_on:
postgres-backend:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:3000/api/v1/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-internal
- coolify
expose:
- "3000"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.backend-http.rule=Host(`api.placebo.mk`)"
- "traefik.http.routers.backend-http.entrypoints=http"
- "traefik.http.routers.backend-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.backend.rule=Host(`api.placebo.mk`)"
- "traefik.http.routers.backend.entrypoints=https"
- "traefik.http.routers.backend.tls=true"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.backend.loadbalancer.server.port=3000"
# Redirect middleware
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
# ===========================================
# CMS (Strapi)
# ===========================================
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 1337
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres-cms
DATABASE_PORT: '5432'
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_SSL: 'false'
APP_KEYS: ${STRAPI_APP_KEYS}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
JWT_SECRET: ${STRAPI_JWT_SECRET}
ENCRYPTION_KEY: ${STRAPI_ENCRYPTION_KEY}
depends_on:
postgres-cms:
condition: service_healthy
volumes:
- placebo-cms-uploads:/app/public/uploads
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:1337/_health', (r) => {if(r.statusCode === 200 || r.statusCode === 204) process.exit(0); process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-internal
- coolify
expose:
- "1337"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.cms-http.rule=Host(`cms.placebo.mk`)"
- "traefik.http.routers.cms-http.entrypoints=http"
- "traefik.http.routers.cms-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.cms.rule=Host(`cms.placebo.mk`)"
- "traefik.http.routers.cms.entrypoints=https"
- "traefik.http.routers.cms.tls=true"
- "traefik.http.routers.cms.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.cms.loadbalancer.server.port=1337"
# ===========================================
# FRONTEND (React)
# ===========================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: https://api.placebo.mk/api/v1
VITE_CMS_URL: https://cms.placebo.mk
container_name: placebo-frontend
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-internal
- coolify
expose:
- "80"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.frontend-http.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
- "traefik.http.routers.frontend-http.entrypoints=http"
- "traefik.http.routers.frontend-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.frontend.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
- "traefik.http.routers.frontend.entrypoints=https"
- "traefik.http.routers.frontend.tls=true"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.frontend.loadbalancer.server.port=80"
# ===========================================
# PWA (Progressive Web App)
# ===========================================
pwa:
build:
context: ./pwa
dockerfile: Dockerfile
args:
VITE_API_URL: https://api.placebo.mk/api/v1
VITE_CMS_URL: https://cms.placebo.mk
container_name: placebo-pwa
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-internal
- coolify
expose:
- "80"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.pwa-http.rule=Host(`app.placebo.mk`)"
- "traefik.http.routers.pwa-http.entrypoints=http"
- "traefik.http.routers.pwa-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.pwa.rule=Host(`app.placebo.mk`)"
- "traefik.http.routers.pwa.entrypoints=https"
- "traefik.http.routers.pwa.tls=true"
- "traefik.http.routers.pwa.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.pwa.loadbalancer.server.port=80"
# ===========================================
# VOLUMES (Managed by Coolify)
# ===========================================
volumes:
placebo-postgres-backend-data:
driver: local
placebo-postgres-cms-data:
driver: local
placebo-cms-uploads:
driver: local
# ===========================================
# NETWORKS
# ===========================================
networks:
placebo-internal:
driver: bridge
coolify:
external: true
+155
View File
@@ -0,0 +1,155 @@
# Production Docker Compose for Coolify
# Each service should be deployed separately in Coolify
# This file is for reference and local testing only
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: placebo-postgres
restart: unless-stopped
environment:
POSTGRES_DB: placebo_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_db']
interval: 10s
timeout: 5s
retries: 5
networks:
- placebo-network
# Backend API (NestJS)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_NAME: placebo_db
DATABASE_SYNCHRONIZE: 'false'
DATABASE_LOGGING: 'false'
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRATION: '86400'
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
ports:
- '3000:3000'
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-network
# Frontend (React)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-frontend
restart: unless-stopped
ports:
- '3001:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# PWA (Progressive Web App)
pwa:
build:
context: ./pwa
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-pwa
restart: unless-stopped
ports:
- '5174:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# CMS (Strapi)
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_SSL: 'false'
HOST: 0.0.0.0
PORT: 1337
APP_KEYS: ${STRAPI_APP_KEYS:-key1,key2,key3,key4}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT:-change-me}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET:-change-me}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT:-change-me}
JWT_SECRET: ${STRAPI_JWT_SECRET:-change-me}
ports:
- '1337:1337'
depends_on:
postgres:
condition: service_healthy
volumes:
- cms_uploads:/app/public/uploads
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-network
volumes:
postgres_data:
driver: local
cms_uploads:
driver: local
networks:
placebo-network:
driver: bridge
+1 -1
View File
@@ -28,7 +28,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: placebo-backend container_name: placebo-backend
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_TYPE: postgres DATABASE_TYPE: postgres
DATABASE_HOST: postgres DATABASE_HOST: postgres
+10 -10
View File
@@ -5,12 +5,19 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Build arguments for environment variables
ARG VITE_API_URL
ARG VITE_CMS_URL
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_CMS_URL=$VITE_CMS_URL
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -21,19 +28,12 @@ RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM nginx:alpine
# Create non-root user
RUN addgroup -g 1001 -S nginx && \
adduser -S nginx -u 1001 -G nginx
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration # Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Switch to non-root user
USER nginx
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
+28 -28
View File
@@ -60,35 +60,35 @@ http {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# API proxy # API proxy (for local dev only - in prod use public URL)
location /api/ { # location /api/ {
proxy_pass http://backend:3000/; # proxy_pass http://backend:3000/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# CMS proxy # CMS proxy (for local dev only - in prod use public URL)
location /cms/ { # location /cms/ {
proxy_pass http://cms:1337/; # proxy_pass http://cms:1337/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# Health check endpoint # Health check endpoint
location /health { location /health {
+29 -28
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import { Zap } from 'lucide-react'
export function ArticleTicker() { export function ArticleTicker() {
const { data } = useQuery({ const { data } = useQuery({
@@ -13,34 +14,34 @@ export function ArticleTicker() {
if (articles.length === 0) return null if (articles.length === 0) return null
return ( return (
<div className="overflow-hidden bg-muted/50 border-y"> <div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
<div className="container mx-auto max-w-6xl px-4"> <div className="py-2 flex items-center">
<div className="py-2 flex items-center gap-4"> <div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
<span className="text-sm font-semibold text-primary whitespace-nowrap"> <Zap className="w-4 h-4" />
Latest: Топ вести
</span> </div>
<div className="overflow-hidden flex-1 relative"> <div className="overflow-hidden flex-1">
<div className="flex animate-marquee whitespace-nowrap"> <div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`${article.id}-${index}`} key={`${article.id}-${index}`}
to={`/articles/${article.id}`} to="/articles/$id"
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" params={{ id: 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> {article.title || 'No title'}
))} </Link>
{/* Duplicate for seamless scrolling */} ))}
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`dup-${article.id}-${index}`} key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}`} to="/articles/$id"
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" params={{ id: 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> {article.title || 'No title'}
))} </Link>
</div> ))}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,166 @@
import { useState } from 'react';
import { usePushStats, useSendPushNotification } from '@/queries/push';
import { Button } from '@/components/ui/button';
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
export function PushNotificationManager() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [url, setUrl] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
null,
);
const { data: stats, isLoading: loadingStats } = usePushStats();
const sendMutation = useSendPushNotification();
const handleSendTest = async () => {
if (!title.trim() || !body.trim()) return;
try {
const result = await sendMutation.mutateAsync({
title: title.trim(),
body: body.trim(),
url: url.trim() || undefined,
});
setResult(result);
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 5000);
} catch (error) {
console.error('Failed to send notification:', error);
}
};
const isValid = title.trim().length > 0 && body.trim().length > 0;
return (
<div className="border-brutal bg-card p-6">
<div className="flex items-center gap-3 mb-6">
<Bell className="h-6 w-6 text-accent" />
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="border-brutal-sm bg-background p-5 h-full">
<div className="flex items-center gap-2 mb-3">
<Users className="h-5 w-5 text-muted-foreground" />
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
Subscribers
</span>
</div>
{loadingStats ? (
<div className="text-4xl font-display animate-pulse">...</div>
) : (
<div className="text-5xl font-display">
{stats?.totalSubscribers ?? 0}
</div>
)}
<p className="text-xs font-body text-muted-foreground mt-2">
Active push subscribers
</p>
</div>
</div>
<div className="lg:col-span-2">
<div className="border-brutal-sm bg-background p-5">
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
Send Notification
</h3>
<div className="space-y-4">
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter notification title..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
maxLength={50}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Body
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Enter notification message..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
rows={3}
maxLength={150}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
URL (optional)
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="/article/slug or /live-blogs/slug"
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-2">
<div className="text-xs font-body text-muted-foreground">
{title.length}/50 chars • {body.length}/150 chars
</div>
<Button
variant="brutalAccent"
onClick={handleSendTest}
disabled={!isValid || sendMutation.isPending}
className="flex items-center gap-2"
>
<Send className="h-4 w-4" />
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
</Button>
</div>
</div>
{showSuccess && result && (
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-body text-sm font-medium">
Notification sent!
</p>
<p className="font-body text-xs text-muted-foreground">
Delivered: {result.sent} • Failed: {result.failed}
</p>
</div>
</div>
)}
{sendMutation.isError && (
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-500" />
<p className="font-body text-sm">
Failed to send notification. Please try again.
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
<p className="font-body text-xs text-muted-foreground">
<strong>Note:</strong> Notifications are sent to all subscribers with
push enabled. Use sparingly to avoid spam. Articles and live blog
updates are automatically pushed when published.
</p>
</div>
</div>
);
}
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs'; import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react'; import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
@@ -9,7 +8,7 @@ interface LiveBlogTickerProps {
className?: string; className?: string;
maxItems?: number; maxItems?: number;
autoScroll?: boolean; autoScroll?: boolean;
scrollSpeed?: number; // pixels per second scrollSpeed?: number;
} }
export function LiveBlogTicker({ export function LiveBlogTicker({
@@ -25,7 +24,6 @@ export function LiveBlogTicker({
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number | undefined>(undefined); const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0); const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => { useEffect(() => {
@@ -35,7 +33,6 @@ export function LiveBlogTicker({
} }
}, [activeBlogs]); }, [activeBlogs]);
// Auto-scroll animation
useEffect(() => { useEffect(() => {
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) { if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
if (animationRef.current) { if (animationRef.current) {
@@ -53,7 +50,6 @@ export function LiveBlogTicker({
setScrollPosition(prev => { setScrollPosition(prev => {
let newPos = prev + (scrollSpeed * delta) / 1000; let newPos = prev + (scrollSpeed * delta) / 1000;
// Reset when scrolled past content
if (newPos > totalWidth) { if (newPos > totalWidth) {
newPos = -(tickerRef.current?.offsetWidth || 0); newPos = -(tickerRef.current?.offsetWidth || 0);
} }
@@ -75,7 +71,7 @@ export function LiveBlogTicker({
if (isLoading) { if (isLoading) {
return ( return (
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}> <div className={`border-brutal-sm bg-card p-3 ${className}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div> <div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
@@ -87,7 +83,7 @@ export function LiveBlogTicker({
} }
if (!activeBlogs || activeBlogs.length === 0) { if (!activeBlogs || activeBlogs.length === 0) {
return null; // Don't show ticker if no active blogs return null;
} }
const displayBlogs = activeBlogs.slice(0, maxItems); const displayBlogs = activeBlogs.slice(0, maxItems);
@@ -97,56 +93,48 @@ export function LiveBlogTicker({
}; };
const handleBlogClick = () => { const handleBlogClick = () => {
// Reset scroll position when user interacts
setScrollPosition(0); setScrollPosition(0);
}; };
return ( return (
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}> <div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
{/* Ticker header */} <div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <Radio className="w-4 h-4 text-accent animate-pulse" />
<Radio className="w-4 h-4 text-primary" /> <span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
<span className="font-medium text-sm">Live Now</span> <span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
<Badge variant="secondary" className="text-xs"> {activeBlogs.length}
{activeBlogs.length} active </span>
</Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{autoScroll && ( {autoScroll && (
<Button <Button
variant="ghost" variant="brutal"
size="sm" size="sm"
onClick={handlePauseToggle} onClick={handlePauseToggle}
className="h-7 px-2" className="h-7 px-2 text-xs"
> >
{isPaused ? ( {isPaused ? (
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
) : ( ) : (
<Pause className="w-3 h-3" /> <Pause className="w-3 h-3" />
)} )}
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
</Button> </Button>
)} )}
<Link to="/live-blogs"> <Link to="/live-blogs">
<Button variant="ghost" size="sm" className="h-7 px-2"> <Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
<span className="text-xs">View All</span> Сите
<ChevronRight className="w-3 h-3 ml-1" /> <ChevronRight className="w-3 h-3 ml-1" />
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Ticker content */}
<div <div
ref={tickerRef} ref={tickerRef}
className="relative h-12 overflow-hidden" className="relative h-12 overflow-hidden"
style={{
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
}}
> >
<div <div
ref={contentRef} ref={contentRef}
@@ -156,76 +144,63 @@ export function LiveBlogTicker({
transition: isPaused ? 'transform 0.3s ease' : 'none' transition: isPaused ? 'transform 0.3s ease' : 'none'
}} }}
> >
{displayBlogs.map((blog, index) => ( {displayBlogs.map((blog) => (
<React.Fragment key={blog.id}> <React.Fragment key={blog.id}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{/* Separator (except after last item) */}
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
{/* Duplicate content for seamless looping */} {displayBlogs.map((blog) => (
{displayBlogs.map((blog, index) => (
<React.Fragment key={`${blog.id}-dup`}> <React.Fragment key={`${blog.id}-dup`}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
</div> </div>
{/* Progress indicator */}
{autoScroll && totalWidth > 0 && ( {autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="h-1 bg-muted rounded-full overflow-hidden"> <div className="h-1 bg-foreground/10">
<div <div
className="h-full bg-primary transition-all duration-300" className="h-full bg-accent transition-all duration-300"
style={{ style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%` width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}} }}
@@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
<div <div
ref={updatesContainerRef} ref={updatesContainerRef}
className="max-h-[600px] overflow-y-auto px-6 pb-6" className="px-6 pb-6"
onScroll={handleScroll} onScroll={handleScroll}
> >
{updates.length === 0 && !updatesLoading ? ( {updates.length === 0 && !updatesLoading ? (
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
import { copyToClipboard } from '@/lib/social-utils';
interface CopyLinkButtonProps {
url: string;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
onCopy?: (success: boolean) => void;
}
export function CopyLinkButton({
url,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
onCopy,
}: CopyLinkButtonProps) {
const [isCopied, setIsCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleCopy = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const success = await copyToClipboard(url);
setIsCopied(success);
if (onCopy) {
onCopy(success);
}
// Reset copied state after 2 seconds
if (success) {
setTimeout(() => setIsCopied(false), 2000);
}
} catch (error) {
console.error('Failed to copy:', error);
if (onCopy) {
onCopy(false);
}
} finally {
setIsLoading(false);
}
};
const label = isCopied ? 'Copied!' : 'Copy Link';
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleCopy}
disabled={isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : isCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { type SharePlatform, getPlatformLabel } from '@/lib/social-utils';
import {
Facebook,
Twitter,
MessageCircle,
Send,
Mail,
Link,
Share2
} from 'lucide-react';
interface ShareButtonProps {
platform: SharePlatform;
onClick: () => void;
disabled?: boolean;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
}
export function ShareButton({
platform,
onClick,
disabled = false,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
}: ShareButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
if (disabled || isLoading) return;
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
const label = getPlatformLabel(platform);
// Get the appropriate icon component
const getIconComponent = () => {
switch (platform) {
case 'facebook':
return Facebook;
case 'twitter':
return Twitter;
case 'whatsapp':
return MessageCircle;
case 'telegram':
return Send;
case 'email':
return Mail;
case 'link':
return Link;
default:
return Share2;
}
};
const IconComponent = getIconComponent();
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleClick}
disabled={disabled || isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<IconComponent className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { ShareButton } from './ShareButton';
import { CopyLinkButton } from './CopyLinkButton';
import { type SharePlatform, type ShareData, getShareUrl } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
export type SocialShareVariant = 'default' | 'compact' | 'footer' | 'floating';
interface SocialShareButtonsProps extends ShareData {
articleId: string;
variant?: SocialShareVariant;
className?: string;
onShare?: (platform: SharePlatform) => void;
}
const PLATFORMS: SharePlatform[] = ['facebook', 'twitter', 'whatsapp', 'telegram', 'email', 'link'];
export function SocialShareButtons({
articleId,
title,
url,
excerpt,
image,
tags,
variant = 'default',
className = '',
onShare,
}: SocialShareButtonsProps) {
const [isTracking, setIsTracking] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const shareData: ShareData = {
title,
url,
excerpt,
image,
tags,
};
const handleShare = async (platform: SharePlatform) => {
try {
// Track the share event
setIsTracking(true);
await trackShare({
articleId,
platform,
userAgent: navigator.userAgent,
// Note: We don't send IP address from frontend for privacy reasons
// Backend should extract it from the request if needed
});
// Call the onShare callback if provided
if (onShare) {
onShare(platform);
}
// Open share URL in new window for social platforms
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error('Failed to track share:', error);
// Still open the share URL even if tracking fails
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsTracking(false);
}
};
const handleCopyLink = async (success: boolean) => {
if (success) {
await handleShare('link');
}
};
// Determine layout based on variant
const getLayoutClasses = () => {
switch (variant) {
case 'compact':
return 'flex items-center space-x-1';
case 'footer':
return 'flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-4';
case 'floating':
return 'fixed right-4 bottom-4 flex flex-col space-y-2 z-50';
default:
return 'flex flex-wrap items-center gap-2';
}
};
const getButtonSize = () => {
switch (variant) {
case 'compact':
return 'sm' as const;
case 'footer':
return 'default' as const;
case 'floating':
return 'default' as const;
default:
return 'default' as const;
}
};
const getButtonVariant = () => {
switch (variant) {
case 'compact':
return 'ghost' as const;
case 'footer':
return 'outline' as const;
case 'floating':
return 'default' as const;
default:
return 'outline' as const;
}
};
const showLabels = variant === 'footer';
// For compact variant, only show a single share button that expands on hover
if (variant === 'compact') {
return (
<div
className={`relative ${className}`}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<div className="flex items-center space-x-1">
<ShareButton
platform="link"
onClick={() => handleShare('link')}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
{isExpanded && (
<div className="flex items-center space-x-1 animate-in slide-in-from-right-2">
{PLATFORMS.filter(p => p !== 'link').map((platform) => (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className={`${getLayoutClasses()} ${className}`}>
{PLATFORMS.map((platform) => {
if (platform === 'link') {
return (
<CopyLinkButton
key={platform}
url={url}
size={getButtonSize()}
variant={getButtonVariant()}
showLabel={showLabels}
onCopy={handleCopyLink}
/>
);
}
return (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
showLabel={showLabels}
/>
);
})}
</div>
);
}
@@ -0,0 +1,4 @@
export { SocialShareButtons } from './SocialShareButtons';
export { ShareButton } from './ShareButton';
export { CopyLinkButton } from './CopyLinkButton';
export type { SocialShareVariant } from './SocialShareButtons';
@@ -0,0 +1,141 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchHeroArticle } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Calendar, User, ArrowRight, Eye } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="border-brutal bg-card p-0 animate-pulse">
<div className="h-80 bg-muted"></div>
<div className="p-8">
<div className="h-10 bg-muted rounded w-3/4 mb-4"></div>
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
<div className="h-20 bg-muted rounded mb-6"></div>
<div className="h-12 bg-muted rounded w-40"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
<Button variant="brutal" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (!article) {
return (
<div className="border-brutal bg-card p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
<span className="font-display text-4xl">?</span>
</div>
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
<p className="font-body text-muted-foreground mb-4">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
This space will showcase your most important story
</div>
</div>
);
}
return (
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
{article.featuredImage && (
<div className="relative overflow-hidden">
<div className="absolute top-0 left-0 z-10">
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
Прекршени Вести
</span>
</div>
<div className="relative h-72 md:h-96 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
</div>
</div>
)}
<div className="p-6 md:p-8">
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h2>
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
<span>{article.views} views</span>
</div>
</div>
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
{article.excerpt}
</p>
)}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{article.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-xs font-body uppercase tracking-wider border-2 border-foreground bg-background"
>
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
<Link to="/articles/$id" params={{ id: 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,137 @@
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/$id"
params={{ id: 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 && (
<a
href={`/${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}
</a>
)}
</div>
<div className="mt-3">
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt ?? undefined}
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>
);
}
+183 -29
View File
@@ -1,64 +1,218 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button'; 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() { export function Header() {
const { user, logout, isAuthenticated, hasRole } = useAuth(); 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 ( return (
<header className="border-b"> <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="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"> <Link to="/" className="group">
<Link to="/" className="hover:underline">Placebo.mk</Link> <h1 className="text-4xl md:text-5xl font-display tracking-tight">
</h1> <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>
<nav className="flex items-center gap-4"> <div className="hidden md:flex items-center gap-1">
<Link to="/" className="text-sm font-medium hover:underline"> {navLinks.map((link, index) => (
Home <Link
</Link> key={link.to}
<Link to="/articles" className="text-sm font-medium hover:underline"> to={link.to}
Articles 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"
</Link> style={{ animationDelay: `${index * 50}ms` }}
<Link to="/live-blogs" className="text-sm font-medium hover:underline"> >
Live {link.label}
</Link> </Link>
))}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
{(hasRole('admin') || hasRole('contributor')) && ( {(hasRole('admin') || hasRole('contributor')) && (
<> <>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary"> {adminLinks.map((link) => (
Admin <Link
</Link> key={link.to}
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary"> to={link.to}
+ New Live Blog 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> >
{link.label}
</Link>
))}
</> </>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
<span className="text-sm text-muted-foreground"> <span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username} {user?.username}
</span> </span>
<Button <Button
variant="outline" variant="brutal"
size="sm" size="sm"
onClick={logout} onClick={logout}
className="text-xs"
> >
Logout OUT
</Button> </Button>
</div> </div>
</> </>
) : ( ) : (
<Link to="/auth" className="text-sm font-medium hover:underline text-primary"> <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 / Register Login
</Link> </Link>
)} )}
</nav>
<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> </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> </div>
</header> </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,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs'; import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles'; import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs'; import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
// State for confirmation dialog and filters
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete'); const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{ const [itemToDelete, setItemToDelete] = useState<{
@@ -35,14 +33,11 @@ export function AdminDashboardComponent() {
const archiveLiveBlogMutation = useArchiveLiveBlog(); const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle(); const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog(); const publishLiveBlogMutation = usePublishLiveBlog();
const updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => { const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title }); setItemToDelete({ type, id, title });
setDialogType('delete'); setDialogType('delete');
@@ -81,7 +76,7 @@ export function AdminDashboardComponent() {
} else { } else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
} }
} else { // archive } else {
if (itemToDelete.type === 'article') { if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id); await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else { } else {
@@ -102,18 +97,32 @@ export function AdminDashboardComponent() {
setItemToDelete(null); setItemToDelete(null);
}; };
const handleSetHero = async (articleId: string, isHero: boolean) => {
setIsProcessing(true);
try {
await updateArticleMutation.mutateAsync({
id: articleId,
dto: { isHero }
});
} catch (error) {
console.error('Failed to update hero status:', error);
} finally {
setIsProcessing(false);
}
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'published': case 'published':
case 'live': case 'live':
return 'bg-green-100 text-green-800 border-green-200'; return 'bg-green-500 text-white border-2 border-foreground';
case 'draft': case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200'; return 'bg-yellow-400 text-black border-2 border-foreground';
case 'archived': case 'archived':
case 'ended': case 'ended':
return 'bg-gray-100 text-gray-800 border-gray-200'; return 'bg-gray-400 text-white border-2 border-foreground';
default: default:
return 'bg-blue-100 text-blue-800 border-blue-200'; return 'bg-blue-500 text-white border-2 border-foreground';
} }
}; };
@@ -129,221 +138,246 @@ export function AdminDashboardComponent() {
}; };
return ( return (
<div className="py-8 space-y-8"> <div className="space-y-8">
<div className="flex justify-between items-center"> <div className="border-b-4 border-foreground pb-6">
<div> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1> <div>
<p className="text-muted-foreground"> <h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
Управување со сите написи и live блогови <p className="font-body text-muted-foreground mt-1">
</p> Управување со сите написи и 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>
<div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild>
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
</div> </div>
{!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>
)}
{!showArchived && <PushNotificationManager />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Live Blogs Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredLiveBlogs.length || 0} {liveBlogs.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите live блогови со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingLiveBlogs ? ( {loadingLiveBlogs ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredLiveBlogs.length === 0 ? ( ) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'} {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p> </p>
{!showArchived && ( </div>
<Button asChild variant="outline" className="mt-4"> ) : (
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link> liveBlogs.map((blog) => (
</Button> <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> <div className="flex-1 min-w-0">
) : ( <div className="flex items-center gap-2 mb-2 flex-wrap">
<div className="space-y-4"> <Link
{filteredLiveBlogs.map((blog) => ( to="/admin/live-blogs/$slug"
<div params={{ slug: blog.slug }}
key={blog.id} className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
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}
> >
Избриши {blog.title}
</Button> </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> </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>
</div> ))
)} )}
</CardContent> </div>
</Card> </div>
{/* Articles Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани написи' : 'Написи'}
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredArticles.length || 0} {articles.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите написи со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingArticles ? ( {loadingArticles ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredArticles.length === 0 ? ( ) : articles.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'} {showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p> </p>
{!showArchived && ( </div>
<Button asChild variant="outline" className="mt-4"> ) : (
<Link to="/">Креирај нов напис</Link> articles.map((article) => (
</Button> <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> <div className="flex-1 min-w-0">
) : ( <div className="flex items-center gap-2 mb-2 flex-wrap">
<div className="space-y-4"> <Link
{filteredArticles.map((article) => ( to="/articles/$id"
<div params={{ id: article.id }}
key={article.id} className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" >
> {article.title}
<div className="flex items-start justify-between"> </Link>
<div className="flex-1"> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
<div className="flex items-center gap-2 mb-1"> {getStatusText(article.status)}
<Link </span>
to="/articles/$id" {article.isHero && (
params={{ id: article.id }} <span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
className="font-medium hover:text-primary hover:underline" ★ Hero
>
{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 })}
</span> </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>
<div className="flex gap-2 ml-4"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<Button asChild size="sm" variant="outline"> <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> <Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank"> <Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
Преглед </Button>
</Link> </div>
<div className="flex gap-1">
<Button
size="sm"
variant={article.isHero ? 'brutal' : 'brutalOutline'}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set Hero'}
</Button> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('article', article.id)} onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -352,7 +386,7 @@ export function AdminDashboardComponent() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('article', article.id, article.title)} onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -370,81 +404,147 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))} </div>
</div> ))
)} )}
</CardContent> </div>
</Card> </div>
</div> </div>
{/* Quick Stats - Only show when not viewing archived items */} {!showArchived && (
{!showArchived && ( <div className="border-brutal bg-card p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
<Card> <div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<CardContent className="pt-6"> <div className="border-brutal-sm bg-background p-4">
<div className="text-2xl font-bold"> <div className="text-3xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0} {articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
</CardContent> </div>
</Card> <div className="border-brutal-sm bg-background p-4">
<Card> <div className="text-3xl font-display">
<CardContent className="pt-6"> {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
<div className="text-2xl font-bold"> </div>
{articles.filter(a => a.status === 'published').length || 0} <p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
</div> </div>
<p className="text-sm text-muted-foreground">Објавени написи</p> <div className="border-brutal-sm bg-background p-4">
</CardContent> <div className="text-3xl font-display">
</Card> {articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
<Card> </div>
<CardContent className="pt-6"> <p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
<div className="text-2xl font-bold"> </div>
{liveBlogs.filter(b => b.isPinned).length || 0} <div className="border-brutal-sm bg-background p-4">
</div> <div className="text-3xl font-display">
<p className="text-sm text-muted-foreground">Закачени live блогови</p> {articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</CardContent> </div>
</Card> <p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
<Card> </div>
<CardContent className="pt-6"> <div className="border-brutal-sm bg-background p-4">
<div className="text-2xl font-bold"> <div className="text-3xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + {articles.reduce((sum, a) =>
(articles.reduce((sum, a) => sum + a.views, 0) || 0)} sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
</div> (a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
<p className="text-sm text-muted-foreground">Вкупно прегледи</p> </div>
</CardContent> <p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
</Card> </div>
</div> </div>
)}
{/* Confirmation Dialog */} <div>
{showConfirmDialog && itemToDelete && ( <h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="space-y-3">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> {articles
<h3 className="text-lg font-semibold mb-2"> .filter(a => a.status === 'published')
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'} .sort((a, b) => {
</h3> const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
<p className="text-muted-foreground mb-4"> (a.whatsappShares || 0) + (a.telegramShares || 0);
{dialogType === 'delete' const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?` (b.whatsappShares || 0) + (b.telegramShares || 0);
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`} return bShares - aShares;
</p> })
<div className="flex justify-end gap-2"> .slice(0, 5)
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}> .map((article) => {
Откажи const totalShares = (article.facebookShares || 0) +
</Button> (article.twitterShares || 0) +
<Button (article.whatsappShares || 0) +
variant={dialogType === 'delete' ? 'destructive' : 'default'} (article.telegramShares || 0);
onClick={handleConfirmAction} const shareRate = article.views > 0
disabled={isProcessing} ? ((totalShares / article.views) * 100).toFixed(2)
> : '0.00';
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...') return (
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')} <div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
</Button> <div className="flex items-start justify-between gap-3">
</div> <div className="flex-1 min-w-0">
</div> <Link
</div> to="/articles/$id"
)} params={{ id: article.id }}
</div> 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,92 @@
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/$id"
params={{ id: 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 ?? undefined}
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="Уметност, култура и забава"
/>
)
}
@@ -7,6 +7,7 @@ import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils' import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
import { CommentSection } from '@/components/features/comments/CommentSection' import { CommentSection } from '@/components/features/comments/CommentSection'
import { ReactionButtons } from '@/components/features/comments/ReactionButtons' import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArticleDetailComponent({ id }: { id: string }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -41,7 +42,7 @@ export function ArticleDetailComponent({ id }: { id: string }) {
return ( return (
<article className="max-w-3xl mx-auto"> <article className="max-w-3xl mx-auto">
<Link <Link
to="/articles" to="/archive"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8" className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
> >
<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"> <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">
@@ -69,9 +70,29 @@ export function ArticleDetailComponent({ id }: { id: string }) {
<span>By {data.author.name}</span> <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 ?? undefined}
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 ${ <div className={`relative mb-4 ${
data.imagePosition === 'top' data.imagePosition === 'top'
? 'w-full mb-8' ? 'w-full mb-8'
@@ -165,10 +186,36 @@ export function ArticleDetailComponent({ id }: { id: string }) {
> >
{data.content} {data.content}
</ReactMarkdown> </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 ?? undefined}
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>
</div> </div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3> <h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -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}`}
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,188 @@
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/$id" params={{ id: 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/$id" params={{ id: 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 ?? undefined}
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/$id"
params={{ id: 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 ?? undefined}
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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data?.data.map((liveBlog) => ( {data?.data.map((liveBlog) => (
<Link <Link
key={liveBlog.id} key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}`} to="/live-blogs/$slug"
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" 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 items-start justify-between mb-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> <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,
};
}
+11 -11
View File
@@ -1,25 +1,25 @@
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-11 px-6 py-2",
sm: "h-9 rounded-md px-3", sm: "h-9 px-4 py-1",
lg: "h-11 rounded-md px-8", lg: "h-14 px-8 text-base",
icon: "h-10 w-10", icon: "h-12 w-12",
}, },
}, },
defaultVariants: { defaultVariants: {
+5 -5
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "border-brutal bg-card text-card-foreground",
className className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-display leading-none tracking-tight",
className className
)} )}
{...props} {...props}
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm font-body text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@@ -69,7 +69,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
+101
View File
@@ -0,0 +1,101 @@
import { useState, useCallback } from 'react';
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
interface UseSocialShareOptions {
onSuccess?: (platform: SharePlatform) => void;
onError?: (platform: SharePlatform, error: Error) => void;
onCopySuccess?: () => void;
onCopyError?: (error: Error) => void;
}
interface UseSocialShareReturn {
isSharing: boolean;
isCopying: boolean;
lastSharedPlatform: SharePlatform | null;
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
copyLink: (url: string) => Promise<boolean>;
}
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
const [isSharing, setIsSharing] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
const share = useCallback(async (
platform: SharePlatform,
data: ShareData & { articleId: string }
) => {
setIsSharing(true);
setLastSharedPlatform(platform);
try {
// Track the share event
await trackShare({
articleId: data.articleId,
platform,
userAgent: navigator.userAgent,
});
// Call success callback
if (options.onSuccess) {
options.onSuccess(platform);
}
// Open share URL for social platforms (not for 'link')
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error(`Failed to share on ${platform}:`, error);
// Call error callback
if (options.onError) {
options.onError(platform, error as Error);
}
// Still open the share URL even if tracking fails (for social platforms)
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsSharing(false);
}
}, [options]);
const copyLink = useCallback(async (url: string): Promise<boolean> => {
setIsCopying(true);
try {
const success = await copyToClipboard(url);
if (success && options.onCopySuccess) {
options.onCopySuccess();
} else if (!success && options.onCopyError) {
options.onCopyError(new Error('Failed to copy to clipboard'));
}
return success;
} catch (error) {
console.error('Failed to copy link:', error);
if (options.onCopyError) {
options.onCopyError(error as Error);
}
return false;
} finally {
setIsCopying(false);
}
}, [options]);
return {
isSharing,
isCopying,
lastSharedPlatform,
share,
copyLink,
};
}
+41 -41
View File
@@ -1,51 +1,51 @@
/* @tailwind base; */ @tailwind base;
/* @tailwind components; */ @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 35 20% 95%;
--card: 222.2 84% 4.9%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 35 20% 95%;
--popover: 222.2 84% 4.9%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 35 20% 95%;
--primary: 210 40% 98%; --primary: 35 20% 95%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 35 20% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 70 100% 50%;
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 8%;
--destructive: 0 62.8% 30.6%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%; --border: 35 20% 95%;
--input: 217.2 32.6% 17.5%; --input: 35 20% 95%;
--ring: 212.7 26.8% 83.9%; --ring: 70 100% 50%;
} }
} }
+96
View File
@@ -0,0 +1,96 @@
import { type SharePlatform } from './social-utils';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface TrackShareParams {
articleId: string;
platform: SharePlatform;
userAgent?: string;
ipAddress?: string;
}
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
console.warn('Failed to track share event:', response.statusText);
return false;
}
return true;
} catch (error) {
console.error('Error tracking share event:', error);
return false;
}
};
export const getShareStats = async (articleId?: string) => {
try {
const url = articleId
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
: `${API_BASE_URL}/api/v1/analytics/shares`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for admin auth
});
if (!response.ok) {
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching share stats:', error);
throw error;
}
};
export const getTopSharedArticles = async (limit: number = 10) => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching top shared articles:', error);
throw error;
}
};
export const getTotalShareStats = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching total share stats:', error);
throw error;
}
};
+83 -5
View File
@@ -73,6 +73,18 @@ export interface Article {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isHero?: boolean;
isPinned?: boolean;
facebookShares?: number;
twitterShares?: number;
whatsappShares?: number;
telegramShares?: number;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
twitterTitle?: string;
twitterDescription?: string;
twitterImage?: string;
} }
export interface ArticlesResponse { export interface ArticlesResponse {
@@ -126,6 +138,7 @@ export interface UpdateArticleDto {
videoUrl?: string; videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string; videoCaption?: string;
isHero?: boolean;
} }
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
@@ -360,8 +373,12 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
return response.json(); return response.json();
} }
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> { export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
const endpoint = isUuid
? `${API_BASE_URL}/live-blogs/${slugOrId}`
: `${API_BASE_URL}/live-blogs/slug/${slugOrId}`;
const response = await authFetch(endpoint);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -398,6 +415,23 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
return response.json(); return response.json();
} }
export async function fetchHeroArticle(): Promise<Article | null> {
const response = await authFetch(`${API_BASE_URL}/articles/hero`);
if (!response.ok) {
throw new Error('Failed to fetch hero article');
}
const article = await response.json();
return article || null;
}
export async function fetchLatestArticles(limit = 12): Promise<ArticlesResponse> {
const response = await authFetch(`${API_BASE_URL}/articles?status=published&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch latest articles');
}
return response.json();
}
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> { export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`); const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
if (!response.ok) { if (!response.ok) {
@@ -599,6 +633,7 @@ export interface FindCommentsParams {
articleId?: string; articleId?: string;
liveBlogId?: string; liveBlogId?: string;
parentCommentId?: string; parentCommentId?: string;
parentId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -646,15 +681,20 @@ interface BackendComment {
// Recursive function to map comment and its replies // Recursive function to map comment and its replies
function mapBackendComment(comment: BackendComment): Comment { function mapBackendComment(comment: BackendComment): Comment {
const mappedComment: Comment = { const mappedComment: Comment = {
...comment, id: comment.id,
content: comment.content,
articleId: comment.articleId,
liveBlogId: comment.liveBlogId,
parentCommentId: comment.parentId, parentCommentId: comment.parentId,
// Ensure reactions object exists userId: comment.userId,
isVisible: comment.isVisible,
reactions: { reactions: {
likes: comment.likeCount || 0, likes: comment.likeCount || 0,
dislikes: comment.dislikeCount || 0, dislikes: comment.dislikeCount || 0,
}, },
// Recursively map replies if they exist
replies: comment.replies?.map(mapBackendComment) || [], replies: comment.replies?.map(mapBackendComment) || [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}; };
return mappedComment; return mappedComment;
} }
@@ -787,3 +827,41 @@ export async function getUserReaction(
} }
return response.json(); return response.json();
} }
// Push Notification Admin Types
export interface PushStats {
totalSubscribers: number;
}
export interface SendNotificationResult {
sent: number;
failed: number;
}
export interface SendNotificationDto {
title: string;
body: string;
url?: string;
}
// Push Notification Admin API Functions
export async function fetchPushStats(): Promise<PushStats> {
const response = await authFetch(`${API_BASE_URL}/push/stats`);
if (!response.ok) {
throw new Error('Failed to fetch push stats');
}
return response.json();
}
export async function sendPushNotification(
dto: SendNotificationDto,
): Promise<SendNotificationResult> {
const response = await authFetch(`${API_BASE_URL}/push/send`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to send push notification');
}
return response.json();
}
+124
View File
@@ -0,0 +1,124 @@
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
export interface ShareData {
title: string;
url: string;
excerpt?: string;
image?: string;
tags?: string[];
}
export const getShareUrl = (
platform: Exclude<SharePlatform, 'link'>,
data: ShareData
): string => {
const { title, url, excerpt } = data;
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
switch (platform) {
case 'facebook':
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
case 'twitter':
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
case 'whatsapp':
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
case 'telegram':
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
case 'email':
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
default:
return url;
}
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
return false;
}
};
export const getPlatformIcon = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'MessageCircle';
case 'telegram':
return 'Send';
case 'email':
return 'Mail';
case 'link':
return 'Link';
default:
return 'Share2';
}
};
export const getPlatformLabel = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'WhatsApp';
case 'telegram':
return 'Telegram';
case 'email':
return 'Email';
case 'link':
return 'Copy Link';
default:
return 'Share';
}
};
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
const { title, excerpt, image, url } = data;
// Default values if not provided
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
const ogImage = image || '/placeholder-image.jpg';
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
return {
ogTitle,
ogDescription,
ogImage,
ogUrl,
ogType: 'article' as const,
twitterCard: 'summary_large_image' as const,
twitterTitle: ogTitle,
twitterDescription: ogDescription,
twitterImage: ogImage,
};
};
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Theme utilities for dark/light mode
*/
export type Theme = 'light' | 'dark';
/**
* Get the current theme from localStorage or system preference
*/
export function getInitialTheme(): Theme {
// Check localStorage first
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// Default to light
return 'light';
}
/**
* Apply theme to document
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Save to localStorage
localStorage.setItem('theme', theme);
}
/**
* Initialize theme on page load
*/
export function initializeTheme(): void {
const theme = getInitialTheme();
applyTheme(theme);
}
/**
* Toggle between light and dark themes
*/
export function toggleTheme(): Theme {
const currentTheme = getInitialTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
return newTheme;
}
/**
* Listen for system theme changes
*/
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a preference in localStorage
if (!localStorage.getItem('theme')) {
const theme = e.matches ? 'dark' : 'light';
callback(theme);
applyTheme(theme);
}
};
mediaQuery.addEventListener('change', handleChange);
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange);
}
+4
View File
@@ -4,6 +4,10 @@ import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme'
// Initialize theme before rendering
initializeTheme();
const queryClient = new QueryClient() const queryClient = new QueryClient()
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function usePushStats() {
return useQuery({
queryKey: ['push', 'stats'],
queryFn: api.fetchPushStats,
});
}
export function useSendPushNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.sendPushNotification,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
},
});
}
+190 -140
View File
@@ -1,6 +1,6 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { ArticleTicker } from './components/ArticleTicker' import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent' import { ArchiveComponent } from './components/routes/ArchiveComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent' import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
@@ -8,17 +8,23 @@ import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminCompone
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { AuthPage } from './components/routes/AuthPage' import { AuthPage } from './components/routes/AuthPage'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { SportComponent } from './components/routes/SportComponent'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
import { ProtectedRoute } from './components/auth/ProtectedRoute' import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { Header } from './components/layout/Header' import { Header } from './components/layout/Header'
import { HeroArticle } from './components/home/HeroArticle'
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
import { Button } from './components/ui/button'
import { Zap, Search, Users } from 'lucide-react'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
head: () => ({ head: () => ({
meta: [ meta: [
{ {
title: 'Placebo.mk - Sarcastic News from Macedonia', title: 'Placebo.mk - Сатирични вести од Македонија',
description: 'Latest news and articles from Macedonia with a sarcastic twist', description: 'Latest news and articles from Macedonia with a sarcastic twist',
}, },
], ],
@@ -26,14 +32,48 @@ const rootRoute = createRootRoute({
component: () => ( component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col"> <div className="min-h-screen bg-background text-foreground flex flex-col">
<Header /> <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 /> <Outlet />
</main> </main>
<footer className="border-t mt-12"> <footer className="border-t-4 border-foreground bg-foreground text-background">
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground"> <div className="container mx-auto max-w-7xl px-4 py-12">
© 2025 Placebo.mk. Sarcastic news from Macedonia. <div className="grid md:grid-cols-3 gap-8">
<div>
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
<p className="font-body text-sm text-background/70">
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
</p>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Категории</h4>
<ul className="space-y-2 font-body text-sm">
<li><Link to="/sport" className="hover:text-accent transition-colors">Спорт</Link></li>
<li><Link to="/art" className="hover:text-accent transition-colors">Уметност</Link></li>
<li><Link to="/science" className="hover:text-accent transition-colors">Наука</Link></li>
<li><Link to="/archive" className="hover:text-accent transition-colors">Архива</Link></li>
</ul>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Следете не</h4>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Zap className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Search className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Users className="w-5 h-5" />
</a>
</div>
</div>
</div>
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -44,141 +84,72 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: () => ( component: () => (
<div className="space-y-6"> <div>
{/* Article Ticker at the top */}
<ArticleTicker />
{/* Live Blog Ticker below article ticker */}
<LiveBlogTicker className="mt-4" />
<div className="py-8 md:py-12"> <div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div className="lg:col-span-2">
{/* Main content - 3 columns */} <HeroArticle />
<div className="lg:col-span-3 space-y-8"> </div>
{/* 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>
{/* Features grid */} <div className="lg:col-span-1">
<div className="grid md:grid-cols-3 gap-6"> <PinnedLiveBlogsSidebar />
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group"> </div>
<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"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
<p className="text-muted-foreground text-sm">
Freshly brewed sarcasm on current events, politics, and everything in between.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group"> <LatestArticlesGrid />
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
<p className="text-muted-foreground text-sm">
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group"> <div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
<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"> <div className="text-center">
<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"> <h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
<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" /> </p>
</svg> <div className="flex flex-col sm:flex-row gap-4 justify-center">
</div> <Link to="/archive">
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3> <Button variant="brutalAccent" className="gap-2">
<p className="text-muted-foreground text-sm"> Прелистај архива
Real-time updates on breaking news with our live blogging system. No delays, just facts. </Button>
</p> </Link>
</div> <Link to="/live-blogs">
</div> <Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
<Zap className="w-4 h-4" />
{/* Call to action */} Live Блогови
<div className="rounded-xl border bg-card p-8 text-center"> </Button>
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2> </Link>
<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> </div>
</div>
</div>
{/* Sidebar - 1 column */} <div className="grid md:grid-cols-3 gap-6 mt-16">
<div className="lg:col-span-1"> <div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
<PinnedLiveBlogSidebar /> <div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<Zap className="w-6 h-6" />
{/* Additional sidebar content */}
<div 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> </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> </div>
</div> </div>
@@ -186,10 +157,28 @@ const indexRoute = createRoute({
), ),
}) })
const articlesRoute = createRoute({ const archiveRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/articles', path: '/archive',
component: ArticlesComponent, component: ArchiveComponent,
})
const sportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sport',
component: SportComponent,
})
const artRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/art',
component: ArtComponent,
})
const scienceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/science',
component: ScienceComponent,
}) })
const articleDetailRoute = createRoute({ const articleDetailRoute = createRoute({
@@ -199,6 +188,64 @@ const articleDetailRoute = createRoute({
const { id } = articleDetailRoute.useParams() const { id } = articleDetailRoute.useParams()
return <ArticleDetailComponent id={id} /> return <ArticleDetailComponent id={id} />
}, },
loader: async ({ params }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
if (!response.ok) {
return { article: null }
}
const data = await response.json()
return { article: data.data }
},
head: ({ loaderData }) => {
const article = loaderData?.article
if (!article) {
return {
meta: [
{ title: 'Article Not Found - Placebo.mk' },
{ name: 'description', content: 'Article not found' },
],
}
}
const ogTitle = article.ogTitle || article.title
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk'
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg'
const twitterTitle = article.twitterTitle || article.title
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk'
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg'
const metaTags = [
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
{ property: 'og:title', content: ogTitle },
{ property: 'og:description', content: ogDescription },
{ property: 'og:image', content: ogImage },
{ property: 'og:url', content: typeof window !== 'undefined' ? window.location.href : '' },
{ property: 'og:type', content: 'article' },
{ property: 'og:locale', content: 'mk_MK' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
]
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name })
}
if (article.tags && article.tags.length > 0) {
article.tags.forEach((tag: string) => {
metaTags.push({ property: 'article:tag', content: tag })
})
}
return {
meta: metaTags,
}
},
}) })
const liveBlogsRoute = createRoute({ const liveBlogsRoute = createRoute({
@@ -257,7 +304,10 @@ const adminDashboardRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
articlesRoute, archiveRoute,
sportRoute,
artRoute,
scienceRoute,
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,
+323 -61
View File
@@ -1,72 +1,167 @@
@import "tailwindcss"; @import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@theme { @theme {
--color-primary: oklch(0.647 0.22 0.23); --color-primary: oklch(0.08 0 0);
--color-primary-foreground: oklch(0.985 0.002 0); --color-primary-foreground: oklch(0.98 0 0);
--color-secondary: oklch(0.97 0.002 0); --color-secondary: oklch(0.93 0.02 50);
--color-secondary-foreground: oklch(0.205 0.02 266.5); --color-secondary-foreground: oklch(0.08 0 0);
--color-muted: oklch(0.97 0 0); --color-muted: oklch(0.91 0.01 50);
--color-muted-foreground: oklch(0.556 0 0); --color-muted-foreground: oklch(0.45 0.01 50);
--color-accent: oklch(0.97 0 0); --color-accent: oklch(0.93 0.8 100);
--color-accent-foreground: oklch(0.205 0.02 266.5); --color-accent-foreground: oklch(0.08 0 0);
--color-destructive: oklch(0.55 0.22 25); --color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0.002 0); --color-destructive-foreground: oklch(0.98 0 0);
--color-border: oklch(0.9 0 0); --color-border: oklch(0.08 0 0);
--color-input: oklch(0.9 0 0); --color-input: oklch(0.08 0 0);
--color-ring: oklch(0.647 0.22 0.23); --color-ring: oklch(0.93 0.8 100);
--radius: 0.5rem; --radius: 0px;
--font-sans: "Inter", sans-serif; --font-display: "Bebas Neue", sans-serif;
--font-body: "IBM Plex Mono", monospace;
} }
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
--shadow-brutal: 4px 4px 0px 0px oklch(0.08 0 0);
--shadow-brutal-sm: 2px 2px 0px 0px oklch(0.08 0 0);
--shadow-brutal-lg: 6px 6px 0px 0px oklch(0.08 0 0);
--shadow-brutal-accent: 4px 4px 0px 0px oklch(0.93 0.8 100);
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 220 17% 22%;
--foreground: 210 40% 98%; --foreground: 220 17% 92%;
--card: 222.2 84% 4.9%; --card: 220 17% 27%;
--card-foreground: 210 40% 98%; --card-foreground: 220 17% 92%;
--popover: 222.2 84% 4.9%; --popover: 220 17% 25%;
--popover-foreground: 210 40% 98%; --popover-foreground: 220 17% 96%;
--primary: 210 40% 98%; --primary: 220 17% 92%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 220 17% 22%;
--secondary: 217.2 32.6% 17.5%; --secondary: 220 17% 32%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 220 17% 88%;
--muted: 217.2 32.6% 17.5%; --muted: 220 17% 36%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 220 17% 70%;
--accent: 217.2 32.6% 17.5%; --accent: 193 48% 67%;
--accent-foreground: 210 40% 98%; --accent-foreground: 220 17% 22%;
--destructive: 0 62.8% 30.6%; --destructive: 354 46% 56%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 220 17% 96%;
--border: 217.2 32.6% 17.5%; --border: 220 17% 36%;
--input: 217.2 32.6% 17.5%; --input: 220 17% 36%;
--ring: 212.7 26.8% 83.9%; --ring: 193 48% 67%;
--shadow-brutal: 4px 4px 0px 0px hsl(220 17% 36%);
--shadow-brutal-sm: 2px 2px 0px 0px hsl(220 17% 36%);
--shadow-brutal-lg: 6px 6px 0px 0px hsl(220 17% 36%);
--shadow-brutal-accent: 4px 4px 0px 0px hsl(193 48% 67%);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-behavior: smooth;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body);
position: relative;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
::selection {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-border {
0%, 100% {
border-color: hsl(var(--border));
}
50% {
border-color: hsl(var(--accent));
}
}
@keyframes marquee { @keyframes marquee {
0% { 0% {
transform: translateX(0); transform: translateX(0);
@@ -76,18 +171,185 @@
} }
} }
@keyframes glitch {
0% {
clip-path: inset(40% 0 61% 0);
transform: translate(-2px, 2px);
}
20% {
clip-path: inset(92% 0 1% 0);
transform: translate(1px, -1px);
}
40% {
clip-path: inset(43% 0 1% 0);
transform: translate(-1px, 2px);
}
60% {
clip-path: inset(25% 0 58% 0);
transform: translate(2px, 1px);
}
80% {
clip-path: inset(54% 0 7% 0);
transform: translate(-2px, -1px);
}
100% {
clip-path: inset(58% 0 43% 0);
transform: translate(2px, 2px);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-right {
animation: slideInRight 0.5s ease-out forwards;
opacity: 0;
}
.animate-scale-in {
animation: scaleIn 0.4s ease-out forwards;
opacity: 0;
}
.animate-marquee {
display: flex;
animation: marquee 25s linear infinite;
width: max-content;
}
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.stagger-9 { animation-delay: 0.45s; }
.stagger-10 { animation-delay: 0.5s; }
.stagger-11 { animation-delay: 0.55s; }
.stagger-12 { animation-delay: 0.6s; }
.font-display {
font-family: var(--font-display);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.font-body {
font-family: var(--font-body);
}
.border-brutal {
border: 3px solid hsl(var(--border));
}
.border-brutal-sm {
border: 2px solid hsl(var(--border));
}
.border-brutal-accent {
border: 3px solid hsl(var(--accent));
border-left-width: 6px;
}
.shadow-brutal {
box-shadow: var(--shadow-brutal);
}
.shadow-brutal-sm {
box-shadow: var(--shadow-brutal-sm);
}
.shadow-brutal-lg {
box-shadow: var(--shadow-brutal-lg);
}
.shadow-brutal-accent {
box-shadow: var(--shadow-brutal-accent);
}
.hover\:shadow-brutal:hover {
box-shadow: var(--shadow-brutal);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.hover\:shadow-brutal-accent:hover {
box-shadow: var(--shadow-brutal-accent);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.bg-noise {
position: relative;
}
.bg-noise::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
}
.text-outline {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.text-outline-hover:hover {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@layer base { @layer base {
* { * {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.animate-marquee { h1, h2, h3, h4, h5, h6 {
display: flex; font-family: var(--font-display);
animation: marquee 30s linear infinite; letter-spacing: 0.02em;
width: max-content; text-transform: uppercase;
} }
} }
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: hsl(var(--secondary));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border: 2px solid hsl(var(--secondary));
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent));
}
+108 -49
View File
@@ -42,7 +42,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -54,6 +55,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -5249,10 +5251,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"backend/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"backend/node_modules/ini": { "backend/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -6520,13 +6518,6 @@
"node": "*" "node": "*"
} }
}, },
"backend/node_modules/minimist": {
"version": "1.2.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"backend/node_modules/minipass": { "backend/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -7610,10 +7601,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"backend/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"backend/node_modules/schema-utils": { "backend/node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
"dev": true, "dev": true,
@@ -14119,16 +14106,6 @@
"version": "2.0.6", "version": "2.0.6",
"license": "MIT" "license": "MIT"
}, },
"cms/cms/node_modules/asn1.js": {
"version": "5.4.1",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"cms/cms/node_modules/ast-types": { "cms/cms/node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"license": "MIT", "license": "MIT",
@@ -14254,10 +14231,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"cms/cms/node_modules/bn.js": {
"version": "4.12.2",
"license": "MIT"
},
"cms/cms/node_modules/boolbase": { "cms/cms/node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@@ -17240,10 +17213,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"cms/cms/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"cms/cms/node_modules/ini": { "cms/cms/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -18716,10 +18685,6 @@
"webpack": "^5.0.0" "webpack": "^5.0.0"
} }
}, },
"cms/cms/node_modules/minimalistic-assert": {
"version": "1.0.1",
"license": "ISC"
},
"cms/cms/node_modules/minimalistic-crypto-utils": { "cms/cms/node_modules/minimalistic-crypto-utils": {
"version": "1.0.1", "version": "1.0.1",
"license": "MIT" "license": "MIT"
@@ -18737,13 +18702,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"cms/cms/node_modules/minimist": {
"version": "1.2.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"cms/cms/node_modules/minipass": { "cms/cms/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -21241,10 +21199,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"cms/cms/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"cms/cms/node_modules/sanitize-html": { "cms/cms/node_modules/sanitize-html": {
"version": "2.13.0", "version": "2.13.0",
"license": "MIT", "license": "MIT",
@@ -27112,12 +27066,31 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -27142,6 +27115,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/backend": { "node_modules/backend": {
"resolved": "backend", "resolved": "backend",
"link": true "link": true
@@ -27170,6 +27155,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -27563,6 +27554,28 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -27583,6 +27596,12 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -28632,6 +28651,21 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -29024,6 +29058,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -29377,6 +29417,25 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+13
View File
@@ -0,0 +1,13 @@
# Frontend Environment Configuration
# Copy to .env and adjust for your setup
# ===== DOCKER MODE (frontend in container) =====
# Use when frontend runs in Docker container
# VITE_API_URL=http://backend:3000/api/v1
# ===== LOCAL MODE (frontend runs locally) =====
# Use when frontend runs locally with npm run dev
VITE_API_URL=http://localhost:3000/api/v1
# ===== COMMON =====
VITE_CMS_URL=http://localhost:1337
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+45
View File
@@ -0,0 +1,45 @@
# PWA Dockerfile for Placebo.mk Progressive Web App
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Build arguments for environment variables
ARG VITE_API_URL
ARG VITE_CMS_URL
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_CMS_URL=$VITE_CMS_URL
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install --legacy-peer-deps
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+28
View File
@@ -0,0 +1,28 @@
# Frontend Development Dockerfile for Placebo.mk TanStack React App
FROM node:20-alpine
WORKDIR /app
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
# Clear npm cache and install dependencies
RUN npm cache clean --force && \
npm install
# Copy source code
COPY . .
# Fix permissions - use node user that exists in base image
RUN chown -R node:node /app
# Switch to non-root user that exists in base image
USER node
# Expose port
EXPOSE 5173
# Start development server with host flag
CMD ["npm", "run", "dev", "--", "--host"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"tailwind": {
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+101
View File
@@ -0,0 +1,101 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didn’t register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-d975e299'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.ek3n41mv3u8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 86400
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
}));
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'dev-dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="mk">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icons/favicon-32.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Сатирични вести од Македонија - Placebo.mk" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Placebo" />
<title>Placebo.mk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_disable "MSIE [1-6]\.";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Frontend server
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers for frontend
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy (for local dev only - in prod use public URL)
# location /api/ {
# proxy_pass http://backend:3000/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
# proxy_read_timeout 300;
# proxy_connect_timeout 300;
# }
# CMS proxy (for local dev only - in prod use public URL)
# location /cms/ {
# proxy_pass http://cms:1337/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
# proxy_read_timeout 300;
# proxy_connect_timeout 300;
# }
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
+10678
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"dev:docker": "vite",
"dev:local": "cp -f .env.local .env && vite",
"dev:reset-env": "cp -f .env.docker .env"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"lucide-react": "^0.562.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"workbox-cacheable-response": "^7.4.0",
"workbox-expiration": "^7.4.0",
"workbox-precaching": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-strategies": "^7.4.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
<rect width="180" height="180" fill="#000"/>
<text x="90" y="125" font-family="system-ui, sans-serif" font-size="110" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 277 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<rect width="72" height="72" fill="#000"/>
<text x="36" y="52" font-family="system-ui, sans-serif" font-size="42" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#000"/>
<text x="16" y="24" font-family="system-ui, sans-serif" font-size="20" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#000"/>
<text x="96" y="120" font-family="system-ui, sans-serif" font-size="96" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 276 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000"/>
<text x="256" y="340" font-family="system-ui, sans-serif" font-size="320" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4327
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+50
View File
@@ -0,0 +1,50 @@
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({
queryKey: ['ticker-articles'],
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
})
const articles = data?.data.slice(0, 10) || []
if (articles.length === 0) return null
return (
<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/$id"
params={{ id: 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/$id"
params={{ id: 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>
</div>
)
}
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { useCreateLiveBlog } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNavigate } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
interface CreateLiveBlogProps {
onSuccess?: () => void;
className?: string;
}
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
slug: '',
description: '',
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const createMutation = useCreateLiveBlog();
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-')
.trim();
};
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const title = e.target.value;
setFormData({
...formData,
title,
slug: formData.slug || generateSlug(title),
});
if (errors.title) setErrors({ ...errors, title: '' });
};
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, slug: e.target.value });
if (errors.slug) setErrors({ ...errors, slug: '' });
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.slug.trim()) {
newErrors.slug = 'Slug is required';
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const result = await createMutation.mutateAsync(formData);
// Reset form
setFormData({
title: '',
slug: '',
description: '',
status: 'draft',
});
setErrors({});
// Navigate to the new live blog
navigate({ to: `/admin/live-blogs/${result.slug}` });
if (onSuccess) onSuccess();
} catch (error) {
console.error('Failed to create live blog:', error);
setErrors({ submit: 'Failed to create live blog. Please try again.' });
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Create New Live Blog
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={handleTitleChange}
placeholder="Enter live blog title"
disabled={createMutation.isPending}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={formData.slug}
onChange={handleSlugChange}
placeholder="URL-friendly identifier"
disabled={createMutation.isPending}
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug}</p>
)}
<p className="text-xs text-muted-foreground">
This will be used in the URL: /live-blogs/your-slug-here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description for this live blog"
rows={3}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Initial Status</Label>
<Select
value={formData.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setFormData({ ...formData, status: value })
}
disabled={createMutation.isPending}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
You can change this later in the settings
</p>
</div>
{errors.submit && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{errors.submit}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="submit"
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more