Compare commits
92
Commits
comments
...
46bbeed525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bbeed525 | ||
|
|
7258f059ce | ||
|
|
a66db56156 | ||
|
|
ebd1fc28d5 | ||
|
|
cba9908180 | ||
|
|
287dcb4070 | ||
|
|
50ac4531d3 | ||
|
|
79bd134977 | ||
|
|
88dbd2719d | ||
|
|
e71dc2b90f | ||
|
|
caf56d9fcd | ||
|
|
86c1c2d366 | ||
|
|
4f2837ad4e | ||
|
|
c6427304ac | ||
|
|
515c4cacf3 | ||
|
|
59891b03dc | ||
|
|
8f68dab53f | ||
|
|
fa7dcc2b08 | ||
|
|
3e61fe5694 | ||
|
|
a5008a3646 | ||
|
|
cbdb801655 | ||
|
|
a5c57b33f7 | ||
|
|
b39972102a | ||
|
|
8fbde18d02 | ||
|
|
94b0239a0a | ||
|
|
ac610e6f6a | ||
|
|
8eaaf4afad | ||
|
|
33008b64ce | ||
|
|
005d368dc7 | ||
|
|
a8c0ab8884 | ||
|
|
e443ece848 | ||
|
|
76963f6eea | ||
|
|
bfc7e76f17 | ||
|
|
06a0c9fe05 | ||
|
|
c1b49a4cb6 | ||
|
|
4f30542014 | ||
|
|
d7281024bf | ||
|
|
4a22e8a18a | ||
|
|
994becc687 | ||
|
|
69e758b841 | ||
|
|
17c6a1593c | ||
|
|
d573846e5c | ||
|
|
c3b01de12e | ||
|
|
80a2ee89a6 | ||
|
|
ff0916b37c | ||
|
|
6eb68b7bd3 | ||
|
|
fcbd082f6d | ||
|
|
183636bceb | ||
|
|
5262e73a12 | ||
|
|
590a96e502 | ||
|
|
0ef26ba2f1 | ||
|
|
2efdf20f88 | ||
|
|
ed723bc429 | ||
|
|
b8dfde512c | ||
|
|
5fc424408e | ||
|
|
749ff5659c | ||
|
|
a2caaab26a | ||
|
|
bc3e834a39 | ||
|
|
7a96b52cfc | ||
|
|
5233eec96a | ||
|
|
831da0af4d | ||
|
|
a9528582fd | ||
|
|
ee676b3916 | ||
|
|
9013bc51f3 | ||
|
|
57ae0bb3b5 | ||
|
|
3b827a90ac | ||
|
|
3af4b92e1b | ||
|
|
cfc43a7119 | ||
|
|
dc93a8b652 | ||
|
|
69833d2067 | ||
|
|
d041575600 | ||
|
|
57864e00da | ||
|
|
0bbf2ab56f | ||
|
|
26a17b5a4c | ||
|
|
7725674fd5 | ||
|
|
f1f16a80a5 | ||
|
|
83c60ce40d | ||
|
|
f20842b3ab | ||
|
|
8510cccb6e | ||
|
|
c2c77ac92a | ||
|
|
71b1b549c3 | ||
|
|
aa79eba06d | ||
|
|
62c68e4bc6 | ||
|
|
2be6216772 | ||
|
|
ae28f56337 | ||
|
|
10fe702749 | ||
|
|
4c4d741b1f | ||
|
|
6abae13dbd | ||
|
|
46eb41aaa5 | ||
|
|
add12b2fbf | ||
|
|
6d65d5975c | ||
|
|
b8779e5a35 |
No files matched your search
@@ -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
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
|
|
||||||
// Relative imports
|
|
||||||
import { UserService } from './user.service';
|
|
||||||
import { UserController } from './user.controller';
|
|
||||||
```
|
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
Group with blank lines: External → Internal → Relative
|
||||||
|
```typescript
|
||||||
|
// External
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// Internal modules
|
||||||
|
import { UserService } from '../users/user.service';
|
||||||
|
|
||||||
|
// 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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ===========================================
|
||||||
|
# COOLIFY ENVIRONMENT VARIABLES
|
||||||
|
# Copy these to Coolify UI: Environment Variables section
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Database Password (you should already have this set)
|
||||||
|
DATABASE_PASSWORD=abreubre776677112233
|
||||||
|
|
||||||
|
# Backend JWT Secret (REQUIRED - MISSING!)
|
||||||
|
JWT_SECRET=Xt4mwkbFEw83dSMXCv6W0Ut6YoTgIkYO62eticw0CfxkZhYSplDbjeUOyrnwyWK34Pt3nrnmtE5+khKxeoHiSA==
|
||||||
|
|
||||||
|
# Strapi API Token (leave empty for now - will set after making API public)
|
||||||
|
STRAPI_API_TOKEN=
|
||||||
|
|
||||||
|
# Strapi Security Keys (REQUIRED)
|
||||||
|
STRAPI_APP_KEYS=Jlt1Pu+ZBzcTSYazU8ZlEMOZyj4F9MO9YVAJmkaKrnk=,V2VSvJQrZ61jk8MtVkhC2RrKcm3XvJzmYTi73NItPYQ=,dhvlKrjeYGbCGaZznVTEJZLcAjIIwAtNmT6/i5Zq09I=,qDCKh9Pdep3P4ZlX+OCsKUwj/VZKul959RGbxXdiyf8=
|
||||||
|
STRAPI_API_TOKEN_SALT=MkkvTfDJkwEPznUVfbiKj3SSWPw/MKqrOIRxN9cyWLk=
|
||||||
|
STRAPI_ADMIN_JWT_SECRET=RpqNlR20k4VF2x1rzRvjUsg46zN2X4YcfBowbjdvqJo=
|
||||||
|
STRAPI_TRANSFER_TOKEN_SALT=96PznECGwwinWXB8fhlHwE11+0XU5TaJwTaztQPaQw4=
|
||||||
|
STRAPI_JWT_SECRET=3CGgFzvM8ykfndK2pqcCb7U5W3FcBF0SXwalj1kby6s=
|
||||||
|
STRAPI_ENCRYPTION_KEY=8V99V0CfSxJZLvgXGRBv/zndKH2FnPQ/JVmXa1OEfZ8=
|
||||||
|
|
||||||
|
# Push Notification VAPID Keys (for PWA notifications)
|
||||||
|
VAPID_PUBLIC_KEY=BEUVi1YA6wyD1Mt31M8nbsz7ctVC1wxURkz4bHdrexbtUzDETS90MOpS-QFebnXt_Dx_zvntPHCno6bwsK3pOxU
|
||||||
|
VAPID_PRIVATE_KEY=XJIYJyV1KkfEwnHa1vy4Jb3FPRg27eFton1Tdsep8fI
|
||||||
|
|
||||||
|
# VAPID Subject (optional - defaults to mailto:contact@placebo.mk)
|
||||||
|
VAPID_SUBJECT=mailto:contact@placebo.mk
|
||||||
+631
-371
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -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"]
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Backend entrypoint script - seeds admin on first run
|
||||||
|
|
||||||
|
echo "Starting Placebo.mk Backend..."
|
||||||
|
|
||||||
|
# Run admin seed script (idempotent - won't recreate if exists)
|
||||||
|
# Temporarily disabled to fix startup issues
|
||||||
|
# echo "Checking for admin user..."
|
||||||
|
# node dist/scripts/seed-admin.js || echo "Warning: Admin seed failed, continuing..."
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "Starting NestJS application..."
|
||||||
|
exec node dist/src/main.js
|
||||||
Generated
+101
-31
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArticlesModule } from './modules/articles.module';
|
import { ArticlesModule } from './modules/articles.module';
|
||||||
@@ -9,6 +10,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 +22,9 @@ 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';
|
||||||
|
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -41,6 +47,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,8 +59,16 @@ import {
|
|||||||
UserModule,
|
UserModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
PushModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthPublicGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
+11
-7
@@ -1,19 +1,27 @@
|
|||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Build allowed origins list from environment variables
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||||
|
'https://www.placebo.mk', // Also allow www subdomain
|
||||||
|
process.env.PWA_URL ?? 'http://localhost:5174',
|
||||||
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
||||||
];
|
].filter(Boolean); // Remove any undefined/null values
|
||||||
|
|
||||||
|
console.log('CORS enabled for origins:', allowedOrigins);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: allowedOrigins,
|
origin: allowedOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin'],
|
||||||
|
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
maxAge: 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
@@ -27,10 +35,6 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply global authentication guard
|
|
||||||
const reflector = app.get(Reflector);
|
|
||||||
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
|
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||||
await app.listen(port, host);
|
await app.listen(port, host);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
UsePipes,
|
||||||
|
ValidationPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
import {
|
||||||
|
TrackShareDto,
|
||||||
|
GetShareStatsDto,
|
||||||
|
ShareStatsResponse,
|
||||||
|
} from './analytics.dto';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
@Controller('analytics')
|
||||||
|
export class AnalyticsController {
|
||||||
|
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('share')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async trackShare(@Body() trackShareDto: TrackShareDto) {
|
||||||
|
return await this.analyticsService.trackShare(trackShareDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('shares')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async getShareStats(
|
||||||
|
@Query() getShareStatsDto: GetShareStatsDto,
|
||||||
|
): Promise<ShareStatsResponse[]> {
|
||||||
|
return await this.analyticsService.getShareStats(getShareStatsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('shares/top')
|
||||||
|
async getTopSharedArticles(@Query('limit') limit: string) {
|
||||||
|
const limitNum = limit ? parseInt(limit) : 10;
|
||||||
|
return await this.analyticsService.getTopSharedArticles(limitNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('shares/trends')
|
||||||
|
async getShareTrends(
|
||||||
|
@Query('startDate') startDate: string,
|
||||||
|
@Query('endDate') endDate: string,
|
||||||
|
@Query('interval') interval: 'day' | 'week' | 'month',
|
||||||
|
) {
|
||||||
|
return await this.analyticsService.getShareTrends(
|
||||||
|
new Date(startDate),
|
||||||
|
new Date(endDate),
|
||||||
|
interval || 'day',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('shares/total')
|
||||||
|
async getTotalShareStats() {
|
||||||
|
return await this.analyticsService.getTotalShareStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
import type { SharePlatform } from './analytics.entity';
|
||||||
|
|
||||||
|
export class TrackShareDto {
|
||||||
|
@IsUUID()
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@IsEnum(['facebook', 'twitter', 'whatsapp', 'telegram', 'link'])
|
||||||
|
platform: SharePlatform;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userAgent?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetShareStatsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
articleId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShareStatsResponse {
|
||||||
|
articleId: string;
|
||||||
|
articleTitle: string;
|
||||||
|
facebookShares: number;
|
||||||
|
twitterShares: number;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
totalShares: number;
|
||||||
|
views: number;
|
||||||
|
shareRate: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Article } from '../entities';
|
||||||
|
|
||||||
|
export type SharePlatform =
|
||||||
|
| 'facebook'
|
||||||
|
| 'twitter'
|
||||||
|
| 'whatsapp'
|
||||||
|
| 'telegram'
|
||||||
|
| 'link';
|
||||||
|
|
||||||
|
@Entity('share_events')
|
||||||
|
export class ShareEvent {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
platform: SharePlatform;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'articleId' })
|
||||||
|
article: Article;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareStats {
|
||||||
|
articleId: string;
|
||||||
|
articleTitle: string;
|
||||||
|
facebookShares: number;
|
||||||
|
twitterShares: number;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
totalShares: number;
|
||||||
|
views: number;
|
||||||
|
shareRate: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AnalyticsController } from './analytics.controller';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
import { ShareEvent } from './analytics.entity';
|
||||||
|
import { Article } from '../entities';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ShareEvent, Article])],
|
||||||
|
controllers: [AnalyticsController],
|
||||||
|
providers: [AnalyticsService],
|
||||||
|
exports: [AnalyticsService],
|
||||||
|
})
|
||||||
|
export class AnalyticsModule {}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ShareEvent, SharePlatform } from './analytics.entity';
|
||||||
|
import { Article } from '../entities';
|
||||||
|
import {
|
||||||
|
TrackShareDto,
|
||||||
|
GetShareStatsDto,
|
||||||
|
ShareStatsResponse,
|
||||||
|
} from './analytics.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnalyticsService {
|
||||||
|
private readonly logger = new Logger(AnalyticsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ShareEvent)
|
||||||
|
private readonly shareEventRepository: Repository<ShareEvent>,
|
||||||
|
@InjectRepository(Article)
|
||||||
|
private readonly articleRepository: Repository<Article>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
|
||||||
|
const shareEvent = this.shareEventRepository.create(trackShareDto);
|
||||||
|
|
||||||
|
// Also update the article's share counters
|
||||||
|
await this.incrementArticleShareCounter(
|
||||||
|
trackShareDto.articleId,
|
||||||
|
trackShareDto.platform,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.shareEventRepository.save(shareEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async incrementArticleShareCounter(
|
||||||
|
articleId: string,
|
||||||
|
platform: SharePlatform,
|
||||||
|
): Promise<void> {
|
||||||
|
const updateField = this.getShareCounterField(platform);
|
||||||
|
if (!updateField) return;
|
||||||
|
|
||||||
|
await this.articleRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Article)
|
||||||
|
.set({ [updateField]: () => `${updateField} + 1` })
|
||||||
|
.where('id = :id', { id: articleId })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getShareCounterField(platform: SharePlatform): string | null {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'facebookShares';
|
||||||
|
case 'twitter':
|
||||||
|
return 'twitterShares';
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'whatsappShares';
|
||||||
|
case 'telegram':
|
||||||
|
return 'telegramShares';
|
||||||
|
default:
|
||||||
|
return null; // 'link' shares don't increment counters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShareStats(
|
||||||
|
getShareStatsDto: GetShareStatsDto,
|
||||||
|
): Promise<ShareStatsResponse[]> {
|
||||||
|
const query = this.articleRepository
|
||||||
|
.createQueryBuilder('article')
|
||||||
|
.select([
|
||||||
|
'article.id as "articleId"',
|
||||||
|
'article.title as "articleTitle"',
|
||||||
|
'article.facebookShares as "facebookShares"',
|
||||||
|
'article.twitterShares as "twitterShares"',
|
||||||
|
'article.whatsappShares as "whatsappShares"',
|
||||||
|
'article.telegramShares as "telegramShares"',
|
||||||
|
'article.views as "views"',
|
||||||
|
'article.createdAt as "createdAt"',
|
||||||
|
'article.updatedAt as "updatedAt"',
|
||||||
|
])
|
||||||
|
.addSelect(
|
||||||
|
`(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`,
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`CASE
|
||||||
|
WHEN article.views > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares)::decimal / article.views * 100,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ELSE 0
|
||||||
|
END as "shareRate"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (getShareStatsDto.articleId) {
|
||||||
|
query.where('article.id = :articleId', {
|
||||||
|
articleId: getShareStatsDto.articleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getShareStatsDto.startDate) {
|
||||||
|
query.andWhere('article.createdAt >= :startDate', {
|
||||||
|
startDate: getShareStatsDto.startDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getShareStatsDto.endDate) {
|
||||||
|
query.andWhere('article.createdAt <= :endDate', {
|
||||||
|
endDate: getShareStatsDto.endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('"totalShares"', 'DESC');
|
||||||
|
|
||||||
|
const rawResults = await query.getRawMany<{
|
||||||
|
articleId: string;
|
||||||
|
articleTitle: string;
|
||||||
|
facebookShares: string;
|
||||||
|
twitterShares: string;
|
||||||
|
whatsappShares: string;
|
||||||
|
telegramShares: string;
|
||||||
|
views: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
totalShares: string;
|
||||||
|
shareRate: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Get link shares from share_events table
|
||||||
|
const results: ShareStatsResponse[] = [];
|
||||||
|
for (const rawResult of rawResults) {
|
||||||
|
const linkShares = await this.shareEventRepository.count({
|
||||||
|
where: {
|
||||||
|
articleId: rawResult.articleId,
|
||||||
|
platform: 'link',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const facebookShares = parseInt(rawResult.facebookShares) || 0;
|
||||||
|
const twitterShares = parseInt(rawResult.twitterShares) || 0;
|
||||||
|
const whatsappShares = parseInt(rawResult.whatsappShares) || 0;
|
||||||
|
const telegramShares = parseInt(rawResult.telegramShares) || 0;
|
||||||
|
const views = parseInt(rawResult.views) || 0;
|
||||||
|
const baseTotalShares =
|
||||||
|
facebookShares + twitterShares + whatsappShares + telegramShares;
|
||||||
|
const totalShares = baseTotalShares + linkShares;
|
||||||
|
const shareRate =
|
||||||
|
views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
articleId: rawResult.articleId,
|
||||||
|
articleTitle: rawResult.articleTitle,
|
||||||
|
facebookShares,
|
||||||
|
twitterShares,
|
||||||
|
whatsappShares,
|
||||||
|
telegramShares,
|
||||||
|
linkShares,
|
||||||
|
totalShares,
|
||||||
|
views,
|
||||||
|
shareRate,
|
||||||
|
createdAt: new Date(rawResult.createdAt),
|
||||||
|
updatedAt: new Date(rawResult.updatedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopSharedArticles(
|
||||||
|
limit: number = 10,
|
||||||
|
): Promise<ShareStatsResponse[]> {
|
||||||
|
const stats = await this.getShareStats({});
|
||||||
|
return stats.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShareTrends(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
interval: 'day' | 'week' | 'month' = 'day',
|
||||||
|
): Promise<
|
||||||
|
Array<{ period: string; platform: SharePlatform; count: number }>
|
||||||
|
> {
|
||||||
|
const dateFormat =
|
||||||
|
interval === 'day'
|
||||||
|
? 'YYYY-MM-DD'
|
||||||
|
: interval === 'week'
|
||||||
|
? 'YYYY-WW'
|
||||||
|
: 'YYYY-MM';
|
||||||
|
|
||||||
|
const query = this.shareEventRepository
|
||||||
|
.createQueryBuilder('share_event')
|
||||||
|
.select([
|
||||||
|
`TO_CHAR(share_event.createdAt, '${dateFormat}') as period`,
|
||||||
|
'share_event.platform as platform',
|
||||||
|
'COUNT(*) as count',
|
||||||
|
])
|
||||||
|
.where('share_event.createdAt >= :startDate', { startDate })
|
||||||
|
.andWhere('share_event.createdAt <= :endDate', { endDate })
|
||||||
|
.groupBy('period, platform')
|
||||||
|
.orderBy('period', 'ASC');
|
||||||
|
|
||||||
|
const rawResults = await query.getRawMany<{
|
||||||
|
period: string;
|
||||||
|
platform: string;
|
||||||
|
count: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Convert platform strings to SharePlatform type
|
||||||
|
return rawResults.map((result) => ({
|
||||||
|
period: result.period,
|
||||||
|
platform: result.platform as SharePlatform,
|
||||||
|
count: parseInt(result.count) || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalShareStats(): Promise<{
|
||||||
|
totalShares: number;
|
||||||
|
facebookShares: number;
|
||||||
|
twitterShares: number;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
}> {
|
||||||
|
interface ArticleStatsRaw {
|
||||||
|
facebookShares: string;
|
||||||
|
twitterShares: string;
|
||||||
|
whatsappShares: string;
|
||||||
|
telegramShares: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleStats = (await this.articleRepository
|
||||||
|
.createQueryBuilder('article')
|
||||||
|
.select([
|
||||||
|
'SUM(article.facebookShares) as facebookShares',
|
||||||
|
'SUM(article.twitterShares) as twitterShares',
|
||||||
|
'SUM(article.whatsappShares) as whatsappShares',
|
||||||
|
'SUM(article.telegramShares) as telegramShares',
|
||||||
|
])
|
||||||
|
.getRawOne()) as ArticleStatsRaw;
|
||||||
|
|
||||||
|
const linkShares = await this.shareEventRepository.count({
|
||||||
|
where: { platform: 'link' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0;
|
||||||
|
const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0;
|
||||||
|
const whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0;
|
||||||
|
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
|
||||||
|
|
||||||
|
const totalShares =
|
||||||
|
facebookShares +
|
||||||
|
twitterShares +
|
||||||
|
whatsappShares +
|
||||||
|
telegramShares +
|
||||||
|
linkShares;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalShares,
|
||||||
|
facebookShares,
|
||||||
|
twitterShares,
|
||||||
|
whatsappShares,
|
||||||
|
telegramShares,
|
||||||
|
linkShares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Logger } from '@nestjs/common';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
import { Public } from './auth/public.decorator';
|
import { Public } from './auth/public.decorator';
|
||||||
|
|
||||||
@@ -120,18 +120,28 @@ export class StrapiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/all')
|
@Post('sync/all')
|
||||||
|
@Public()
|
||||||
async syncAllArticles() {
|
async syncAllArticles() {
|
||||||
await this.strapiService.syncArticles();
|
await this.strapiService.syncArticles();
|
||||||
return { message: 'Articles sync completed' };
|
return { message: 'Articles sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('sync/all')
|
||||||
|
@Public()
|
||||||
|
async syncAllArticlesGet() {
|
||||||
|
await this.strapiService.syncArticles();
|
||||||
|
return { message: 'Articles sync completed' };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('sync/live-blogs')
|
@Post('sync/live-blogs')
|
||||||
|
@Public()
|
||||||
async syncAllLiveBlogs() {
|
async syncAllLiveBlogs() {
|
||||||
await this.strapiService.syncLiveBlogs();
|
await this.strapiService.syncLiveBlogs();
|
||||||
return { message: 'Live blogs sync completed' };
|
return { message: 'Live blogs sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/everything')
|
@Post('sync/everything')
|
||||||
|
@Public()
|
||||||
async syncEverything() {
|
async syncEverything() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.strapiService.syncArticles(),
|
this.strapiService.syncArticles(),
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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> {
|
||||||
@@ -82,6 +87,7 @@ interface StrapiResponse<T> {
|
|||||||
export class StrapiService {
|
export class StrapiService {
|
||||||
private readonly logger = new Logger(StrapiService.name);
|
private readonly logger = new Logger(StrapiService.name);
|
||||||
private readonly strapiUrl: string;
|
private readonly strapiUrl: string;
|
||||||
|
private readonly strapiPublicUrl: string;
|
||||||
private readonly strapiApiToken: string;
|
private readonly strapiApiToken: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -91,21 +97,76 @@ 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';
|
||||||
|
this.strapiPublicUrl =
|
||||||
|
this.configService.get<string>('STRAPI_PUBLIC_URL') ||
|
||||||
|
this.strapiUrl.replace('cms:', 'localhost:');
|
||||||
this.strapiApiToken =
|
this.strapiApiToken =
|
||||||
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders() {
|
private getHeaders() {
|
||||||
const headers: Record<string, string> = {
|
// Use public API access - no authentication needed
|
||||||
'Content-Type': 'application/json',
|
// Strapi Public role has been configured to allow article access
|
||||||
};
|
return {};
|
||||||
if (this.strapiApiToken) {
|
}
|
||||||
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
|
|
||||||
|
private generateSlug(title: string): string {
|
||||||
|
// Generate slug from title if missing
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||||
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||||
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||||
|
.substring(0, 100); // Limit length
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }> = {
|
||||||
|
general: { name: 'Општо', description: 'Општи вести и теми' },
|
||||||
|
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 {
|
||||||
@@ -125,10 +186,8 @@ export class StrapiService {
|
|||||||
|
|
||||||
// If URL is relative, prepend Strapi base URL
|
// If URL is relative, prepend Strapi base URL
|
||||||
if (imageUrl.startsWith('/')) {
|
if (imageUrl.startsWith('/')) {
|
||||||
// Convert Docker service URL to localhost for frontend access
|
// Use public URL for frontend access
|
||||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
|
||||||
return `${frontendStrapiUrl}${imageUrl}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
@@ -153,10 +212,8 @@ export class StrapiService {
|
|||||||
|
|
||||||
// If URL is relative, prepend Strapi base URL
|
// If URL is relative, prepend Strapi base URL
|
||||||
if (imageUrl.startsWith('/')) {
|
if (imageUrl.startsWith('/')) {
|
||||||
// Convert Docker service URL to localhost for frontend access
|
// Use public URL for frontend access
|
||||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
|
||||||
return `${frontendStrapiUrl}${imageUrl}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
@@ -181,11 +238,26 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug if missing or null
|
||||||
|
const slug =
|
||||||
|
strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status: strapiArticle.publishedAt
|
status: strapiArticle.publishedAt
|
||||||
? ArticleStatus.PUBLISHED
|
? ArticleStatus.PUBLISHED
|
||||||
: ArticleStatus.DRAFT,
|
: ArticleStatus.DRAFT,
|
||||||
@@ -198,6 +270,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,11 +329,25 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug if missing or null
|
||||||
|
const slug = strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status,
|
status,
|
||||||
tags: [],
|
tags: [],
|
||||||
featuredImage: imageUrl,
|
featuredImage: imageUrl,
|
||||||
@@ -270,6 +357,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 +421,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 +445,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 +500,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 +523,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(
|
||||||
|
|||||||
@@ -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
|
||||||
+31
-16
@@ -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,40 +17,56 @@ 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
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install su-exec for user switching in entrypoint
|
||||||
|
RUN apk add --no-cache su-exec
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
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/src ./src
|
||||||
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/.strapi ./.strapi
|
||||||
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 entrypoint script
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create the directory structure Strapi expects for the admin build
|
||||||
RUN mkdir -p /app/.tmp && \
|
RUN mkdir -p /app/node_modules/@strapi/admin/dist/server/server && \
|
||||||
chown -R nodejs:nodejs /app/.tmp
|
ln -sf /app/dist/build /app/node_modules/@strapi/admin/dist/server/server/build && \
|
||||||
|
chown -R nodejs:nodejs /app/node_modules/@strapi/admin
|
||||||
|
|
||||||
# Switch to non-root user
|
# Create data and database directories with proper permissions
|
||||||
USER nodejs
|
RUN mkdir -p /app/.tmp /app/database /app/database/migrations /app/public/uploads && \
|
||||||
|
chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
# Don't switch to nodejs user yet - entrypoint will handle it
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Use entrypoint to fix permissions on startup then switch to nodejs user
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
# Start Strapi
|
# Start Strapi
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb
|
||||||
@@ -17,4 +17,9 @@ export default ({ env }) => ({
|
|||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
},
|
},
|
||||||
|
// Specify the build directory path for production
|
||||||
|
path: env('ADMIN_PATH', '/admin'),
|
||||||
|
build: {
|
||||||
|
backend: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+38
-45
@@ -3,58 +3,51 @@ 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 CONFIGURATION ===');
|
||||||
mysql: {
|
console.log('DATABASE_CLIENT:', client);
|
||||||
|
console.log('DATABASE_HOST:', env('DATABASE_HOST', 'not-set'));
|
||||||
|
console.log('DATABASE_PORT:', env('DATABASE_PORT', 'not-set'));
|
||||||
|
console.log('DATABASE_USERNAME:', env('DATABASE_USERNAME', 'not-set'));
|
||||||
|
console.log('DATABASE_NAME:', env('DATABASE_NAME', 'not-set'));
|
||||||
|
console.log('DATABASE_PASSWORD:', env('DATABASE_PASSWORD') ? '***SET***' : '***NOT SET***');
|
||||||
|
console.log('DATABASE_SSL:', env('DATABASE_SSL', 'not-set'));
|
||||||
|
|
||||||
|
if (client === 'postgres') {
|
||||||
|
const connectionConfig = {
|
||||||
|
host: env('DATABASE_HOST', 'localhost'),
|
||||||
|
port: env.int('DATABASE_PORT', 5432),
|
||||||
|
database: env('DATABASE_NAME', 'strapi'),
|
||||||
|
user: env('DATABASE_USERNAME', 'strapi'),
|
||||||
|
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||||
|
ssl: env.bool('DATABASE_SSL', false) ? { rejectUnauthorized: false } : false,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('PostgreSQL connection config:', JSON.stringify({
|
||||||
|
...connectionConfig,
|
||||||
|
password: connectionConfig.password ? `***${connectionConfig.password.length} chars***` : '***NOT SET***'
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
connection: {
|
connection: {
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
client: 'postgres',
|
||||||
port: env.int('DATABASE_PORT', 3306),
|
connection: connectionConfig,
|
||||||
database: env('DATABASE_NAME', 'strapi'),
|
pool: {
|
||||||
user: env('DATABASE_USERNAME', 'strapi'),
|
min: env.int('DATABASE_POOL_MIN', 2),
|
||||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
max: env.int('DATABASE_POOL_MAX', 10),
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
|
acquireConnectionTimeout: env.int('DATABASE_TIMEOUT', 60000),
|
||||||
},
|
},
|
||||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
};
|
||||||
},
|
}
|
||||||
postgres: {
|
|
||||||
connection: {
|
console.log('Using SQLite configuration');
|
||||||
connectionString: env('DATABASE_URL'),
|
return {
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
connection: {
|
||||||
port: env.int('DATABASE_PORT', 5432),
|
client: 'sqlite',
|
||||||
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),
|
|
||||||
},
|
|
||||||
schema: env('DATABASE_SCHEMA', 'public'),
|
|
||||||
},
|
|
||||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
|
||||||
},
|
|
||||||
sqlite: {
|
|
||||||
connection: {
|
connection: {
|
||||||
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
||||||
},
|
},
|
||||||
useNullAsDefault: true,
|
useNullAsDefault: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
connection: {
|
|
||||||
client,
|
|
||||||
...connections[client],
|
|
||||||
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,17 @@ export default [
|
|||||||
'strapi::cors',
|
'strapi::cors',
|
||||||
'strapi::poweredBy',
|
'strapi::poweredBy',
|
||||||
'strapi::query',
|
'strapi::query',
|
||||||
'strapi::body',
|
{
|
||||||
|
name: 'strapi::body',
|
||||||
|
config: {
|
||||||
|
formLimit: '256mb', // Max form size
|
||||||
|
jsonLimit: '256mb', // Max JSON payload size
|
||||||
|
textLimit: '256mb', // Max text payload size
|
||||||
|
formidable: {
|
||||||
|
maxFileSize: 200 * 1024 * 1024, // 200MB in bytes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'strapi::session',
|
'strapi::session',
|
||||||
'strapi::favicon',
|
'strapi::favicon',
|
||||||
'strapi::public',
|
'strapi::public',
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export default () => ({});
|
export default ({ env }) => ({
|
||||||
|
upload: {
|
||||||
|
config: {
|
||||||
|
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export default ({ env }) => ({
|
export default ({ env }) => ({
|
||||||
host: env('HOST', '0.0.0.0'),
|
host: env('HOST', '0.0.0.0'),
|
||||||
port: env.int('PORT', 1337),
|
port: env.int('PORT', 1337),
|
||||||
|
url: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||||
app: {
|
app: {
|
||||||
keys: env.array('APP_KEYS'),
|
keys: env.array('APP_KEYS'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ensure uploads directory exists and is writable
|
||||||
|
mkdir -p /app/public/uploads
|
||||||
|
|
||||||
|
# Fix permissions for uploads directory (needed for volume mounts)
|
||||||
|
chown -R nodejs:nodejs /app/public/uploads
|
||||||
|
chmod -R 755 /app/public/uploads
|
||||||
|
|
||||||
|
# Switch to nodejs user and execute the main command
|
||||||
|
exec su-exec nodejs "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Script to create an API token for backend integration
|
||||||
|
* Run this inside the CMS container if the UI doesn't work
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
async function createApiToken() {
|
||||||
|
const strapi = await factories.createCoreStore()();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create API token
|
||||||
|
const tokenService = strapi.service('admin::api-token');
|
||||||
|
|
||||||
|
const token = await tokenService.create({
|
||||||
|
name: 'Backend Integration',
|
||||||
|
description: 'Token for backend to fetch articles',
|
||||||
|
type: 'read-only',
|
||||||
|
permissions: [],
|
||||||
|
lifespan: null, // Unlimited
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ API Token created successfully!');
|
||||||
|
console.log('\nToken details:');
|
||||||
|
console.log('Name:', token.name);
|
||||||
|
console.log('Type:', token.type);
|
||||||
|
console.log('\nTOKEN (copy this to Coolify as STRAPI_API_TOKEN):');
|
||||||
|
console.log(token.accessKey);
|
||||||
|
console.log('\n⚠️ Save this token - you won\'t see it again!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating API token:', error.message);
|
||||||
|
} finally {
|
||||||
|
await strapi.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createApiToken();
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"singularName": "article",
|
"singularName": "article",
|
||||||
"pluralName": "articles",
|
"pluralName": "articles",
|
||||||
"displayName": "article"
|
"displayName": "Article",
|
||||||
|
"description": "News articles for Placebo.mk"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"draftAndPublish": true
|
"draftAndPublish": true
|
||||||
@@ -12,33 +13,28 @@
|
|||||||
"pluginOptions": {},
|
"pluginOptions": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
},
|
||||||
"media": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": true,
|
|
||||||
"allowedTypes": [
|
|
||||||
"images",
|
|
||||||
"files",
|
|
||||||
"videos",
|
|
||||||
"audios"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"author": {
|
"author": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"img": {
|
"img": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"multiple": false,
|
"multiple": false,
|
||||||
"allowedTypes": [
|
"required": false,
|
||||||
"images",
|
"allowedTypes": ["images"]
|
||||||
"files",
|
|
||||||
"videos",
|
|
||||||
"audios"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"imagePosition": {
|
"imagePosition": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
@@ -50,10 +46,14 @@
|
|||||||
"enum": ["small", "medium", "large"],
|
"enum": ["small", "medium", "large"],
|
||||||
"default": "medium"
|
"default": "medium"
|
||||||
},
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": true,
|
||||||
|
"required": false,
|
||||||
|
"allowedTypes": ["images", "files", "videos", "audios"]
|
||||||
|
},
|
||||||
"videoUrl": {
|
"videoUrl": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
|
|
||||||
"default": ""
|
|
||||||
},
|
},
|
||||||
"videoPosition": {
|
"videoPosition": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
@@ -61,8 +61,13 @@
|
|||||||
"default": "inline"
|
"default": "inline"
|
||||||
},
|
},
|
||||||
"videoCaption": {
|
"videoCaption": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"default": ""
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["general", "sport", "art", "science"],
|
||||||
|
"default": "general",
|
||||||
|
"required": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreController } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreController('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article controller
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreController('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article router
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreRouter } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreRouter('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article router
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreRouter('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article service
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreService } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreService('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article service
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreService('api::article.article');
|
|
||||||
@@ -16,5 +16,9 @@ export default {
|
|||||||
* This gives you an opportunity to set up your data model,
|
* This gives you an opportunity to set up your data model,
|
||||||
* run jobs, or perform some special logic.
|
* run jobs, or perform some special logic.
|
||||||
*/
|
*/
|
||||||
bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
|
async bootstrap({ strapi }) {
|
||||||
|
console.log('=== Strapi Bootstrap ===');
|
||||||
|
console.log('Available content types:', Object.keys(strapi.contentTypes || {}));
|
||||||
|
console.log('Article content type exists:', !!strapi.contentTypes['api::article.article']);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# 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
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_backend_db']
|
||||||
|
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
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_cms_db']
|
||||||
|
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: 'true'
|
||||||
|
DATABASE_LOGGING: 'true'
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION: '86400'
|
||||||
|
FRONTEND_URL: https://placebo.mk
|
||||||
|
PWA_URL: https://app.placebo.mk
|
||||||
|
STRAPI_URL: http://cms:1337
|
||||||
|
STRAPI_PUBLIC_URL: https://cms.placebo.mk
|
||||||
|
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"
|
||||||
|
- "traefik.http.routers.backend.service=backend"
|
||||||
|
# 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
|
||||||
|
PUBLIC_URL: https://cms.placebo.mk
|
||||||
|
BACKEND_WEBHOOK_URL: http://backend:3000
|
||||||
|
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"
|
||||||
|
- "traefik.http.routers.cms.service=cms"
|
||||||
|
# Service with timeouts
|
||||||
|
- "traefik.http.services.cms.loadbalancer.server.port=1337"
|
||||||
|
- "traefik.http.services.cms.loadbalancer.responseForwarding.flushInterval=100ms"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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"
|
||||||
|
- "traefik.http.routers.frontend.service=frontend"
|
||||||
|
# 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"
|
||||||
|
- "traefik.http.routers.pwa.service=pwa"
|
||||||
|
# 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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+11
-11
@@ -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
|
||||||
@@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
+6
-3
@@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="mk">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Placebo.mk - Сатирични вести од Македонија</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+29
-29
@@ -46,7 +46,7 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers for frontend
|
# 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;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 http://localhost:1337 https://api.placebo.mk https://cms.placebo.mk;" always;
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
@@ -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 {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
@@ -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,37 +14,37 @@ 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>
|
||||||
</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)}%`
|
||||||
}}
|
}}
|
||||||
@@ -235,4 +210,4 @@ export function LiveBlogTicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 ? (
|
||||||
@@ -223,8 +223,16 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{updates.map((update) => (
|
{updates.map((update, index) => (
|
||||||
<LiveBlogUpdate key={update.id} update={update} />
|
<div
|
||||||
|
key={update.id}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
index % 2 === 0 ? 'justify-start' : 'justify-end'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LiveBlogUpdate update={update} alignRight={index % 2 !== 0} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{updatesLoading && (
|
{updatesLoading && (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
@@ -255,16 +263,19 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
|||||||
|
|
||||||
interface LiveBlogUpdateProps {
|
interface LiveBlogUpdateProps {
|
||||||
update: ApiLiveBlogUpdate;
|
update: ApiLiveBlogUpdate;
|
||||||
|
alignRight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveBlogUpdate({ update }: LiveBlogUpdateProps) {
|
function LiveBlogUpdate({ update, alignRight = false }: LiveBlogUpdateProps) {
|
||||||
const isPinned = update.isPinned;
|
const isPinned = update.isPinned;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative p-4 rounded-lg border',
|
'relative p-4 rounded-lg border max-w-[80%]',
|
||||||
isPinned && 'border-primary bg-primary/5'
|
isPinned && 'border-primary bg-primary/5',
|
||||||
|
!isPinned && alignRight && 'bg-accent/10 border-accent/20',
|
||||||
|
!isPinned && !alignRight && 'bg-muted/50 border-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { LiveBlog } from '@/lib/api';
|
import type { LiveBlog } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Eye,
|
Eye,
|
||||||
Pin,
|
Pin,
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
ChevronRight
|
ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -19,9 +19,9 @@ interface PinnedLiveBlogSidebarProps {
|
|||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PinnedLiveBlogSidebar({
|
export function PinnedLiveBlogSidebar({
|
||||||
className = '',
|
className = '',
|
||||||
maxItems = 3
|
maxItems = 3
|
||||||
}: PinnedLiveBlogSidebarProps) {
|
}: PinnedLiveBlogSidebarProps) {
|
||||||
const { data: pinnedBlogs, isLoading, error } = usePinnedLiveBlogs();
|
const { data: pinnedBlogs, isLoading, error } = usePinnedLiveBlogs();
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function PinnedLiveBlogSidebar({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Pin className="w-4 h-4" />
|
<Pin className="w-4 h-4" />
|
||||||
Pinned Live Blogs
|
Во живо
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -114,7 +114,7 @@ export function PinnedLiveBlogSidebar({
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
if (diffMins < 60) {
|
if (diffMins < 60) {
|
||||||
return `${diffMins}m ago`;
|
return `${diffMins}m ago`;
|
||||||
} else if (diffMins < 1440) {
|
} else if (diffMins < 1440) {
|
||||||
@@ -137,13 +137,13 @@ export function PinnedLiveBlogSidebar({
|
|||||||
<Pin className="w-4 h-4" />
|
<Pin className="w-4 h-4" />
|
||||||
Live Coverage
|
Live Coverage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{(pinnedBlogs || []).length} pinned
|
{(pinnedBlogs || []).length} pinned
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
{displayBlogs.length === 0 ? (
|
{displayBlogs.length === 0 ? (
|
||||||
<div className="py-6 text-center">
|
<div className="py-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -158,7 +158,7 @@ export function PinnedLiveBlogSidebar({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{displayBlogs.map((blog) => {
|
{displayBlogs.map((blog) => {
|
||||||
const latestUpdate = getLatestUpdate(blog);
|
const latestUpdate = getLatestUpdate(blog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={blog.id}
|
key={blog.id}
|
||||||
@@ -177,9 +177,9 @@ export function PinnedLiveBlogSidebar({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
className={`${getStatusColor(blog.status)} text-xs`}
|
className={`${getStatusColor(blog.status)} text-xs`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -235,4 +235,4 @@ export function PinnedLiveBlogSidebar({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
Прочитај повеќе
|
||||||
|
<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,289 @@
|
|||||||
|
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, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function PinnedLiveBlogsSidebar() {
|
||||||
|
const [showUpdates, setShowUpdates] = useState(true);
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'сега';
|
||||||
|
if (diffMins < 60) return `${diffMins}м`;
|
||||||
|
if (diffHours < 24) return `${diffHours}ч`;
|
||||||
|
return `${diffDays}д`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect last 5 updates from all pinned live blogs
|
||||||
|
const getLastFiveUpdates = () => {
|
||||||
|
if (!liveBlogs) return [];
|
||||||
|
|
||||||
|
const allUpdates: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
liveBlogTitle: string;
|
||||||
|
liveBlogSlug: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
liveBlogs.forEach((liveBlog) => {
|
||||||
|
if (liveBlog.updates && liveBlog.updates.length > 0) {
|
||||||
|
liveBlog.updates.forEach((update) => {
|
||||||
|
allUpdates.push({
|
||||||
|
...update,
|
||||||
|
liveBlogTitle: liveBlog.title,
|
||||||
|
liveBlogSlug: liveBlog.slug,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by date descending and take first 5
|
||||||
|
return allUpdates
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastFiveUpdates = getLastFiveUpdates();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Latest Updates Section - Collapsible */}
|
||||||
|
{lastFiveUpdates.length > 0 && (
|
||||||
|
<div className="mb-6 pb-6 border-b-2 border-foreground/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdates(!showUpdates)}
|
||||||
|
className="w-full flex items-center justify-between mb-4 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-accent" />
|
||||||
|
<h3 className="text-xl font-display">Последни Update</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
|
||||||
|
{lastFiveUpdates.length}
|
||||||
|
</span>
|
||||||
|
{showUpdates ? (
|
||||||
|
<ChevronUp className="h-4 w-4 transition-transform" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 transition-transform" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showUpdates && (
|
||||||
|
<div className="space-y-3 animate-scale-in">
|
||||||
|
{lastFiveUpdates.map((update) => (
|
||||||
|
<Link
|
||||||
|
key={update.id}
|
||||||
|
to="/live-blogs/$slug"
|
||||||
|
params={{ slug: update.liveBlogSlug }}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="p-3 border-2 border-foreground/10 hover:border-accent hover:bg-accent/5 transition-all duration-150">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<span className="text-xs font-body font-bold text-muted-foreground line-clamp-1">
|
||||||
|
{update.liveBlogTitle}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-body text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatRelativeTime(update.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-body text-foreground line-clamp-2 group-hover:text-accent transition-colors">
|
||||||
|
{update.content.replace(/<[^>]*>/g, '').substring(0, 120)}
|
||||||
|
{update.content.length > 120 ? '...' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,220 @@
|
|||||||
|
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' },
|
||||||
|
{ to: '/about', label: 'Упатство за употреба' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 whitespace-nowrap">
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AlertTriangle, Laugh, Coffee } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AboutComponent() {
|
||||||
|
return (
|
||||||
|
<div className="py-8 max-w-4xl mx-auto">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-display mb-4">
|
||||||
|
Упатство за употреба
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-body">
|
||||||
|
Сè што треба да знаете за Placebo.mk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Предупредување</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 font-body p-3">
|
||||||
|
{/* <p className="text-lg"> */}
|
||||||
|
{/* <strong>Placebo.mk</strong> е сатиричен портал за вести. Сè што читате овде е измислено, преувеличено или целосно извадено од контекст. */}
|
||||||
|
{/* </p> */}
|
||||||
|
<p>
|
||||||
|
Ако веќе се налутивте, не се грижете - тоа ни беше целта. Ако сте се насмеале, уште подобро!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Laugh className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Што е Placebo.mk?</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 font-body p-3">
|
||||||
|
<p>
|
||||||
|
Placebo.mk е портал кој ja преработува реалноста и ја претвора во апсурд. Македонските а богами и глобалните политика, култура и спорт се сервираат со добра доза сарказам и црен хумор.
|
||||||
|
</p>
|
||||||
|
{/* <p> */}
|
||||||
|
{/* Нашата мисија е едноставна: да ве насмееме, да ве натераме да размислите и да ве потсетиме дека понекогаш вистината е толку apsурдна што единствено може да се коментира со хумор. */}
|
||||||
|
{/* </p> */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-display">Правила за читање</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="font-body">
|
||||||
|
<ol className="list-decimal list-inside space-y-3 p-3">
|
||||||
|
<li>Не земајте ништо [од ова] во животот премногу сериозно (освен кафето што ќе ни го купите).</li>
|
||||||
|
<li>Ако не ви е смешно тогаш е трагично. Ако не ви се допаѓаме. Најдете друга страна.</li>
|
||||||
|
<li>Смеата е најдобар лек. Користете ја дневно.</li>
|
||||||
|
<li>Ако ви се допаѓа, споделете. Ако не ви се допаѓа, сепак споделете. Гледаме статистики.</li>
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-display">Категории</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="font-body">
|
||||||
|
<ul className="space-y-3 p-3">
|
||||||
|
<li>
|
||||||
|
<strong>Општо:</strong> Општи вести и теми кои не паѓаат во другите категории
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Спорт:</strong> Спортски новости, победи, порази и сè помеѓу (најчесто порази)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Уметност:</strong> Култура, музика, филм и претставување дека разбираме од уметност
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Наука:</strong> Научни откритија објаснети на начин што ќе разбере и вашата баба
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>LIVE Блогови:</strong> Покривање во реално време на настани што го заслужуваат нашето внимание
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm bg-accent/5">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Coffee className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Поддржете не</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 font-body">
|
||||||
|
<p>
|
||||||
|
Сатирата не се пишува сама (иако понекогаш реалноста е поапсурдна од фикцијата).
|
||||||
|
Ако ви се допаѓа она што го правиме, размислете да ни купите кафе. Или две. Или три.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
* Сите донации одат за кафе, инспирација и плаќање на серверите. Не нудиме фискални сметки.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-12 p-8 border-4 border-foreground bg-foreground text-background text-center">
|
||||||
|
<p className="text-2xl font-display mb-4">
|
||||||
|
Запомнете:
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-body">
|
||||||
|
Ако не можете да разликувате сатира од вистина,<br />
|
||||||
|
проблемот не е во нас. Проблемот е во реалноста.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<{
|
||||||
@@ -20,12 +18,12 @@ export function AdminDashboardComponent() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
|
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
status: showArchived ? 'archived' : 'draft,live,ended'
|
status: showArchived ? 'archived' : 'draft,live,ended'
|
||||||
});
|
});
|
||||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({
|
const { data: articlesData, isLoading: loadingArticles } = useArticles({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
status: showArchived ? 'archived' : 'draft,published'
|
status: showArchived ? 'archived' : 'draft,published'
|
||||||
});
|
});
|
||||||
@@ -35,13 +33,10 @@ 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 });
|
||||||
@@ -72,7 +67,7 @@ export function AdminDashboardComponent() {
|
|||||||
|
|
||||||
const handleConfirmAction = async () => {
|
const handleConfirmAction = async () => {
|
||||||
if (!itemToDelete) return;
|
if (!itemToDelete) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
if (dialogType === 'delete') {
|
if (dialogType === '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,238 +138,263 @@ 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}
|
||||||
>
|
>
|
||||||
Објави
|
Објави
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<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}
|
||||||
>
|
>
|
||||||
Архивирај
|
Архивирај
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
onClick={() => handleDeleteClick('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
@@ -27,4 +27,4 @@ export const buttonVariants = cva(
|
|||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
|
||||||
|
import { trackShare } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface UseSocialShareOptions {
|
||||||
|
onSuccess?: (platform: SharePlatform) => void;
|
||||||
|
onError?: (platform: SharePlatform, error: Error) => void;
|
||||||
|
onCopySuccess?: () => void;
|
||||||
|
onCopyError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSocialShareReturn {
|
||||||
|
isSharing: boolean;
|
||||||
|
isCopying: boolean;
|
||||||
|
lastSharedPlatform: SharePlatform | null;
|
||||||
|
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
|
||||||
|
copyLink: (url: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
|
||||||
|
const [isSharing, setIsSharing] = useState(false);
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
|
||||||
|
|
||||||
|
const share = useCallback(async (
|
||||||
|
platform: SharePlatform,
|
||||||
|
data: ShareData & { articleId: string }
|
||||||
|
) => {
|
||||||
|
setIsSharing(true);
|
||||||
|
setLastSharedPlatform(platform);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Track the share event
|
||||||
|
await trackShare({
|
||||||
|
articleId: data.articleId,
|
||||||
|
platform,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
if (options.onSuccess) {
|
||||||
|
options.onSuccess(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open share URL for social platforms (not for 'link')
|
||||||
|
if (platform !== 'link') {
|
||||||
|
const shareUrl = getShareUrl(platform, data);
|
||||||
|
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to share on ${platform}:`, error);
|
||||||
|
|
||||||
|
// Call error callback
|
||||||
|
if (options.onError) {
|
||||||
|
options.onError(platform, error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still open the share URL even if tracking fails (for social platforms)
|
||||||
|
if (platform !== 'link') {
|
||||||
|
const shareUrl = getShareUrl(platform, data);
|
||||||
|
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const copyLink = useCallback(async (url: string): Promise<boolean> => {
|
||||||
|
setIsCopying(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await copyToClipboard(url);
|
||||||
|
|
||||||
|
if (success && options.onCopySuccess) {
|
||||||
|
options.onCopySuccess();
|
||||||
|
} else if (!success && options.onCopyError) {
|
||||||
|
options.onCopyError(new Error('Failed to copy to clipboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy link:', error);
|
||||||
|
|
||||||
|
if (options.onCopyError) {
|
||||||
|
options.onCopyError(error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsCopying(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSharing,
|
||||||
|
isCopying,
|
||||||
|
lastSharedPlatform,
|
||||||
|
share,
|
||||||
|
copyLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
+41
-41
@@ -1,51 +1,51 @@
|
|||||||
/* @tailwind base; */
|
@tailwind 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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { type SharePlatform } from './social-utils';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
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}/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}/analytics/shares?articleId=${articleId}`
|
||||||
|
: `${API_BASE_URL}/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}/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}/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
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
|
||||||
|
|
||||||
|
export interface ShareData {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
excerpt?: string;
|
||||||
|
image?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getShareUrl = (
|
||||||
|
platform: Exclude<SharePlatform, 'link'>,
|
||||||
|
data: ShareData
|
||||||
|
): string => {
|
||||||
|
const { title, url, excerpt } = data;
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const encodedTitle = encodeURIComponent(title);
|
||||||
|
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
|
||||||
|
case 'twitter':
|
||||||
|
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
|
||||||
|
case 'whatsapp':
|
||||||
|
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
|
||||||
|
case 'telegram':
|
||||||
|
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
|
||||||
|
case 'email':
|
||||||
|
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
|
||||||
|
default:
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const success = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlatformIcon = (platform: SharePlatform): string => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'Facebook';
|
||||||
|
case 'twitter':
|
||||||
|
return 'Twitter';
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'MessageCircle';
|
||||||
|
case 'telegram':
|
||||||
|
return 'Send';
|
||||||
|
case 'email':
|
||||||
|
return 'Mail';
|
||||||
|
case 'link':
|
||||||
|
return 'Link';
|
||||||
|
default:
|
||||||
|
return 'Share2';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlatformLabel = (platform: SharePlatform): string => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'Facebook';
|
||||||
|
case 'twitter':
|
||||||
|
return 'Twitter';
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'WhatsApp';
|
||||||
|
case 'telegram':
|
||||||
|
return 'Telegram';
|
||||||
|
case 'email':
|
||||||
|
return 'Email';
|
||||||
|
case 'link':
|
||||||
|
return 'Copy Link';
|
||||||
|
default:
|
||||||
|
return 'Share';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
|
||||||
|
const { title, excerpt, image, url } = data;
|
||||||
|
|
||||||
|
// Default values if not provided
|
||||||
|
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
|
||||||
|
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
|
||||||
|
const ogImage = image || '/placeholder-image.jpg';
|
||||||
|
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
ogImage,
|
||||||
|
ogUrl,
|
||||||
|
ogType: 'article' as const,
|
||||||
|
twitterCard: 'summary_large_image' as const,
|
||||||
|
twitterTitle: ogTitle,
|
||||||
|
twitterDescription: ogDescription,
|
||||||
|
twitterImage: ogImage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength - 3) + '...';
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Theme utilities for dark/light mode
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current theme from localStorage or system preference
|
||||||
|
*/
|
||||||
|
export function getInitialTheme(): Theme {
|
||||||
|
// Check localStorage first
|
||||||
|
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||||
|
if (savedTheme) {
|
||||||
|
return savedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check system preference
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to light
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme to document
|
||||||
|
*/
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize theme on page load
|
||||||
|
*/
|
||||||
|
export function initializeTheme(): void {
|
||||||
|
const theme = getInitialTheme();
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between light and dark themes
|
||||||
|
*/
|
||||||
|
export function toggleTheme(): Theme {
|
||||||
|
const currentTheme = getInitialTheme();
|
||||||
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
|
applyTheme(newTheme);
|
||||||
|
return newTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for system theme changes
|
||||||
|
*/
|
||||||
|
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
// Only update if user hasn't set a preference in localStorage
|
||||||
|
if (!localStorage.getItem('theme')) {
|
||||||
|
const theme = e.matches ? 'dark' : 'light';
|
||||||
|
callback(theme);
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+200
-142
@@ -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,24 @@ 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 { AboutComponent } from './components/routes/AboutComponent'
|
||||||
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 +33,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">
|
||||||
|
© 2026 Placebo.mk — Сите права се заштитени. Или не се.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,141 +85,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">
|
||||||
|
Ажурирања во реално време, прекршени вести. Нема одложувања, само факти.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,10 +158,34 @@ 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 aboutRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/about',
|
||||||
|
component: AboutComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
const articleDetailRoute = createRoute({
|
const articleDetailRoute = createRoute({
|
||||||
@@ -199,6 +195,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({
|
||||||
@@ -256,8 +310,12 @@ const adminDashboardRoute = createRoute({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
articlesRoute,
|
archiveRoute,
|
||||||
|
sportRoute,
|
||||||
|
artRoute,
|
||||||
|
scienceRoute,
|
||||||
|
aboutRoute,
|
||||||
articleDetailRoute,
|
articleDetailRoute,
|
||||||
liveBlogsRoute,
|
liveBlogsRoute,
|
||||||
liveBlogDetailRoute,
|
liveBlogDetailRoute,
|
||||||
@@ -273,4 +331,4 @@ declare module '@tanstack/react-router' {
|
|||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router
|
router: typeof router
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+323
-66
@@ -1,70 +1,172 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@layer base {
|
||||||
box-sizing: border-box;
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -76,18 +178,173 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@keyframes glitch {
|
||||||
* {
|
0% {
|
||||||
border-color: hsl(var(--border));
|
clip-path: inset(40% 0 61% 0);
|
||||||
|
transform: translate(-2px, 2px);
|
||||||
}
|
}
|
||||||
body {
|
20% {
|
||||||
background-color: hsl(var(--background));
|
clip-path: inset(92% 0 1% 0);
|
||||||
color: hsl(var(--foreground));
|
transform: translate(1px, -1px);
|
||||||
}
|
}
|
||||||
|
40% {
|
||||||
.animate-marquee {
|
clip-path: inset(43% 0 1% 0);
|
||||||
display: flex;
|
transform: translate(-1px, 2px);
|
||||||
animation: marquee 30s linear infinite;
|
}
|
||||||
width: max-content;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-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));
|
||||||
|
}
|
||||||
Generated
+108
-49
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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?
|
||||||
@@ -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;"]
|
||||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user