21 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
31 changed files with 1351 additions and 736 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(),
};
}
} }
@@ -30,12 +30,6 @@ export class PushController {
@Post('subscribe') @Post('subscribe')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> { async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
console.log('Received push subscription:', {
endpoint: dto.endpoint?.substring(0, 50) + '...',
hasP256dh: !!dto.p256dh,
hasAuth: !!dto.auth,
userId: dto.userId,
});
await this.pushService.subscribe(dto); await this.pushService.subscribe(dto);
return { success: true }; return { success: true };
} }
+1 -8
View File
@@ -49,15 +49,11 @@ export class PushService implements OnModuleInit {
} }
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> { async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
this.logger.log('Subscribing push notification...');
this.logger.debug(`Endpoint: ${dto.endpoint?.substring(0, 50)}...`);
const existing = await this.subscriptionRepo.findOne({ const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint }, where: { endpoint: dto.endpoint },
}); });
if (existing) { if (existing) {
this.logger.log('Subscription already exists, updating...');
if (dto.userId && existing.userId !== dto.userId) { if (dto.userId && existing.userId !== dto.userId) {
existing.userId = dto.userId; existing.userId = dto.userId;
return this.subscriptionRepo.save(existing); return this.subscriptionRepo.save(existing);
@@ -65,7 +61,6 @@ export class PushService implements OnModuleInit {
return existing; return existing;
} }
this.logger.log('Creating new subscription...');
const subscription = this.subscriptionRepo.create({ const subscription = this.subscriptionRepo.create({
endpoint: dto.endpoint, endpoint: dto.endpoint,
p256dh: dto.p256dh, p256dh: dto.p256dh,
@@ -73,9 +68,7 @@ export class PushService implements OnModuleInit {
userId: dto.userId ?? null, userId: dto.userId ?? null,
}); });
const saved = await this.subscriptionRepo.save(subscription); return this.subscriptionRepo.save(subscription);
this.logger.log(`Subscription saved with ID: ${saved.id}`);
return saved;
} }
async unsubscribe(dto: UnsubscribeDto): Promise<void> { async unsubscribe(dto: UnsubscribeDto): Promise<void> {
+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
+13 -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,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,31 +29,27 @@ 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
# Start Strapi # Start Strapi
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]
+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
+11 -11
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
@@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
EXPOSE 80 EXPOSE 80
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+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"
@@ -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>
))} ))}
+12 -12
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
@@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
EXPOSE 80 EXPOSE 80
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+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>
))} ))}
+3 -12
View File
@@ -32,18 +32,9 @@ export function NotificationBanner() {
}; };
const handleSubscribe = async () => { const handleSubscribe = async () => {
console.log('handleSubscribe called'); const success = await subscribe();
console.log('isSupported:', isSupported); if (success) {
console.log('isSubscribed:', isSubscribed); setIsVisible(false);
try {
const success = await subscribe();
console.log('subscribe() returned:', success);
if (success) {
setIsVisible(false);
}
} catch (error) {
console.error('handleSubscribe error:', error);
} }
}; };
+10 -39
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>;
@@ -40,30 +40,21 @@ export function usePushNotifications(): UsePushNotificationsReturn {
const [permissionState, setPermissionState] = useState(getInitialPermission); const [permissionState, setPermissionState] = useState(getInitialPermission);
const hasCheckedRef = useRef(false); const hasCheckedRef = useRef(false);
useEffect(() => {
console.log('usePushNotifications - isSupported:', checkPushSupport());
console.log('usePushNotifications - permissionState:', getInitialPermission());
}, []);
useEffect(() => { useEffect(() => {
if (!isSupported || hasCheckedRef.current) return; if (!isSupported || hasCheckedRef.current) return;
hasCheckedRef.current = true; hasCheckedRef.current = true;
console.log('Checking existing push subscription...');
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription()) .then((registration) => registration.pushManager.getSubscription())
.then((subscription) => { .then((subscription) => {
console.log('Existing subscription:', 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);
@@ -78,38 +69,26 @@ export function usePushNotifications(): UsePushNotificationsReturn {
try { try {
const permission = await requestPermission(); const permission = await requestPermission();
if (permission !== 'granted') { if (permission !== 'granted') {
console.log('Push permission denied');
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
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;
} }
console.log('Got VAPID public key, subscribing...');
// Check if service worker is already ready
console.log('Checking service worker status...');
const swController = navigator.serviceWorker.controller;
console.log('Service worker controller:', swController);
// Wait for service worker to be ready with timeout
const registration = await Promise.race([ const registration = await Promise.race([
navigator.serviceWorker.ready, navigator.serviceWorker.ready,
new Promise<ServiceWorkerRegistration>((_, reject) => { new Promise<ServiceWorkerRegistration>((_, reject) => {
setTimeout(() => { setTimeout(() => {
reject(new Error('Service worker ready timeout after 10s')); reject(new Error('Service worker ready timeout'));
}, 10000); }, 10000);
}), }),
]); ]);
console.log('Service worker ready:', registration);
if (!registration.pushManager) { if (!registration.pushManager) {
console.error('PushManager not available in service worker registration');
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
@@ -117,31 +96,25 @@ export function usePushNotifications(): UsePushNotificationsReturn {
let subscription = await registration.pushManager.getSubscription(); let subscription = await registration.pushManager.getSubscription();
if (!subscription) { if (!subscription) {
console.log('Creating new push 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,
}); });
console.log('Push subscription created');
} else {
console.log('Existing push subscription found');
} }
const subJson = subscription.toJSON(); const subJson = subscription.toJSON();
console.log('Subscription JSON:', subJson);
if ( if (
!subJson.endpoint || !subJson.endpoint ||
!subJson.keys?.p256dh || !subJson.keys?.p256dh ||
!subJson.keys?.auth !subJson.keys?.auth
) { ) {
console.error('Invalid subscription data:', subJson);
setIsLoading(false); setIsLoading(false);
return false; return false;
} }
const success = await subscribeToPush(subJson as PushSubscriptionData); const success = await subscribeToPush(subJson as PushSubscriptionData);
console.log('Subscribe to push result:', success);
if (success) { if (success) {
setIsSubscribed(true); setIsSubscribed(true);
@@ -150,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;
} }
@@ -175,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;
} }
+9 -30
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;
} }
} }
@@ -30,39 +29,20 @@ export async function subscribeToPush(
subscription: PushSubscriptionData, subscription: PushSubscriptionData,
): Promise<boolean> { ): Promise<boolean> {
try { try {
console.log('Sending subscription to server:', {
endpoint: subscription.endpoint,
hasKeys: !!subscription.keys,
});
const payload = {
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
};
console.log('Payload:', payload);
console.log('API URL:', `${API_BASE_URL}/push/subscribe`);
const response = await fetch(`${API_BASE_URL}/push/subscribe`, { const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(payload), body: JSON.stringify({
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
}),
}); });
console.log('Subscribe response status:', response.status); return response.ok;
} catch {
if (!response.ok) {
const errorText = await response.text();
console.error('Subscribe failed:', errorText);
return false;
}
return true;
} catch (error) {
console.error('Error subscribing to push:', error);
return false; return false;
} }
} }
@@ -79,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;
} }
} }
-10
View File
@@ -11,22 +11,12 @@ initializeTheme()
const queryClient = new QueryClient() const queryClient = new QueryClient()
// Register service worker
const updateSW = registerSW({ const updateSW = registerSW({
onNeedRefresh() { onNeedRefresh() {
if (confirm('New content available. Reload?')) { if (confirm('New content available. Reload?')) {
updateSW(true) updateSW(true)
} }
}, },
onOfflineReady() {
console.log('App ready to work offline')
},
onRegistered(registration) {
console.log('SW Registered:', registration?.scope)
},
onRegisterError(error) {
console.error('SW registration error:', error)
},
}) })
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(