22 Commits
Author SHA1 Message Date
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
39 changed files with 6378 additions and 663 deletions

No files matched your search

+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"]
+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(),
};
}
} }
+26 -1
View File
@@ -6,12 +6,17 @@ import {
Body, Body,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { PushService } from './push.service'; import { PushService } from './push.service';
import { SubscribeDto, UnsubscribeDto } from './push.dto'; import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
import { Public } from '../auth/public.decorator'; import { Public } from '../auth/public.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { UserRole } from '../entities';
@Controller('push') @Controller('push')
@UseGuards(RolesGuard)
export class PushController { export class PushController {
constructor(private readonly pushService: PushService) {} constructor(private readonly pushService: PushService) {}
@@ -35,4 +40,24 @@ export class PushController {
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> { async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
await this.pushService.unsubscribe(dto); 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',
});
}
} }
+17 -3
View File
@@ -1,7 +1,7 @@
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class SubscribeDto { export class SubscribeDto {
@IsUrl() @IsString()
@IsNotEmpty() @IsNotEmpty()
endpoint: string; endpoint: string;
@@ -19,7 +19,21 @@ export class SubscribeDto {
} }
export class UnsubscribeDto { export class UnsubscribeDto {
@IsUrl() @IsString()
@IsNotEmpty() @IsNotEmpty()
endpoint: string; endpoint: string;
} }
export class SendNotificationDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
body: string;
@IsOptional()
@IsString()
url?: string;
}
+5
View File
@@ -170,4 +170,9 @@ export class PushService implements OnModuleInit {
tag: `live-blog-${blogSlug}`, tag: `live-blog-${blogSlug}`,
}); });
} }
async getStats(): Promise<{ totalSubscribers: number }> {
const totalSubscribers = await this.subscriptionRepo.count();
return { totalSubscribers };
}
} }
+20
View File
@@ -0,0 +1,20 @@
# Server
HOST=0.0.0.0
PORT=1337
# Secrets (these should be overridden by docker env vars)
APP_KEYS=tobereplaced
API_TOKEN_SALT=tobereplaced
ADMIN_JWT_SECRET=tobereplaced
TRANSFER_TOKEN_SALT=tobereplaced
JWT_SECRET=tobereplaced
# Database (these MUST be overridden by docker env vars)
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres-cms
DATABASE_PORT=5432
DATABASE_NAME=placebo_cms_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_pass
DATABASE_SSL=false
+12 -14
View File
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci --only=production RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -18,6 +17,9 @@ COPY . .
# Build Strapi # Build Strapi
RUN npm run build RUN npm run build
# Prune devDependencies after build (suppress warning)
RUN npm prune --omit=dev
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
@@ -27,28 +29,24 @@ WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \ RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 adduser -S nodejs -u 1001
# Install SQLite for development (will use PostgreSQL in production)
RUN apk add --no-cache sqlite
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/config ./config
COPY --from=builder --chown=nodejs:nodejs /app/public ./public COPY --from=builder --chown=nodejs:nodejs /app/public ./public
COPY --from=builder --chown=nodejs:nodejs /app/favicon.png ./favicon.png
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration # Create data and database directories with proper permissions
COPY --chown=nodejs:nodejs .env.example .env RUN mkdir -p /app/.tmp /app/database /app/uploads /app/database/migrations && \
chown -R nodejs:nodejs /app
# Create data directory for SQLite
RUN mkdir -p /app/.tmp && \
chown -R nodejs:nodejs /app/.tmp
# Switch to non-root user # Switch to non-root user
USER nodejs USER nodejs
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})" CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"
# Expose port # Expose port
EXPOSE 1337 EXPOSE 1337
+22 -45
View File
@@ -3,58 +3,35 @@ import path from 'path';
export default ({ env }) => { export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite'); const client = env('DATABASE_CLIENT', 'sqlite');
const connections = { console.log('DATABASE_CLIENT:', client);
mysql: { console.log('DATABASE_HOST:', env('DATABASE_HOST', 'not-set'));
console.log('DATABASE_USERNAME:', env('DATABASE_USERNAME', 'not-set'));
console.log('DATABASE_NAME:', env('DATABASE_NAME', 'not-set'));
if (client === 'postgres') {
return {
connection: { connection: {
host: env('DATABASE_HOST', 'localhost'), client,
port: env.int('DATABASE_PORT', 3306), connection: {
database: env('DATABASE_NAME', 'strapi'), host: env('DATABASE_HOST', 'localhost'),
user: env('DATABASE_USERNAME', 'strapi'), port: env.int('DATABASE_PORT', 5432),
password: env('DATABASE_PASSWORD', 'strapi'), database: env('DATABASE_NAME', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && { user: env('DATABASE_USERNAME', 'strapi'),
key: env('DATABASE_SSL_KEY', undefined), password: env('DATABASE_PASSWORD', 'strapi'),
cert: env('DATABASE_SSL_CERT', undefined), ssl: env.bool('DATABASE_SSL', false),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
}, },
pool: { min: 2, max: 10 },
}, },
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, };
}, }
postgres: {
connection: { return {
connectionString: env('DATABASE_URL'), connection: {
host: env('DATABASE_HOST', 'localhost'), client,
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) && {
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),
},
};
}; };
+268
View File
@@ -0,0 +1,268 @@
# Docker Compose for Coolify Deployment
# Deploy all services in one run
#
# Usage in Coolify:
# 1. Create new Docker Compose service
# 2. Point to this file
# 3. Set environment variables in Coolify UI
# 4. Deploy
services:
# ===========================================
# DATABASES
# ===========================================
postgres-backend:
image: postgres:16-alpine
container_name: placebo-postgres-backend
restart: unless-stopped
environment:
POSTGRES_DB: placebo_backend_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- placebo-postgres-backend-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready']
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
networks:
- placebo-internal
postgres-cms:
image: postgres:16-alpine
container_name: placebo-postgres-cms
restart: unless-stopped
environment:
POSTGRES_DB: placebo_cms_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- placebo-postgres-cms-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready']
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
networks:
- placebo-internal
# ===========================================
# BACKEND (NestJS API)
# ===========================================
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
DATABASE_TYPE: postgres
DATABASE_HOST: postgres-backend
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: 'false'
DATABASE_LOGGING: 'false'
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION: '86400'
CORS_ORIGIN: https://placebo.mk,https://www.placebo.mk,https://app.placebo.mk
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
depends_on:
postgres-backend:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:3000/api/v1/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-internal
- coolify
expose:
- "3000"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.backend-http.rule=Host(`api.placebo.mk`)"
- "traefik.http.routers.backend-http.entrypoints=http"
- "traefik.http.routers.backend-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.backend.rule=Host(`api.placebo.mk`)"
- "traefik.http.routers.backend.entrypoints=https"
- "traefik.http.routers.backend.tls=true"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.backend.loadbalancer.server.port=3000"
# Redirect middleware
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
# ===========================================
# CMS (Strapi)
# ===========================================
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 1337
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres-cms
DATABASE_PORT: '5432'
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_SSL: 'false'
APP_KEYS: ${STRAPI_APP_KEYS}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
JWT_SECRET: ${STRAPI_JWT_SECRET}
ENCRYPTION_KEY: ${STRAPI_ENCRYPTION_KEY}
depends_on:
postgres-cms:
condition: service_healthy
volumes:
- placebo-cms-uploads:/app/public/uploads
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:1337/_health', (r) => {if(r.statusCode === 200 || r.statusCode === 204) process.exit(0); process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-internal
- coolify
expose:
- "1337"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.cms-http.rule=Host(`cms.placebo.mk`)"
- "traefik.http.routers.cms-http.entrypoints=http"
- "traefik.http.routers.cms-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.cms.rule=Host(`cms.placebo.mk`)"
- "traefik.http.routers.cms.entrypoints=https"
- "traefik.http.routers.cms.tls=true"
- "traefik.http.routers.cms.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.cms.loadbalancer.server.port=1337"
# ===========================================
# FRONTEND (React)
# ===========================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: https://api.placebo.mk/api/v1
VITE_CMS_URL: https://cms.placebo.mk
container_name: placebo-frontend
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-internal
- coolify
expose:
- "80"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.frontend-http.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
- "traefik.http.routers.frontend-http.entrypoints=http"
- "traefik.http.routers.frontend-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.frontend.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
- "traefik.http.routers.frontend.entrypoints=https"
- "traefik.http.routers.frontend.tls=true"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.frontend.loadbalancer.server.port=80"
# ===========================================
# PWA (Progressive Web App)
# ===========================================
pwa:
build:
context: ./pwa
dockerfile: Dockerfile
args:
VITE_API_URL: https://api.placebo.mk/api/v1
VITE_CMS_URL: https://cms.placebo.mk
container_name: placebo-pwa
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-internal
- coolify
expose:
- "80"
labels:
- "traefik.enable=true"
# HTTP router (for Let's Encrypt challenge and redirect)
- "traefik.http.routers.pwa-http.rule=Host(`app.placebo.mk`)"
- "traefik.http.routers.pwa-http.entrypoints=http"
- "traefik.http.routers.pwa-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.pwa.rule=Host(`app.placebo.mk`)"
- "traefik.http.routers.pwa.entrypoints=https"
- "traefik.http.routers.pwa.tls=true"
- "traefik.http.routers.pwa.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.pwa.loadbalancer.server.port=80"
# ===========================================
# VOLUMES (Managed by Coolify)
# ===========================================
volumes:
placebo-postgres-backend-data:
driver: local
placebo-postgres-cms-data:
driver: local
placebo-cms-uploads:
driver: local
# ===========================================
# NETWORKS
# ===========================================
networks:
placebo-internal:
driver: bridge
coolify:
external: true
+155
View File
@@ -0,0 +1,155 @@
# Production Docker Compose for Coolify
# Each service should be deployed separately in Coolify
# This file is for reference and local testing only
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: placebo-postgres
restart: unless-stopped
environment:
POSTGRES_DB: placebo_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_db']
interval: 10s
timeout: 5s
retries: 5
networks:
- placebo-network
# Backend API (NestJS)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_NAME: placebo_db
DATABASE_SYNCHRONIZE: 'false'
DATABASE_LOGGING: 'false'
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRATION: '86400'
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
ports:
- '3000:3000'
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-network
# Frontend (React)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-frontend
restart: unless-stopped
ports:
- '3001:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# PWA (Progressive Web App)
pwa:
build:
context: ./pwa
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
container_name: placebo-pwa
restart: unless-stopped
ports:
- '5174:80'
depends_on:
- backend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 10s
retries: 3
networks:
- placebo-network
# CMS (Strapi)
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
DATABASE_SSL: 'false'
HOST: 0.0.0.0
PORT: 1337
APP_KEYS: ${STRAPI_APP_KEYS:-key1,key2,key3,key4}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT:-change-me}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET:-change-me}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT:-change-me}
JWT_SECRET: ${STRAPI_JWT_SECRET:-change-me}
ports:
- '1337:1337'
depends_on:
postgres:
condition: service_healthy
volumes:
- cms_uploads:/app/public/uploads
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-network
volumes:
postgres_data:
driver: local
cms_uploads:
driver: local
networks:
placebo-network:
driver: bridge
+10 -10
View File
@@ -5,12 +5,19 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Build arguments for environment variables
ARG VITE_API_URL
ARG VITE_CMS_URL
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_CMS_URL=$VITE_CMS_URL
# Copy package files # Copy package files
COPY package*.json ./ COPY package.json package-lock.json* ./
COPY package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
@@ -21,19 +28,12 @@ RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM nginx:alpine
# Create non-root user
RUN addgroup -g 1001 -S nginx && \
adduser -S nginx -u 1001 -G nginx
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration # Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Switch to non-root user
USER nginx
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
+28 -28
View File
@@ -60,35 +60,35 @@ http {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# API proxy # API proxy (for local dev only - in prod use public URL)
location /api/ { # location /api/ {
proxy_pass http://backend:3000/; # proxy_pass http://backend:3000/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# CMS proxy # CMS proxy (for local dev only - in prod use public URL)
location /cms/ { # location /cms/ {
proxy_pass http://cms:1337/; # proxy_pass http://cms:1337/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# Health check endpoint # Health check endpoint
location /health { location /health {
+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>
);
}
+1 -1
View File
@@ -122,7 +122,7 @@ 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 Read Full Story
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
@@ -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"
@@ -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">
@@ -74,14 +74,14 @@ export function ArticleDetailComponent({ id }: { id: string }) {
{/* Social Sharing */} {/* Social Sharing */}
<div className="mb-8 flex flex-wrap items-center gap-4"> <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 <a
href="https://www.buymeacoffee.com/placebomk" href="https://www.buymeacoffee.com/placebomk"
target="_blank" target="_blank"
@@ -195,14 +195,14 @@ export function ArticleDetailComponent({ id }: { id: string }) {
<p className="text-sm text-muted-foreground mb-2">Share this article:</p> <p className="text-sm text-muted-foreground mb-2">Share this article:</p>
<div className="flex flex-wrap items-center justify-center gap-4"> <div className="flex flex-wrap items-center justify-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}
variant="footer" variant="footer"
/> />
<a <a
href="https://www.buymeacoffee.com/placebomk" href="https://www.buymeacoffee.com/placebomk"
target="_blank" target="_blank"
+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>
))} ))}
+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'] });
},
});
}
+11 -11
View File
@@ -1,16 +1,23 @@
# Frontend Dockerfile for Placebo.mk TanStack React App # PWA Dockerfile for Placebo.mk Progressive Web App
# Build stage # Build stage
FROM node:20-alpine AS builder 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
+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
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist', 'dev-dist']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [
+28 -28
View File
@@ -60,35 +60,35 @@ http {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# API proxy # API proxy (for local dev only - in prod use public URL)
location /api/ { # location /api/ {
proxy_pass http://backend:3000/; # proxy_pass http://backend:3000/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# CMS proxy # CMS proxy (for local dev only - in prod use public URL)
location /cms/ { # location /cms/ {
proxy_pass http://cms:1337/; # proxy_pass http://cms:1337/;
proxy_http_version 1.1; # proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; # proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; # proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300; # proxy_read_timeout 300;
proxy_connect_timeout 300; # proxy_connect_timeout 300;
} # }
# Health check endpoint # Health check endpoint
location /health { location /health {
+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'}
+1 -1
View File
@@ -122,7 +122,7 @@ 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 Read Full Story
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
@@ -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"
+12 -11
View File
@@ -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">
@@ -74,14 +74,14 @@ export function ArticleDetailComponent({ id }: { id: string }) {
{/* Social Sharing */} {/* Social Sharing */}
<div className="mb-8 flex flex-wrap items-center gap-4"> <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 <a
href="https://www.buymeacoffee.com/placebomk" href="https://www.buymeacoffee.com/placebomk"
target="_blank" target="_blank"
@@ -195,14 +195,14 @@ export function ArticleDetailComponent({ id }: { id: string }) {
<p className="text-sm text-muted-foreground mb-2">Share this article:</p> <p className="text-sm text-muted-foreground mb-2">Share this article:</p>
<div className="flex flex-wrap items-center justify-center gap-4"> <div className="flex flex-wrap items-center justify-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}
variant="footer" variant="footer"
/> />
<a <a
href="https://www.buymeacoffee.com/placebomk" href="https://www.buymeacoffee.com/placebomk"
target="_blank" target="_blank"
+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>
))} ))}
+34 -16
View File
@@ -18,8 +18,8 @@ function checkPushSupport(): boolean {
); );
} }
function getInitialPermission(): NotificationPermission | 'not-supported' { function getInitialPermission(): NotificationPermission {
if (!checkPushSupport()) return 'not-supported'; if (!checkPushSupport()) return 'denied';
return Notification.permission; return Notification.permission;
} }
@@ -27,7 +27,7 @@ export interface UsePushNotificationsReturn {
isSupported: boolean; isSupported: boolean;
isSubscribed: boolean; isSubscribed: boolean;
isLoading: boolean; isLoading: boolean;
permissionState: NotificationPermission | 'not-supported'; permissionState: NotificationPermission;
subscribe: () => Promise<boolean>; subscribe: () => Promise<boolean>;
unsubscribe: () => Promise<boolean>; unsubscribe: () => Promise<boolean>;
requestPermission: () => Promise<NotificationPermission>; requestPermission: () => Promise<NotificationPermission>;
@@ -49,14 +49,12 @@ export function usePushNotifications(): UsePushNotificationsReturn {
.then((subscription) => { .then((subscription) => {
setIsSubscribed(!!subscription); setIsSubscribed(!!subscription);
}) })
.catch((error) => { .catch(() => {});
console.error('Error checking subscription:', error);
});
}, [isSupported]); }, [isSupported]);
const requestPermission = const requestPermission =
useCallback(async (): Promise<NotificationPermission> => { useCallback(async (): Promise<NotificationPermission> => {
if (!isSupported) return 'not-supported'; if (!isSupported) return 'denied';
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
setPermissionState(permission); setPermissionState(permission);
@@ -77,24 +75,46 @@ export function usePushNotifications(): UsePushNotificationsReturn {
const publicKey = await getVapidPublicKey(); const publicKey = await getVapidPublicKey();
if (!publicKey) { if (!publicKey) {
console.error('VAPID public key not available');
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
const registration = await navigator.serviceWorker.ready; const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<ServiceWorkerRegistration>((_, reject) => {
setTimeout(() => {
reject(new Error('Service worker ready timeout'));
}, 10000);
}),
]);
if (!registration.pushManager) {
setIsLoading(false);
return false;
}
let subscription = await registration.pushManager.getSubscription(); let subscription = await registration.pushManager.getSubscription();
if (!subscription) { if (!subscription) {
const keyArray = urlBase64ToUint8Array(publicKey);
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey), applicationServerKey: keyArray.buffer as ArrayBuffer,
}); });
} }
const subJson = subscription.toJSON() as PushSubscriptionData; const subJson = subscription.toJSON();
const success = await subscribeToPush(subJson);
if (
!subJson.endpoint ||
!subJson.keys?.p256dh ||
!subJson.keys?.auth
) {
setIsLoading(false);
return false;
}
const success = await subscribeToPush(subJson as PushSubscriptionData);
if (success) { if (success) {
setIsSubscribed(true); setIsSubscribed(true);
@@ -103,8 +123,7 @@ export function usePushNotifications(): UsePushNotificationsReturn {
setIsLoading(false); setIsLoading(false);
return success; return success;
} catch (error) { } catch {
console.error('Error subscribing to push notifications:', error);
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
@@ -128,8 +147,7 @@ export function usePushNotifications(): UsePushNotificationsReturn {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setIsLoading(false); setIsLoading(false);
return true; return true;
} catch (error) { } catch {
console.error('Error unsubscribing from push notifications:', error);
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
+4 -6
View File
@@ -20,8 +20,7 @@ export async function getVapidPublicKey(): Promise<string | null> {
} }
const data: VapidPublicKeyResponse = await response.json(); const data: VapidPublicKeyResponse = await response.json();
return data.publicKey; return data.publicKey;
} catch (error) { } catch {
console.error('Error fetching VAPID public key:', error);
return null; return null;
} }
} }
@@ -41,9 +40,9 @@ export async function subscribeToPush(
auth: subscription.keys.auth, auth: subscription.keys.auth,
}), }),
}); });
return response.ok; return response.ok;
} catch (error) { } catch {
console.error('Error subscribing to push:', error);
return false; return false;
} }
} }
@@ -60,8 +59,7 @@ export async function unsubscribeFromPush(
body: JSON.stringify({ endpoint }), body: JSON.stringify({ endpoint }),
}); });
return response.ok; return response.ok;
} catch (error) { } catch {
console.error('Error unsubscribing from push:', error);
return false; return false;
} }
} }
+9 -1
View File
@@ -5,12 +5,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme' import { initializeTheme } from './lib/theme'
import 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
initializeTheme() initializeTheme()
const queryClient = new QueryClient() const queryClient = new QueryClient()
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('New content available. Reload?')) {
updateSW(true)
}
},
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig({
}, },
devOptions: { devOptions: {
enabled: true, enabled: true,
type: 'module',
}, },
}), }),
], ],