80 Commits
Author SHA1 Message Date
dimitar 46bbeed525 hero fix 2026-02-28 23:38:11 +01:00
dimitar 7258f059ce fix: remove duplicate /api/v1 in article detail route loader 2026-02-28 23:37:12 +01:00
dimitar a66db56156 fix: resolve API URL duplication, CORS issues, and image localhost URLs
- Fix duplicate /api/v1 in analytics.ts API calls
- Add STRAPI_PUBLIC_URL env var for public CMS access
- Update strapi.service to use public URL for images instead of localhost
- Images now use https://cms.placebo.mk instead of http://localhost:1337
2026-02-28 23:29:54 +01:00
dimitar ebd1fc28d5 fix: remove Traefik buffering middleware causing CMS timeout 2026-02-28 23:22:07 +01:00
dimitar cba9908180 fix: move Google Fonts to HTML head to resolve CSS @import order warning 2026-02-28 23:06:15 +01:00
dimitar 287dcb4070 fav 2026-02-28 22:46:42 +01:00
dimitar 50ac4531d3 typo fix 2026-02-28 22:38:23 +01:00
dimitar 79bd134977 fix: correct CSS @import order to resolve 504 gateway timeout 2026-02-28 22:14:53 +01:00
dimitar 88dbd2719d typo 2026-02-28 22:06:15 +01:00
dimitar e71dc2b90f foot 2026-02-28 21:38:07 +01:00
dimitar caf56d9fcd fix me 2026-02-28 20:32:10 +01:00
dimitar 86c1c2d366 upatstvo za upotreba 2026-02-28 20:22:35 +01:00
dimitar 4f2837ad4e feat: add About/Instructions page with comprehensive usage guide 2026-02-28 20:08:25 +01:00
dimitar c6427304ac feat: show live blog updates section open by default on homepage 2026-02-28 20:02:52 +01:00
dimitar 515c4cacf3 feat: update live blog UI with chat layout, fix page title and favicon 2026-02-28 19:48:56 +01:00
dimitar 59891b03dc fix: resolve file permissions issue for Strapi media uploads 2026-02-28 19:22:31 +01:00
dimitar 8f68dab53f fix: increase file upload limits for Strapi media library to 200MB 2026-02-28 19:07:02 +01:00
dimitar fa7dcc2b08 feat: add general category, fix header wrapping, show last 5 live blog updates 2026-02-28 18:47:11 +01:00
dimitar 3e61fe5694 fix: ensure slug and excerpt are never null to pass validation
- Extract slug generation to separate variable before using in DTO
- Convert null description to empty string for excerpt field
- Prevents class-validator from rejecting null values
- Fixes 'slug must be a string type' validation errors
2026-02-28 18:22:48 +01:00
dimitar a5008a3646 fix: add slug auto-generation fallback for articles without slugs
- Add generateSlug() helper to create URL-friendly slugs from titles
- Apply slug fallback in syncArticles() and syncSingleArticle()
- Handles articles created before slug field was added to schema
- Prevents 'slug cannot be null' database constraint errors
2026-02-28 18:02:33 +01:00
dimitar cbdb801655 fix: disable Authorization header for Strapi public API access
- Modify getHeaders() to always return empty object
- Prevents sending invalid Bearer token that causes 401 errors
- Strapi Public role permissions configured for unauthenticated access
- Will allow proper article sync from Strapi to backend
2026-02-28 17:46:59 +01:00
dimitar a5c57b33f7 fix: remove STRAPI_API_TOKEN to allow public API access
- Remove STRAPI_API_TOKEN environment variable from backend service
- Backend will now make requests to Strapi without Authorization header
- Allows Strapi Public role permissions to work correctly
- Fixes 401 Unauthorized error when syncing articles
2026-02-28 17:03:06 +01:00
dimitar b39972102a fix: use APP_GUARD provider for global authentication guard
- Replace manual guard instantiation with DI-managed APP_GUARD provider
- Fixes request timeout issue caused by improper Passport JWT strategy initialization
- Add @Public() decorator to remaining sync endpoints for public access
- Remove unused Reflector import from main.ts
2026-02-28 16:49:24 +01:00
dimitar 8fbde18d02 feat: add GET endpoint for sync/all for easier testing 2026-02-28 16:33:27 +01:00
dimitar 94b0239a0a fix: re-enable DATABASE_SYNCHRONIZE to create missing tables 2026-02-28 16:25:16 +01:00
dimitar ac610e6f6a fix: add missing slug and description fields to Article schema and fix auth header 2026-02-28 16:10:48 +01:00
dimitar 8eaaf4afad fix: convert Article API files from TypeScript to JavaScript for Strapi compatibility 2026-02-28 15:48:47 +01:00
dimitar 33008b64ce fix: enhance CORS configuration with explicit methods and headers 2026-02-28 15:33:45 +01:00
dimitar 005d368dc7 debug: add logging to check if article content type loads 2026-02-28 15:29:41 +01:00
dimitar a8c0ab8884 feat: add Article content type schema and API endpoints 2026-02-27 20:42:53 +01:00
dimitar e443ece848 fix: remove article API files to allow Strapi UI to recreate them 2026-02-27 20:32:22 +01:00
dimitar 76963f6eea aa 2026-02-27 20:10:49 +01:00
dimitar bfc7e76f17 fix: disable database sync and simplify backend startup 2026-02-27 20:10:03 +01:00
dimitar 06a0c9fe05 fix: temporarily disable lifecycle hooks and CMS dependency to fix startup
- Disable lifecycle hooks (rename to .disabled) to prevent CMS crash
- Remove backend dependency on CMS to allow independent startup
- This should fix the Gateway Timeout on both services
2026-02-27 18:25:22 +01:00
dimitar c1b49a4cb6 fix: use internal Docker network for backend-to-CMS communication
- Changed STRAPI_URL from https://cms.placebo.mk to http://cms:1337
- Added cms dependency to backend service
- This fixes Gateway Timeout errors when backend tries to fetch from Strapi
- Enables proper container-to-container communication
2026-02-27 17:47:42 +01:00
dimitar 4f30542014 feat: implement automatic webhook via Strapi lifecycle hooks 2026-02-27 17:11:04 +01:00
dimitar d7281024bf fix: configure Strapi public URL to prevent Gateway Timeout on publish 2026-02-27 16:18:24 +01:00
dimitar 4a22e8a18a feat: auto-seed admin user on backend startup 2026-02-27 16:06:08 +01:00
dimitar 994becc687 fix: copy src directory to include content type schemas in production 2026-02-24 21:01:13 +01:00
dimitar 69e758b841 fix: explicitly link Traefik routers to services to resolve Gateway Timeout 2026-02-24 19:59:15 +01:00
dimitar 17c6a1593c feat: enable database synchronization to initialize backend schema 2026-02-24 19:40:56 +01:00
dimitar d573846e5c fix: configure CORS to allow frontend and PWA domains
- Update backend main.ts to include www.placebo.mk in allowed origins
- Add logging to show which origins are allowed
- Set FRONTEND_URL, PWA_URL, STRAPI_URL env vars in docker-compose
- This fixes 'No Access-Control-Allow-Origin header' CORS errors
2026-02-24 19:28:54 +01:00
dimitar c3b01de12e fix: update CSP headers to allow production API domains
- Add https://api.placebo.mk to connect-src for API requests
- Add https://cms.placebo.mk to connect-src for CMS API
- Add Google Fonts domains to style-src and font-src
- Add manifest-src for PWA manifest
- This fixes 'Refused to connect' CSP violations
2026-02-24 19:16:22 +01:00
dimitar 80a2ee89a6 fix: create symlink for Strapi admin build location
Strapi 5 looks for the admin build at
/app/node_modules/@strapi/admin/dist/server/server/build
but we have it at /app/dist/build. Create a symlink to fix this.
2026-02-24 19:02:05 +01:00
dimitar ff0916b37c fix: copy .strapi metadata directory for admin panel
- Add .strapi directory copy to Dockerfile
- Add admin path configuration
- This should fix the admin panel serving from correct location
2026-02-24 18:42:51 +01:00
dimitar 6eb68b7bd3 debug: add detailed connection config logging
- Log the actual connection config being passed to Knex
- Show password length to verify it's set
- This should help identify why PostgreSQL sees 'root' user
2026-02-24 18:33:58 +01:00
dimitar fcbd082f6d feat: add detailed database configuration logging
- Log whether DATABASE_PASSWORD is set (without revealing value)
- Add timeout and debug options
- Restore correct nested connection structure for Strapi 5
- Add more diagnostic logging to troubleshoot connection issues
2026-02-24 18:26:25 +01:00
dimitar 183636bceb fix: improve PostgreSQL health checks and expose port 5432
- Add explicit expose directive for port 5432 on both postgres containers
- Improve health check to test specific database and user
- Should help with database connection issues
2026-02-24 18:13:10 +01:00
dimitar 5262e73a12 cms fix 2026-02-24 18:02:03 +01:00
dimitar 590a96e502 fix: correct Strapi database configuration structure
The database config had an incorrect nested structure with
connection.client.connection which caused Strapi to fail with
'Cannot destructure property client of db.config.connection'.

Fixed to use the correct flat structure:
connection.client, connection.host, etc.
2026-02-24 18:01:30 +01:00
dimitar 0ef26ba2f1 fix: add HTTP to HTTPS redirect and fix CMS Dockerfile
- Add HTTP router and redirect middleware for Let's Encrypt challenges
- Fix CMS Dockerfile to copy config from source (not dist)
- Add favicon.png to CMS container
- Enable automatic HTTPS redirect for all services
2026-02-24 17:52:51 +01:00
dimitar 2efdf20f88 refactor: use npm prune --omit=dev instead of deprecated --production flag 2026-02-24 17:41:38 +01:00
dimitar ed723bc429 fix: use 'https' entrypoint instead of 'websecure' for Traefik 2026-02-24 17:34:51 +01:00
dimitar b8dfde512c feat: add manual Traefik labels and coolify network to all services 2026-02-24 17:16:28 +01:00
dimitar 5fc424408e fix: correct health check endpoints and use IPv4 addresses 2026-02-24 16:49:17 +01:00
dimitar 749ff5659c port conf 2026-02-24 16:44:40 +01:00
dimitar a2caaab26a devops again 2026-02-24 15:13:50 +01:00
dimitar bc3e834a39 tr 2026-02-24 14:39:41 +01:00
dimitar 7a96b52cfc devops doodle 2026-02-24 14:17:29 +01:00
dimitar 5233eec96a devopse secret 2026-02-24 14:01:10 +01:00
dimitar 831da0af4d messing up deployment 2026-02-24 13:49:03 +01:00
dimitar a9528582fd strapi config 2026-02-24 13:31:31 +01:00
dimitar ee676b3916 db config 2026-02-24 13:15:47 +01:00
dimitar 9013bc51f3 docker files fix 2026-02-24 12:58:38 +01:00
dimitar 57ae0bb3b5 cool up 2026-02-23 06:21:45 +01:00
dimitar 3b827a90ac prod update 2026-02-23 05:54:35 +01:00
dimitar 3af4b92e1b legacy 2026-02-23 05:28:46 +01:00
dimitar cfc43a7119 docker fix 2026-02-23 05:18:40 +01:00
dimitar dc93a8b652 compose coolify 2026-02-23 04:51:14 +01:00
dimitar 69833d2067 prod 2026-02-22 05:30:47 +01:00
dimitar d041575600 logs removed 2026-02-22 04:49:38 +01:00
dimitar 57864e00da push notification
fix implemented
2026-02-22 04:00:20 +01:00
dimitar 0bbf2ab56f push notification implemented
need testing
2026-02-22 02:23:41 +01:00
dimitar 26a17b5a4c Merge branch 'pwa' 2026-02-22 01:27:13 +01:00
dimitar 7725674fd5 implemented 2026-02-22 01:20:59 +01:00
dimitar f1f16a80a5 pwa ready 2026-02-22 01:11:20 +01:00
dimitar 83c60ce40d ag 2026-02-22 00:44:46 +01:00
dimitar f20842b3ab xxl 2026-02-16 21:50:11 +01:00
dimitar 8510cccb6e buymeacoffe added 2026-02-16 21:20:58 +01:00
dimitar c2c77ac92a Merge branch 'redesignNo1' 2026-02-16 20:45:29 +01:00
167 changed files with 32476 additions and 935 deletions

No files matched your search

+112 -161
View File
@@ -1,218 +1,169 @@
# Agent Guidelines for placebo.mk # Agent Guidelines for placebo.mk
## Project Overview ## Project Overview
News site in Macedonia with sarcastic tone. Minimalistic design using TanStack stack. Macedonian satirical news site using TanStack stack + NestJS backend.
## Tech Stack ## Tech Stack
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS + shadcn/ui - **Frontend**: React 19 + TanStack (Query, Router) + Vite + Tailwind CSS + shadcn/ui
- **Backend**: NestJS + TypeORM + SQLite - **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi (in /cms) - **CMS**: Strapi (in /cms)
## Root-Level Commands ## Commands
### Root Level
```bash ```bash
# Docker development
npm run docker:up # Start all services
npm run docker:down # Stop all services
npm run docker:build # Rebuild containers
npm run docker:logs # View logs
npm run dev:docker # Start docker dev (alias)
# Local development
npm run dev:local # Start backend & frontend locally npm run dev:local # Start backend & frontend locally
npm run dev:backend:local # Backend only (local env) npm run dev:docker # Start via docker-compose
npm run dev:frontend:local # Frontend only (local env)
# Code quality
npm run lint # Lint both projects npm run lint # Lint both projects
npm run lint:fix # Auto-fix lint issues npm run lint:fix # Auto-fix lint issues
npm run type-check # Type check both projects npm run type-check # Type check both projects
# Database operations
npm run db:backup # Backup database
npm run db:restore # Restore database
npm run db:reset # Reset database
# Environment
npm run reset:env # Reset to docker environment
``` ```
## Build Commands ### Backend (cd backend)
### Backend (NestJS)
```bash ```bash
cd backend
npm install
npm run start:dev # Watch mode npm run start:dev # Watch mode
npm run start:debug # Debug mode npm run lint / lint:fix # ESLint
npm run build
npm run start:prod
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests npm test # All tests
npm test:watch # Watch mode tests npm test app.service.spec # Single file (omit .ts)
npm test -t "should return" # By pattern
npm test:cov # Coverage npm test:cov # Coverage
npm test:e2e # E2E tests npm test:e2e # E2E tests
npm test app.service.spec.ts # Single file
npm test -t "should return" # By test name pattern
npm run format # Format with Prettier
npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
### Frontend (TanStack) ### Frontend (cd frontend)
```bash ```bash
cd frontend
npm install
npm run dev # Vite dev server npm run dev # Vite dev server
npm run build npm run lint / lint:fix # ESLint
npm run preview
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests (Vitest) npm test # All tests (Vitest)
npm test Header.test # Single file (omit .tsx)
npm test -t "renders" # By pattern
npm test:ui # Vitest UI npm test:ui # Vitest UI
npm test:coverage # Test coverage npm test:coverage # Coverage
npm test Header.test.tsx # Single file
npm test -t "renders" # By test name pattern
npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
## Code Style ## Code Style
### Formatting & TypeScript ### TypeScript
- Use Prettier (2 spaces, single quotes, trailing commas, 100 char max) - Strict mode: `noImplicitAny`, `strictNullChecks`, `noFallthroughCasesInSwitch`
- Strict TypeScript - no implicit `any`, avoid `any`, use `unknown` - No implicit `any` - use `unknown` when type uncertain
- Explicit return types for public methods - Explicit return types for public methods
- Prefer interfaces over types for objects - Prefer `interface` over `type` for objects
- Use `readonly` for immutable properties - Use `readonly` for immutable properties
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
- Frontend: Path alias `@/*` maps to `./src/*`
### Naming Conventions ### Formatting (Prettier)
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`) - Single quotes, trailing commas, 2-space indent
- **Classes**: PascalCase (`UserService`, `AuthGuard`) - No semicolons in comments
- **Functions/Variables**: camelCase (`getUser`, `isLoading`)
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
- **Private members**: underscore prefix (`_cache`, `_validate()`)
- **Interfaces**: PascalCase, no 'I' prefix (`User`, `ApiResponse`)
### Import Order ### Naming
- External libraries first, then internal modules, then relative imports | Element | Convention | Example |
- Group imports with blank lines between groups |---------|------------|---------|
- Example: | Files | kebab-case | `user-profile.ts`, `auth.service.ts` |
```typescript | Classes | PascalCase | `UserService`, `AuthGuard` |
// External | Functions/Variables | camelCase | `getUser`, `isLoading` |
import { Module } from '@nestjs/common'; | Constants | UPPER_SNAKE_CASE | `MAX_RETRIES` |
import { TypeOrmModule } from '@nestjs/typeorm'; | Private members | underscore prefix | `_cache`, `_validate()` |
| Interfaces | PascalCase (no I prefix) | `User`, `ApiResponse` |
// Internal modules ### Imports
import { AuthModule } from '../auth/auth.module'; Group with blank lines: External → Internal → Relative
```typescript
// External
import { Injectable } from '@nestjs/common';
import { useQuery } from '@tanstack/react-query';
// Relative imports // Internal modules
import { UserService } from './user.service'; import { UserService } from '../users/user.service';
import { UserController } from './user.controller';
```
// Relative
import { AuthResponse } from './types';
```
### File Structure ### File Structure
``` ```
backend/src/ backend/src/
modules/feature-name/ modules/{feature}/
feature-name.module.ts {feature}.module.ts
feature-name.controller.ts {feature}.controller.ts
feature-name.service.ts {feature}.service.ts
feature-name.entity.ts {feature}.dto.ts
feature-name.dto.ts
common/{decorators,filters,guards,interceptors,pipes} common/{decorators,filters,guards,interceptors,pipes}
config/ entities.ts # All TypeORM entities
frontend/src/ frontend/src/
components/{ui,layout,features}/ components/{ui,layout,features,admin,routes}/
hooks/ hooks/ # Custom React hooks
lib/ queries/ # TanStack Query hooks
queries/ lib/ # Utilities, API client
routes/ routes/ # TanStack Router routes
types/ types/ # TypeScript types
utils/ ```
### Path Aliases
- Frontend: `@/*` maps to `./src/*`
- Import UI components: `import { Button } from '@/components/ui/button'`
## Patterns
### Backend Services (NestJS)
```typescript
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async login(dto: LoginUserDto): Promise<AuthResponse> {
// Implementation
}
}
```
### Frontend Hooks (TanStack Query)
```typescript
export function useArticles(params: FindArticlesParams = {}) {
return useQuery({
queryKey: ['articles', params],
queryFn: () => api.fetchArticles(params),
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createArticle,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
}
``` ```
### Error Handling ### Error Handling
- **Backend**: Use NestJS exceptions (`NotFoundException`, `BadRequestException`), custom exceptions, Logger - **Backend**: NestJS exceptions (`NotFoundException`, `BadRequestException`)
- **Frontend**: Error boundaries, try-catch with user-friendly messages - **Frontend**: Error boundaries, try-catch with user-friendly messages
### API Conventions ### API Response Format
- REST endpoints: `/api/v1/resources` ```typescript
- JSON fields: snake_case { data: T, meta: { total, page, limit }, error?: { code, message } }
- Pagination: `page`, `limit` query params ```
- Response format:
```typescript
{
data: T,
meta: { total, page, limit },
error?: { code, message }
}
```
### Testing ## Testing
- **Backend**: Jest with ts-jest transformer - **Backend**: Jest (`*.spec.ts`), AAA pattern, mock dependencies
- **Frontend**: Vitest with React Testing Library patterns - **Frontend**: Vitest + React Testing Library (`*.test.tsx`)
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert) - Descriptive names: `should return user data when valid ID provided`
- Integration tests for API endpoints
- E2E tests for critical user flows
- Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`)
- Mock external dependencies (use Jest for backend, Vitest for frontend)
- Run `npm test` for all tests or target specific files/names
- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend)
- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend)
### Git Workflow ## Git Commits
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` - Conventional: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
- Keep commits atomic and focused - Atomic, imperative mood
- Write clear commit messages in imperative mood
### Component Guidelines ## UI Components
- Keep components small and focused (< 200 lines) - Use shadcn/ui from `components/ui/`
- Use functional components with hooks - CVA for variants, clsx + tailwind-merge for classes
- Prefer composition over inheritance
- Extract complex logic into custom hooks
- Use TanStack Query for server state management
- Use TanStack Router for routing
- Validate props with TypeScript
### Database ## Agent Instructions
- Use TypeORM for SQLite - **ALWAYS** run `npm run lint` and `npm run type-check` after changes
- Define entities with proper relationships - **NEVER** commit without explicit request
- Use migrations for schema changes
- Seed data with factory functions
- Use TypeORM repositories for data access
### UI Components
- Use shadcn/ui components from `components/ui/`
- Use class-variance-authority (CVA) for component variants
- Use clsx and tailwind-merge for class composition
- Follow shadcn patterns for new component creation
## Environment Variables
- Backend: `@nestjs/config` with `.env` files
- Frontend: Vite env vars (`VITE_API_URL`)
- Never commit secrets to the repository.
- Example frontend env: `VITE_API_URL=http://localhost:3000`
- Example backend env: `DATABASE_PATH=./data.db`
- Store sensitive config in `.env` files
- Use `DATABASE_PATH` for SQLite location
- Use `@nestjs/config` for backend environment management
- Frontend env vars must be prefixed with `VITE_`
## Agent-Specific Instructions
- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes
- **NEVER** commit changes without explicit user request
- **ALWAYS** follow existing code patterns and conventions
- **PREFER** editing existing files over creating new ones - **PREFER** editing existing files over creating new ones
- **VERIFY** tests pass before considering work complete - **VERIFY** tests pass before considering work complete
- **USE** the root-level commands for common operations - **CHECK** both backend/frontend when making API changes
- **CHECK** both backend and frontend when making API changes
+28
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+7 -8
View File
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install ALL dependencies (including devDependencies for build)
RUN npm ci --only=production RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -18,6 +17,9 @@ COPY . .
# Build TypeScript # Build TypeScript
RUN npm run build RUN npm run build
# Prune devDependencies after build (suppress warning)
RUN npm prune --omit=dev
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
@@ -32,9 +34,6 @@ COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration
COPY --chown=nodejs:nodejs .env.example .env
# Switch to non-root user # Switch to non-root user
USER nodejs USER nodejs
@@ -46,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
EXPOSE 3000 EXPOSE 3000
# Start application # Start application
CMD ["node", "dist/main.js"] CMD ["node", "dist/src/main.js"]
+13
View File
@@ -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
+101 -31
View File
@@ -31,7 +31,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -43,6 +44,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -234,7 +236,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -2160,7 +2161,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2331,7 +2331,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz",
"integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"file-type": "21.3.0", "file-type": "21.3.0",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -2379,7 +2378,6 @@
"integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@nuxt/opencollective": "0.4.1", "@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1", "fast-safe-stringify": "2.1.1",
@@ -2456,7 +2454,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz",
"integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
@@ -2904,7 +2901,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "*", "@types/estree": "*",
"@types/json-schema": "*" "@types/json-schema": "*"
@@ -3030,7 +3026,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3143,6 +3138,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3205,7 +3210,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -3894,7 +3898,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3984,7 +3987,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -4199,6 +4201,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4401,6 +4415,12 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -4469,7 +4489,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4789,7 +4808,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -4846,15 +4864,13 @@
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
@@ -5662,7 +5678,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5723,7 +5738,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -6208,6 +6222,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
}, },
@@ -6744,6 +6759,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -7221,7 +7245,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -8544,6 +8567,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9292,7 +9321,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"passport-strategy": "1.x.x", "passport-strategy": "1.x.x",
"pause": "0.0.1", "pause": "0.0.1",
@@ -9421,7 +9449,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0", "pg-pool": "^3.11.0",
@@ -9705,7 +9732,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9795,7 +9821,8 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
@@ -9946,8 +9973,7 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
@@ -10092,7 +10118,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -10980,7 +11005,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11322,7 +11346,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -11495,7 +11518,6 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sqltools/formatter": "^1.2.5", "@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0", "ansis": "^4.2.0",
@@ -11702,7 +11724,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11996,6 +12017,47 @@
"defaults": "^1.0.3" "defaults": "^1.0.3"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.105.0", "version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
@@ -12090,6 +12152,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@@ -12108,6 +12171,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
}, },
@@ -12121,6 +12185,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"esrecurse": "^4.3.0", "esrecurse": "^4.3.0",
"estraverse": "^4.1.1" "estraverse": "^4.1.1"
@@ -12135,6 +12200,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
} }
@@ -12144,7 +12210,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/webpack/node_modules/mime-db": { "node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
@@ -12152,6 +12219,7 @@
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -12162,6 +12230,7 @@
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -12175,6 +12244,7 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.9", "@types/json-schema": "^7.0.9",
"ajv": "^8.9.0", "ajv": "^8.9.0",
+5 -2
View File
@@ -24,7 +24,8 @@
"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" "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",
@@ -49,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",
@@ -61,6 +63,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
+46
View File
@@ -0,0 +1,46 @@
import * as webpush from 'web-push';
import * as fs from 'fs';
import * as path from 'path';
const envPath = path.join(__dirname, '..', '.env');
function generateVapidKeys(): void {
console.log('Generating VAPID keys...');
const vapidKeys = webpush.generateVAPIDKeys();
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf-8');
}
const lines = envContent.split('\n');
const vapidSubject = `VAPID_SUBJECT=mailto:contact@placebo.mk`;
const vapidPublicKey = `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`;
const vapidPrivateKey = `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`;
const updatedLines = lines.filter(
(line) =>
!line.startsWith('VAPID_SUBJECT=') &&
!line.startsWith('VAPID_PUBLIC_KEY=') &&
!line.startsWith('VAPID_PRIVATE_KEY='),
);
const nonEmptyLines = updatedLines.filter((line) => line.trim() !== '');
const newEnvContent =
nonEmptyLines.join('\n') +
(nonEmptyLines.length > 0 ? '\n' : '') +
`${vapidSubject}\n${vapidPublicKey}\n${vapidPrivateKey}\n`;
fs.writeFileSync(envPath, newEnvContent);
console.log('VAPID keys generated and added to .env file:');
console.log(` Public Key: ${vapidKeys.publicKey}`);
console.log(` Private Key: ${vapidKeys.privateKey}`);
console.log('\nKeep your private key secret!');
}
generateVapidKeys();
+10
View File
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Public } from './modules/auth/public.decorator';
@Controller() @Controller()
export class AppController { export class AppController {
@@ -9,4 +10,13 @@ export class AppController {
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
@Public()
@Get('health')
healthCheck(): { status: string; timestamp: string } {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
} }
+13 -1
View File
@@ -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';
@@ -10,6 +11,7 @@ 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 { AnalyticsModule } from './modules/analytics/analytics.module';
import { PushModule } from './modules/push/push.module';
import { import {
Article, Article,
Author, Author,
@@ -21,6 +23,8 @@ import {
Reaction, Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity'; 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: [
@@ -44,6 +48,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
Comment, Comment,
Reaction, Reaction,
ShareEvent, 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',
@@ -55,8 +60,15 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
AuthModule, AuthModule,
CommentModule, CommentModule,
AnalyticsModule, AnalyticsModule,
PushModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthPublicGuard,
},
],
}) })
export class AppModule {} export class AppModule {}
+11 -7
View File
@@ -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);
+2
View File
@@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller'; import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities'; import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Article, Author, Category]), TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [ArticlesController], controllers: [ArticlesController],
providers: [ArticlesService], providers: [ArticlesService],
+21
View File
@@ -4,6 +4,7 @@ import {
Logger, Logger,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -14,6 +15,7 @@ import {
UpdateArticleDto, UpdateArticleDto,
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
@Injectable() @Injectable()
export class ArticlesService { export class ArticlesService {
@@ -24,6 +26,9 @@ export class ArticlesService {
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
async create(dto: CreateArticleDto): Promise<Article> { async create(dto: CreateArticleDto): Promise<Article> {
@@ -183,6 +188,7 @@ export class ArticlesService {
status: ArticleStatus = ArticleStatus.PUBLISHED, status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> { ): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
const wasDraft = article.status === ArticleStatus.DRAFT;
article.status = status; article.status = status;
const savedArticle = await this.articleRepository.save(article); const savedArticle = await this.articleRepository.save(article);
@@ -202,6 +208,21 @@ export class ArticlesService {
} }
} }
// Send push notification for newly published articles
if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) {
try {
await this.pushService.notifyNewArticle(
savedArticle.title,
savedArticle.slug,
);
this.logger.log(
`Push notification sent for article: ${savedArticle.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedArticle; return savedArticle;
} }
+2
View File
@@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller'; import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [LiveBlogController], controllers: [LiveBlogController],
providers: [LiveBlogService], providers: [LiveBlogService],
+21
View File
@@ -5,6 +5,7 @@ import {
OnModuleInit, OnModuleInit,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
@@ -19,6 +20,7 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
interface SseClient { interface SseClient {
id: string; id: string;
@@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
update: savedUpdate, update: savedUpdate,
}); });
// Send push notification for live blog updates (only for live blogs)
if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) {
try {
await this.pushService.notifyLiveBlogUpdate(
liveBlogEntity.title,
liveBlogEntity.slug,
savedUpdate.content,
);
this.logger.debug(
`Push notification sent for live blog update: ${liveBlogEntity.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedUpdate; return savedUpdate;
} }
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('push_subscriptions')
export class PushSubscriptionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ type: 'text', unique: true })
endpoint: string;
@Column({ type: 'text' })
p256dh: string;
@Column({ type: 'text' })
auth: string;
@Column({ type: 'varchar', nullable: true })
userId: string | null;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Delete,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { PushService } from './push.service';
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
import { Public } from '../auth/public.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { UserRole } from '../entities';
@Controller('push')
@UseGuards(RolesGuard)
export class PushController {
constructor(private readonly pushService: PushService) {}
@Public()
@Get('vapid-public-key')
getVapidPublicKey(): { publicKey: string | null } {
return { publicKey: this.pushService.getPublicKey() };
}
@Public()
@Post('subscribe')
@HttpCode(HttpStatus.CREATED)
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
await this.pushService.subscribe(dto);
return { success: true };
}
@Public()
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
await this.pushService.unsubscribe(dto);
}
@Roles(UserRole.ADMIN)
@Get('stats')
getStats(): Promise<{ totalSubscribers: number }> {
return this.pushService.getStats();
}
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Post('send')
@HttpCode(HttpStatus.OK)
async sendNotification(
@Body() dto: SendNotificationDto,
): Promise<{ sent: number; failed: number }> {
return this.pushService.sendToAll({
title: dto.title,
body: dto.body,
url: dto.url,
tag: 'admin-notification',
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class SubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
@IsString()
@IsNotEmpty()
p256dh: string;
@IsString()
@IsNotEmpty()
auth: string;
@IsOptional()
@IsString()
userId?: string;
}
export class UnsubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
}
export class SendNotificationDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
body: string;
@IsOptional()
@IsString()
url?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PushController } from './push.controller';
import { PushService } from './push.service';
import { PushSubscriptionEntity } from './push-subscription.entity';
@Module({
imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])],
controllers: [PushController],
providers: [PushService],
exports: [PushService],
})
export class PushModule {}
+178
View File
@@ -0,0 +1,178 @@
import * as webpush from 'web-push';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PushSubscriptionEntity } from './push-subscription.entity';
import { SubscribeDto, UnsubscribeDto } from './push.dto';
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
url?: string;
tag?: string;
}
@Injectable()
export class PushService implements OnModuleInit {
private readonly logger = new Logger(PushService.name);
constructor(
private configService: ConfigService,
@InjectRepository(PushSubscriptionEntity)
private subscriptionRepo: Repository<PushSubscriptionEntity>,
) {}
onModuleInit() {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
const subject = this.configService.get<string>(
'VAPID_SUBJECT',
'mailto:contact@placebo.mk',
);
if (!publicKey || !privateKey) {
this.logger.warn(
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
);
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
this.logger.log('VAPID keys configured successfully');
}
getPublicKey(): string | null {
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
}
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint },
});
if (existing) {
if (dto.userId && existing.userId !== dto.userId) {
existing.userId = dto.userId;
return this.subscriptionRepo.save(existing);
}
return existing;
}
const subscription = this.subscriptionRepo.create({
endpoint: dto.endpoint,
p256dh: dto.p256dh,
auth: dto.auth,
userId: dto.userId ?? null,
});
return this.subscriptionRepo.save(subscription);
}
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
}
async sendToAll(
payload: PushPayload,
): Promise<{ sent: number; failed: number }> {
const subscriptions = await this.subscriptionRepo.find();
if (subscriptions.length === 0) {
return { sent: 0, failed: 0 };
}
const notification = JSON.stringify({
title: payload.title,
body: payload.body,
icon: payload.icon ?? '/icons/icon-192.png',
badge: payload.badge ?? '/icons/badge-72.png',
url: payload.url ?? '/',
tag: payload.tag,
});
let sent = 0;
let failed = 0;
const results = await Promise.allSettled(
subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
notification,
{
TTL: 86400,
},
);
return { success: true, id: sub.id };
} catch (error) {
if (error instanceof Error) {
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
}
if (
error instanceof webpush.WebPushError &&
(error.statusCode === 410 || error.statusCode === 404)
) {
await this.subscriptionRepo.delete({ id: sub.id });
this.logger.log(`Removed invalid subscription: ${sub.id}`);
}
return { success: false, id: sub.id };
}
}),
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) {
sent++;
} else {
failed++;
}
}
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
return { sent, failed };
}
async notifyNewArticle(
articleTitle: string,
articleSlug: string,
): Promise<void> {
await this.sendToAll({
title: 'Нови вести! 📰',
body: articleTitle,
url: `/article/${articleSlug}`,
tag: 'new-article',
});
}
async notifyLiveBlogUpdate(
blogTitle: string,
blogSlug: string,
updatePreview: string,
): Promise<void> {
const body =
updatePreview.length > 100
? updatePreview.substring(0, 97) + '...'
: updatePreview;
await this.sendToAll({
title: `${blogTitle} 📡`,
body,
url: `/live-blog/${blogSlug}`,
tag: `live-blog-${blogSlug}`,
});
}
async getStats(): Promise<{ totalSubscribers: number }> {
const totalSubscribers = await this.subscriptionRepo.count();
return { totalSubscribers };
}
}
+11 -1
View File
@@ -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(),
+34 -15
View File
@@ -87,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(
@@ -101,14 +102,28 @@ export class StrapiService {
) { ) {
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() {
return { // Use public API access - no authentication needed
Authorization: `Bearer ${this.strapiApiToken}`, // Strapi Public role has been configured to allow article access
}; return {};
}
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( private async findOrCreateCategory(
@@ -120,6 +135,7 @@ export class StrapiService {
// Map CMS category slugs to Macedonian display names // Map CMS category slugs to Macedonian display names
const categoryMap: Record<string, { name: string; description: string }> = { const categoryMap: Record<string, { name: string; description: string }> = {
general: { name: 'Општо', description: 'Општи вести и теми' },
sport: { name: 'Спорт', description: 'Спортски вести и анализи' }, sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
art: { name: 'Уметност', description: 'Уметност, култура и забава' }, art: { name: 'Уметност', description: 'Уметност, култура и забава' },
science: { name: 'Наука', description: 'Научни откритија и технологија' }, science: { name: 'Наука', description: 'Научни откритија и технологија' },
@@ -170,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;
@@ -198,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;
@@ -237,11 +249,15 @@ export class StrapiService {
} }
} }
// 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,
@@ -324,11 +340,14 @@ export class StrapiService {
} }
} }
// 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,
+20
View File
@@ -0,0 +1,20 @@
# Server
HOST=0.0.0.0
PORT=1337
# Secrets (these should be overridden by docker env vars)
APP_KEYS=tobereplaced
API_TOKEN_SALT=tobereplaced
ADMIN_JWT_SECRET=tobereplaced
TRANSFER_TOKEN_SALT=tobereplaced
JWT_SECRET=tobereplaced
# Database (these MUST be overridden by docker env vars)
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres-cms
DATABASE_PORT=5432
DATABASE_NAME=placebo_cms_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_pass
DATABASE_SSL=false
+30 -15
View File
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci --only=production RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -18,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"]
+1
View File
@@ -0,0 +1 @@
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb
+5
View File
@@ -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
View File
@@ -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),
},
};
}; };
+11 -1
View File
@@ -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',
+7 -1
View File
@@ -1 +1,7 @@
export default () => ({}); export default ({ env }) => ({
upload: {
config: {
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
},
},
});
+1
View File
@@ -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'),
}, },
+12
View File
@@ -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 "$@"
+2
View File
@@ -0,0 +1,2 @@
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999
+38
View File
@@ -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,13 +61,12 @@
"default": "inline" "default": "inline"
}, },
"videoCaption": { "videoCaption": {
"type": "string", "type": "string"
"default": ""
}, },
"category": { "category": {
"type": "enumeration", "type": "enumeration",
"enum": ["sport", "art", "science"], "enum": ["general", "sport", "art", "science"],
"default": "sport", "default": "general",
"required": true "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');
+5 -1
View File
@@ -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']);
},
}; };
+280
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
# Production Docker Compose for Coolify
# Each service should be deployed separately in Coolify
# This file is for reference and local testing only
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: placebo-postgres
restart: unless-stopped
environment:
POSTGRES_DB: placebo_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_db']
interval: 10s
timeout: 5s
retries: 5
networks:
- placebo-network
# Backend API (NestJS)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_NAME: placebo_db
DATABASE_SYNCHRONIZE: 'false'
DATABASE_LOGGING: 'false'
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRATION: '86400'
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
ports:
- '3000:3000'
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-network
# Frontend (React)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-frontend
restart: unless-stopped
ports:
- '3001:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# PWA (Progressive Web App)
pwa:
build:
context: ./pwa
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-pwa
restart: unless-stopped
ports:
- '5174:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# CMS (Strapi)
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_SSL: 'false'
HOST: 0.0.0.0
PORT: 1337
APP_KEYS: ${STRAPI_APP_KEYS:-key1,key2,key3,key4}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT:-change-me}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET:-change-me}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT:-change-me}
JWT_SECRET: ${STRAPI_JWT_SECRET:-change-me}
ports:
- '1337:1337'
depends_on:
postgres:
condition: service_healthy
volumes:
- cms_uploads:/app/public/uploads
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-network
volumes:
postgres_data:
driver: local
cms_uploads:
driver: local
networks:
placebo-network:
driver: bridge
+10 -10
View File
@@ -5,12 +5,19 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Build arguments for environment variables
ARG VITE_API_URL
ARG VITE_CMS_URL
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_CMS_URL=$VITE_CMS_URL
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -21,19 +28,12 @@ RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM nginx:alpine
# Create non-root user
RUN addgroup -g 1001 -S nginx && \
adduser -S nginx -u 1001 -G nginx
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration # Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Switch to non-root user
USER nginx
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
+6 -3
View File
@@ -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
View File
@@ -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

+4 -2
View File
@@ -25,7 +25,8 @@ export function ArticleTicker() {
{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"
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" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
@@ -34,7 +35,8 @@ export function ArticleTicker() {
{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"
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" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
@@ -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>
);
}
@@ -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 && (
@@ -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>
@@ -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">
+5 -5
View File
@@ -57,9 +57,9 @@ export function HeroArticle() {
{article.featuredImage && ( {article.featuredImage && (
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<div className="absolute top-0 left-0 z-10"> <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 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> </span>
</div> </div>
<div className="relative h-72 md:h-96 overflow-hidden"> <div className="relative h-72 md:h-96 overflow-hidden">
<img <img
@@ -122,9 +122,9 @@ export function HeroArticle() {
)} )}
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10"> <div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
<Link to={`/articles/${article.id}`}> <Link to="/articles/$id" params={{ id: article.id }}>
<Button variant="brutalAccent" className="gap-2"> <Button variant="brutalAccent" className="gap-2">
Read Full Story Прочитај повеќе
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Button> </Button>
</Link> </Link>
@@ -65,7 +65,8 @@ export function LatestArticlesGrid() {
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)}`} 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 <Link
to={`/articles/${article.id}`} to="/articles/$id"
params={{ id: article.id }}
className="block" className="block"
> >
{article.featuredImage ? ( {article.featuredImage ? (
@@ -107,12 +108,12 @@ export function LatestArticlesGrid() {
</span> </span>
{article.category && ( {article.category && (
<Link <a
to={`/${article.category.slug}`} 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" 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} {article.category.name}
</Link> </a>
)} )}
</div> </div>
@@ -121,7 +122,7 @@ export function LatestArticlesGrid() {
articleId={article.id} articleId={article.id}
title={article.title} title={article.title}
url={`${window.location.origin}/articles/${article.id}`} url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt} excerpt={article.excerpt ?? undefined}
image={article.featuredImage} image={article.featuredImage}
tags={article.tags} tags={article.tags}
variant="compact" variant="compact"
@@ -2,9 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api'; import { fetchPinnedLiveBlogs } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react'; import { Calendar, Eye, MessageSquare, Pin, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import { useState } from 'react';
export function PinnedLiveBlogsSidebar() { export function PinnedLiveBlogsSidebar() {
const [showUpdates, setShowUpdates] = useState(true);
const { data: liveBlogs, isLoading, error } = useQuery({ const { data: liveBlogs, isLoading, error } = useQuery({
queryKey: ['pinned-live-blogs'], queryKey: ['pinned-live-blogs'],
queryFn: fetchPinnedLiveBlogs, queryFn: fetchPinnedLiveBlogs,
@@ -47,6 +50,52 @@ export function PinnedLiveBlogsSidebar() {
}); });
}; };
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) { if (isLoading) {
return ( return (
<div className="border-brutal-sm bg-card p-6"> <div className="border-brutal-sm bg-card p-6">
@@ -102,6 +151,59 @@ export function PinnedLiveBlogsSidebar() {
return ( return (
<div className="border-brutal-sm bg-card p-6"> <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 justify-between mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-accent" /> <Pin className="h-5 w-5 text-accent" />
+12 -3
View File
@@ -32,6 +32,7 @@ export function Header() {
{ to: '/science', label: 'Наука' }, { to: '/science', label: 'Наука' },
{ to: '/archive', label: 'Архива' }, { to: '/archive', label: 'Архива' },
{ to: '/live-blogs', label: 'LIVE' }, { to: '/live-blogs', label: 'LIVE' },
{ to: '/about', label: 'Упатство за употреба' },
]; ];
const adminLinks = [ const adminLinks = [
@@ -42,7 +43,7 @@ export function Header() {
return ( return (
<header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]"> <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="border-b-2 border-foreground/20">
<div className="container mx-auto max-w-6xl px-4"> <div className="container mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between py-2"> <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"> <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" /> <Zap className="w-3 h-3" />
@@ -58,7 +59,7 @@ export function Header() {
<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">
<Link to="/" className="group"> <Link to="/" className="group">
<h1 className="text-4xl md:text-5xl font-display tracking-tight"> <h1 className="text-4xl md:text-5xl font-display tracking-tight whitespace-nowrap">
<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-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.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 delay-100">a</span>
@@ -119,7 +120,15 @@ export function Header() {
</Link> </Link>
)} )}
<div className="ml-4 pl-4 border-l-2 border-foreground/20"> <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 /> <ThemeToggle />
</div> </div>
</div> </div>
@@ -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>
);
}
@@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
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() {
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0} {liveBlogs.filter((b) => b.status === 'live').length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">
Активни live блогови
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0} {articles.filter((a) => a.status === 'published').length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p> <p className="text-xs font-body text-muted-foreground mt-1">
Објавени написи
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0} {liveBlogs.filter((b) => b.isPinned).length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">
Закачени live блогови
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + {(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)} (articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p> <p className="text-xs font-body text-muted-foreground mt-1">
Вкупни прегледи
</p>
</div> </div>
</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">
<div className="border-brutal bg-card"> <div className="border-brutal bg-card">
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between"> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
@@ -39,9 +39,10 @@ export function ArchiveComponent() {
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
> >
<Link <Link
to={`/articles/${article.id}`} to="/articles/$id"
className="block mb-4" params={{ id: article.id }}
> className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2"> <h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title} {article.title}
</h2> </h2>
@@ -66,14 +67,14 @@ export function ArchiveComponent() {
</div> </div>
<SocialShareButtons <SocialShareButtons
articleId={article.id} articleId={article.id}
title={article.title} title={article.title}
url={`${window.location.origin}/articles/${article.id}`} url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt} excerpt={article.excerpt ?? undefined}
image={article.featuredImage} image={article.featuredImage}
tags={article.tags} tags={article.tags}
variant="compact" variant="compact"
/> />
</div> </div>
</div> </div>
))} ))}
@@ -42,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">
@@ -73,15 +73,23 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
{/* Social Sharing */} {/* Social Sharing */}
<div className="mb-8"> <div className="mb-8 flex flex-wrap items-center gap-4">
<SocialShareButtons <SocialShareButtons
articleId={data.id} articleId={data.id}
title={data.title} title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''} url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt} excerpt={data.excerpt ?? undefined}
image={data.featuredImage} image={data.featuredImage}
tags={data.tags} 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> </div>
{data.featuredImage && data.imagePosition !== 'none' && ( {data.featuredImage && data.imagePosition !== 'none' && (
@@ -181,21 +189,31 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
{/* Social Sharing Footer */} {/* Social Sharing Footer */}
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center gap-4">
<p className="text-sm text-muted-foreground mb-4">Share this article:</p> <p className="text-sm text-muted-foreground mb-2">Share this article:</p>
<SocialShareButtons <div className="flex flex-wrap items-center justify-center gap-4">
articleId={data.id} <SocialShareButtons
title={data.title} articleId={data.id}
url={typeof window !== 'undefined' ? window.location.href : ''} title={data.title}
excerpt={data.excerpt} url={typeof window !== 'undefined' ? window.location.href : ''}
image={data.featuredImage} excerpt={data.excerpt ?? undefined}
tags={data.tags} image={data.featuredImage}
variant="footer" tags={data.tags}
/> variant="footer"
</div> />
</div> <a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
</div>
</div>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
+21 -20
View File
@@ -57,7 +57,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
{/* Hero Article - 2/3 width */} {/* Hero Article - 2/3 width */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow"> <div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
<Link to={`/articles/${heroArticle.id}`} className="block"> <Link to="/articles/$id" params={{ id: heroArticle.id }} className="block">
{heroArticle.featuredImage ? ( {heroArticle.featuredImage ? (
<div className="relative h-64 md:h-80 overflow-hidden"> <div className="relative h-64 md:h-80 overflow-hidden">
<img <img
@@ -80,7 +80,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
</Link> </Link>
<div className="p-6"> <div className="p-6">
<Link to={`/articles/${heroArticle.id}`} className="block"> <Link to="/articles/$id" params={{ id: heroArticle.id }} className="block">
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors"> <h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
{heroArticle.title} {heroArticle.title}
</h2> </h2>
@@ -105,14 +105,14 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
</div> </div>
<SocialShareButtons <SocialShareButtons
articleId={heroArticle.id} articleId={heroArticle.id}
title={heroArticle.title} title={heroArticle.title}
url={`${window.location.origin}/articles/${heroArticle.id}`} url={`${window.location.origin}/articles/${heroArticle.id}`}
excerpt={heroArticle.excerpt} excerpt={heroArticle.excerpt ?? undefined}
image={heroArticle.featuredImage} image={heroArticle.featuredImage}
tags={heroArticle.tags} tags={heroArticle.tags}
variant="compact" variant="compact"
/> />
</div> </div>
</div> </div>
</div> </div>
@@ -135,8 +135,9 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
> >
<Link <Link
to={`/articles/${article.id}`} to="/articles/$id"
className="block mb-4" 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"> <h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title} {article.title}
@@ -162,14 +163,14 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
</div> </div>
<SocialShareButtons <SocialShareButtons
articleId={article.id} articleId={article.id}
title={article.title} title={article.title}
url={`${window.location.origin}/articles/${article.id}`} url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt} excerpt={article.excerpt ?? undefined}
image={article.featuredImage} image={article.featuredImage}
tags={article.tags} tags={article.tags}
variant="compact" variant="compact"
/> />
</div> </div>
</div> </div>
))} ))}
+6 -6
View File
@@ -1,6 +1,6 @@
import { type SharePlatform } from './social-utils'; import { type SharePlatform } from './social-utils';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
export interface TrackShareParams { export interface TrackShareParams {
articleId: string; articleId: string;
@@ -11,7 +11,7 @@ export interface TrackShareParams {
export const trackShare = async (params: TrackShareParams): Promise<boolean> => { export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
try { try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, { const response = await fetch(`${API_BASE_URL}/analytics/share`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -34,8 +34,8 @@ export const trackShare = async (params: TrackShareParams): Promise<boolean> =>
export const getShareStats = async (articleId?: string) => { export const getShareStats = async (articleId?: string) => {
try { try {
const url = articleId const url = articleId
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}` ? `${API_BASE_URL}/analytics/shares?articleId=${articleId}`
: `${API_BASE_URL}/api/v1/analytics/shares`; : `${API_BASE_URL}/analytics/shares`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
@@ -57,7 +57,7 @@ export const getShareStats = async (articleId?: string) => {
export const getTopSharedArticles = async (limit: number = 10) => { export const getTopSharedArticles = async (limit: number = 10) => {
try { try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, { const response = await fetch(`${API_BASE_URL}/analytics/shares/top?limit=${limit}`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -77,7 +77,7 @@ export const getTopSharedArticles = async (limit: number = 10) => {
export const getTotalShareStats = async () => { export const getTotalShareStats = async () => {
try { try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, { const response = await fetch(`${API_BASE_URL}/analytics/shares/total`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
+38
View File
@@ -827,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();
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function usePushStats() {
return useQuery({
queryKey: ['push', 'stats'],
queryFn: api.fetchPushStats,
});
}
export function useSendPushNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.sendPushNotification,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
},
});
}
+16 -8
View File
@@ -11,6 +11,7 @@ import { AuthPage } from './components/routes/AuthPage'
import { SportComponent } from './components/routes/SportComponent' import { SportComponent } from './components/routes/SportComponent'
import { ArtComponent } from './components/routes/ArtComponent' import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent' 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 { HeroArticle } from './components/home/HeroArticle'
@@ -34,17 +35,17 @@ const rootRoute = createRootRoute({
<Header /> <Header />
<ArticleTicker /> <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-4 border-foreground bg-foreground text-background"> <footer className="border-t-4 border-foreground bg-foreground text-background">
<div className="container mx-auto max-w-6xl px-4 py-12"> <div className="container mx-auto max-w-7xl px-4 py-12">
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
<div> <div>
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3> <h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
<p className="font-body text-sm text-background/70"> <p className="font-body text-sm text-background/70">
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија. Сатрирични вести и коментари за локални и глобални настани.
</p> </p>
</div> </div>
<div> <div>
@@ -72,7 +73,7 @@ const rootRoute = createRootRoute({
</div> </div>
</div> </div>
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider"> <div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
© 2025 Placebo.mk — Сите права се заштитени. Или не се. © 2026 Placebo.mk — Сите права се заштитени. Или не се.
</div> </div>
</div> </div>
</footer> </footer>
@@ -102,7 +103,7 @@ const indexRoute = createRoute({
<div className="text-center"> <div className="text-center">
<h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2> <h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8"> <p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
Непристојно сатрирични вести и коментари за локални и глобални настани во Македонија. Сатрирични вести и коментари за локални и глобални настани.
Затоа што понекогаш вистината боли повеќе од фикцијата. Затоа што понекогаш вистината боли повеќе од фикцијата.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
@@ -138,7 +139,7 @@ const indexRoute = createRoute({
</div> </div>
<h3 className="text-2xl font-display mb-2">Без филтер</h3> <h3 className="text-2xl font-display mb-2">Без филтер</h3>
<p className="font-body text-sm text-muted-foreground"> <p className="font-body text-sm text-muted-foreground">
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари. Не разликуване нијанси. Не користиме дипломатски јазик. Само искрени коментари.
</p> </p>
</div> </div>
@@ -148,7 +149,7 @@ const indexRoute = createRoute({
</div> </div>
<h3 className="text-2xl font-display mb-2">Live Покривање</h3> <h3 className="text-2xl font-display mb-2">Live Покривање</h3>
<p className="font-body text-sm text-muted-foreground"> <p className="font-body text-sm text-muted-foreground">
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти. Ажурирања во реално време, прекршени вести. Нема одложувања, само факти.
</p> </p>
</div> </div>
</div> </div>
@@ -181,6 +182,12 @@ const scienceRoute = createRoute({
component: ScienceComponent, component: ScienceComponent,
}) })
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: AboutComponent,
})
const articleDetailRoute = createRoute({ const articleDetailRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/articles/$id', path: '/articles/$id',
@@ -189,7 +196,7 @@ const articleDetailRoute = createRoute({
return <ArticleDetailComponent id={id} /> return <ArticleDetailComponent id={id} />
}, },
loader: async ({ params }) => { loader: async ({ params }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`) const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'}/articles/${params.id}`)
if (!response.ok) { if (!response.ok) {
return { article: null } return { article: null }
} }
@@ -308,6 +315,7 @@ const routeTree = rootRoute.addChildren([
sportRoute, sportRoute,
artRoute, artRoute,
scienceRoute, scienceRoute,
aboutRoute,
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,
+36 -36
View File
@@ -1,7 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@theme { @theme {
--color-primary: oklch(0.08 0 0); --color-primary: oklch(0.08 0 0);
--color-primary-foreground: oklch(0.98 0 0); --color-primary-foreground: oklch(0.98 0 0);
@@ -50,29 +48,43 @@
} }
.dark { .dark {
--background: 0 0% 8%; --background: 220 17% 22%;
--foreground: 35 20% 95%; --foreground: 220 17% 92%;
--card: 0 0% 12%; --card: 220 17% 27%;
--card-foreground: 35 20% 95%; --card-foreground: 220 17% 92%;
--popover: 0 0% 12%; --popover: 220 17% 25%;
--popover-foreground: 35 20% 95%; --popover-foreground: 220 17% 96%;
--primary: 35 20% 95%; --primary: 220 17% 92%;
--primary-foreground: 0 0% 8%; --primary-foreground: 220 17% 22%;
--secondary: 0 0% 15%; --secondary: 220 17% 32%;
--secondary-foreground: 35 20% 95%; --secondary-foreground: 220 17% 88%;
--muted: 0 0% 15%; --muted: 220 17% 36%;
--muted-foreground: 0 0% 60%; --muted-foreground: 220 17% 70%;
--accent: 70 100% 50%; --accent: 193 48% 67%;
--accent-foreground: 0 0% 8%; --accent-foreground: 220 17% 22%;
--destructive: 0 80% 50%; --destructive: 354 46% 56%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 220 17% 96%;
--border: 35 20% 95%; --border: 220 17% 36%;
--input: 35 20% 95%; --input: 220 17% 36%;
--ring: 70 100% 50%; --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 { html {
@@ -320,18 +332,6 @@ body::before {
overflow: hidden; overflow: hidden;
} }
@layer base {
* {
border-color: hsl(var(--border));
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
letter-spacing: 0.02em;
text-transform: uppercase;
}
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 12px; width: 12px;
} }
@@ -341,7 +341,7 @@ body::before {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: hsl(var(--border)); background: hsl(var(--muted-foreground) / 0.3);
border: 2px solid hsl(var(--secondary)); border: 2px solid hsl(var(--secondary));
} }
+108 -49
View File
@@ -42,7 +42,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -54,6 +55,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -5249,10 +5251,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"backend/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"backend/node_modules/ini": { "backend/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -6520,13 +6518,6 @@
"node": "*" "node": "*"
} }
}, },
"backend/node_modules/minimist": {
"version": "1.2.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"backend/node_modules/minipass": { "backend/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -7610,10 +7601,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"backend/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"backend/node_modules/schema-utils": { "backend/node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
"dev": true, "dev": true,
@@ -14119,16 +14106,6 @@
"version": "2.0.6", "version": "2.0.6",
"license": "MIT" "license": "MIT"
}, },
"cms/cms/node_modules/asn1.js": {
"version": "5.4.1",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"cms/cms/node_modules/ast-types": { "cms/cms/node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"license": "MIT", "license": "MIT",
@@ -14254,10 +14231,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"cms/cms/node_modules/bn.js": {
"version": "4.12.2",
"license": "MIT"
},
"cms/cms/node_modules/boolbase": { "cms/cms/node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@@ -17240,10 +17213,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"cms/cms/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"cms/cms/node_modules/ini": { "cms/cms/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -18716,10 +18685,6 @@
"webpack": "^5.0.0" "webpack": "^5.0.0"
} }
}, },
"cms/cms/node_modules/minimalistic-assert": {
"version": "1.0.1",
"license": "ISC"
},
"cms/cms/node_modules/minimalistic-crypto-utils": { "cms/cms/node_modules/minimalistic-crypto-utils": {
"version": "1.0.1", "version": "1.0.1",
"license": "MIT" "license": "MIT"
@@ -18737,13 +18702,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"cms/cms/node_modules/minimist": {
"version": "1.2.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"cms/cms/node_modules/minipass": { "cms/cms/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -21241,10 +21199,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"cms/cms/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"cms/cms/node_modules/sanitize-html": { "cms/cms/node_modules/sanitize-html": {
"version": "2.13.0", "version": "2.13.0",
"license": "MIT", "license": "MIT",
@@ -27112,12 +27066,31 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -27142,6 +27115,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/backend": { "node_modules/backend": {
"resolved": "backend", "resolved": "backend",
"link": true "link": true
@@ -27170,6 +27155,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -27563,6 +27554,28 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -27583,6 +27596,12 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -28632,6 +28651,21 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -29024,6 +29058,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -29377,6 +29417,25 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+13
View File
@@ -0,0 +1,13 @@
# Frontend Environment Configuration
# Copy to .env and adjust for your setup
# ===== DOCKER MODE (frontend in container) =====
# Use when frontend runs in Docker container
# VITE_API_URL=http://backend:3000/api/v1
# ===== LOCAL MODE (frontend runs locally) =====
# Use when frontend runs locally with npm run dev
VITE_API_URL=http://localhost:3000/api/v1
# ===== COMMON =====
VITE_CMS_URL=http://localhost:1337
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+45
View File
@@ -0,0 +1,45 @@
# PWA Dockerfile for Placebo.mk Progressive Web App
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Build arguments for environment variables
ARG VITE_API_URL
ARG VITE_CMS_URL
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_CMS_URL=$VITE_CMS_URL
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install --legacy-peer-deps
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+28
View File
@@ -0,0 +1,28 @@
# Frontend Development Dockerfile for Placebo.mk TanStack React App
FROM node:20-alpine
WORKDIR /app
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
# Clear npm cache and install dependencies
RUN npm cache clean --force && \
npm install
# Copy source code
COPY . .
# Fix permissions - use node user that exists in base image
RUN chown -R node:node /app
# Switch to non-root user that exists in base image
USER node
# Expose port
EXPOSE 5173
# Start development server with host flag
CMD ["npm", "run", "dev", "--", "--host"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"tailwind": {
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+101
View File
@@ -0,0 +1,101 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didn’t register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-d975e299'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.ek3n41mv3u8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 86400
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
}));
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'dev-dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="mk">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icons/favicon-32.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Сатирични вести од Македонија - Placebo.mk" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Placebo" />
<title>Placebo.mk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_disable "MSIE [1-6]\.";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Frontend server
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers for frontend
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' 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; manifest-src 'self';" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy (for local dev only - in prod use public URL)
# location /api/ {
# proxy_pass http://backend:3000/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
# proxy_read_timeout 300;
# proxy_connect_timeout 300;
# }
# CMS proxy (for local dev only - in prod use public URL)
# location /cms/ {
# proxy_pass http://cms:1337/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
# proxy_read_timeout 300;
# proxy_connect_timeout 300;
# }
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
+10678
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"dev:docker": "vite",
"dev:local": "cp -f .env.local .env && vite",
"dev:reset-env": "cp -f .env.docker .env"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"lucide-react": "^0.562.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"workbox-cacheable-response": "^7.4.0",
"workbox-expiration": "^7.4.0",
"workbox-precaching": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-strategies": "^7.4.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
<rect width="180" height="180" fill="#000"/>
<text x="90" y="125" font-family="system-ui, sans-serif" font-size="110" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 277 B

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

After

Width:  |  Height:  |  Size: 269 B

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

After

Width:  |  Height:  |  Size: 269 B

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

After

Width:  |  Height:  |  Size: 276 B

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

After

Width:  |  Height:  |  Size: 278 B

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

After

Width:  |  Height:  |  Size: 4.0 KiB

+50
View File
@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { Zap } from 'lucide-react'
export function ArticleTicker() {
const { data } = useQuery({
queryKey: ['ticker-articles'],
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
})
const articles = data?.data.slice(0, 10) || []
if (articles.length === 0) return null
return (
<div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
<div className="py-2 flex items-center">
<div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
<Zap className="w-4 h-4" />
Топ вести
</div>
<div className="overflow-hidden flex-1">
<div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => (
<Link
key={`${article.id}-${index}`}
to="/articles/$id"
params={{ id: article.id }}
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
>
{article.title || 'No title'}
</Link>
))}
{articles.map((article, index) => (
<Link
key={`dup-${article.id}-${index}`}
to="/articles/$id"
params={{ id: article.id }}
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
>
{article.title || 'No title'}
</Link>
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { useCreateLiveBlog } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNavigate } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
interface CreateLiveBlogProps {
onSuccess?: () => void;
className?: string;
}
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
slug: '',
description: '',
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const createMutation = useCreateLiveBlog();
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-')
.trim();
};
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const title = e.target.value;
setFormData({
...formData,
title,
slug: formData.slug || generateSlug(title),
});
if (errors.title) setErrors({ ...errors, title: '' });
};
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, slug: e.target.value });
if (errors.slug) setErrors({ ...errors, slug: '' });
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.slug.trim()) {
newErrors.slug = 'Slug is required';
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const result = await createMutation.mutateAsync(formData);
// Reset form
setFormData({
title: '',
slug: '',
description: '',
status: 'draft',
});
setErrors({});
// Navigate to the new live blog
navigate({ to: `/admin/live-blogs/${result.slug}` });
if (onSuccess) onSuccess();
} catch (error) {
console.error('Failed to create live blog:', error);
setErrors({ submit: 'Failed to create live blog. Please try again.' });
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Create New Live Blog
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={handleTitleChange}
placeholder="Enter live blog title"
disabled={createMutation.isPending}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={formData.slug}
onChange={handleSlugChange}
placeholder="URL-friendly identifier"
disabled={createMutation.isPending}
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug}</p>
)}
<p className="text-xs text-muted-foreground">
This will be used in the URL: /live-blogs/your-slug-here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description for this live blog"
rows={3}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Initial Status</Label>
<Select
value={formData.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setFormData({ ...formData, status: value })
}
disabled={createMutation.isPending}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
You can change this later in the settings
</p>
</div>
{errors.submit && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{errors.submit}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="submit"
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,531 @@
import { useState } from 'react';
import {
useLiveBlog,
useDeleteLiveBlogUpdate,
useUpdateLiveBlog,
useDeleteLiveBlog
} from '@/queries/live-blogs';
import { UpdatePublisher } from './UpdatePublisher';
import { LiveBlogUpdate } from '@/components/features/live-blog/LiveBlogUpdate';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { LiveBlogUpdate as ApiLiveBlogUpdate, UpdateLiveBlogDto } from '@/lib/api';
import {
ArrowLeft,
Edit,
Play,
Square,
Archive,
Eye,
MessageSquare,
Pin,
PinOff
} from 'lucide-react';
interface LiveBlogManagerProps {
slug: string;
onBack?: () => void;
className?: string;
}
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
const [activeTab, setActiveTab] = useState('updates');
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
const updateLiveBlogMutation = useUpdateLiveBlog();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const handleDeleteUpdate = async (update: ApiLiveBlogUpdate) => {
if (!liveBlog) return;
if (confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
try {
await deleteUpdateMutation.mutateAsync({
liveBlogId: liveBlog.id,
updateId: update.id,
});
} catch (error) {
console.error('Failed to delete update:', error);
}
}
};
const handleEditUpdate = (update: ApiLiveBlogUpdate) => {
// The UpdatePublisher component handles editing internally
console.log('Editing update:', update);
};
const handleStartEdit = () => {
if (!liveBlog) return;
setIsEditing(true);
setEditForm({
title: liveBlog.title,
slug: liveBlog.slug,
description: liveBlog.description || '',
status: liveBlog.status,
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm({});
};
const handleSaveEdit = async () => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: editForm,
});
setIsEditing(false);
setEditForm({});
} catch (error) {
console.error('Failed to update live blog:', error);
}
};
const handleStatusChange = async (newStatus: 'draft' | 'live' | 'ended' | 'archived') => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: { status: newStatus },
});
} catch (error) {
console.error('Failed to update status:', error);
alert(`Failed to update status: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTogglePin = async () => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: { isPinned: !liveBlog.isPinned },
});
} catch (error) {
console.error('Failed to toggle pin:', error);
alert(`Failed to toggle pin: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleDeleteLiveBlog = async () => {
if (!liveBlog) return;
if (confirm('Are you sure you want to delete this live blog? This action cannot be undone and will delete all updates.')) {
try {
await deleteLiveBlogMutation.mutateAsync(liveBlog.id);
if (onBack) onBack();
} catch (error) {
console.error('Failed to delete live blog:', error);
}
}
};
if (isLoading) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="h-32 bg-muted rounded"></div>
</div>
</CardContent>
</Card>
);
}
if (error || !liveBlog) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="text-center text-destructive">
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
<p className="text-sm mb-4">
{error instanceof Error ? error.message : 'Unknown error occurred'}
</p>
{onBack && (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
)}
</div>
</CardContent>
</Card>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'live': return 'bg-green-100 text-green-800 border-green-200';
case 'draft': return 'bg-gray-100 text-gray-800 border-gray-200';
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
case 'archived': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'live': return <Play className="w-3 h-3" />;
case 'draft': return <Edit className="w-3 h-3" />;
case 'ended': return <Square className="w-3 h-3" />;
case 'archived': return <Archive className="w-3 h-3" />;
default: return null;
}
};
return (
<div className={className}>
{/* Header */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<CardTitle className="text-2xl">{liveBlog.title}</CardTitle>
</div>
{liveBlog.description && (
<p className="text-muted-foreground mb-4">{liveBlog.description}</p>
)}
<div className="flex items-center gap-4 text-sm">
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
<div className="flex items-center gap-1">
{getStatusIcon(liveBlog.status)}
{liveBlog.status}
</div>
</Badge>
<div className="flex items-center gap-1 text-muted-foreground">
<Eye className="w-4 h-4" />
{liveBlog.viewCount} views
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<MessageSquare className="w-4 h-4" />
{liveBlog.updates?.length || 0} updates
</div>
{liveBlog.author && (
<div className="flex items-center gap-2">
{liveBlog.author.avatar && (
<img
src={liveBlog.author.avatar}
alt={liveBlog.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-muted-foreground">{liveBlog.author.name}</span>
</div>
)}
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Main content */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="updates">Updates</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="updates" className="space-y-6">
<UpdatePublisher
liveBlogId={liveBlog.id}
authorId={liveBlog.authorId || undefined}
/>
{/* Recent updates preview */}
{liveBlog.updates && liveBlog.updates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">All Updates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{liveBlog.updates.map((update) => (
<LiveBlogUpdate
key={update.id}
update={update}
showAdminControls={true}
onEdit={handleEditUpdate}
onDelete={handleDeleteUpdate}
onPinToggle={(update) => {
// Handle pin toggle
console.log('Toggle pin for update:', update);
}}
/>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Live Blog Settings</CardTitle>
{!isEditing ? (
<Button variant="outline" size="sm" onClick={handleStartEdit}>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveEdit}>
Save Changes
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{isEditing ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={editForm.title || ''}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={editForm.slug || ''}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
placeholder="URL-friendly identifier"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={editForm.description || ''}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={editForm.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setEditForm({ ...editForm, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="live">Live</SelectItem>
<SelectItem value="ended">Ended</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Status Management</h4>
<p className="text-sm text-muted-foreground mb-4">
Control the live blog status and visibility
</p>
<div className="flex flex-wrap gap-2">
{liveBlog.status !== 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('live')}
disabled={updateLiveBlogMutation.isPending}
>
<Play className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Go Live'}
</Button>
)}
{liveBlog.status === 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('ended')}
disabled={updateLiveBlogMutation.isPending}
>
<Square className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'End Live Session'}
</Button>
)}
{liveBlog.status !== 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('archived')}
disabled={updateLiveBlogMutation.isPending}
>
<Archive className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Archive'}
</Button>
)}
{liveBlog.status === 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('draft')}
disabled={updateLiveBlogMutation.isPending}
>
<Edit className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'}
</Button>
)}
<Button
variant={liveBlog.isPinned ? "default" : "outline"}
size="sm"
onClick={handleTogglePin}
disabled={updateLiveBlogMutation.isPending}
>
{liveBlog.isPinned ? (
<>
<PinOff className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unpin'}
</>
) : (
<>
<Pin className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Pin to Homepage'}
</>
)}
</Button>
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Live Blog Information</h4>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Title:</span>
<span className="font-medium">{liveBlog.title}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Slug:</span>
<span className="font-medium">{liveBlog.slug}</span>
</div>
{liveBlog.description && (
<div className="flex justify-between">
<span className="text-muted-foreground">Description:</span>
<span className="font-medium text-right max-w-xs">{liveBlog.description}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Status:</span>
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
<div className="flex items-center gap-1">
{getStatusIcon(liveBlog.status)}
{liveBlog.status}
</div>
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pinned:</span>
<Badge variant={liveBlog.isPinned ? "default" : "outline"}>
<div className="flex items-center gap-1">
{liveBlog.isPinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{liveBlog.isPinned ? 'Yes' : 'No'}
</div>
</Badge>
</div>
</div>
</div>
</>
)}
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Analytics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Total Views:</span>
<div className="font-medium">{liveBlog.viewCount}</div>
</div>
<div>
<span className="text-muted-foreground">Total Updates:</span>
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
</div>
<div>
<span className="text-muted-foreground">Created:</span>
<div className="font-medium">
{new Date(liveBlog.createdAt).toLocaleDateString()}
</div>
</div>
<div>
<span className="text-muted-foreground">Last Updated:</span>
<div className="font-medium">
{new Date(liveBlog.updatedAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
<div className="p-4 border border-destructive/20 rounded-lg">
<h4 className="font-medium mb-2 text-destructive">Danger Zone</h4>
<p className="text-sm text-muted-foreground mb-4">
Once you delete a live blog, there is no going back. Please be certain.
</p>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteLiveBlog}
disabled={deleteLiveBlogMutation.isPending}
>
{deleteLiveBlogMutation.isPending ? 'Deleting...' : 'Delete Live Blog'}
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,263 @@
import React, { useState, useRef } from 'react';
import { useCreateLiveBlogUpdate, useUpdateLiveBlogUpdate, useLiveBlogUpdates } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import type { LiveBlogUpdate, CreateLiveBlogUpdateDto } from '@/lib/api';
import { cn } from '@/lib/utils';
import { Send, Pin, Calendar, Edit } from 'lucide-react';
interface UpdatePublisherProps {
liveBlogId: string;
authorId?: string;
className?: string;
}
export function UpdatePublisher({ liveBlogId, authorId, className }: UpdatePublisherProps) {
const [content, setContent] = useState('');
const [isPinned, setIsPinned] = useState(false);
const [scheduledAt, setScheduledAt] = useState('');
const [isScheduleMode, setIsScheduleMode] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const createMutation = useCreateLiveBlogUpdate();
const updateMutation = useUpdateLiveBlogUpdate();
const [editingUpdate, setEditingUpdate] = useState<LiveBlogUpdate | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
const updateData: CreateLiveBlogUpdateDto = {
content: content.trim(),
isPinned,
authorId,
...(isScheduleMode && scheduledAt ? { scheduledAt } : {}),
};
try {
if (editingUpdate) {
// Update existing update
await updateMutation.mutateAsync({
liveBlogId,
updateId: editingUpdate.id,
dto: {
content: content.trim(),
isPinned,
},
});
setEditingUpdate(null);
} else {
// Create new update
await createMutation.mutateAsync({
liveBlogId,
dto: updateData,
});
}
// Reset form
setContent('');
setIsPinned(false);
setScheduledAt('');
setIsScheduleMode(false);
textareaRef.current?.focus();
} catch (error) {
console.error('Failed to save update:', error);
}
};
const handleEdit = (update: LiveBlogUpdate) => {
setContent(update.content);
setIsPinned(update.isPinned);
setEditingUpdate(update);
setIsScheduleMode(false);
textareaRef.current?.focus();
};
const handleCancelEdit = () => {
setEditingUpdate(null);
setContent('');
setIsPinned(false);
setIsScheduleMode(false);
};
const isLoading = createMutation.isPending || updateMutation.isPending;
// Get minimum date for scheduling (now + 5 minutes)
const minScheduleDate = new Date().toISOString().slice(0, 16);
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{editingUpdate ? 'Edit Update' : 'Publish Update'}
{editingUpdate && (
<span className="text-xs bg-muted px-2 py-1 rounded-full">
ID: {editingUpdate.id.slice(0, 8)}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Content textarea */}
<div className="space-y-2">
<Label htmlFor="content">
{editingUpdate ? 'Edit content' : 'Update content'}
</Label>
<Textarea
id="content"
ref={textareaRef}
placeholder="What's the latest update?"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="resize-none"
disabled={isLoading}
/>
</div>
{/* Options */}
<div className="flex flex-wrap gap-4">
{/* Pin toggle */}
<div className="flex items-center space-x-2">
<Switch
id="pin"
checked={isPinned}
onCheckedChange={setIsPinned}
disabled={isLoading}
/>
<Label htmlFor="pin" className="flex items-center gap-2">
<Pin className="w-4 h-4" />
Pin to top
</Label>
</div>
{/* Schedule toggle */}
<div className="flex items-center space-x-2">
<Switch
id="schedule"
checked={isScheduleMode}
onCheckedChange={setIsScheduleMode}
disabled={isLoading}
/>
<Label htmlFor="schedule" className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
Schedule
</Label>
</div>
</div>
{/* Schedule date/time picker */}
{isScheduleMode && (
<div className="space-y-2">
<Label htmlFor="scheduleAt">Schedule for (minimum 5 minutes from now)</Label>
<input
id="scheduleAt"
type="datetime-local"
min={minScheduleDate}
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full p-2 border rounded-md"
disabled={isLoading}
/>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
<Button
type="submit"
disabled={!content.trim() || isLoading}
className="flex-1"
>
{isLoading ? (
'Saving...'
) : editingUpdate ? (
'Update'
) : (
<>
<Send className="w-4 h-4 mr-2" />
{isScheduleMode ? 'Schedule' : 'Publish'}
</>
)}
</Button>
{editingUpdate && (
<Button
type="button"
variant="outline"
onClick={handleCancelEdit}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
{/* Recent updates for editing */}
{editingUpdate === null && (
<div className="mt-6 pt-6 border-t">
<h4 className="text-sm font-medium mb-3">Recent Updates (Click to edit)</h4>
<RecentUpdates
liveBlogId={liveBlogId}
onEdit={handleEdit}
isLoading={isLoading}
/>
</div>
)}
</CardContent>
</Card>
);
}
interface RecentUpdatesProps {
liveBlogId: string;
onEdit: (update: LiveBlogUpdate) => void;
isLoading: boolean;
}
function RecentUpdates({ liveBlogId, onEdit, isLoading }: RecentUpdatesProps) {
const { data: updatesData } = useLiveBlogUpdates(liveBlogId, 1, 5);
const updates = updatesData?.data || [];
if (updates.length === 0) {
return (
<p className="text-sm text-muted-foreground">No updates yet. Start by publishing your first update!</p>
);
}
return (
<div className="space-y-2">
{updates.map((update) => (
<div
key={update.id}
className={cn(
'p-3 rounded border cursor-pointer hover:bg-muted/50 transition-colors',
isLoading && 'opacity-50 pointer-events-none'
)}
onClick={() => onEdit(update)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{update.isPinned && <Pin className="w-3 h-3 text-primary" />}
<span className="text-xs text-muted-foreground">
{new Date(update.createdAt).toLocaleString()}
</span>
</div>
<Edit className="w-3 h-3 text-muted-foreground" />
</div>
<p className="text-sm line-clamp-2">{update.content}</p>
</div>
))}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface LoginFormProps {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
}
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
const { login, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
{onSwitchToRegister && (
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-primary hover:underline"
disabled={isLoading}
>
Register here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
import React from 'react';
import { Navigate } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'admin' | 'contributor' | 'user';
redirectTo?: string;
}
export function ProtectedRoute({
children,
requiredRole,
redirectTo = '/'
}: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/" />;
}
return <>{children}</>;
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface RegisterFormProps {
onSuccess?: () => void;
onSwitchToLogin?: () => void;
}
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
const { register, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username, email, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>
Create a new account to start commenting and reacting
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Choose a password (min 6 characters)"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Register'}
</Button>
{onSwitchToLogin && (
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-primary hover:underline"
disabled={isLoading}
>
Login here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { useCreateComment } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import { Card, CardContent } from '../../ui/card';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
import type { Comment } from '../../../lib/api';
interface CommentItemProps {
comment: Comment;
articleId?: string;
liveBlogId?: string;
depth?: number;
}
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
const { isAuthenticated } = useAuth();
const [showReplyForm, setShowReplyForm] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const createCommentMutation = useCreateComment();
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || !isAuthenticated) return;
setIsSubmittingReply(true);
try {
await createCommentMutation.mutateAsync({
content: replyContent,
articleId,
liveBlogId,
parentCommentId: comment.id,
});
setReplyContent('');
setShowReplyForm(false);
} catch (error) {
console.error('Failed to post reply:', error);
} finally {
setIsSubmittingReply(false);
}
};
// Maximum depth to prevent infinite nesting (optional)
const maxDepth = 5;
const canReply = depth < maxDepth;
return (
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="font-medium">
{comment.user?.username || 'Анонимен корисник'}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
</div>
</div>
{comment.user?.role === 'admin' && (
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
Администратор
</span>
)}
</div>
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
{/* Reply button and form */}
{isAuthenticated && canReply && (
<div className="mt-4">
{!showReplyForm ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplyForm(true)}
className="text-sm"
>
Одговори
</Button>
) : (
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
<form onSubmit={handleSubmitReply}>
<Textarea
placeholder="Вашиот одговор..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
className="min-h-[80px] mb-3"
disabled={isSubmittingReply}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowReplyForm(false);
setReplyContent('');
}}
disabled={isSubmittingReply}
>
Откажи
</Button>
<Button
type="submit"
size="sm"
disabled={!replyContent.trim() || isSubmittingReply}
>
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
</Button>
</div>
</form>
</div>
)}
</div>
)}
{/* Reactions (if implemented) */}
{comment.reactions && (
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Render replies recursively */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
articleId={articleId}
liveBlogId={liveBlogId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { useComments, useCreateComment } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import { Card, CardContent } from '../../ui/card';
import { CommentItem } from './CommentItem';
interface CommentSectionProps {
articleId?: string;
liveBlogId?: string;
}
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
const { isAuthenticated } = useAuth();
const [newComment, setNewComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: commentsData, isLoading } = useComments({
articleId,
liveBlogId,
limit: 50,
});
const createCommentMutation = useCreateComment();
const comments = commentsData?.data || [];
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !isAuthenticated) return;
setIsSubmitting(true);
try {
await createCommentMutation.mutateAsync({
content: newComment,
articleId,
liveBlogId,
});
setNewComment('');
} catch (error) {
console.error('Failed to post comment:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-12 pt-8 border-t">
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
{isAuthenticated ? (
<Card className="mb-8">
<CardContent className="pt-6">
<form onSubmit={handleSubmitComment}>
<Textarea
placeholder="Што мислите за овој напис?"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[100px] mb-4"
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!newComment.trim() || isSubmitting}
>
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
</Button>
</div>
</form>
</CardContent>
</Card>
) : (
<Card className="mb-8">
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground mb-4">
Најавете се за да можете да коментирате
</p>
<Button asChild>
<a href="/auth">Најави се</a>
</Button>
</CardContent>
</Card>
)}
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување коментари...</p>
</div>
) : comments.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground">
Сè уште нема коментари. Бидете првиот што ќе коментира!
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
articleId={articleId}
liveBlogId={liveBlogId}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
import { useAuth } from '../../../contexts/AuthContext';
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface ReactionButtonsProps {
articleId?: string;
liveBlogId?: string;
commentId?: string;
compact?: boolean;
}
export function ReactionButtons({
articleId,
liveBlogId,
commentId,
compact = false
}: ReactionButtonsProps) {
const { isAuthenticated } = useAuth();
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
const addReactionMutation = useAddReaction();
const userReactionType = userReaction?.type;
const handleReaction = async (type: 'like' | 'dislike') => {
if (!isAuthenticated) {
window.location.href = '/auth';
return;
}
try {
await addReactionMutation.mutateAsync({
type,
articleId,
liveBlogId,
commentId,
});
} catch (error) {
console.error('Failed to add reaction:', error);
}
};
const likes = counts?.likes || 0;
const dislikes = counts?.dislikes || 0;
if (compact) {
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('like')}
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
>
<ThumbsUp className="w-4 h-4 mr-1" />
{likes > 0 && <span>{likes}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('dislike')}
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
>
<ThumbsDown className="w-4 h-4 mr-1" />
{dislikes > 0 && <span>{dislikes}</span>}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'like' ? 'default' : 'outline'}
size="sm"
onClick={() => handleReaction('like')}
className="gap-2"
>
<ThumbsUp className="w-4 h-4" />
<span>Допаѓа ми</span>
{likes > 0 && (
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
{likes}
</span>
)}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleReaction('dislike')}
className="gap-2"
>
<ThumbsDown className="w-4 h-4" />
<span>Не ми се допаѓа</span>
{dislikes > 0 && (
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
{dislikes}
</span>
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,213 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
interface LiveBlogTickerProps {
className?: string;
maxItems?: number;
autoScroll?: boolean;
scrollSpeed?: number;
}
export function LiveBlogTicker({
className = '',
maxItems = 10,
autoScroll = true,
scrollSpeed = 30
}: LiveBlogTickerProps) {
const { data: activeBlogs, isLoading } = useActiveLiveBlogs();
const [isPaused, setIsPaused] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0);
const tickerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number | undefined>(undefined);
const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => {
if (contentRef.current) {
const width = contentRef.current.scrollWidth;
setTotalWidth(width);
}
}, [activeBlogs]);
useEffect(() => {
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
return;
}
let lastTime: number;
const animate = (time: number) => {
if (!lastTime) lastTime = time;
const delta = time - lastTime;
lastTime = time;
setScrollPosition(prev => {
let newPos = prev + (scrollSpeed * delta) / 1000;
if (newPos > totalWidth) {
newPos = -(tickerRef.current?.offsetWidth || 0);
}
return newPos;
});
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [autoScroll, isPaused, activeBlogs, scrollSpeed, totalWidth]);
if (isLoading) {
return (
<div className={`border-brutal-sm bg-card p-3 ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
</div>
<div className="animate-pulse h-8 w-20 bg-muted rounded"></div>
</div>
</div>
);
}
if (!activeBlogs || activeBlogs.length === 0) {
return null;
}
const displayBlogs = activeBlogs.slice(0, maxItems);
const handlePauseToggle = () => {
setIsPaused(!isPaused);
};
const handleBlogClick = () => {
setScrollPosition(0);
};
return (
<div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
<div className="flex items-center gap-3">
<Radio className="w-4 h-4 text-accent animate-pulse" />
<span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
{activeBlogs.length}
</span>
</div>
<div className="flex items-center gap-2">
{autoScroll && (
<Button
variant="brutal"
size="sm"
onClick={handlePauseToggle}
className="h-7 px-2 text-xs"
>
{isPaused ? (
<Play className="w-3 h-3" />
) : (
<Pause className="w-3 h-3" />
)}
</Button>
)}
<Link to="/live-blogs">
<Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
Сите
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</div>
</div>
<div
ref={tickerRef}
className="relative h-12 overflow-hidden"
>
<div
ref={contentRef}
className="absolute top-0 left-0 h-full flex items-center whitespace-nowrap"
style={{
transform: `translateX(-${scrollPosition}px)`,
transition: isPaused ? 'transform 0.3s ease' : 'none'
}}
>
{displayBlogs.map((blog) => (
<React.Fragment key={blog.id}>
<Link
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
</div>
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length}
</span>
)}
</div>
</Link>
</React.Fragment>
))}
{displayBlogs.map((blog) => (
<React.Fragment key={`${blog.id}-dup`}>
<Link
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
</div>
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length}
</span>
)}
</div>
</Link>
</React.Fragment>
))}
</div>
</div>
{autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2">
<div className="h-1 bg-foreground/10">
<div
className="h-full bg-accent transition-all duration-300"
style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}}
/>
</div>
</div>
)}
</div>
);
}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more