Compare commits
72
Commits
push
...
46bbeed525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bbeed525 | ||
|
|
7258f059ce | ||
|
|
a66db56156 | ||
|
|
ebd1fc28d5 | ||
|
|
cba9908180 | ||
|
|
287dcb4070 | ||
|
|
50ac4531d3 | ||
|
|
79bd134977 | ||
|
|
88dbd2719d | ||
|
|
e71dc2b90f | ||
|
|
caf56d9fcd | ||
|
|
86c1c2d366 | ||
|
|
4f2837ad4e | ||
|
|
c6427304ac | ||
|
|
515c4cacf3 | ||
|
|
59891b03dc | ||
|
|
8f68dab53f | ||
|
|
fa7dcc2b08 | ||
|
|
3e61fe5694 | ||
|
|
a5008a3646 | ||
|
|
cbdb801655 | ||
|
|
a5c57b33f7 | ||
|
|
b39972102a | ||
|
|
8fbde18d02 | ||
|
|
94b0239a0a | ||
|
|
ac610e6f6a | ||
|
|
8eaaf4afad | ||
|
|
33008b64ce | ||
|
|
005d368dc7 | ||
|
|
a8c0ab8884 | ||
|
|
e443ece848 | ||
|
|
76963f6eea | ||
|
|
bfc7e76f17 | ||
|
|
06a0c9fe05 | ||
|
|
c1b49a4cb6 | ||
|
|
4f30542014 | ||
|
|
d7281024bf | ||
|
|
4a22e8a18a | ||
|
|
994becc687 | ||
|
|
69e758b841 | ||
|
|
17c6a1593c | ||
|
|
d573846e5c | ||
|
|
c3b01de12e | ||
|
|
80a2ee89a6 | ||
|
|
ff0916b37c | ||
|
|
6eb68b7bd3 | ||
|
|
fcbd082f6d | ||
|
|
183636bceb | ||
|
|
5262e73a12 | ||
|
|
590a96e502 | ||
|
|
0ef26ba2f1 | ||
|
|
2efdf20f88 | ||
|
|
ed723bc429 | ||
|
|
b8dfde512c | ||
|
|
5fc424408e | ||
|
|
749ff5659c | ||
|
|
a2caaab26a | ||
|
|
bc3e834a39 | ||
|
|
7a96b52cfc | ||
|
|
5233eec96a | ||
|
|
831da0af4d | ||
|
|
a9528582fd | ||
|
|
ee676b3916 | ||
|
|
9013bc51f3 | ||
|
|
57ae0bb3b5 | ||
|
|
3b827a90ac | ||
|
|
3af4b92e1b | ||
|
|
cfc43a7119 | ||
|
|
dc93a8b652 | ||
|
|
69833d2067 | ||
|
|
d041575600 | ||
|
|
57864e00da |
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
# ===========================================
|
||||||
|
# COOLIFY ENVIRONMENT VARIABLES
|
||||||
|
# Copy these to Coolify UI: Environment Variables section
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Database Password (you should already have this set)
|
||||||
|
DATABASE_PASSWORD=abreubre776677112233
|
||||||
|
|
||||||
|
# Backend JWT Secret (REQUIRED - MISSING!)
|
||||||
|
JWT_SECRET=Xt4mwkbFEw83dSMXCv6W0Ut6YoTgIkYO62eticw0CfxkZhYSplDbjeUOyrnwyWK34Pt3nrnmtE5+khKxeoHiSA==
|
||||||
|
|
||||||
|
# Strapi API Token (leave empty for now - will set after making API public)
|
||||||
|
STRAPI_API_TOKEN=
|
||||||
|
|
||||||
|
# Strapi Security Keys (REQUIRED)
|
||||||
|
STRAPI_APP_KEYS=Jlt1Pu+ZBzcTSYazU8ZlEMOZyj4F9MO9YVAJmkaKrnk=,V2VSvJQrZ61jk8MtVkhC2RrKcm3XvJzmYTi73NItPYQ=,dhvlKrjeYGbCGaZznVTEJZLcAjIIwAtNmT6/i5Zq09I=,qDCKh9Pdep3P4ZlX+OCsKUwj/VZKul959RGbxXdiyf8=
|
||||||
|
STRAPI_API_TOKEN_SALT=MkkvTfDJkwEPznUVfbiKj3SSWPw/MKqrOIRxN9cyWLk=
|
||||||
|
STRAPI_ADMIN_JWT_SECRET=RpqNlR20k4VF2x1rzRvjUsg46zN2X4YcfBowbjdvqJo=
|
||||||
|
STRAPI_TRANSFER_TOKEN_SALT=96PznECGwwinWXB8fhlHwE11+0XU5TaJwTaztQPaQw4=
|
||||||
|
STRAPI_JWT_SECRET=3CGgFzvM8ykfndK2pqcCb7U5W3FcBF0SXwalj1kby6s=
|
||||||
|
STRAPI_ENCRYPTION_KEY=8V99V0CfSxJZLvgXGRBv/zndKH2FnPQ/JVmXa1OEfZ8=
|
||||||
|
|
||||||
|
# Push Notification VAPID Keys (for PWA notifications)
|
||||||
|
VAPID_PUBLIC_KEY=BEUVi1YA6wyD1Mt31M8nbsz7ctVC1wxURkz4bHdrexbtUzDETS90MOpS-QFebnXt_Dx_zvntPHCno6bwsK3pOxU
|
||||||
|
VAPID_PRIVATE_KEY=XJIYJyV1KkfEwnHa1vy4Jb3FPRg27eFton1Tdsep8fI
|
||||||
|
|
||||||
|
# VAPID Subject (optional - defaults to mailto:contact@placebo.mk)
|
||||||
|
VAPID_SUBJECT=mailto:contact@placebo.mk
|
||||||
+646
-386
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install ALL dependencies (including devDependencies for build)
|
||||||
RUN npm ci --only=production
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,6 +17,9 @@ COPY . .
|
|||||||
# Build TypeScript
|
# Build TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune devDependencies after build (suppress warning)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
@@ -32,9 +34,6 @@ COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
@@ -46,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start application
|
# Start application
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/src/main.js"]
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Backend entrypoint script - seeds admin on first run
|
||||||
|
|
||||||
|
echo "Starting Placebo.mk Backend..."
|
||||||
|
|
||||||
|
# Run admin seed script (idempotent - won't recreate if exists)
|
||||||
|
# Temporarily disabled to fix startup issues
|
||||||
|
# echo "Checking for admin user..."
|
||||||
|
# node dist/scripts/seed-admin.js || echo "Warning: Admin seed failed, continuing..."
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "Starting NestJS application..."
|
||||||
|
exec node dist/src/main.js
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { Public } from './modules/auth/public.decorator';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@@ -9,4 +10,13 @@ export class AppController {
|
|||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('health')
|
||||||
|
healthCheck(): { status: string; timestamp: string } {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArticlesModule } from './modules/articles.module';
|
import { ArticlesModule } from './modules/articles.module';
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from './modules/entities';
|
} from './modules/entities';
|
||||||
import { ShareEvent } from './modules/analytics/analytics.entity';
|
import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||||
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
|
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
|
||||||
|
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,6 +63,12 @@ import { PushSubscriptionEntity } from './modules/push/push-subscription.entity'
|
|||||||
PushModule,
|
PushModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthPublicGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
+10
-7
@@ -1,20 +1,27 @@
|
|||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Build allowed origins list from environment variables
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||||
|
'https://www.placebo.mk', // Also allow www subdomain
|
||||||
process.env.PWA_URL ?? 'http://localhost:5174',
|
process.env.PWA_URL ?? 'http://localhost:5174',
|
||||||
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
||||||
];
|
].filter(Boolean); // Remove any undefined/null values
|
||||||
|
|
||||||
|
console.log('CORS enabled for origins:', allowedOrigins);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: allowedOrigins,
|
origin: allowedOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin'],
|
||||||
|
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
maxAge: 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
@@ -28,10 +35,6 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply global authentication guard
|
|
||||||
const reflector = app.get(Reflector);
|
|
||||||
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
|
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||||
await app.listen(port, host);
|
await app.listen(port, host);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Logger } from '@nestjs/common';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
import { Public } from './auth/public.decorator';
|
import { Public } from './auth/public.decorator';
|
||||||
|
|
||||||
@@ -120,18 +120,28 @@ export class StrapiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/all')
|
@Post('sync/all')
|
||||||
|
@Public()
|
||||||
async syncAllArticles() {
|
async syncAllArticles() {
|
||||||
await this.strapiService.syncArticles();
|
await this.strapiService.syncArticles();
|
||||||
return { message: 'Articles sync completed' };
|
return { message: 'Articles sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('sync/all')
|
||||||
|
@Public()
|
||||||
|
async syncAllArticlesGet() {
|
||||||
|
await this.strapiService.syncArticles();
|
||||||
|
return { message: 'Articles sync completed' };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('sync/live-blogs')
|
@Post('sync/live-blogs')
|
||||||
|
@Public()
|
||||||
async syncAllLiveBlogs() {
|
async syncAllLiveBlogs() {
|
||||||
await this.strapiService.syncLiveBlogs();
|
await this.strapiService.syncLiveBlogs();
|
||||||
return { message: 'Live blogs sync completed' };
|
return { message: 'Live blogs sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/everything')
|
@Post('sync/everything')
|
||||||
|
@Public()
|
||||||
async syncEverything() {
|
async syncEverything() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.strapiService.syncArticles(),
|
this.strapiService.syncArticles(),
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ interface StrapiResponse<T> {
|
|||||||
export class StrapiService {
|
export class StrapiService {
|
||||||
private readonly logger = new Logger(StrapiService.name);
|
private readonly logger = new Logger(StrapiService.name);
|
||||||
private readonly strapiUrl: string;
|
private readonly strapiUrl: string;
|
||||||
|
private readonly strapiPublicUrl: string;
|
||||||
private readonly strapiApiToken: string;
|
private readonly strapiApiToken: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -101,14 +102,28 @@ export class StrapiService {
|
|||||||
) {
|
) {
|
||||||
this.strapiUrl =
|
this.strapiUrl =
|
||||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
||||||
|
this.strapiPublicUrl =
|
||||||
|
this.configService.get<string>('STRAPI_PUBLIC_URL') ||
|
||||||
|
this.strapiUrl.replace('cms:', 'localhost:');
|
||||||
this.strapiApiToken =
|
this.strapiApiToken =
|
||||||
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders() {
|
private getHeaders() {
|
||||||
return {
|
// Use public API access - no authentication needed
|
||||||
Authorization: `Bearer ${this.strapiApiToken}`,
|
// Strapi Public role has been configured to allow article access
|
||||||
};
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSlug(title: string): string {
|
||||||
|
// Generate slug from title if missing
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||||
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||||
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||||
|
.substring(0, 100); // Limit length
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrCreateCategory(
|
private async findOrCreateCategory(
|
||||||
@@ -120,6 +135,7 @@ export class StrapiService {
|
|||||||
|
|
||||||
// Map CMS category slugs to Macedonian display names
|
// Map CMS category slugs to Macedonian display names
|
||||||
const categoryMap: Record<string, { name: string; description: string }> = {
|
const categoryMap: Record<string, { name: string; description: string }> = {
|
||||||
|
general: { name: 'Општо', description: 'Општи вести и теми' },
|
||||||
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
|
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
|
||||||
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
|
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
|
||||||
science: { name: 'Наука', description: 'Научни откритија и технологија' },
|
science: { name: 'Наука', description: 'Научни откритија и технологија' },
|
||||||
@@ -170,10 +186,8 @@ export class StrapiService {
|
|||||||
|
|
||||||
// If URL is relative, prepend Strapi base URL
|
// If URL is relative, prepend Strapi base URL
|
||||||
if (imageUrl.startsWith('/')) {
|
if (imageUrl.startsWith('/')) {
|
||||||
// Convert Docker service URL to localhost for frontend access
|
// Use public URL for frontend access
|
||||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
|
||||||
return `${frontendStrapiUrl}${imageUrl}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
@@ -198,10 +212,8 @@ export class StrapiService {
|
|||||||
|
|
||||||
// If URL is relative, prepend Strapi base URL
|
// If URL is relative, prepend Strapi base URL
|
||||||
if (imageUrl.startsWith('/')) {
|
if (imageUrl.startsWith('/')) {
|
||||||
// Convert Docker service URL to localhost for frontend access
|
// Use public URL for frontend access
|
||||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
|
||||||
return `${frontendStrapiUrl}${imageUrl}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
@@ -237,11 +249,15 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate slug if missing or null
|
||||||
|
const slug =
|
||||||
|
strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status: strapiArticle.publishedAt
|
status: strapiArticle.publishedAt
|
||||||
? ArticleStatus.PUBLISHED
|
? ArticleStatus.PUBLISHED
|
||||||
: ArticleStatus.DRAFT,
|
: ArticleStatus.DRAFT,
|
||||||
@@ -324,11 +340,14 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate slug if missing or null
|
||||||
|
const slug = strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status,
|
status,
|
||||||
tags: [],
|
tags: [],
|
||||||
featuredImage: imageUrl,
|
featuredImage: imageUrl,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
# Server
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=1337
|
||||||
|
|
||||||
|
# Secrets (these should be overridden by docker env vars)
|
||||||
|
APP_KEYS=tobereplaced
|
||||||
|
API_TOKEN_SALT=tobereplaced
|
||||||
|
ADMIN_JWT_SECRET=tobereplaced
|
||||||
|
TRANSFER_TOKEN_SALT=tobereplaced
|
||||||
|
JWT_SECRET=tobereplaced
|
||||||
|
|
||||||
|
# Database (these MUST be overridden by docker env vars)
|
||||||
|
DATABASE_CLIENT=postgres
|
||||||
|
DATABASE_HOST=postgres-cms
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_NAME=placebo_cms_db
|
||||||
|
DATABASE_USERNAME=placebo_user
|
||||||
|
DATABASE_PASSWORD=placebo_pass
|
||||||
|
DATABASE_SSL=false
|
||||||
+30
-15
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci --only=production
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,40 +17,56 @@ COPY . .
|
|||||||
# Build Strapi
|
# Build Strapi
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune devDependencies after build (suppress warning)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install su-exec for user switching in entrypoint
|
||||||
|
RUN apk add --no-cache su-exec
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
# Install SQLite for development (will use PostgreSQL in production)
|
|
||||||
RUN apk add --no-cache sqlite
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy built application from builder stage
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/dist/config ./config
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/src ./src
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/public ./public
|
COPY --from=builder --chown=nodejs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/favicon.png ./favicon.png
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/.strapi ./.strapi
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
# Copy entrypoint script
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create the directory structure Strapi expects for the admin build
|
||||||
RUN mkdir -p /app/.tmp && \
|
RUN mkdir -p /app/node_modules/@strapi/admin/dist/server/server && \
|
||||||
chown -R nodejs:nodejs /app/.tmp
|
ln -sf /app/dist/build /app/node_modules/@strapi/admin/dist/server/server/build && \
|
||||||
|
chown -R nodejs:nodejs /app/node_modules/@strapi/admin
|
||||||
|
|
||||||
# Switch to non-root user
|
# Create data and database directories with proper permissions
|
||||||
USER nodejs
|
RUN mkdir -p /app/.tmp /app/database /app/database/migrations /app/public/uploads && \
|
||||||
|
chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
# Don't switch to nodejs user yet - entrypoint will handle it
|
||||||
|
# USER nodejs
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})"
|
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 1337
|
EXPOSE 1337
|
||||||
|
|
||||||
|
# Use entrypoint to fix permissions on startup then switch to nodejs user
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
# Start Strapi
|
# Start Strapi
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb
|
||||||
@@ -17,4 +17,9 @@ export default ({ env }) => ({
|
|||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
},
|
},
|
||||||
|
// Specify the build directory path for production
|
||||||
|
path: env('ADMIN_PATH', '/admin'),
|
||||||
|
build: {
|
||||||
|
backend: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+34
-41
@@ -3,58 +3,51 @@ import path from 'path';
|
|||||||
export default ({ env }) => {
|
export default ({ env }) => {
|
||||||
const client = env('DATABASE_CLIENT', 'sqlite');
|
const client = env('DATABASE_CLIENT', 'sqlite');
|
||||||
|
|
||||||
const connections = {
|
console.log('=== DATABASE CONFIGURATION ===');
|
||||||
mysql: {
|
console.log('DATABASE_CLIENT:', client);
|
||||||
connection: {
|
console.log('DATABASE_HOST:', env('DATABASE_HOST', 'not-set'));
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
console.log('DATABASE_PORT:', env('DATABASE_PORT', 'not-set'));
|
||||||
port: env.int('DATABASE_PORT', 3306),
|
console.log('DATABASE_USERNAME:', env('DATABASE_USERNAME', 'not-set'));
|
||||||
database: env('DATABASE_NAME', 'strapi'),
|
console.log('DATABASE_NAME:', env('DATABASE_NAME', 'not-set'));
|
||||||
user: env('DATABASE_USERNAME', 'strapi'),
|
console.log('DATABASE_PASSWORD:', env('DATABASE_PASSWORD') ? '***SET***' : '***NOT SET***');
|
||||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
console.log('DATABASE_SSL:', env('DATABASE_SSL', 'not-set'));
|
||||||
ssl: env.bool('DATABASE_SSL', false) && {
|
|
||||||
key: env('DATABASE_SSL_KEY', undefined),
|
if (client === 'postgres') {
|
||||||
cert: env('DATABASE_SSL_CERT', undefined),
|
const connectionConfig = {
|
||||||
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: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
|
||||||
},
|
|
||||||
postgres: {
|
|
||||||
connection: {
|
|
||||||
connectionString: env('DATABASE_URL'),
|
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
host: env('DATABASE_HOST', 'localhost'),
|
||||||
port: env.int('DATABASE_PORT', 5432),
|
port: env.int('DATABASE_PORT', 5432),
|
||||||
database: env('DATABASE_NAME', 'strapi'),
|
database: env('DATABASE_NAME', 'strapi'),
|
||||||
user: env('DATABASE_USERNAME', 'strapi'),
|
user: env('DATABASE_USERNAME', 'strapi'),
|
||||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||||
ssl: env.bool('DATABASE_SSL', false) && {
|
ssl: env.bool('DATABASE_SSL', false) ? { rejectUnauthorized: false } : false,
|
||||||
key: env('DATABASE_SSL_KEY', undefined),
|
};
|
||||||
cert: env('DATABASE_SSL_CERT', undefined),
|
|
||||||
ca: env('DATABASE_SSL_CA', undefined),
|
console.log('PostgreSQL connection config:', JSON.stringify({
|
||||||
capath: env('DATABASE_SSL_CAPATH', undefined),
|
...connectionConfig,
|
||||||
cipher: env('DATABASE_SSL_CIPHER', undefined),
|
password: connectionConfig.password ? `***${connectionConfig.password.length} chars***` : '***NOT SET***'
|
||||||
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
|
}, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
client: 'postgres',
|
||||||
|
connection: connectionConfig,
|
||||||
|
pool: {
|
||||||
|
min: env.int('DATABASE_POOL_MIN', 2),
|
||||||
|
max: env.int('DATABASE_POOL_MAX', 10),
|
||||||
},
|
},
|
||||||
schema: env('DATABASE_SCHEMA', 'public'),
|
acquireConnectionTimeout: env.int('DATABASE_TIMEOUT', 60000),
|
||||||
},
|
},
|
||||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
};
|
||||||
},
|
}
|
||||||
sqlite: {
|
|
||||||
|
console.log('Using SQLite configuration');
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
client: 'sqlite',
|
||||||
connection: {
|
connection: {
|
||||||
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
||||||
},
|
},
|
||||||
useNullAsDefault: true,
|
useNullAsDefault: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
connection: {
|
|
||||||
client,
|
|
||||||
...connections[client],
|
|
||||||
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,17 @@ export default [
|
|||||||
'strapi::cors',
|
'strapi::cors',
|
||||||
'strapi::poweredBy',
|
'strapi::poweredBy',
|
||||||
'strapi::query',
|
'strapi::query',
|
||||||
'strapi::body',
|
{
|
||||||
|
name: 'strapi::body',
|
||||||
|
config: {
|
||||||
|
formLimit: '256mb', // Max form size
|
||||||
|
jsonLimit: '256mb', // Max JSON payload size
|
||||||
|
textLimit: '256mb', // Max text payload size
|
||||||
|
formidable: {
|
||||||
|
maxFileSize: 200 * 1024 * 1024, // 200MB in bytes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'strapi::session',
|
'strapi::session',
|
||||||
'strapi::favicon',
|
'strapi::favicon',
|
||||||
'strapi::public',
|
'strapi::public',
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export default () => ({});
|
export default ({ env }) => ({
|
||||||
|
upload: {
|
||||||
|
config: {
|
||||||
|
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export default ({ env }) => ({
|
export default ({ env }) => ({
|
||||||
host: env('HOST', '0.0.0.0'),
|
host: env('HOST', '0.0.0.0'),
|
||||||
port: env.int('PORT', 1337),
|
port: env.int('PORT', 1337),
|
||||||
|
url: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||||
app: {
|
app: {
|
||||||
keys: env.array('APP_KEYS'),
|
keys: env.array('APP_KEYS'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ensure uploads directory exists and is writable
|
||||||
|
mkdir -p /app/public/uploads
|
||||||
|
|
||||||
|
# Fix permissions for uploads directory (needed for volume mounts)
|
||||||
|
chown -R nodejs:nodejs /app/public/uploads
|
||||||
|
chmod -R 755 /app/public/uploads
|
||||||
|
|
||||||
|
# Switch to nodejs user and execute the main command
|
||||||
|
exec su-exec nodejs "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Script to create an API token for backend integration
|
||||||
|
* Run this inside the CMS container if the UI doesn't work
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
async function createApiToken() {
|
||||||
|
const strapi = await factories.createCoreStore()();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create API token
|
||||||
|
const tokenService = strapi.service('admin::api-token');
|
||||||
|
|
||||||
|
const token = await tokenService.create({
|
||||||
|
name: 'Backend Integration',
|
||||||
|
description: 'Token for backend to fetch articles',
|
||||||
|
type: 'read-only',
|
||||||
|
permissions: [],
|
||||||
|
lifespan: null, // Unlimited
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ API Token created successfully!');
|
||||||
|
console.log('\nToken details:');
|
||||||
|
console.log('Name:', token.name);
|
||||||
|
console.log('Type:', token.type);
|
||||||
|
console.log('\nTOKEN (copy this to Coolify as STRAPI_API_TOKEN):');
|
||||||
|
console.log(token.accessKey);
|
||||||
|
console.log('\n⚠️ Save this token - you won\'t see it again!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating API token:', error.message);
|
||||||
|
} finally {
|
||||||
|
await strapi.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createApiToken();
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"singularName": "article",
|
"singularName": "article",
|
||||||
"pluralName": "articles",
|
"pluralName": "articles",
|
||||||
"displayName": "article"
|
"displayName": "Article",
|
||||||
|
"description": "News articles for Placebo.mk"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"draftAndPublish": true
|
"draftAndPublish": true
|
||||||
@@ -12,33 +13,28 @@
|
|||||||
"pluginOptions": {},
|
"pluginOptions": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
},
|
||||||
"media": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": true,
|
|
||||||
"allowedTypes": [
|
|
||||||
"images",
|
|
||||||
"files",
|
|
||||||
"videos",
|
|
||||||
"audios"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"author": {
|
"author": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"img": {
|
"img": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"multiple": false,
|
"multiple": false,
|
||||||
"allowedTypes": [
|
"required": false,
|
||||||
"images",
|
"allowedTypes": ["images"]
|
||||||
"files",
|
|
||||||
"videos",
|
|
||||||
"audios"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"imagePosition": {
|
"imagePosition": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
@@ -50,10 +46,14 @@
|
|||||||
"enum": ["small", "medium", "large"],
|
"enum": ["small", "medium", "large"],
|
||||||
"default": "medium"
|
"default": "medium"
|
||||||
},
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": true,
|
||||||
|
"required": false,
|
||||||
|
"allowedTypes": ["images", "files", "videos", "audios"]
|
||||||
|
},
|
||||||
"videoUrl": {
|
"videoUrl": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
|
|
||||||
"default": ""
|
|
||||||
},
|
},
|
||||||
"videoPosition": {
|
"videoPosition": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
@@ -61,13 +61,12 @@
|
|||||||
"default": "inline"
|
"default": "inline"
|
||||||
},
|
},
|
||||||
"videoCaption": {
|
"videoCaption": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"default": ""
|
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
"enum": ["sport", "art", "science"],
|
"enum": ["general", "sport", "art", "science"],
|
||||||
"default": "sport",
|
"default": "general",
|
||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreController } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreController('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article controller
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreController('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article router
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreRouter } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreRouter('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article router
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreRouter('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article service
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreService } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreService('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article service
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreService('api::article.article');
|
|
||||||
@@ -16,5 +16,9 @@ export default {
|
|||||||
* This gives you an opportunity to set up your data model,
|
* This gives you an opportunity to set up your data model,
|
||||||
* run jobs, or perform some special logic.
|
* run jobs, or perform some special logic.
|
||||||
*/
|
*/
|
||||||
bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
|
async bootstrap({ strapi }) {
|
||||||
|
console.log('=== Strapi Bootstrap ===');
|
||||||
|
console.log('Available content types:', Object.keys(strapi.contentTypes || {}));
|
||||||
|
console.log('Article content type exists:', !!strapi.contentTypes['api::article.article']);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Docker Compose for Coolify Deployment
|
||||||
|
# Deploy all services in one run
|
||||||
|
#
|
||||||
|
# Usage in Coolify:
|
||||||
|
# 1. Create new Docker Compose service
|
||||||
|
# 2. Point to this file
|
||||||
|
# 3. Set environment variables in Coolify UI
|
||||||
|
# 4. Deploy
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===========================================
|
||||||
|
# DATABASES
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
postgres-backend:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: placebo-postgres-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: placebo_backend_db
|
||||||
|
POSTGRES_USER: placebo_user
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- placebo-postgres-backend-data:/var/lib/postgresql/data
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_backend_db']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
|
||||||
|
postgres-cms:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: placebo-postgres-cms
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: placebo_cms_db
|
||||||
|
POSTGRES_USER: placebo_user
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- placebo-postgres-cms-data:/var/lib/postgresql/data
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_cms_db']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# BACKEND (NestJS API)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: placebo-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_TYPE: postgres
|
||||||
|
DATABASE_HOST: postgres-backend
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USERNAME: placebo_user
|
||||||
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
DATABASE_NAME: placebo_backend_db
|
||||||
|
DATABASE_SYNCHRONIZE: 'true'
|
||||||
|
DATABASE_LOGGING: 'true'
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION: '86400'
|
||||||
|
FRONTEND_URL: https://placebo.mk
|
||||||
|
PWA_URL: https://app.placebo.mk
|
||||||
|
STRAPI_URL: http://cms:1337
|
||||||
|
STRAPI_PUBLIC_URL: https://cms.placebo.mk
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
depends_on:
|
||||||
|
postgres-backend:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:3000/api/v1/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
- coolify
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||||
|
- "traefik.http.routers.backend-http.rule=Host(`api.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.backend-http.entrypoints=http"
|
||||||
|
- "traefik.http.routers.backend-http.middlewares=redirect-to-https"
|
||||||
|
# HTTPS router
|
||||||
|
- "traefik.http.routers.backend.rule=Host(`api.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.backend.entrypoints=https"
|
||||||
|
- "traefik.http.routers.backend.tls=true"
|
||||||
|
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.backend.service=backend"
|
||||||
|
# Service
|
||||||
|
- "traefik.http.services.backend.loadbalancer.server.port=3000"
|
||||||
|
# Redirect middleware
|
||||||
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CMS (Strapi)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
cms:
|
||||||
|
build:
|
||||||
|
context: ./cms/cms
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: placebo-cms
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 1337
|
||||||
|
PUBLIC_URL: https://cms.placebo.mk
|
||||||
|
BACKEND_WEBHOOK_URL: http://backend:3000
|
||||||
|
DATABASE_CLIENT: postgres
|
||||||
|
DATABASE_HOST: postgres-cms
|
||||||
|
DATABASE_PORT: '5432'
|
||||||
|
DATABASE_NAME: placebo_cms_db
|
||||||
|
DATABASE_USERNAME: placebo_user
|
||||||
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
DATABASE_SSL: 'false'
|
||||||
|
APP_KEYS: ${STRAPI_APP_KEYS}
|
||||||
|
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
|
||||||
|
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
|
||||||
|
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
|
||||||
|
JWT_SECRET: ${STRAPI_JWT_SECRET}
|
||||||
|
ENCRYPTION_KEY: ${STRAPI_ENCRYPTION_KEY}
|
||||||
|
depends_on:
|
||||||
|
postgres-cms:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- placebo-cms-uploads:/app/public/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:1337/_health', (r) => {if(r.statusCode === 200 || r.statusCode === 204) process.exit(0); process.exit(1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
- coolify
|
||||||
|
expose:
|
||||||
|
- "1337"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||||
|
- "traefik.http.routers.cms-http.rule=Host(`cms.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.cms-http.entrypoints=http"
|
||||||
|
- "traefik.http.routers.cms-http.middlewares=redirect-to-https"
|
||||||
|
# HTTPS router
|
||||||
|
- "traefik.http.routers.cms.rule=Host(`cms.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.cms.entrypoints=https"
|
||||||
|
- "traefik.http.routers.cms.tls=true"
|
||||||
|
- "traefik.http.routers.cms.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.cms.service=cms"
|
||||||
|
# Service with timeouts
|
||||||
|
- "traefik.http.services.cms.loadbalancer.server.port=1337"
|
||||||
|
- "traefik.http.services.cms.loadbalancer.responseForwarding.flushInterval=100ms"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# FRONTEND (React)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: https://api.placebo.mk/api/v1
|
||||||
|
VITE_CMS_URL: https://cms.placebo.mk
|
||||||
|
container_name: placebo-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
- coolify
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||||
|
- "traefik.http.routers.frontend-http.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.frontend-http.entrypoints=http"
|
||||||
|
- "traefik.http.routers.frontend-http.middlewares=redirect-to-https"
|
||||||
|
# HTTPS router
|
||||||
|
- "traefik.http.routers.frontend.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.frontend.entrypoints=https"
|
||||||
|
- "traefik.http.routers.frontend.tls=true"
|
||||||
|
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.frontend.service=frontend"
|
||||||
|
# Service
|
||||||
|
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# PWA (Progressive Web App)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
pwa:
|
||||||
|
build:
|
||||||
|
context: ./pwa
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: https://api.placebo.mk/api/v1
|
||||||
|
VITE_CMS_URL: https://cms.placebo.mk
|
||||||
|
container_name: placebo-pwa
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- placebo-internal
|
||||||
|
- coolify
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||||
|
- "traefik.http.routers.pwa-http.rule=Host(`app.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.pwa-http.entrypoints=http"
|
||||||
|
- "traefik.http.routers.pwa-http.middlewares=redirect-to-https"
|
||||||
|
# HTTPS router
|
||||||
|
- "traefik.http.routers.pwa.rule=Host(`app.placebo.mk`)"
|
||||||
|
- "traefik.http.routers.pwa.entrypoints=https"
|
||||||
|
- "traefik.http.routers.pwa.tls=true"
|
||||||
|
- "traefik.http.routers.pwa.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.pwa.service=pwa"
|
||||||
|
# Service
|
||||||
|
- "traefik.http.services.pwa.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# VOLUMES (Managed by Coolify)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
placebo-postgres-backend-data:
|
||||||
|
driver: local
|
||||||
|
placebo-postgres-cms-data:
|
||||||
|
driver: local
|
||||||
|
placebo-cms-uploads:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NETWORKS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
networks:
|
||||||
|
placebo-internal:
|
||||||
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Production Docker Compose for Coolify
|
||||||
|
# Each service should be deployed separately in Coolify
|
||||||
|
# This file is for reference and local testing only
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: placebo-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: placebo_db
|
||||||
|
POSTGRES_USER: placebo_user
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_db']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- placebo-network
|
||||||
|
|
||||||
|
# Backend API (NestJS)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: placebo-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_TYPE: postgres
|
||||||
|
DATABASE_HOST: postgres
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USERNAME: placebo_user
|
||||||
|
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||||
|
DATABASE_NAME: placebo_db
|
||||||
|
DATABASE_SYNCHRONIZE: 'false'
|
||||||
|
DATABASE_LOGGING: 'false'
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
JWT_EXPIRATION: '86400'
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- placebo-network
|
||||||
|
|
||||||
|
# Frontend (React)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
|
||||||
|
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
|
||||||
|
container_name: placebo-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3001:80'
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- placebo-network
|
||||||
|
|
||||||
|
# PWA (Progressive Web App)
|
||||||
|
pwa:
|
||||||
|
build:
|
||||||
|
context: ./pwa
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
|
||||||
|
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
|
||||||
|
container_name: placebo-pwa
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '5174:80'
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- placebo-network
|
||||||
|
|
||||||
|
# CMS (Strapi)
|
||||||
|
cms:
|
||||||
|
build:
|
||||||
|
context: ./cms/cms
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: placebo-cms
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_CLIENT: postgres
|
||||||
|
DATABASE_HOST: postgres
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_NAME: placebo_cms_db
|
||||||
|
DATABASE_USERNAME: placebo_user
|
||||||
|
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||||
|
DATABASE_SSL: 'false'
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 1337
|
||||||
|
APP_KEYS: ${STRAPI_APP_KEYS:-key1,key2,key3,key4}
|
||||||
|
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT:-change-me}
|
||||||
|
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET:-change-me}
|
||||||
|
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT:-change-me}
|
||||||
|
JWT_SECRET: ${STRAPI_JWT_SECRET:-change-me}
|
||||||
|
ports:
|
||||||
|
- '1337:1337'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- cms_uploads:/app/public/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- placebo-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
cms_uploads:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
placebo-network:
|
||||||
|
driver: bridge
|
||||||
+10
-10
@@ -5,12 +5,19 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build arguments for environment variables
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_CMS_URL
|
||||||
|
|
||||||
|
# Set environment variables for build
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_CMS_URL=$VITE_CMS_URL
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -21,19 +28,12 @@ RUN npm run build
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nginx && \
|
|
||||||
adduser -S nginx -u 1001 -G nginx
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy built application from builder stage
|
||||||
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy nginx configuration
|
# Copy nginx configuration
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nginx
|
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||||
|
|||||||
+6
-3
@@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="mk">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Placebo.mk - Сатирични вести од Македонија</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+29
-29
@@ -46,7 +46,7 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers for frontend
|
# Security headers for frontend
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 http://localhost:1337 https://api.placebo.mk https://cms.placebo.mk;" always;
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
@@ -60,35 +60,35 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy (for local dev only - in prod use public URL)
|
||||||
location /api/ {
|
# location /api/ {
|
||||||
proxy_pass http://backend:3000/;
|
# proxy_pass http://backend:3000/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# CMS proxy
|
# CMS proxy (for local dev only - in prod use public URL)
|
||||||
location /cms/ {
|
# location /cms/ {
|
||||||
proxy_pass http://cms:1337/;
|
# proxy_pass http://cms:1337/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -25,7 +25,8 @@ export function ArticleTicker() {
|
|||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${article.id}-${index}`}
|
key={`${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}`}
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||||
>
|
>
|
||||||
{article.title || 'No title'}
|
{article.title || 'No title'}
|
||||||
@@ -34,7 +35,8 @@ export function ArticleTicker() {
|
|||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`dup-${article.id}-${index}`}
|
key={`dup-${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}`}
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||||
>
|
>
|
||||||
{article.title || 'No title'}
|
{article.title || 'No title'}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { usePushStats, useSendPushNotification } from '@/queries/push';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function PushNotificationManager() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: stats, isLoading: loadingStats } = usePushStats();
|
||||||
|
const sendMutation = useSendPushNotification();
|
||||||
|
|
||||||
|
const handleSendTest = async () => {
|
||||||
|
if (!title.trim() || !body.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendMutation.mutateAsync({
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim(),
|
||||||
|
url: url.trim() || undefined,
|
||||||
|
});
|
||||||
|
setResult(result);
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 5000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = title.trim().length > 0 && body.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-brutal bg-card p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Bell className="h-6 w-6 text-accent" />
|
||||||
|
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="border-brutal-sm bg-background p-5 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
|
Subscribers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadingStats ? (
|
||||||
|
<div className="text-4xl font-display animate-pulse">...</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-5xl font-display">
|
||||||
|
{stats?.totalSubscribers ?? 0}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-body text-muted-foreground mt-2">
|
||||||
|
Active push subscribers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="border-brutal-sm bg-background p-5">
|
||||||
|
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
|
Send Notification
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter notification title..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Body
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Enter notification message..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
|
||||||
|
rows={3}
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="/article/slug or /live-blogs/slug"
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 pt-2">
|
||||||
|
<div className="text-xs font-body text-muted-foreground">
|
||||||
|
{title.length}/50 chars • {body.length}/150 chars
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="brutalAccent"
|
||||||
|
onClick={handleSendTest}
|
||||||
|
disabled={!isValid || sendMutation.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuccess && result && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-body text-sm font-medium">
|
||||||
|
Notification sent!
|
||||||
|
</p>
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
Delivered: {result.sent} • Failed: {result.failed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMutation.isError && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||||
|
<p className="font-body text-sm">
|
||||||
|
Failed to send notification. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
<strong>Note:</strong> Notifications are sent to all subscribers with
|
||||||
|
push enabled. Use sparingly to avoid spam. Articles and live blog
|
||||||
|
updates are automatically pushed when published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -223,8 +223,16 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{updates.map((update) => (
|
{updates.map((update, index) => (
|
||||||
<LiveBlogUpdate key={update.id} update={update} />
|
<div
|
||||||
|
key={update.id}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
index % 2 === 0 ? 'justify-start' : 'justify-end'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LiveBlogUpdate update={update} alignRight={index % 2 !== 0} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{updatesLoading && (
|
{updatesLoading && (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
@@ -255,16 +263,19 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
|||||||
|
|
||||||
interface LiveBlogUpdateProps {
|
interface LiveBlogUpdateProps {
|
||||||
update: ApiLiveBlogUpdate;
|
update: ApiLiveBlogUpdate;
|
||||||
|
alignRight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveBlogUpdate({ update }: LiveBlogUpdateProps) {
|
function LiveBlogUpdate({ update, alignRight = false }: LiveBlogUpdateProps) {
|
||||||
const isPinned = update.isPinned;
|
const isPinned = update.isPinned;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative p-4 rounded-lg border',
|
'relative p-4 rounded-lg border max-w-[80%]',
|
||||||
isPinned && 'border-primary bg-primary/5'
|
isPinned && 'border-primary bg-primary/5',
|
||||||
|
!isPinned && alignRight && 'bg-accent/10 border-accent/20',
|
||||||
|
!isPinned && !alignRight && 'bg-muted/50 border-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function PinnedLiveBlogSidebar({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Pin className="w-4 h-4" />
|
<Pin className="w-4 h-4" />
|
||||||
Pinned Live Blogs
|
Во живо
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ export function HeroArticle() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
||||||
<Link to={`/articles/${article.id}`}>
|
<Link to="/articles/$id" params={{ id: article.id }}>
|
||||||
<Button variant="brutalAccent" className="gap-2">
|
<Button variant="brutalAccent" className="gap-2">
|
||||||
Read Full Story
|
Прочитај повеќе
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export function LatestArticlesGrid() {
|
|||||||
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
|
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/articles/${article.id}`}
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
{article.featuredImage ? (
|
{article.featuredImage ? (
|
||||||
@@ -107,12 +108,12 @@ export function LatestArticlesGrid() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<Link
|
<a
|
||||||
to={`/${article.category.slug}`}
|
href={`/${article.category.slug}`}
|
||||||
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
|
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
|
||||||
>
|
>
|
||||||
{article.category.name}
|
{article.category.name}
|
||||||
</Link>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export function LatestArticlesGrid() {
|
|||||||
articleId={article.id}
|
articleId={article.id}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
url={`${window.location.origin}/articles/${article.id}`}
|
url={`${window.location.origin}/articles/${article.id}`}
|
||||||
excerpt={article.excerpt}
|
excerpt={article.excerpt ?? undefined}
|
||||||
image={article.featuredImage}
|
image={article.featuredImage}
|
||||||
tags={article.tags}
|
tags={article.tags}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { fetchPinnedLiveBlogs } from '@/lib/api';
|
import { fetchPinnedLiveBlogs } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
|
import { Calendar, Eye, MessageSquare, Pin, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function PinnedLiveBlogsSidebar() {
|
export function PinnedLiveBlogsSidebar() {
|
||||||
|
const [showUpdates, setShowUpdates] = useState(true);
|
||||||
|
|
||||||
const { data: liveBlogs, isLoading, error } = useQuery({
|
const { data: liveBlogs, isLoading, error } = useQuery({
|
||||||
queryKey: ['pinned-live-blogs'],
|
queryKey: ['pinned-live-blogs'],
|
||||||
queryFn: fetchPinnedLiveBlogs,
|
queryFn: fetchPinnedLiveBlogs,
|
||||||
@@ -47,6 +50,52 @@ export function PinnedLiveBlogsSidebar() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatRelativeTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'сега';
|
||||||
|
if (diffMins < 60) return `${diffMins}м`;
|
||||||
|
if (diffHours < 24) return `${diffHours}ч`;
|
||||||
|
return `${diffDays}д`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect last 5 updates from all pinned live blogs
|
||||||
|
const getLastFiveUpdates = () => {
|
||||||
|
if (!liveBlogs) return [];
|
||||||
|
|
||||||
|
const allUpdates: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
liveBlogTitle: string;
|
||||||
|
liveBlogSlug: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
liveBlogs.forEach((liveBlog) => {
|
||||||
|
if (liveBlog.updates && liveBlog.updates.length > 0) {
|
||||||
|
liveBlog.updates.forEach((update) => {
|
||||||
|
allUpdates.push({
|
||||||
|
...update,
|
||||||
|
liveBlogTitle: liveBlog.title,
|
||||||
|
liveBlogSlug: liveBlog.slug,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by date descending and take first 5
|
||||||
|
return allUpdates
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastFiveUpdates = getLastFiveUpdates();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="border-brutal-sm bg-card p-6">
|
<div className="border-brutal-sm bg-card p-6">
|
||||||
@@ -102,6 +151,59 @@ export function PinnedLiveBlogsSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-brutal-sm bg-card p-6">
|
<div className="border-brutal-sm bg-card p-6">
|
||||||
|
{/* Latest Updates Section - Collapsible */}
|
||||||
|
{lastFiveUpdates.length > 0 && (
|
||||||
|
<div className="mb-6 pb-6 border-b-2 border-foreground/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdates(!showUpdates)}
|
||||||
|
className="w-full flex items-center justify-between mb-4 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-accent" />
|
||||||
|
<h3 className="text-xl font-display">Последни Update</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
|
||||||
|
{lastFiveUpdates.length}
|
||||||
|
</span>
|
||||||
|
{showUpdates ? (
|
||||||
|
<ChevronUp className="h-4 w-4 transition-transform" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 transition-transform" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showUpdates && (
|
||||||
|
<div className="space-y-3 animate-scale-in">
|
||||||
|
{lastFiveUpdates.map((update) => (
|
||||||
|
<Link
|
||||||
|
key={update.id}
|
||||||
|
to="/live-blogs/$slug"
|
||||||
|
params={{ slug: update.liveBlogSlug }}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="p-3 border-2 border-foreground/10 hover:border-accent hover:bg-accent/5 transition-all duration-150">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<span className="text-xs font-body font-bold text-muted-foreground line-clamp-1">
|
||||||
|
{update.liveBlogTitle}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-body text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatRelativeTime(update.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-body text-foreground line-clamp-2 group-hover:text-accent transition-colors">
|
||||||
|
{update.content.replace(/<[^>]*>/g, '').substring(0, 120)}
|
||||||
|
{update.content.length > 120 ? '...' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
|
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pin className="h-5 w-5 text-accent" />
|
<Pin className="h-5 w-5 text-accent" />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function Header() {
|
|||||||
{ to: '/science', label: 'Наука' },
|
{ to: '/science', label: 'Наука' },
|
||||||
{ to: '/archive', label: 'Архива' },
|
{ to: '/archive', label: 'Архива' },
|
||||||
{ to: '/live-blogs', label: 'LIVE' },
|
{ to: '/live-blogs', label: 'LIVE' },
|
||||||
|
{ to: '/about', label: 'Упатство за употреба' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
@@ -58,7 +59,7 @@ export function Header() {
|
|||||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link to="/" className="group">
|
<Link to="/" className="group">
|
||||||
<h1 className="text-4xl md:text-5xl font-display tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-display tracking-tight whitespace-nowrap">
|
||||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
|
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
|
||||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
|
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
|
||||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
|
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AlertTriangle, Laugh, Coffee } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AboutComponent() {
|
||||||
|
return (
|
||||||
|
<div className="py-8 max-w-4xl mx-auto">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-display mb-4">
|
||||||
|
Упатство за употреба
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-body">
|
||||||
|
Сè што треба да знаете за Placebo.mk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Предупредување</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 font-body p-3">
|
||||||
|
{/* <p className="text-lg"> */}
|
||||||
|
{/* <strong>Placebo.mk</strong> е сатиричен портал за вести. Сè што читате овде е измислено, преувеличено или целосно извадено од контекст. */}
|
||||||
|
{/* </p> */}
|
||||||
|
<p>
|
||||||
|
Ако веќе се налутивте, не се грижете - тоа ни беше целта. Ако сте се насмеале, уште подобро!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Laugh className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Што е Placebo.mk?</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 font-body p-3">
|
||||||
|
<p>
|
||||||
|
Placebo.mk е портал кој ja преработува реалноста и ја претвора во апсурд. Македонските а богами и глобалните политика, култура и спорт се сервираат со добра доза сарказам и црен хумор.
|
||||||
|
</p>
|
||||||
|
{/* <p> */}
|
||||||
|
{/* Нашата мисија е едноставна: да ве насмееме, да ве натераме да размислите и да ве потсетиме дека понекогаш вистината е толку apsурдна што единствено може да се коментира со хумор. */}
|
||||||
|
{/* </p> */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-display">Правила за читање</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="font-body">
|
||||||
|
<ol className="list-decimal list-inside space-y-3 p-3">
|
||||||
|
<li>Не земајте ништо [од ова] во животот премногу сериозно (освен кафето што ќе ни го купите).</li>
|
||||||
|
<li>Ако не ви е смешно тогаш е трагично. Ако не ви се допаѓаме. Најдете друга страна.</li>
|
||||||
|
<li>Смеата е најдобар лек. Користете ја дневно.</li>
|
||||||
|
<li>Ако ви се допаѓа, споделете. Ако не ви се допаѓа, сепак споделете. Гледаме статистики.</li>
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-display">Категории</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="font-body">
|
||||||
|
<ul className="space-y-3 p-3">
|
||||||
|
<li>
|
||||||
|
<strong>Општо:</strong> Општи вести и теми кои не паѓаат во другите категории
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Спорт:</strong> Спортски новости, победи, порази и сè помеѓу (најчесто порази)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Уметност:</strong> Култура, музика, филм и претставување дека разбираме од уметност
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Наука:</strong> Научни откритија објаснети на начин што ќе разбере и вашата баба
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>LIVE Блогови:</strong> Покривање во реално време на настани што го заслужуваат нашето внимание
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-brutal-sm bg-accent/5">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Coffee className="w-8 h-8 text-accent" />
|
||||||
|
<CardTitle className="text-2xl font-display">Поддржете не</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 font-body">
|
||||||
|
<p>
|
||||||
|
Сатирата не се пишува сама (иако понекогаш реалноста е поапсурдна од фикцијата).
|
||||||
|
Ако ви се допаѓа она што го правиме, размислете да ни купите кафе. Или две. Или три.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
* Сите донации одат за кафе, инспирација и плаќање на серверите. Не нудиме фискални сметки.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-12 p-8 border-4 border-foreground bg-foreground text-background text-center">
|
||||||
|
<p className="text-2xl font-display mb-4">
|
||||||
|
Запомнете:
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-body">
|
||||||
|
Ако не можете да разликувате сатира од вистина,<br />
|
||||||
|
проблемот не е во нас. Проблемот е во реалноста.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { mk } from 'date-fns/locale';
|
import { mk } from 'date-fns/locale';
|
||||||
|
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
|
||||||
|
|
||||||
export function AdminDashboardComponent() {
|
export function AdminDashboardComponent() {
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
@@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
{liveBlogs.filter((b) => b.status === 'live').length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Активни live блогови
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{articles.filter(a => a.status === 'published').length || 0}
|
{articles.filter((a) => a.status === 'published').length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Објавени написи
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
{liveBlogs.filter((b) => b.isPinned).length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Закачени live блогови
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Вкупни прегледи
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!showArchived && <PushNotificationManager />}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="border-brutal bg-card">
|
<div className="border-brutal bg-card">
|
||||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ 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"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block mb-4"
|
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">
|
||||||
@@ -69,7 +70,7 @@ export function ArchiveComponent() {
|
|||||||
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"
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -78,7 +78,7 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +198,7 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -108,7 +108,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
|
|||||||
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"
|
||||||
@@ -135,7 +135,8 @@ 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"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block mb-4"
|
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">
|
||||||
@@ -165,7 +166,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
|
|||||||
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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type SharePlatform } from './social-utils';
|
import { type SharePlatform } from './social-utils';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
export interface TrackShareParams {
|
export interface TrackShareParams {
|
||||||
articleId: string;
|
articleId: string;
|
||||||
@@ -11,7 +11,7 @@ export interface TrackShareParams {
|
|||||||
|
|
||||||
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
|
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
|
const response = await fetch(`${API_BASE_URL}/analytics/share`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -34,8 +34,8 @@ export const trackShare = async (params: TrackShareParams): Promise<boolean> =>
|
|||||||
export const getShareStats = async (articleId?: string) => {
|
export const getShareStats = async (articleId?: string) => {
|
||||||
try {
|
try {
|
||||||
const url = articleId
|
const url = articleId
|
||||||
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
|
? `${API_BASE_URL}/analytics/shares?articleId=${articleId}`
|
||||||
: `${API_BASE_URL}/api/v1/analytics/shares`;
|
: `${API_BASE_URL}/analytics/shares`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -57,7 +57,7 @@ export const getShareStats = async (articleId?: string) => {
|
|||||||
|
|
||||||
export const getTopSharedArticles = async (limit: number = 10) => {
|
export const getTopSharedArticles = async (limit: number = 10) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
|
const response = await fetch(`${API_BASE_URL}/analytics/shares/top?limit=${limit}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -77,7 +77,7 @@ export const getTopSharedArticles = async (limit: number = 10) => {
|
|||||||
|
|
||||||
export const getTotalShareStats = async () => {
|
export const getTotalShareStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
|
const response = await fetch(`${API_BASE_URL}/analytics/shares/total`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+14
-6
@@ -11,6 +11,7 @@ import { AuthPage } from './components/routes/AuthPage'
|
|||||||
import { SportComponent } from './components/routes/SportComponent'
|
import { SportComponent } from './components/routes/SportComponent'
|
||||||
import { ArtComponent } from './components/routes/ArtComponent'
|
import { ArtComponent } from './components/routes/ArtComponent'
|
||||||
import { ScienceComponent } from './components/routes/ScienceComponent'
|
import { ScienceComponent } from './components/routes/ScienceComponent'
|
||||||
|
import { AboutComponent } from './components/routes/AboutComponent'
|
||||||
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||||
import { Header } from './components/layout/Header'
|
import { Header } from './components/layout/Header'
|
||||||
import { HeroArticle } from './components/home/HeroArticle'
|
import { HeroArticle } from './components/home/HeroArticle'
|
||||||
@@ -44,7 +45,7 @@ const rootRoute = createRootRoute({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
|
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
|
||||||
<p className="font-body text-sm text-background/70">
|
<p className="font-body text-sm text-background/70">
|
||||||
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
|
Сатрирични вести и коментари за локални и глобални настани.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -72,7 +73,7 @@ const rootRoute = createRootRoute({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
|
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
|
||||||
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
|
© 2026 Placebo.mk — Сите права се заштитени. Или не се.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -102,7 +103,7 @@ const indexRoute = createRoute({
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
|
<h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
|
||||||
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
|
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
|
||||||
Непристојно сатрирични вести и коментари за локални и глобални настани во Македонија.
|
Сатрирични вести и коментари за локални и глобални настани.
|
||||||
Затоа што понекогаш вистината боли повеќе од фикцијата.
|
Затоа што понекогаш вистината боли повеќе од фикцијата.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
@@ -138,7 +139,7 @@ const indexRoute = createRoute({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
|
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
|
||||||
<p className="font-body text-sm text-muted-foreground">
|
<p className="font-body text-sm text-muted-foreground">
|
||||||
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
|
Не разликуване нијанси. Не користиме дипломатски јазик. Само искрени коментари.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ const indexRoute = createRoute({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
|
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
|
||||||
<p className="font-body text-sm text-muted-foreground">
|
<p className="font-body text-sm text-muted-foreground">
|
||||||
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти.
|
Ажурирања во реално време, прекршени вести. Нема одложувања, само факти.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +182,12 @@ const scienceRoute = createRoute({
|
|||||||
component: ScienceComponent,
|
component: ScienceComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const aboutRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/about',
|
||||||
|
component: AboutComponent,
|
||||||
|
})
|
||||||
|
|
||||||
const articleDetailRoute = createRoute({
|
const articleDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/articles/$id',
|
path: '/articles/$id',
|
||||||
@@ -189,7 +196,7 @@ const articleDetailRoute = createRoute({
|
|||||||
return <ArticleDetailComponent id={id} />
|
return <ArticleDetailComponent id={id} />
|
||||||
},
|
},
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
|
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'}/articles/${params.id}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { article: null }
|
return { article: null }
|
||||||
}
|
}
|
||||||
@@ -308,6 +315,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
sportRoute,
|
sportRoute,
|
||||||
artRoute,
|
artRoute,
|
||||||
scienceRoute,
|
scienceRoute,
|
||||||
|
aboutRoute,
|
||||||
articleDetailRoute,
|
articleDetailRoute,
|
||||||
liveBlogsRoute,
|
liveBlogsRoute,
|
||||||
liveBlogDetailRoute,
|
liveBlogDetailRoute,
|
||||||
|
|||||||
+9
-14
@@ -1,7 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-primary: oklch(0.08 0 0);
|
--color-primary: oklch(0.08 0 0);
|
||||||
--color-primary-foreground: oklch(0.98 0 0);
|
--color-primary-foreground: oklch(0.98 0 0);
|
||||||
@@ -76,10 +74,19 @@
|
|||||||
--shadow-brutal-accent: 4px 4px 0px 0px hsl(193 48% 67%);
|
--shadow-brutal-accent: 4px 4px 0px 0px hsl(193 48% 67%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
* {
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
@@ -325,18 +332,6 @@ body::before {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
border-color: hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -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
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
+29
-29
@@ -46,7 +46,7 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers for frontend
|
# Security headers for frontend
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 http://localhost:1337 https://api.placebo.mk https://cms.placebo.mk; manifest-src 'self';" always;
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
@@ -60,35 +60,35 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy (for local dev only - in prod use public URL)
|
||||||
location /api/ {
|
# location /api/ {
|
||||||
proxy_pass http://backend:3000/;
|
# proxy_pass http://backend:3000/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# CMS proxy
|
# CMS proxy (for local dev only - in prod use public URL)
|
||||||
location /cms/ {
|
# location /cms/ {
|
||||||
proxy_pass http://cms:1337/;
|
# proxy_pass http://cms:1337/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
@@ -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,9 +122,9 @@ export function HeroArticle() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
||||||
<Link to={`/articles/${article.id}`}>
|
<Link to="/articles/$id" params={{ id: article.id }}>
|
||||||
<Button variant="brutalAccent" className="gap-2">
|
<Button variant="brutalAccent" className="gap-2">
|
||||||
Read Full Story
|
Прочитај повеќе
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export function LatestArticlesGrid() {
|
|||||||
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
|
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/articles/${article.id}`}
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
{article.featuredImage ? (
|
{article.featuredImage ? (
|
||||||
@@ -107,12 +108,12 @@ export function LatestArticlesGrid() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<Link
|
<a
|
||||||
to={`/${article.category.slug}`}
|
href={`/${article.category.slug}`}
|
||||||
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
|
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
|
||||||
>
|
>
|
||||||
{article.category.name}
|
{article.category.name}
|
||||||
</Link>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export function LatestArticlesGrid() {
|
|||||||
articleId={article.id}
|
articleId={article.id}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
url={`${window.location.origin}/articles/${article.id}`}
|
url={`${window.location.origin}/articles/${article.id}`}
|
||||||
excerpt={article.excerpt}
|
excerpt={article.excerpt ?? undefined}
|
||||||
image={article.featuredImage}
|
image={article.featuredImage}
|
||||||
tags={article.tags}
|
tags={article.tags}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ 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"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block mb-4"
|
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">
|
||||||
@@ -69,7 +70,7 @@ export function ArchiveComponent() {
|
|||||||
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"
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -78,7 +78,7 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +198,7 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -108,7 +108,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
|
|||||||
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"
|
||||||
@@ -135,7 +135,8 @@ 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"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="block mb-4"
|
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">
|
||||||
@@ -165,7 +166,7 @@ export function CategoryPage({ categorySlug, categoryName, categoryDescription }
|
|||||||
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"
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to create Strapi API token via command line
|
||||||
|
# Run this on the VPS: ssh root@109.199.109.108 "bash -s" < create-strapi-token.sh
|
||||||
|
|
||||||
|
echo "Creating Strapi API token..."
|
||||||
|
|
||||||
|
# Execute inside the CMS container
|
||||||
|
docker exec placebo-cms node -e "
|
||||||
|
const strapi = require('@strapi/strapi');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const app = await strapi.createStrapi().load();
|
||||||
|
|
||||||
|
const tokenService = app.service('admin::api-token');
|
||||||
|
|
||||||
|
const token = await tokenService.create({
|
||||||
|
name: 'Backend Integration',
|
||||||
|
description: 'Token for backend to fetch articles',
|
||||||
|
type: 'read-only',
|
||||||
|
permissions: [],
|
||||||
|
lifespan: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n✅ API Token created successfully!');
|
||||||
|
console.log('\nCopy this token to Coolify as STRAPI_API_TOKEN:');
|
||||||
|
console.log(token.accessKey);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"
|
||||||
Reference in new issue
Block a user