Compare commits
22
Commits
push
...
0ef26ba2f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef26ba2f1 | ||
|
|
2efdf20f88 | ||
|
|
ed723bc429 | ||
|
|
b8dfde512c | ||
|
|
5fc424408e | ||
|
|
749ff5659c | ||
|
|
a2caaab26a | ||
|
|
bc3e834a39 | ||
|
|
7a96b52cfc | ||
|
|
5233eec96a | ||
|
|
831da0af4d | ||
|
|
a9528582fd | ||
|
|
ee676b3916 | ||
|
|
9013bc51f3 | ||
|
|
57ae0bb3b5 | ||
|
|
3b827a90ac | ||
|
|
3af4b92e1b | ||
|
|
cfc43a7119 | ||
|
|
dc93a8b652 | ||
|
|
69833d2067 | ||
|
|
d041575600 | ||
|
|
57864e00da |
No files matched your search
+631
-371
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install ALL dependencies (including devDependencies for build)
|
||||||
RUN npm ci --only=production
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,6 +17,9 @@ COPY . .
|
|||||||
# Build TypeScript
|
# Build TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune devDependencies after build (suppress warning)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
@@ -32,9 +34,6 @@ COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
@@ -46,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start application
|
# Start application
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/src/main.js"]
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+12
-12
@@ -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;"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user