Merge branch 'pwa'
@ -8,6 +8,7 @@ async function bootstrap() {
|
||||
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||
process.env.PWA_URL ?? 'http://localhost:5174',
|
||||
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
||||
];
|
||||
|
||||
|
||||
@ -50,25 +50,30 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
--background: 220 17% 22%;
|
||||
--foreground: 220 17% 92%;
|
||||
--card: 220 17% 27%;
|
||||
--card-foreground: 220 17% 92%;
|
||||
--popover: 220 17% 25%;
|
||||
--popover-foreground: 220 17% 96%;
|
||||
--primary: 220 17% 92%;
|
||||
--primary-foreground: 220 17% 22%;
|
||||
--secondary: 220 17% 32%;
|
||||
--secondary-foreground: 220 17% 88%;
|
||||
--muted: 220 17% 36%;
|
||||
--muted-foreground: 220 17% 70%;
|
||||
--accent: 193 48% 67%;
|
||||
--accent-foreground: 220 17% 22%;
|
||||
--destructive: 354 46% 56%;
|
||||
--destructive-foreground: 220 17% 96%;
|
||||
--border: 220 17% 36%;
|
||||
--input: 220 17% 36%;
|
||||
--ring: 193 48% 67%;
|
||||
|
||||
--shadow-brutal: 4px 4px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-sm: 2px 2px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-lg: 6px 6px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-accent: 4px 4px 0px 0px hsl(193 48% 67%);
|
||||
}
|
||||
|
||||
* {
|
||||
@ -341,7 +346,7 @@ body::before {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border: 2px solid hsl(var(--secondary));
|
||||
}
|
||||
|
||||
|
||||
13
pwa/.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# Frontend Environment Configuration
|
||||
# Copy to .env and adjust for your setup
|
||||
|
||||
# ===== DOCKER MODE (frontend in container) =====
|
||||
# Use when frontend runs in Docker container
|
||||
# VITE_API_URL=http://backend:3000/api/v1
|
||||
|
||||
# ===== LOCAL MODE (frontend runs locally) =====
|
||||
# Use when frontend runs locally with npm run dev
|
||||
VITE_API_URL=http://localhost:3000/api/v1
|
||||
|
||||
# ===== COMMON =====
|
||||
VITE_CMS_URL=http://localhost:1337
|
||||
24
pwa/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
45
pwa/Dockerfile
Normal file
@ -0,0 +1,45 @@
|
||||
# Frontend Dockerfile for Placebo.mk TanStack React App
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
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 --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Switch to non-root user
|
||||
USER nginx
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
28
pwa/Dockerfile.dev
Normal file
@ -0,0 +1,28 @@
|
||||
# Frontend Development Dockerfile for Placebo.mk TanStack React App
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies with better error handling
|
||||
COPY package*.json ./
|
||||
COPY package-lock.json* ./
|
||||
|
||||
# Clear npm cache and install dependencies
|
||||
RUN npm cache clean --force && \
|
||||
npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Fix permissions - use node user that exists in base image
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to non-root user that exists in base image
|
||||
USER node
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server with host flag
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
73
pwa/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
15
pwa/components.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
23
pwa/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
19
pwa/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="mk">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/favicon-32.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Сатирични вести од Македонија - Placebo.mk" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Placebo" />
|
||||
<title>Placebo.mk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
107
pwa/nginx.conf
Normal file
@ -0,0 +1,107 @@
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Basic settings
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Frontend server
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers for frontend
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
|
||||
|
||||
# Handle React Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# CMS proxy
|
||||
location /cms/ {
|
||||
proxy_pass http://cms:1337/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
10678
pwa/package-lock.json
generated
Normal file
62
pwa/package.json
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "pwa",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"dev:docker": "vite",
|
||||
"dev:local": "cp -f .env.local .env && vite",
|
||||
"dev:reset-env": "cp -f .env.docker .env"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-router": "^1.144.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"workbox-cacheable-response": "^7.4.0",
|
||||
"workbox-expiration": "^7.4.0",
|
||||
"workbox-precaching": "^7.4.0",
|
||||
"workbox-routing": "^7.4.0",
|
||||
"workbox-strategies": "^7.4.0"
|
||||
}
|
||||
}
|
||||
4
pwa/public/icons/apple-touch-icon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
|
||||
<rect width="180" height="180" fill="#000"/>
|
||||
<text x="90" y="125" font-family="system-ui, sans-serif" font-size="110" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 277 B |
4
pwa/public/icons/badge-72.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
|
||||
<rect width="72" height="72" fill="#000"/>
|
||||
<text x="36" y="52" font-family="system-ui, sans-serif" font-size="42" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
4
pwa/public/icons/favicon-32.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#000"/>
|
||||
<text x="16" y="24" font-family="system-ui, sans-serif" font-size="20" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
4
pwa/public/icons/icon-192.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" fill="#000"/>
|
||||
<text x="96" y="120" font-family="system-ui, sans-serif" font-size="96" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 276 B |
4
pwa/public/icons/icon-512.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#000"/>
|
||||
<text x="256" y="340" font-family="system-ui, sans-serif" font-size="320" font-weight="bold" fill="#fff" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
1
pwa/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
4327
pwa/session.md
Normal file
42
pwa/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
1
pwa/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
48
pwa/src/components/ArticleTicker.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { Zap } from 'lucide-react'
|
||||
|
||||
export function ArticleTicker() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['ticker-articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
|
||||
})
|
||||
|
||||
const articles = data?.data.slice(0, 10) || []
|
||||
|
||||
if (articles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
|
||||
<div className="py-2 flex items-center">
|
||||
<div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
|
||||
<Zap className="w-4 h-4" />
|
||||
Топ вести
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex animate-marquee whitespace-nowrap">
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
))}
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`dup-${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
pwa/src/components/admin/live-blog/CreateLiveBlog.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCreateLiveBlog } from '@/queries/live-blogs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface CreateLiveBlogProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const createMutation = useCreateLiveBlog();
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/--+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const title = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
title,
|
||||
slug: formData.slug || generateSlug(title),
|
||||
});
|
||||
if (errors.title) setErrors({ ...errors, title: '' });
|
||||
};
|
||||
|
||||
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, slug: e.target.value });
|
||||
if (errors.slug) setErrors({ ...errors, slug: '' });
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.slug.trim()) {
|
||||
newErrors.slug = 'Slug is required';
|
||||
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
|
||||
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync(formData);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'draft',
|
||||
});
|
||||
setErrors({});
|
||||
|
||||
// Navigate to the new live blog
|
||||
navigate({ to: `/admin/live-blogs/${result.slug}` });
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Failed to create live blog:', error);
|
||||
setErrors({ submit: 'Failed to create live blog. Please try again.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
Create New Live Blog
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={handleTitleChange}
|
||||
placeholder="Enter live blog title"
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleSlugChange}
|
||||
placeholder="URL-friendly identifier"
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive">{errors.slug}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be used in the URL: /live-blogs/your-slug-here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optional description for this live blog"
|
||||
rows={3}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Initial Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
|
||||
setFormData({ ...formData, status: value })
|
||||
}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
|
||||
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
|
||||
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
|
||||
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can change this later in the settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
531
pwa/src/components/admin/live-blog/LiveBlogManager.tsx
Normal file
@ -0,0 +1,531 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useLiveBlog,
|
||||
useDeleteLiveBlogUpdate,
|
||||
useUpdateLiveBlog,
|
||||
useDeleteLiveBlog
|
||||
} from '@/queries/live-blogs';
|
||||
import { UpdatePublisher } from './UpdatePublisher';
|
||||
import { LiveBlogUpdate } from '@/components/features/live-blog/LiveBlogUpdate';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate, UpdateLiveBlogDto } from '@/lib/api';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Play,
|
||||
Square,
|
||||
Archive,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
PinOff
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LiveBlogManagerProps {
|
||||
slug: string;
|
||||
onBack?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
|
||||
const [activeTab, setActiveTab] = useState('updates');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
|
||||
|
||||
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
|
||||
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
|
||||
const updateLiveBlogMutation = useUpdateLiveBlog();
|
||||
const deleteLiveBlogMutation = useDeleteLiveBlog();
|
||||
|
||||
|
||||
|
||||
const handleDeleteUpdate = async (update: ApiLiveBlogUpdate) => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
if (confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
|
||||
try {
|
||||
await deleteUpdateMutation.mutateAsync({
|
||||
liveBlogId: liveBlog.id,
|
||||
updateId: update.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete update:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUpdate = (update: ApiLiveBlogUpdate) => {
|
||||
// The UpdatePublisher component handles editing internally
|
||||
console.log('Editing update:', update);
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
setIsEditing(true);
|
||||
setEditForm({
|
||||
title: liveBlog.title,
|
||||
slug: liveBlog.slug,
|
||||
description: liveBlog.description || '',
|
||||
status: liveBlog.status,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
try {
|
||||
await updateLiveBlogMutation.mutateAsync({
|
||||
id: liveBlog.id,
|
||||
dto: editForm,
|
||||
});
|
||||
setIsEditing(false);
|
||||
setEditForm({});
|
||||
} catch (error) {
|
||||
console.error('Failed to update live blog:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: 'draft' | 'live' | 'ended' | 'archived') => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
try {
|
||||
await updateLiveBlogMutation.mutateAsync({
|
||||
id: liveBlog.id,
|
||||
dto: { status: newStatus },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
alert(`Failed to update status: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
try {
|
||||
await updateLiveBlogMutation.mutateAsync({
|
||||
id: liveBlog.id,
|
||||
dto: { isPinned: !liveBlog.isPinned },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle pin:', error);
|
||||
alert(`Failed to toggle pin: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLiveBlog = async () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
if (confirm('Are you sure you want to delete this live blog? This action cannot be undone and will delete all updates.')) {
|
||||
try {
|
||||
await deleteLiveBlogMutation.mutateAsync(liveBlog.id);
|
||||
if (onBack) onBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete live blog:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-32 bg-muted rounded"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !liveBlog) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center text-destructive">
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
|
||||
<p className="text-sm mb-4">
|
||||
{error instanceof Error ? error.message : 'Unknown error occurred'}
|
||||
</p>
|
||||
{onBack && (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'live': return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'draft': return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'archived': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'live': return <Play className="w-3 h-3" />;
|
||||
case 'draft': return <Edit className="w-3 h-3" />;
|
||||
case 'ended': return <Square className="w-3 h-3" />;
|
||||
case 'archived': return <Archive className="w-3 h-3" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<CardTitle className="text-2xl">{liveBlog.title}</CardTitle>
|
||||
</div>
|
||||
|
||||
{liveBlog.description && (
|
||||
<p className="text-muted-foreground mb-4">{liveBlog.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(liveBlog.status)}
|
||||
{liveBlog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Eye className="w-4 h-4" />
|
||||
{liveBlog.viewCount} views
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{liveBlog.updates?.length || 0} updates
|
||||
</div>
|
||||
|
||||
{liveBlog.author && (
|
||||
<div className="flex items-center gap-2">
|
||||
{liveBlog.author.avatar && (
|
||||
<img
|
||||
src={liveBlog.author.avatar}
|
||||
alt={liveBlog.author.name}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted-foreground">{liveBlog.author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Main content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="updates">Updates</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="updates" className="space-y-6">
|
||||
<UpdatePublisher
|
||||
liveBlogId={liveBlog.id}
|
||||
authorId={liveBlog.authorId || undefined}
|
||||
/>
|
||||
|
||||
{/* Recent updates preview */}
|
||||
{liveBlog.updates && liveBlog.updates.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">All Updates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{liveBlog.updates.map((update) => (
|
||||
<LiveBlogUpdate
|
||||
key={update.id}
|
||||
update={update}
|
||||
showAdminControls={true}
|
||||
onEdit={handleEditUpdate}
|
||||
onDelete={handleDeleteUpdate}
|
||||
onPinToggle={(update) => {
|
||||
// Handle pin toggle
|
||||
console.log('Toggle pin for update:', update);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Live Blog Settings</CardTitle>
|
||||
{!isEditing ? (
|
||||
<Button variant="outline" size="sm" onClick={handleStartEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={editForm.title || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={editForm.slug || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||
placeholder="URL-friendly identifier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editForm.status}
|
||||
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
|
||||
setEditForm({ ...editForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="live">Live</SelectItem>
|
||||
<SelectItem value="ended">Ended</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Status Management</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control the live blog status and visibility
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{liveBlog.status !== 'live' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('live')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Go Live'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status === 'live' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('ended')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'End Live Session'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status !== 'archived' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('archived')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Archive'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status === 'archived' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('draft')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={liveBlog.isPinned ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleTogglePin}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
{liveBlog.isPinned ? (
|
||||
<>
|
||||
<PinOff className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unpin'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Pin to Homepage'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Live Blog Information</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Title:</span>
|
||||
<span className="font-medium">{liveBlog.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Slug:</span>
|
||||
<span className="font-medium">{liveBlog.slug}</span>
|
||||
</div>
|
||||
{liveBlog.description && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<span className="font-medium text-right max-w-xs">{liveBlog.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(liveBlog.status)}
|
||||
{liveBlog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pinned:</span>
|
||||
<Badge variant={liveBlog.isPinned ? "default" : "outline"}>
|
||||
<div className="flex items-center gap-1">
|
||||
{liveBlog.isPinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
|
||||
{liveBlog.isPinned ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Analytics</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Views:</span>
|
||||
<div className="font-medium">{liveBlog.viewCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Updates:</span>
|
||||
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last Updated:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-destructive/20 rounded-lg">
|
||||
<h4 className="font-medium mb-2 text-destructive">Danger Zone</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Once you delete a live blog, there is no going back. Please be certain.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteLiveBlog}
|
||||
disabled={deleteLiveBlogMutation.isPending}
|
||||
>
|
||||
{deleteLiveBlogMutation.isPending ? 'Deleting...' : 'Delete Live Blog'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
pwa/src/components/admin/live-blog/UpdatePublisher.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useCreateLiveBlogUpdate, useUpdateLiveBlogUpdate, useLiveBlogUpdates } from '@/queries/live-blogs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { LiveBlogUpdate, CreateLiveBlogUpdateDto } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Send, Pin, Calendar, Edit } from 'lucide-react';
|
||||
|
||||
interface UpdatePublisherProps {
|
||||
liveBlogId: string;
|
||||
authorId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UpdatePublisher({ liveBlogId, authorId, className }: UpdatePublisherProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [isScheduleMode, setIsScheduleMode] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const createMutation = useCreateLiveBlogUpdate();
|
||||
const updateMutation = useUpdateLiveBlogUpdate();
|
||||
|
||||
const [editingUpdate, setEditingUpdate] = useState<LiveBlogUpdate | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
const updateData: CreateLiveBlogUpdateDto = {
|
||||
content: content.trim(),
|
||||
isPinned,
|
||||
authorId,
|
||||
...(isScheduleMode && scheduledAt ? { scheduledAt } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingUpdate) {
|
||||
// Update existing update
|
||||
await updateMutation.mutateAsync({
|
||||
liveBlogId,
|
||||
updateId: editingUpdate.id,
|
||||
dto: {
|
||||
content: content.trim(),
|
||||
isPinned,
|
||||
},
|
||||
});
|
||||
setEditingUpdate(null);
|
||||
} else {
|
||||
// Create new update
|
||||
await createMutation.mutateAsync({
|
||||
liveBlogId,
|
||||
dto: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setContent('');
|
||||
setIsPinned(false);
|
||||
setScheduledAt('');
|
||||
setIsScheduleMode(false);
|
||||
textareaRef.current?.focus();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (update: LiveBlogUpdate) => {
|
||||
setContent(update.content);
|
||||
setIsPinned(update.isPinned);
|
||||
setEditingUpdate(update);
|
||||
setIsScheduleMode(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingUpdate(null);
|
||||
setContent('');
|
||||
setIsPinned(false);
|
||||
setIsScheduleMode(false);
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Get minimum date for scheduling (now + 5 minutes)
|
||||
const minScheduleDate = new Date().toISOString().slice(0, 16);
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{editingUpdate ? 'Edit Update' : 'Publish Update'}
|
||||
{editingUpdate && (
|
||||
<span className="text-xs bg-muted px-2 py-1 rounded-full">
|
||||
ID: {editingUpdate.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Content textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">
|
||||
{editingUpdate ? 'Edit content' : 'Update content'}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
ref={textareaRef}
|
||||
placeholder="What's the latest update?"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Pin toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pin"
|
||||
checked={isPinned}
|
||||
onCheckedChange={setIsPinned}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="pin" className="flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Pin to top
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Schedule toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="schedule"
|
||||
checked={isScheduleMode}
|
||||
onCheckedChange={setIsScheduleMode}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="schedule" className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Schedule
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule date/time picker */}
|
||||
{isScheduleMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduleAt">Schedule for (minimum 5 minutes from now)</Label>
|
||||
<input
|
||||
id="scheduleAt"
|
||||
type="datetime-local"
|
||||
min={minScheduleDate}
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-full p-2 border rounded-md"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content.trim() || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
'Saving...'
|
||||
) : editingUpdate ? (
|
||||
'Update'
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isScheduleMode ? 'Schedule' : 'Publish'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{editingUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Recent updates for editing */}
|
||||
{editingUpdate === null && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<h4 className="text-sm font-medium mb-3">Recent Updates (Click to edit)</h4>
|
||||
<RecentUpdates
|
||||
liveBlogId={liveBlogId}
|
||||
onEdit={handleEdit}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecentUpdatesProps {
|
||||
liveBlogId: string;
|
||||
onEdit: (update: LiveBlogUpdate) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function RecentUpdates({ liveBlogId, onEdit, isLoading }: RecentUpdatesProps) {
|
||||
const { data: updatesData } = useLiveBlogUpdates(liveBlogId, 1, 5);
|
||||
const updates = updatesData?.data || [];
|
||||
|
||||
if (updates.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No updates yet. Start by publishing your first update!</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{updates.map((update) => (
|
||||
<div
|
||||
key={update.id}
|
||||
className={cn(
|
||||
'p-3 rounded border cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
isLoading && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
onClick={() => onEdit(update)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{update.isPinned && <Pin className="w-3 h-3 text-primary" />}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(update.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Edit className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{update.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
pwa/src/components/auth/LoginForm.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void;
|
||||
onSwitchToRegister?: () => void;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||
const { login, isLoading } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
|
||||
{onSwitchToRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRegister}
|
||||
className="text-primary hover:underline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Register here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
pwa/src/components/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredRole?: 'admin' | 'contributor' | 'user';
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requiredRole,
|
||||
redirectTo = '/'
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-4 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} />;
|
||||
}
|
||||
|
||||
if (requiredRole && user?.role !== requiredRole) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
132
pwa/src/components/auth/RegisterForm.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void;
|
||||
onSwitchToLogin?: () => void;
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||
const { register, isLoading } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(username, email, password);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Register</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new account to start commenting and reacting
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Choose a password (min 6 characters)"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Register'}
|
||||
</Button>
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToLogin}
|
||||
className="text-primary hover:underline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Login here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
154
pwa/src/components/features/comments/CommentItem.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useCreateComment } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
import type { Comment } from '../../../lib/api';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||
|
||||
const createCommentMutation = useCreateComment();
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!replyContent.trim() || !isAuthenticated) return;
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content: replyContent,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
parentCommentId: comment.id,
|
||||
});
|
||||
setReplyContent('');
|
||||
setShowReplyForm(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to post reply:', error);
|
||||
} finally {
|
||||
setIsSubmittingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Maximum depth to prevent infinite nesting (optional)
|
||||
const maxDepth = 5;
|
||||
const canReply = depth < maxDepth;
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{comment.user?.username || 'Анонимен корисник'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||
</div>
|
||||
</div>
|
||||
{comment.user?.role === 'admin' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||
Администратор
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
|
||||
|
||||
{/* Reply button and form */}
|
||||
{isAuthenticated && canReply && (
|
||||
<div className="mt-4">
|
||||
{!showReplyForm ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowReplyForm(true)}
|
||||
className="text-sm"
|
||||
>
|
||||
Одговори
|
||||
</Button>
|
||||
) : (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
|
||||
<form onSubmit={handleSubmitReply}>
|
||||
<Textarea
|
||||
placeholder="Вашиот одговор..."
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
className="min-h-[80px] mb-3"
|
||||
disabled={isSubmittingReply}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowReplyForm(false);
|
||||
setReplyContent('');
|
||||
}}
|
||||
disabled={isSubmittingReply}
|
||||
>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!replyContent.trim() || isSubmittingReply}
|
||||
>
|
||||
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions (if implemented) */}
|
||||
{comment.reactions && (
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Render replies recursively */}
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
articleId={articleId}
|
||||
liveBlogId={liveBlogId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
pwa/src/components/features/comments/CommentSection.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useComments, useCreateComment } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { CommentItem } from './CommentItem';
|
||||
|
||||
interface CommentSectionProps {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
}
|
||||
|
||||
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: commentsData, isLoading } = useComments({
|
||||
articleId,
|
||||
liveBlogId,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const createCommentMutation = useCreateComment();
|
||||
|
||||
const comments = commentsData?.data || [];
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newComment.trim() || !isAuthenticated) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content: newComment,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
});
|
||||
setNewComment('');
|
||||
} catch (error) {
|
||||
console.error('Failed to post comment:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-12 pt-8 border-t">
|
||||
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSubmitComment}>
|
||||
<Textarea
|
||||
placeholder="Што мислите за овој напис?"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="min-h-[100px] mb-4"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Најавете се за да можете да коментирате
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/auth">Најави се</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Вчитување коментари...</p>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Сè уште нема коментари. Бидете првиот што ќе коментира!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
articleId={articleId}
|
||||
liveBlogId={liveBlogId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
pwa/src/components/features/comments/ReactionButtons.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
|
||||
interface ReactionButtonsProps {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ReactionButtons({
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
compact = false
|
||||
}: ReactionButtonsProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
|
||||
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
|
||||
const addReactionMutation = useAddReaction();
|
||||
|
||||
const userReactionType = userReaction?.type;
|
||||
|
||||
const handleReaction = async (type: 'like' | 'dislike') => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addReactionMutation.mutateAsync({
|
||||
type,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add reaction:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const likes = counts?.likes || 0;
|
||||
const dislikes = counts?.dislikes || 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReaction('like')}
|
||||
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
{likes > 0 && <span>{likes}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReaction('dislike')}
|
||||
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
{dislikes > 0 && <span>{dislikes}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={userReactionType === 'like' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleReaction('like')}
|
||||
className="gap-2"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span>Допаѓа ми</span>
|
||||
{likes > 0 && (
|
||||
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{likes}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleReaction('dislike')}
|
||||
className="gap-2"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span>Не ми се допаѓа</span>
|
||||
{dislikes > 0 && (
|
||||
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{dislikes}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
pwa/src/components/features/live-blog/LiveBlogTicker.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useActiveLiveBlogs } from '@/queries/live-blogs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
interface LiveBlogTickerProps {
|
||||
className?: string;
|
||||
maxItems?: number;
|
||||
autoScroll?: boolean;
|
||||
scrollSpeed?: number;
|
||||
}
|
||||
|
||||
export function LiveBlogTicker({
|
||||
className = '',
|
||||
maxItems = 10,
|
||||
autoScroll = true,
|
||||
scrollSpeed = 30
|
||||
}: LiveBlogTickerProps) {
|
||||
const { data: activeBlogs, isLoading } = useActiveLiveBlogs();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const tickerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [totalWidth, setTotalWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const width = contentRef.current.scrollWidth;
|
||||
setTotalWidth(width);
|
||||
}
|
||||
}, [activeBlogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let lastTime: number;
|
||||
const animate = (time: number) => {
|
||||
if (!lastTime) lastTime = time;
|
||||
const delta = time - lastTime;
|
||||
lastTime = time;
|
||||
|
||||
setScrollPosition(prev => {
|
||||
let newPos = prev + (scrollSpeed * delta) / 1000;
|
||||
|
||||
if (newPos > totalWidth) {
|
||||
newPos = -(tickerRef.current?.offsetWidth || 0);
|
||||
}
|
||||
|
||||
return newPos;
|
||||
});
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoScroll, isPaused, activeBlogs, scrollSpeed, totalWidth]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`border-brutal-sm bg-card p-3 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="animate-pulse h-8 w-20 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeBlogs || activeBlogs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBlogs = activeBlogs.slice(0, maxItems);
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
const handleBlogClick = () => {
|
||||
setScrollPosition(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radio className="w-4 h-4 text-accent animate-pulse" />
|
||||
<span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
|
||||
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
|
||||
{activeBlogs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{autoScroll && (
|
||||
<Button
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={handlePauseToggle}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-3 h-3" />
|
||||
) : (
|
||||
<Pause className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
|
||||
Сите
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tickerRef}
|
||||
className="relative h-12 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="absolute top-0 left-0 h-full flex items-center whitespace-nowrap"
|
||||
style={{
|
||||
transform: `translateX(-${scrollPosition}px)`,
|
||||
transition: isPaused ? 'transform 0.3s ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={blog.id}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={`${blog.id}-dup`}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoScroll && totalWidth > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="h-1 bg-foreground/10">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
pwa/src/components/features/live-blog/LiveBlogUpdate.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
|
||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Pin,
|
||||
PinOff,
|
||||
Edit,
|
||||
Trash2,
|
||||
Clock,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LiveBlogUpdateProps {
|
||||
update: ApiLiveBlogUpdate;
|
||||
isEditing?: boolean;
|
||||
onEdit?: (update: ApiLiveBlogUpdate) => void;
|
||||
onDelete?: (update: ApiLiveBlogUpdate) => void;
|
||||
onPinToggle?: (update: ApiLiveBlogUpdate) => void;
|
||||
showAdminControls?: boolean;
|
||||
}
|
||||
|
||||
export function LiveBlogUpdate({
|
||||
update,
|
||||
isEditing = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPinToggle,
|
||||
showAdminControls = false,
|
||||
}: LiveBlogUpdateProps) {
|
||||
const isPinned = update.isPinned;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 rounded-lg border transition-all duration-200',
|
||||
isPinned && 'border-primary bg-primary/5 shadow-sm',
|
||||
!isPinned && 'border-border hover:border-muted-foreground/20'
|
||||
)}
|
||||
>
|
||||
{/* Pinned indicator */}
|
||||
{isPinned && (
|
||||
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<Pin className="w-3 h-3" />
|
||||
Pinned
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin controls */}
|
||||
{showAdminControls && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPinToggle?.(update)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit?.(update)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete?.(update)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{update.author ? (
|
||||
<>
|
||||
{update.author.avatar ? (
|
||||
<img
|
||||
src={update.author.avatar}
|
||||
alt={update.author.name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-sm">{update.author.name}</div>
|
||||
{update.author.slug && (
|
||||
<div className="text-xs text-muted-foreground">@{update.author.slug}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="w-4 h-4" />
|
||||
<span className="text-sm">Anonymous</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{new Date(update.createdAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update content */}
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
defaultValue={update.content}
|
||||
className="w-full p-2 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{update.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scheduled indicator */}
|
||||
{update.scheduledAt && new Date(update.scheduledAt) > new Date() && (
|
||||
<div className="mt-2 text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Scheduled for: {new Date(update.scheduledAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update metadata */}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
ID: {update.id.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
pwa/src/components/features/live-blog/LiveBlogViewer.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLiveBlogStream } from '@/hooks/useLiveBlogStream';
|
||||
import { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
|
||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LiveBlogViewerProps {
|
||||
slug: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [newUpdatesCount, setNewUpdatesCount] = useState(0);
|
||||
const [isScrolledUp, setIsScrolledUp] = useState(false);
|
||||
|
||||
const updatesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastUpdateCountRef = useRef(0);
|
||||
|
||||
const { data: liveBlog, isLoading: blogLoading, error: blogError } = useLiveBlog(slug);
|
||||
const {
|
||||
data: updatesData,
|
||||
isLoading: updatesLoading,
|
||||
refetch: refetchUpdates
|
||||
} = useLiveBlogUpdates(liveBlog?.id || '', 1, 100);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
lastEvent,
|
||||
connectionError,
|
||||
reconnectAttempts,
|
||||
connect
|
||||
} = useLiveBlogStream(liveBlog?.id || '');
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (updatesContainerRef.current && autoScroll) {
|
||||
const container = updatesContainerRef.current;
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!updatesContainerRef.current) return;
|
||||
|
||||
const container = updatesContainerRef.current;
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50;
|
||||
|
||||
setIsScrolledUp(!isAtBottom);
|
||||
|
||||
if (isAtBottom) {
|
||||
setNewUpdatesCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAutoScrollToggle = useCallback(() => {
|
||||
setAutoScroll(!autoScroll);
|
||||
if (!autoScroll) {
|
||||
scrollToBottom();
|
||||
setNewUpdatesCount(0);
|
||||
}
|
||||
}, [autoScroll, scrollToBottom]);
|
||||
|
||||
const showNewUpdates = useCallback(() => {
|
||||
setNewUpdatesCount(0);
|
||||
scrollToBottom();
|
||||
setIsScrolledUp(false);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
// Handle new updates from SSE
|
||||
useEffect(() => {
|
||||
if (lastEvent?.type === 'update') {
|
||||
const handleUpdate = () => {
|
||||
refetchUpdates();
|
||||
|
||||
if (autoScroll && !isScrolledUp) {
|
||||
// Add a small delay to ensure the update is in the DOM
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
} else if (!autoScroll || isScrolledUp) {
|
||||
setNewUpdatesCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdate();
|
||||
}
|
||||
}, [lastEvent, autoScroll, isScrolledUp, refetchUpdates, scrollToBottom]);
|
||||
|
||||
// Initial scroll to bottom when data loads
|
||||
useEffect(() => {
|
||||
if (updatesData?.data && updatesData.data.length > 0) {
|
||||
const currentUpdateCount = updatesData.data.length;
|
||||
|
||||
// Only auto-scroll if this is the initial load or user is at bottom
|
||||
if (currentUpdateCount > lastUpdateCountRef.current && !isScrolledUp) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
|
||||
lastUpdateCountRef.current = currentUpdateCount;
|
||||
}
|
||||
}, [updatesData, scrollToBottom, isScrolledUp]);
|
||||
|
||||
if (blogLoading) {
|
||||
return (
|
||||
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-16 bg-muted rounded"></div>
|
||||
<div className="h-16 bg-muted rounded"></div>
|
||||
<div className="h-16 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (blogError || !liveBlog) {
|
||||
return (
|
||||
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center text-destructive">
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
|
||||
<p className="text-sm">
|
||||
{blogError instanceof Error ? blogError.message : 'Unknown error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const updates = updatesData?.data || [];
|
||||
const isLive = liveBlog.status === 'live';
|
||||
|
||||
return (
|
||||
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-2xl mb-2">{liveBlog.title}</CardTitle>
|
||||
{liveBlog.description && (
|
||||
<p className="text-muted-foreground">{liveBlog.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
isLive ? 'bg-green-500' : 'bg-gray-400'
|
||||
)} />
|
||||
<span>{isLive ? 'Live' : 'Ended'}</span>
|
||||
</div>
|
||||
{isLive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
)} />
|
||||
<span>
|
||||
{isConnected ? 'Connected' : `Reconnecting... (${reconnectAttempts})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span>{liveBlog.viewCount} views</span>
|
||||
<span>{updates.length} updates</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={autoScroll ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleAutoScrollToggle}
|
||||
>
|
||||
{autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF'}
|
||||
</Button>
|
||||
{!isConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{connectionError && (
|
||||
<div className="m-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-destructive">
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={updatesContainerRef}
|
||||
className="px-6 pb-6"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{updates.length === 0 && !updatesLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No updates yet. Check back soon!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{updates.map((update) => (
|
||||
<LiveBlogUpdate key={update.id} update={update} />
|
||||
))}
|
||||
{updatesLoading && (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-16 bg-muted rounded"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New updates indicator */}
|
||||
{isScrolledUp && newUpdatesCount > 0 && (
|
||||
<div className="sticky bottom-0 bg-background border-t p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{newUpdatesCount} new {newUpdatesCount === 1 ? 'update' : 'updates'}
|
||||
</span>
|
||||
<Button size="sm" onClick={showNewUpdates}>
|
||||
Show Updates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LiveBlogUpdateProps {
|
||||
update: ApiLiveBlogUpdate;
|
||||
}
|
||||
|
||||
function LiveBlogUpdate({ update }: LiveBlogUpdateProps) {
|
||||
const isPinned = update.isPinned;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 rounded-lg border',
|
||||
isPinned && 'border-primary bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{isPinned && (
|
||||
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full">
|
||||
Pinned
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{update.author && (
|
||||
<>
|
||||
{update.author.avatar && (
|
||||
<img
|
||||
src={update.author.avatar}
|
||||
alt={update.author.name}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium">{update.author.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(update.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{update.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
pwa/src/components/features/live-blog/PinnedLiveBlogSidebar.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePinnedLiveBlogs } from '@/queries/live-blogs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { LiveBlog } from '@/lib/api';
|
||||
import {
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
Pin,
|
||||
Play,
|
||||
Square,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PinnedLiveBlogSidebarProps {
|
||||
className?: string;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export function PinnedLiveBlogSidebar({
|
||||
className = '',
|
||||
maxItems = 3
|
||||
}: PinnedLiveBlogSidebarProps) {
|
||||
const { data: pinnedBlogs, isLoading, error } = usePinnedLiveBlogs();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Pinned Live Blogs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-2 bg-muted rounded w-1/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Live Coverage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-2 bg-muted rounded w-1/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Live Coverage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Error loading live coverage
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const displayBlogs = (pinnedBlogs || []).slice(0, maxItems);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'live': return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'live': return <Play className="w-3 h-3" />;
|
||||
case 'ended': return <Square className="w-3 h-3" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}m ago`;
|
||||
} else if (diffMins < 1440) {
|
||||
return `${Math.floor(diffMins / 60)}h ago`;
|
||||
} else {
|
||||
return `${Math.floor(diffMins / 1440)}d ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestUpdate = (blog: LiveBlog) => {
|
||||
if (!blog.updates || blog.updates.length === 0) return null;
|
||||
return blog.updates[blog.updates.length - 1];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Live Coverage
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{(pinnedBlogs || []).length} pinned
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{displayBlogs.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No pinned live blogs at the moment
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Check back later for live coverage
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{displayBlogs.map((blog) => {
|
||||
const latestUpdate = getLatestUpdate(blog);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={blog.id}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="block p-3 rounded-lg border hover:bg-accent/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
|
||||
{blog.title}
|
||||
</h4>
|
||||
{blog.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{blog.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
className={`${getStatusColor(blog.status)} text-xs`}
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(blog.status)}
|
||||
{blog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Latest update preview */}
|
||||
{latestUpdate && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTime(latestUpdate.createdAt)}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2">{latestUpdate.content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>{blog.updates?.length || 0} updates</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>{blog.viewCount} views</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* View all button */}
|
||||
{(pinnedBlogs || []).length > maxItems && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-4"
|
||||
asChild
|
||||
>
|
||||
<Link to="/live-blogs">
|
||||
View all pinned blogs
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
pwa/src/components/features/social-share/CopyLinkButton.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/lib/social-utils';
|
||||
|
||||
interface CopyLinkButtonProps {
|
||||
url: string;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
onCopy?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export function CopyLinkButton({
|
||||
url,
|
||||
size = 'default',
|
||||
variant = 'outline',
|
||||
className = '',
|
||||
showLabel = false,
|
||||
onCopy,
|
||||
}: CopyLinkButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const success = await copyToClipboard(url);
|
||||
setIsCopied(success);
|
||||
|
||||
if (onCopy) {
|
||||
onCopy(success);
|
||||
}
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
if (success) {
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
if (onCopy) {
|
||||
onCopy(false);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const label = isCopied ? 'Copied!' : 'Copy Link';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
className={`relative ${className}`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
|
||||
) : isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{showLabel && <span className="ml-2">{label}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
89
pwa/src/components/features/social-share/ShareButton.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { type SharePlatform, getPlatformLabel } from '@/lib/social-utils';
|
||||
import {
|
||||
Facebook,
|
||||
Twitter,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Mail,
|
||||
Link,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ShareButtonProps {
|
||||
platform: SharePlatform;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function ShareButton({
|
||||
platform,
|
||||
onClick,
|
||||
disabled = false,
|
||||
size = 'default',
|
||||
variant = 'outline',
|
||||
className = '',
|
||||
showLabel = false,
|
||||
}: ShareButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onClick();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const label = getPlatformLabel(platform);
|
||||
|
||||
// Get the appropriate icon component
|
||||
const getIconComponent = () => {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return Facebook;
|
||||
case 'twitter':
|
||||
return Twitter;
|
||||
case 'whatsapp':
|
||||
return MessageCircle;
|
||||
case 'telegram':
|
||||
return Send;
|
||||
case 'email':
|
||||
return Mail;
|
||||
case 'link':
|
||||
return Link;
|
||||
default:
|
||||
return Share2;
|
||||
}
|
||||
};
|
||||
|
||||
const IconComponent = getIconComponent();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
className={`relative ${className}`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
|
||||
) : (
|
||||
<IconComponent className="h-4 w-4" />
|
||||
)}
|
||||
{showLabel && <span className="ml-2">{label}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
188
pwa/src/components/features/social-share/SocialShareButtons.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { ShareButton } from './ShareButton';
|
||||
import { CopyLinkButton } from './CopyLinkButton';
|
||||
import { type SharePlatform, type ShareData, getShareUrl } from '@/lib/social-utils';
|
||||
import { trackShare } from '@/lib/analytics';
|
||||
|
||||
export type SocialShareVariant = 'default' | 'compact' | 'footer' | 'floating';
|
||||
|
||||
interface SocialShareButtonsProps extends ShareData {
|
||||
articleId: string;
|
||||
variant?: SocialShareVariant;
|
||||
className?: string;
|
||||
onShare?: (platform: SharePlatform) => void;
|
||||
}
|
||||
|
||||
const PLATFORMS: SharePlatform[] = ['facebook', 'twitter', 'whatsapp', 'telegram', 'email', 'link'];
|
||||
|
||||
export function SocialShareButtons({
|
||||
articleId,
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
image,
|
||||
tags,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
onShare,
|
||||
}: SocialShareButtonsProps) {
|
||||
const [isTracking, setIsTracking] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const shareData: ShareData = {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
image,
|
||||
tags,
|
||||
};
|
||||
|
||||
const handleShare = async (platform: SharePlatform) => {
|
||||
try {
|
||||
// Track the share event
|
||||
setIsTracking(true);
|
||||
await trackShare({
|
||||
articleId,
|
||||
platform,
|
||||
userAgent: navigator.userAgent,
|
||||
// Note: We don't send IP address from frontend for privacy reasons
|
||||
// Backend should extract it from the request if needed
|
||||
});
|
||||
|
||||
// Call the onShare callback if provided
|
||||
if (onShare) {
|
||||
onShare(platform);
|
||||
}
|
||||
|
||||
// Open share URL in new window for social platforms
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, shareData);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to track share:', error);
|
||||
// Still open the share URL even if tracking fails
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, shareData);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} finally {
|
||||
setIsTracking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async (success: boolean) => {
|
||||
if (success) {
|
||||
await handleShare('link');
|
||||
}
|
||||
};
|
||||
|
||||
// Determine layout based on variant
|
||||
const getLayoutClasses = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return 'flex items-center space-x-1';
|
||||
case 'footer':
|
||||
return 'flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-4';
|
||||
case 'floating':
|
||||
return 'fixed right-4 bottom-4 flex flex-col space-y-2 z-50';
|
||||
default:
|
||||
return 'flex flex-wrap items-center gap-2';
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonSize = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return 'sm' as const;
|
||||
case 'footer':
|
||||
return 'default' as const;
|
||||
case 'floating':
|
||||
return 'default' as const;
|
||||
default:
|
||||
return 'default' as const;
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonVariant = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return 'ghost' as const;
|
||||
case 'footer':
|
||||
return 'outline' as const;
|
||||
case 'floating':
|
||||
return 'default' as const;
|
||||
default:
|
||||
return 'outline' as const;
|
||||
}
|
||||
};
|
||||
|
||||
const showLabels = variant === 'footer';
|
||||
|
||||
// For compact variant, only show a single share button that expands on hover
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onMouseEnter={() => setIsExpanded(true)}
|
||||
onMouseLeave={() => setIsExpanded(false)}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ShareButton
|
||||
platform="link"
|
||||
onClick={() => handleShare('link')}
|
||||
size={getButtonSize()}
|
||||
variant={getButtonVariant()}
|
||||
disabled={isTracking}
|
||||
/>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex items-center space-x-1 animate-in slide-in-from-right-2">
|
||||
{PLATFORMS.filter(p => p !== 'link').map((platform) => (
|
||||
<ShareButton
|
||||
key={platform}
|
||||
platform={platform}
|
||||
onClick={() => handleShare(platform)}
|
||||
size={getButtonSize()}
|
||||
variant={getButtonVariant()}
|
||||
disabled={isTracking}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${getLayoutClasses()} ${className}`}>
|
||||
{PLATFORMS.map((platform) => {
|
||||
if (platform === 'link') {
|
||||
return (
|
||||
<CopyLinkButton
|
||||
key={platform}
|
||||
url={url}
|
||||
size={getButtonSize()}
|
||||
variant={getButtonVariant()}
|
||||
showLabel={showLabels}
|
||||
onCopy={handleCopyLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareButton
|
||||
key={platform}
|
||||
platform={platform}
|
||||
onClick={() => handleShare(platform)}
|
||||
size={getButtonSize()}
|
||||
variant={getButtonVariant()}
|
||||
disabled={isTracking}
|
||||
showLabel={showLabels}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
pwa/src/components/features/social-share/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { SocialShareButtons } from './SocialShareButtons';
|
||||
export { ShareButton } from './ShareButton';
|
||||
export { CopyLinkButton } from './CopyLinkButton';
|
||||
export type { SocialShareVariant } from './SocialShareButtons';
|
||||
141
pwa/src/components/home/HeroArticle.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { fetchHeroArticle } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, User, ArrowRight, Eye } from 'lucide-react';
|
||||
|
||||
export function HeroArticle() {
|
||||
const { data: article, isLoading, error } = useQuery({
|
||||
queryKey: ['hero-article'],
|
||||
queryFn: fetchHeroArticle,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="border-brutal bg-card p-0 animate-pulse">
|
||||
<div className="h-80 bg-muted"></div>
|
||||
<div className="p-8">
|
||||
<div className="h-10 bg-muted rounded w-3/4 mb-4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
|
||||
<div className="h-20 bg-muted rounded mb-6"></div>
|
||||
<div className="h-12 bg-muted rounded w-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-brutal bg-card p-8 text-center">
|
||||
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
|
||||
<Button variant="brutal" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<div className="border-brutal bg-card p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
|
||||
<span className="font-display text-4xl">?</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
|
||||
<p className="font-body text-muted-foreground mb-4">
|
||||
Mark an article as "Hero" in the admin panel to feature it here.
|
||||
</p>
|
||||
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
|
||||
This space will showcase your most important story
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
|
||||
{article.featuredImage && (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 z-10">
|
||||
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
|
||||
Прекршени Вести
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-72 md:h-96 overflow-hidden">
|
||||
<img
|
||||
src={article.featuredImage}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.author && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{article.author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{article.tags && article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 text-xs font-body uppercase tracking-wider border-2 border-foreground bg-background"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
||||
<Link to={`/articles/${article.id}`}>
|
||||
<Button variant="brutalAccent" className="gap-2">
|
||||
Read Full Story
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-bold text-foreground">
|
||||
{(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
|
||||
</span> shares
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
136
pwa/src/components/home/LatestArticlesGrid.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
export function LatestArticlesGrid() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['latest-articles'],
|
||||
queryFn: () => api.fetchLatestArticles(12),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="border-brutal-sm bg-card p-4 animate-pulse">
|
||||
<div className="h-40 bg-muted mb-4"></div>
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-brutal bg-destructive/10 p-8 text-center">
|
||||
<div className="text-destructive text-2xl font-display mb-2">ГРЕШКА</div>
|
||||
<p className="font-body text-sm text-destructive">Обидете се повторно</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const articles = data?.data || []
|
||||
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<div className="border-brutal bg-card p-8 text-center">
|
||||
<div className="font-display text-2xl mb-2">НЕМА СТАТИИ</div>
|
||||
<p className="font-body text-sm text-muted-foreground">Проверете подоцна</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between border-b-4 border-foreground pb-4">
|
||||
<h2 className="text-3xl md:text-4xl font-display">Најнови</h2>
|
||||
<Link
|
||||
to="/archive"
|
||||
className="font-body text-sm uppercase tracking-wider border-2 border-foreground px-4 py-2 hover:bg-accent hover:border-accent transition-all duration-150 flex items-center gap-2"
|
||||
>
|
||||
Сите
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{articles.map((article, index) => (
|
||||
<article
|
||||
key={article.id}
|
||||
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
|
||||
to={`/articles/${article.id}`}
|
||||
className="block"
|
||||
>
|
||||
{article.featuredImage ? (
|
||||
<div className="relative h-40 overflow-hidden border-b-2 border-foreground">
|
||||
<img
|
||||
src={article.featuredImage}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors duration-300" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-40 bg-secondary border-b-2 border-foreground flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)', backgroundSize: '10px 10px' }}></div>
|
||||
<span className="font-display text-4xl text-foreground/30">N</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-display leading-tight mb-2 line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-xs font-body line-clamp-2 mb-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="px-4 pb-4 border-t-2 border-foreground/10 pt-3 mt-auto">
|
||||
<div className="flex items-center justify-between font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
|
||||
{article.category && (
|
||||
<Link
|
||||
to={`/${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"
|
||||
>
|
||||
{article.category.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
pwa/src/components/home/PinnedLiveBlogsSidebar.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { fetchPinnedLiveBlogs } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
|
||||
|
||||
export function PinnedLiveBlogsSidebar() {
|
||||
const { data: liveBlogs, isLoading, error } = useQuery({
|
||||
queryKey: ['pinned-live-blogs'],
|
||||
queryFn: fetchPinnedLiveBlogs,
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return (
|
||||
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
|
||||
LIVE
|
||||
</span>
|
||||
);
|
||||
case 'ended':
|
||||
return (
|
||||
<span className="px-2 py-0.5 border-2 border-foreground/40 text-foreground/40 text-xs font-body font-bold uppercase">
|
||||
ENDED
|
||||
</span>
|
||||
);
|
||||
case 'archived':
|
||||
return (
|
||||
<span className="px-2 py-0.5 border-2 border-foreground/30 text-foreground/30 text-xs font-body font-bold uppercase">
|
||||
ARCHIVED
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-0.5 border-2 border-foreground text-foreground text-xs font-body font-bold uppercase">
|
||||
DRAFT
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="text-center py-4">
|
||||
<div className="text-destructive font-body text-sm mb-2">ERROR</div>
|
||||
<Button variant="brutal" size="sm" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!liveBlogs || liveBlogs.length === 0) {
|
||||
return (
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="text-center py-6 border-2 border-dashed border-foreground/20 p-4">
|
||||
<div className="font-body text-muted-foreground mb-2">No pinned live blogs</div>
|
||||
<p className="font-body text-xs text-muted-foreground">
|
||||
Pin live blogs from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5 text-accent" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
|
||||
{liveBlogs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{liveBlogs.map((liveBlog) => (
|
||||
<Link
|
||||
key={liveBlog.id}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: liveBlog.slug }}
|
||||
className="block group"
|
||||
>
|
||||
<div className="p-4 border-2 border-foreground/10 hover:border-foreground hover:shadow-brutal-sm transition-all duration-150 group-hover:-translate-y-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-body text-sm font-bold leading-tight line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{liveBlog.title}
|
||||
</h4>
|
||||
{getStatusBadge(liveBlog.status)}
|
||||
</div>
|
||||
|
||||
{liveBlog.description && (
|
||||
<p className="text-xs font-body text-muted-foreground mb-3 line-clamp-2">
|
||||
{liveBlog.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs font-body text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(liveBlog.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{liveBlog.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{liveBlog.updates && liveBlog.updates.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>{liveBlog.updates.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveBlog.author && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{liveBlog.author.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liveBlog.featuredImage && (
|
||||
<div className="mt-3">
|
||||
<div className="relative h-20 border-2 border-foreground/10 overflow-hidden">
|
||||
<img
|
||||
src={liveBlog.featuredImage}
|
||||
alt={liveBlog.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t-2 border-foreground/10">
|
||||
<Link to="/live-blogs" className="block">
|
||||
<Button variant="brutal" className="w-full justify-center">
|
||||
Сите Live
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
pwa/src/components/layout/Header.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { Menu, X, Zap } from 'lucide-react';
|
||||
|
||||
const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември'];
|
||||
const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела'];
|
||||
|
||||
const formatDateMk = () => {
|
||||
const d = new Date();
|
||||
return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`;
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const { user, logout, isAuthenticated, hasRole } = useAuth();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Почетна' },
|
||||
{ to: '/sport', label: 'Спорт' },
|
||||
{ to: '/art', label: 'Уметност' },
|
||||
{ to: '/science', label: 'Наука' },
|
||||
{ to: '/archive', label: 'Архива' },
|
||||
{ to: '/live-blogs', label: 'LIVE' },
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
{ to: '/admin', label: 'Admin' },
|
||||
{ to: '/admin/live-blogs/create', label: '+ New Live' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]">
|
||||
<div className="border-b-2 border-foreground/20">
|
||||
<div className="container mx-auto max-w-7xl px-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>Сатирични вести од Македонија</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
{formatDateMk()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="group">
|
||||
<h1 className="text-4xl md:text-5xl font-display tracking-tight">
|
||||
<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 delay-100">a</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-150">c</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-200">e</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-250">b</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-300">o</span>
|
||||
<span className="text-accent inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-350">.</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1 delay-400">m</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-500">k</span>
|
||||
</h1>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-transparent hover:border-foreground hover:bg-accent hover:text-foreground transition-all duration-150"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{(hasRole('admin') || hasRole('contributor')) && (
|
||||
<>
|
||||
{adminLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="px-3 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent transition-all duration-150"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
|
||||
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
>
|
||||
OUT
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/auth" className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 ml-2">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="ml-4 pl-4 border-l-2 border-foreground/20 flex items-center gap-3">
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/placebomk"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden lg:block border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-3 py-1 font-body text-xs font-bold uppercase tracking-wider"
|
||||
>
|
||||
☕ Купи ми кафе
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="brutal"
|
||||
size="icon"
|
||||
onClick={toggleMobileMenu}
|
||||
className="h-10 w-10"
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden mt-4 pt-4 border-t-4 border-foreground animate-scale-in">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground hover:bg-accent hover:border-accent transition-all duration-150"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{(hasRole('admin') || hasRole('contributor')) && (
|
||||
<div className="pt-3 mt-2 border-t-4 border-foreground">
|
||||
<p className="font-body text-xs uppercase tracking-wider text-muted-foreground mb-2 pl-2">Admin</p>
|
||||
{adminLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent hover:bg-foreground hover:text-accent transition-all duration-150 block"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-3 mt-2 border-t-4 border-foreground">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
closeMobileMenu();
|
||||
}}
|
||||
>
|
||||
OUT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth"
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 mt-2"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
38
pwa/src/components/layout/ThemeToggle.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { getInitialTheme, toggleTheme, watchSystemTheme } from '@/lib/theme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for system theme changes
|
||||
const cleanup = watchSystemTheme((newTheme) => {
|
||||
setTheme(newTheme);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newTheme = toggleTheme();
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
className="h-9 w-9"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
539
pwa/src/components/routes/AdminDashboardComponent.tsx
Normal file
@ -0,0 +1,539 @@
|
||||
import { useState } from 'react';
|
||||
import { useLiveBlogs } from '@/queries/live-blogs';
|
||||
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
|
||||
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
|
||||
export function AdminDashboardComponent() {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
|
||||
const [itemToDelete, setItemToDelete] = useState<{
|
||||
type: 'article' | 'liveBlog';
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
|
||||
limit: 50,
|
||||
status: showArchived ? 'archived' : 'draft,live,ended'
|
||||
});
|
||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({
|
||||
limit: 50,
|
||||
status: showArchived ? 'archived' : 'draft,published'
|
||||
});
|
||||
const deleteArticleMutation = useDeleteArticle();
|
||||
const deleteLiveBlogMutation = useDeleteLiveBlog();
|
||||
const archiveArticleMutation = useArchiveArticle();
|
||||
const archiveLiveBlogMutation = useArchiveLiveBlog();
|
||||
const publishArticleMutation = usePublishArticle();
|
||||
const publishLiveBlogMutation = usePublishLiveBlog();
|
||||
const updateArticleMutation = useUpdateArticle();
|
||||
|
||||
const liveBlogs = liveBlogsData?.data || [];
|
||||
const articles = articlesData?.data || [];
|
||||
|
||||
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('delete');
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('archive');
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (type === 'article') {
|
||||
await publishArticleMutation.mutateAsync({ id, status: 'published' });
|
||||
} else {
|
||||
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!itemToDelete) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (dialogType === 'delete') {
|
||||
if (itemToDelete.type === 'article') {
|
||||
await deleteArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
} else {
|
||||
if (itemToDelete.type === 'article') {
|
||||
await archiveArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
setItemToDelete(null);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${dialogType}:`, error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAction = () => {
|
||||
setShowConfirmDialog(false);
|
||||
setItemToDelete(null);
|
||||
};
|
||||
|
||||
const handleSetHero = async (articleId: string, isHero: boolean) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await updateArticleMutation.mutateAsync({
|
||||
id: articleId,
|
||||
dto: { isHero }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update hero status:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
case 'live':
|
||||
return 'bg-green-500 text-white border-2 border-foreground';
|
||||
case 'draft':
|
||||
return 'bg-yellow-400 text-black border-2 border-foreground';
|
||||
case 'archived':
|
||||
case 'ended':
|
||||
return 'bg-gray-400 text-white border-2 border-foreground';
|
||||
default:
|
||||
return 'bg-blue-500 text-white border-2 border-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return 'Објавено';
|
||||
case 'draft': return 'Нацрт';
|
||||
case 'archived': return 'Архивирано';
|
||||
case 'live': return 'Во живо';
|
||||
case 'ended': return 'Завршено';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="border-b-4 border-foreground pb-6">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
|
||||
<p className="font-body text-muted-foreground mt-1">
|
||||
Управување со сите написи и live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
variant={showArchived ? 'brutal' : 'brutalOutline'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||
</Button>
|
||||
<Button asChild variant="brutalAccent">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild variant="brutalOutline">
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showArchived && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{liveBlogs.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{loadingLiveBlogs ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||
</div>
|
||||
) : liveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
liveBlogs.map((blog) => (
|
||||
<div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/admin/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
{blog.title}
|
||||
</Link>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
|
||||
{getStatusText(blog.status)}
|
||||
</span>
|
||||
{blog.isPinned && (
|
||||
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
||||
Закачено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {blog.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {blog.viewCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Објави
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Архивирај
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Избриши
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани написи' : 'Написи'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{articles.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{loadingArticles ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
|
||||
{getStatusText(article.status)}
|
||||
</span>
|
||||
{article.isHero && (
|
||||
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
||||
★ Hero
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {article.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {article.views}</span>
|
||||
<span>•</span>
|
||||
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
|
||||
</div>
|
||||
{article.excerpt && (
|
||||
<p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 flex-col">
|
||||
<div className="flex gap-1">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={article.isHero ? 'brutal' : 'brutalOutline'}
|
||||
onClick={() => handleSetHero(article.id, !article.isHero)}
|
||||
disabled={isProcessing || updateArticleMutation.isPending}
|
||||
>
|
||||
{article.isHero ? '★ Hero' : 'Set Hero'}
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('article', article.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Објави
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Архивирај
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Избриши
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showArchived && (
|
||||
<div className="border-brutal bg-card p-6">
|
||||
<h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) =>
|
||||
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
|
||||
<div className="space-y-3">
|
||||
{articles
|
||||
.filter(a => a.status === 'published')
|
||||
.sort((a, b) => {
|
||||
const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||
(a.whatsappShares || 0) + (a.telegramShares || 0);
|
||||
const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
|
||||
(b.whatsappShares || 0) + (b.telegramShares || 0);
|
||||
return bShares - aShares;
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map((article) => {
|
||||
const totalShares = (article.facebookShares || 0) +
|
||||
(article.twitterShares || 0) +
|
||||
(article.whatsappShares || 0) +
|
||||
(article.telegramShares || 0);
|
||||
const shareRate = article.views > 0
|
||||
? ((totalShares / article.views) * 100).toFixed(2)
|
||||
: '0.00';
|
||||
|
||||
return (
|
||||
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
|
||||
<span>Views: {article.views}</span>
|
||||
<span>•</span>
|
||||
<span>Shares: {totalShares}</span>
|
||||
<span>•</span>
|
||||
<span>Share Rate: {shareRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-600"></span>
|
||||
FB: {article.facebookShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-sky-500"></span>
|
||||
X: {article.twitterShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500"></span>
|
||||
WA: {article.whatsappShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-cyan-500"></span>
|
||||
TG: {article.telegramShares || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-2xl font-display uppercase mb-3">
|
||||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||
</h3>
|
||||
<p className="font-body text-muted-foreground mb-6">
|
||||
{dialogType === 'delete'
|
||||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||||
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
pwa/src/components/routes/ArchiveComponent.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
|
||||
export function ArchiveComponent() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published' }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading articles...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading articles</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Articles</h1>
|
||||
<p className="text-muted-foreground">Latest news and articles</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data?.data.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No articles published yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
pwa/src/components/routes/ArtComponent.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function ArtComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="art"
|
||||
categoryName="Уметност"
|
||||
categoryDescription="Уметност, култура и забава"
|
||||
/>
|
||||
)
|
||||
}
|
||||
246
pwa/src/components/routes/ArticleDetailComponent.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||
import { CommentSection } from '@/components/features/comments/CommentSection'
|
||||
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
|
||||
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['article', id],
|
||||
queryFn: () => api.fetchArticleById(id),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading article...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading article</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Article not found</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto">
|
||||
<Link
|
||||
to="/articles"
|
||||
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">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
<path d="M19 6H5" />
|
||||
</svg>
|
||||
Back to articles
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
|
||||
<span>
|
||||
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{data.views} views</span>
|
||||
{data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>By {data.author.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mb-8 flex flex-wrap items-center gap-4">
|
||||
<SocialShareButtons
|
||||
articleId={data.id}
|
||||
title={data.title}
|
||||
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||
excerpt={data.excerpt}
|
||||
image={data.featuredImage}
|
||||
tags={data.tags}
|
||||
/>
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/placebomk"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
|
||||
>
|
||||
☕ Купи ми кафе
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||
<div className={`relative mb-4 ${
|
||||
data.imagePosition === 'top'
|
||||
? 'w-full mb-8'
|
||||
: data.imagePosition === 'left'
|
||||
? 'float-none md:float-left mr-0 md:mr-6'
|
||||
: 'float-none md:float-right ml-0 md:ml-6'
|
||||
}`}>
|
||||
<img
|
||||
src={data.featuredImage}
|
||||
alt={data.title}
|
||||
className={`rounded-xl object-cover ${
|
||||
data.imagePosition === 'top'
|
||||
? data.imageSize === 'small'
|
||||
? 'h-32'
|
||||
: data.imageSize === 'medium'
|
||||
? 'h-48'
|
||||
: 'h-64 md:h-96'
|
||||
: data.imageSize === 'small'
|
||||
? 'w-full md:w-48 h-32'
|
||||
: data.imageSize === 'medium'
|
||||
? 'w-full md:w-64 h-48'
|
||||
: 'w-full md:w-96 h-64'
|
||||
}`}
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image:', data.featuredImage, e);
|
||||
e.currentTarget.style.display = 'none';
|
||||
// Show fallback
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (fallback) {
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<span>Image not available</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video rendering */}
|
||||
{data.videoUrl && data.videoPosition !== 'none' && (
|
||||
<div className={getVideoPositionClasses(data.videoPosition)}>
|
||||
<YouTubeEmbed
|
||||
url={data.videoUrl}
|
||||
title={data.title}
|
||||
caption={data.videoCaption}
|
||||
autoplay={false}
|
||||
controls={true}
|
||||
modestbranding={true}
|
||||
showRelated={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<div className="text-lg leading-relaxed mb-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
img: (props) => (
|
||||
<img
|
||||
{...props}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
alt={props.alt || 'Article image'}
|
||||
/>
|
||||
),
|
||||
a: (props) => {
|
||||
// Check if the link is a YouTube URL
|
||||
const videoId = extractYouTubeVideoId(props.href || '');
|
||||
if (videoId) {
|
||||
return (
|
||||
<div className="my-6">
|
||||
<YouTubeEmbed
|
||||
url={props.href || ''}
|
||||
title={props.title || 'YouTube video'}
|
||||
autoplay={false}
|
||||
controls={true}
|
||||
modestbranding={true}
|
||||
showRelated={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Regular link
|
||||
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing Footer */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">Share this article:</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<SocialShareButtons
|
||||
articleId={data.id}
|
||||
title={data.title}
|
||||
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||
excerpt={data.excerpt}
|
||||
image={data.featuredImage}
|
||||
tags={data.tags}
|
||||
variant="footer"
|
||||
/>
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/placebomk"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
|
||||
>
|
||||
☕ Купи ми кафе
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
|
||||
<ReactionButtons articleId={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<CommentSection articleId={data.id} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
58
pwa/src/components/routes/AuthPage.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { LoginForm } from '../auth/LoginForm';
|
||||
import { RegisterForm } from '../auth/RegisterForm';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
|
||||
export function AuthPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
Welcome to <span className="text-primary">Placebo.mk</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isLogin
|
||||
? 'Login to comment, react, and access admin features'
|
||||
: 'Join our community of sarcastic news enthusiasts'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLogin ? (
|
||||
<LoginForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToRegister={() => setIsLogin(false)}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToLogin={() => setIsLogin(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
|
||||
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
|
||||
and{' '}
|
||||
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Need help?{' '}
|
||||
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
pwa/src/components/routes/CategoryPage.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
|
||||
|
||||
interface CategoryPageProps {
|
||||
categorySlug: string
|
||||
categoryName: string
|
||||
categoryDescription?: string
|
||||
}
|
||||
|
||||
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
|
||||
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
|
||||
queryKey: ['category-articles', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
|
||||
})
|
||||
|
||||
const { data: heroData, isLoading: heroLoading } = useQuery({
|
||||
queryKey: ['category-hero', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
|
||||
enabled: !!categorySlug,
|
||||
})
|
||||
|
||||
const heroArticle = heroData?.data?.[0]
|
||||
const articles = articlesData?.data || []
|
||||
|
||||
if (articlesLoading || heroLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Вчитување...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (articlesError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Category Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{categoryName}</h1>
|
||||
{categoryDescription && (
|
||||
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero Section with Pinned Live Blogs Sidebar */}
|
||||
{heroArticle && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Hero Article - 2/3 width */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
{heroArticle.featuredImage ? (
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<img
|
||||
src={heroArticle.featuredImage}
|
||||
alt={heroArticle.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="p-6">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
|
||||
{heroArticle.title}
|
||||
</h2>
|
||||
{heroArticle.excerpt && (
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3">
|
||||
{heroArticle.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{heroArticle.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={heroArticle.id}
|
||||
title={heroArticle.title}
|
||||
url={`${window.location.origin}/articles/${heroArticle.id}`}
|
||||
excerpt={heroArticle.excerpt}
|
||||
image={heroArticle.featuredImage}
|
||||
tags={heroArticle.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned Live Blogs Sidebar - 1/3 width */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{articles
|
||||
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
|
||||
.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{articles.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Нема објавени статии во оваа категорија. Проверете подоцна!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
pwa/src/components/routes/CreateLiveBlogComponent.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { CreateLiveBlog } from '@/components/admin/live-blog/CreateLiveBlog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export function CreateLiveBlogComponent() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Live Blogs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CreateLiveBlog
|
||||
onSuccess={() => {
|
||||
// Success is handled by navigation in the component
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-medium mb-2">Tips for creating a successful live blog:</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc pl-5">
|
||||
<li>Choose a clear, descriptive title that tells readers what to expect</li>
|
||||
<li>Use a URL-friendly slug (lowercase, hyphens, no spaces)</li>
|
||||
<li>Start with a draft status to prepare content before going live</li>
|
||||
<li>Use the description to provide context for readers</li>
|
||||
<li>Remember: you can always edit these details later</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
pwa/src/components/routes/LiveBlogAdminComponent.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { LiveBlogManager } from '@/components/admin/live-blog/LiveBlogManager'
|
||||
|
||||
export function LiveBlogAdminComponent({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<LiveBlogManager
|
||||
slug={slug}
|
||||
onBack={() => window.history.back()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
pwa/src/components/routes/LiveBlogDetailComponent.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { LiveBlogViewer } from '@/components/features/live-blog/LiveBlogViewer'
|
||||
|
||||
export function LiveBlogDetailComponent({ slug }: { slug: string }) {
|
||||
return <LiveBlogViewer slug={slug} className="py-8" />
|
||||
}
|
||||
105
pwa/src/components/routes/LiveBlogsComponent.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { LiveBlogTicker } from '@/components/features/live-blog/LiveBlogTicker'
|
||||
|
||||
export function LiveBlogsComponent() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['liveBlogs'],
|
||||
queryFn: () => api.fetchLiveBlogs({ status: 'live' }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading live blogs...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading live blogs</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LiveBlogTicker />
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Live Blogs</h1>
|
||||
<p className="text-muted-foreground">Breaking news and live updates</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{data?.data.map((liveBlog) => (
|
||||
<Link
|
||||
key={liveBlog.id}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: liveBlog.slug }}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{liveBlog.status === 'live' ? 'LIVE NOW' : liveBlog.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{liveBlog.title}
|
||||
</h2>
|
||||
{liveBlog.description && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{liveBlog.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{liveBlog.viewCount} views</span>
|
||||
<span>{liveBlog.updates?.length || 0} updates</span>
|
||||
</div>
|
||||
<span>
|
||||
{new Date(liveBlog.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{liveBlog.author && (
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
|
||||
{liveBlog.author.avatar && (
|
||||
<img
|
||||
src={liveBlog.author.avatar}
|
||||
alt={liveBlog.author.name}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
By {liveBlog.author.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No live blogs are currently active. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
pwa/src/components/routes/ScienceComponent.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function ScienceComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="science"
|
||||
categoryName="Наука"
|
||||
categoryDescription="Научни откритија и технологија"
|
||||
/>
|
||||
)
|
||||
}
|
||||
11
pwa/src/components/routes/SportComponent.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function SportComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="sport"
|
||||
categoryName="Спорт"
|
||||
categoryDescription="Спортски вести и анализи"
|
||||
/>
|
||||
)
|
||||
}
|
||||
69
pwa/src/components/seo/SocialMetaTags.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { Article } from '@/lib/api';
|
||||
|
||||
interface SocialMetaTagsProps {
|
||||
article: Article;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function SocialMetaTags({ article, url }: SocialMetaTagsProps) {
|
||||
// This component doesn't render anything directly
|
||||
// It's used to generate meta tags for TanStack Router's head API
|
||||
// Mark props as used to avoid ESLint warnings
|
||||
void article;
|
||||
void url;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSocialMetaTags(article: Article, url?: string) {
|
||||
// Use article's social metadata if available, otherwise generate from article data
|
||||
const ogTitle = article.ogTitle || article.title;
|
||||
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk';
|
||||
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg';
|
||||
const twitterTitle = article.twitterTitle || article.title;
|
||||
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk';
|
||||
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg';
|
||||
|
||||
// Use provided URL or construct from article ID
|
||||
const articleUrl = url || `${typeof window !== 'undefined' ? window.location.origin : ''}/articles/${article.id}`;
|
||||
|
||||
const metaTags = [
|
||||
// Basic SEO
|
||||
{ title: `${article.title} - Placebo.mk` },
|
||||
{ name: 'description', content: ogDescription },
|
||||
|
||||
// Open Graph tags
|
||||
{ property: 'og:title', content: ogTitle },
|
||||
{ property: 'og:description', content: ogDescription },
|
||||
{ property: 'og:image', content: ogImage },
|
||||
{ property: 'og:url', content: articleUrl },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:locale', content: 'mk_MK' },
|
||||
|
||||
// Twitter Card tags
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: twitterTitle },
|
||||
{ name: 'twitter:description', content: twitterDescription },
|
||||
{ name: 'twitter:image', content: twitterImage },
|
||||
|
||||
// Article-specific tags
|
||||
{ property: 'article:published_time', content: article.createdAt },
|
||||
{ property: 'article:modified_time', content: article.updatedAt },
|
||||
];
|
||||
|
||||
// Add author if available
|
||||
if (article.author?.name) {
|
||||
metaTags.push({ property: 'article:author', content: article.author.name });
|
||||
}
|
||||
|
||||
// Add tags if available
|
||||
if (article.tags && article.tags.length > 0) {
|
||||
article.tags.forEach(tag => {
|
||||
metaTags.push({ property: 'article:tag', content: tag });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta: metaTags,
|
||||
};
|
||||
}
|
||||
21
pwa/src/components/ui/badge-variants.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
16
pwa/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "./badge-variants"
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
30
pwa/src/components/ui/button-variants.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
|
||||
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
|
||||
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-6 py-2",
|
||||
sm: "h-9 px-4 py-1",
|
||||
lg: "h-14 px-8 text-base",
|
||||
icon: "h-12 w-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
28
pwa/src/components/ui/button.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "./button-variants"
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
78
pwa/src/components/ui/card.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-brutal bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-display leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm font-body text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
24
pwa/src/components/ui/input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
23
pwa/src/components/ui/label.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
119
pwa/src/components/ui/select.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
}
|
||||
26
pwa/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
52
pwa/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
22
pwa/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
134
pwa/src/components/ui/youtube-embed.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
|
||||
|
||||
interface YouTubeEmbedProps {
|
||||
/** YouTube video URL or video ID */
|
||||
url: string;
|
||||
/** Video title for accessibility */
|
||||
title?: string;
|
||||
/** Optional caption below the video */
|
||||
caption?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Autoplay the video (muted on mobile) */
|
||||
autoplay?: boolean;
|
||||
/** Show video controls */
|
||||
controls?: boolean;
|
||||
/** Reduce YouTube branding */
|
||||
modestbranding?: boolean;
|
||||
/** Show related videos at the end */
|
||||
showRelated?: boolean;
|
||||
/** Start time in seconds */
|
||||
startTime?: number;
|
||||
/** End time in seconds */
|
||||
endTime?: number;
|
||||
/** Show loading state */
|
||||
showLoading?: boolean;
|
||||
}
|
||||
|
||||
export function YouTubeEmbed({
|
||||
url,
|
||||
title = 'YouTube video',
|
||||
caption,
|
||||
className,
|
||||
autoplay = false,
|
||||
controls = true,
|
||||
modestbranding = true,
|
||||
showRelated = false,
|
||||
startTime,
|
||||
endTime,
|
||||
showLoading = true,
|
||||
}: YouTubeEmbedProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
// Extract video ID from URL
|
||||
const videoId = extractYouTubeVideoId(url);
|
||||
|
||||
if (!videoId) {
|
||||
return (
|
||||
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
|
||||
<p className="text-destructive text-sm">
|
||||
Invalid YouTube URL: {url}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate embed URL with options
|
||||
const embedUrl = generateYouTubeEmbedUrl(videoId, {
|
||||
autoplay,
|
||||
controls,
|
||||
modestbranding,
|
||||
rel: showRelated,
|
||||
playsinline: true,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Video container with aspect ratio */}
|
||||
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
|
||||
{/* Loading state */}
|
||||
{showLoading && !isLoaded && !hasError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{hasError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="text-destructive mb-2">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-destructive font-medium">Video failed to load</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
The YouTube video could not be loaded. Please check the URL or try again later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube iframe */}
|
||||
{!hasError && (
|
||||
<iframe
|
||||
className={cn(
|
||||
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
|
||||
!isLoaded && 'opacity-0'
|
||||
)}
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{caption && (
|
||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
pwa/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as api from '../lib/api';
|
||||
import type { User } from '@/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = () => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user:', error);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
const response = await api.login({ username, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
setToken(response.access_token);
|
||||
setUser(response.user);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
const response = await api.register({ username, email, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
setToken(response.access_token);
|
||||
setUser(response.user);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
api.logout();
|
||||
};
|
||||
|
||||
const isAuthenticated = !!user && !!token;
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
if (!user) return false;
|
||||
return user.role === role;
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
hasRole,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
179
pwa/src/hooks/useLiveBlogStream.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
|
||||
export interface LiveBlogStreamEvent {
|
||||
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
|
||||
data: unknown;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface LiveBlogStreamOptions {
|
||||
autoReconnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export function useLiveBlogStream(
|
||||
liveBlogId: string,
|
||||
options: LiveBlogStreamOptions = {}
|
||||
) {
|
||||
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
}), []);
|
||||
|
||||
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastEventIdRef = useRef<string | null>(null);
|
||||
const optionsRef = useRef(mergedOptions);
|
||||
const reconnectAttemptsRef = useRef(reconnectAttempts);
|
||||
|
||||
// Update refs when props change
|
||||
useEffect(() => {
|
||||
optionsRef.current = mergedOptions;
|
||||
}, [mergedOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
reconnectAttemptsRef.current = reconnectAttempts;
|
||||
}, [reconnectAttempts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveBlogId) return;
|
||||
|
||||
const disconnect = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
setReconnectAttempts(0);
|
||||
console.log('Disconnected from live blog stream');
|
||||
};
|
||||
|
||||
const createConnection = () => {
|
||||
// Close existing connection if any
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const url = new URL(`${import.meta.env.VITE_API_URL}/live-blogs/${liveBlogId}/stream`, window.location.origin);
|
||||
|
||||
if (lastEventIdRef.current) {
|
||||
url.searchParams.set('last-event-id', lastEventIdRef.current);
|
||||
}
|
||||
|
||||
try {
|
||||
eventSourceRef.current = new EventSource(url.toString());
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setConnectionError(null);
|
||||
setReconnectAttempts(0);
|
||||
console.log(`Connected to live blog stream for ${liveBlogId}`);
|
||||
};
|
||||
|
||||
eventSourceRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const parsedEvent: LiveBlogStreamEvent = JSON.parse(event.data);
|
||||
setLastEvent(parsedEvent);
|
||||
|
||||
// Store last event ID for reconnection
|
||||
if (event.lastEventId) {
|
||||
lastEventIdRef.current = event.lastEventId;
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
switch (parsedEvent.type) {
|
||||
case 'connected':
|
||||
console.log('Stream connected:', parsedEvent.clientId);
|
||||
break;
|
||||
case 'update':
|
||||
console.log('New update received:', parsedEvent.data);
|
||||
break;
|
||||
case 'status-change':
|
||||
console.log('Status change received:', parsedEvent.data);
|
||||
break;
|
||||
case 'pin-update':
|
||||
console.log('Pin update received:', parsedEvent.data);
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown event type:', parsedEvent.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE event:', error);
|
||||
setLastEvent({
|
||||
type: 'error',
|
||||
data: 'Failed to parse server event',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
eventSourceRef.current.onerror = () => {
|
||||
console.error('SSE connection error');
|
||||
setIsConnected(false);
|
||||
setConnectionError('Connection to live blog lost');
|
||||
|
||||
// Attempt reconnection if enabled and within limits
|
||||
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 10)) {
|
||||
const nextAttempt = reconnectAttemptsRef.current + 1;
|
||||
setReconnectAttempts(nextAttempt);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
|
||||
createConnection();
|
||||
}, optionsRef.current.reconnectInterval || 3000);
|
||||
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
|
||||
setConnectionError('Failed to reconnect after multiple attempts');
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create EventSource connection:', error);
|
||||
setConnectionError('Failed to connect to live blog stream');
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
createConnection();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [liveBlogId]);
|
||||
|
||||
const manualReconnect = () => {
|
||||
setReconnectAttempts(0);
|
||||
setConnectionError(null);
|
||||
// Trigger reconnection by updating liveBlogId dependency
|
||||
// This will cause the useEffect to run again
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// The useEffect will automatically reconnect
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastEvent,
|
||||
connectionError,
|
||||
reconnectAttempts,
|
||||
connect: manualReconnect,
|
||||
disconnect: () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
101
pwa/src/hooks/useSocialShare.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
|
||||
import { trackShare } from '@/lib/analytics';
|
||||
|
||||
interface UseSocialShareOptions {
|
||||
onSuccess?: (platform: SharePlatform) => void;
|
||||
onError?: (platform: SharePlatform, error: Error) => void;
|
||||
onCopySuccess?: () => void;
|
||||
onCopyError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseSocialShareReturn {
|
||||
isSharing: boolean;
|
||||
isCopying: boolean;
|
||||
lastSharedPlatform: SharePlatform | null;
|
||||
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
|
||||
copyLink: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
|
||||
|
||||
const share = useCallback(async (
|
||||
platform: SharePlatform,
|
||||
data: ShareData & { articleId: string }
|
||||
) => {
|
||||
setIsSharing(true);
|
||||
setLastSharedPlatform(platform);
|
||||
|
||||
try {
|
||||
// Track the share event
|
||||
await trackShare({
|
||||
articleId: data.articleId,
|
||||
platform,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
// Call success callback
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(platform);
|
||||
}
|
||||
|
||||
// Open share URL for social platforms (not for 'link')
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, data);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to share on ${platform}:`, error);
|
||||
|
||||
// Call error callback
|
||||
if (options.onError) {
|
||||
options.onError(platform, error as Error);
|
||||
}
|
||||
|
||||
// Still open the share URL even if tracking fails (for social platforms)
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, data);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const copyLink = useCallback(async (url: string): Promise<boolean> => {
|
||||
setIsCopying(true);
|
||||
|
||||
try {
|
||||
const success = await copyToClipboard(url);
|
||||
|
||||
if (success && options.onCopySuccess) {
|
||||
options.onCopySuccess();
|
||||
} else if (!success && options.onCopyError) {
|
||||
options.onCopyError(new Error('Failed to copy to clipboard'));
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
|
||||
if (options.onCopyError) {
|
||||
options.onCopyError(error as Error);
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
isSharing,
|
||||
isCopying,
|
||||
lastSharedPlatform,
|
||||
share,
|
||||
copyLink,
|
||||
};
|
||||
}
|
||||
59
pwa/src/index.css
Normal file
@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
96
pwa/src/lib/analytics.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { type SharePlatform } from './social-utils';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
export interface TrackShareParams {
|
||||
articleId: string;
|
||||
platform: SharePlatform;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to track share event:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error tracking share event:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getShareStats = async (articleId?: string) => {
|
||||
try {
|
||||
const url = articleId
|
||||
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
|
||||
: `${API_BASE_URL}/api/v1/analytics/shares`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // Include cookies for admin auth
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching share stats:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTopSharedArticles = async (limit: number = 10) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching top shared articles:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTotalShareStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching total share stats:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
829
pwa/src/lib/api.ts
Normal file
@ -0,0 +1,829 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
// Debug logging
|
||||
console.log('API_BASE_URL:', API_BASE_URL);
|
||||
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper
|
||||
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle 401 unauthorized
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string | null;
|
||||
slug: string;
|
||||
featuredImage: string;
|
||||
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize: 'small' | 'medium' | 'large';
|
||||
videoUrl: string;
|
||||
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption: string;
|
||||
tags: string[];
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
views: number;
|
||||
strapiId: string | null;
|
||||
authorId: string | null;
|
||||
categoryId: string | null;
|
||||
author?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
bio: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isHero?: boolean;
|
||||
isPinned?: boolean;
|
||||
facebookShares?: number;
|
||||
twitterShares?: number;
|
||||
whatsappShares?: number;
|
||||
telegramShares?: number;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
twitterTitle?: string;
|
||||
twitterDescription?: string;
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export interface ArticlesResponse {
|
||||
data: Article[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface FindArticlesParams {
|
||||
category?: string;
|
||||
author?: string;
|
||||
tag?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CreateArticleDto {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
slug?: string;
|
||||
featuredImage?: string;
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
strapiId?: string;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize?: 'small' | 'medium' | 'large';
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export interface UpdateArticleDto {
|
||||
title?: string;
|
||||
content?: string;
|
||||
excerpt?: string;
|
||||
slug?: string;
|
||||
featuredImage?: string;
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
strapiId?: string;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize?: 'small' | 'medium' | 'large';
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
isHero?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
|
||||
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Convert parameters to proper types for URLSearchParams
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
searchParams.append(key, value.toString());
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${API_BASE_URL}/articles?${searchParams}`;
|
||||
console.log('Fetching from:', url);
|
||||
|
||||
const response = await authFetch(url);
|
||||
console.log('Response status:', response.status, 'ok:', response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch articles');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data:', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchArticleBySlug(slug: string): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/slug/${slug}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchArticleById(id: string): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteArticle(id: string): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete article');
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveArticle(id: string): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to archive article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to publish article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Live Blog Types
|
||||
export interface LiveBlogUpdate {
|
||||
id: string;
|
||||
content: string;
|
||||
isPinned: boolean;
|
||||
authorId: string | null;
|
||||
scheduledAt: string | null;
|
||||
strapiId: string | null;
|
||||
author?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
bio: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LiveBlog {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
status: 'draft' | 'live' | 'ended' | 'archived';
|
||||
isPinned: boolean;
|
||||
strapiId: string | null;
|
||||
authorId: string | null;
|
||||
categoryId: string | null;
|
||||
featuredImage: string;
|
||||
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize: 'small' | 'medium' | 'large';
|
||||
videoUrl: string;
|
||||
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption: string;
|
||||
viewCount: number;
|
||||
author?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
bio: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
};
|
||||
updates?: LiveBlogUpdate[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LiveBlogsResponse {
|
||||
data: LiveBlog[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface LiveBlogUpdatesResponse {
|
||||
data: LiveBlogUpdate[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface FindLiveBlogsParams {
|
||||
category?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CreateLiveBlogUpdateDto {
|
||||
content: string;
|
||||
isPinned?: boolean;
|
||||
authorId?: string;
|
||||
scheduledAt?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLiveBlogUpdateDto {
|
||||
content?: string;
|
||||
isPinned?: boolean;
|
||||
authorId?: string;
|
||||
scheduledAt?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export interface CreateLiveBlogDto {
|
||||
title: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLiveBlogDto {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
isPinned?: boolean;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
// Live Blog API Functions
|
||||
export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<LiveBlogsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
searchParams.append(key, value.toString());
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
|
||||
const endpoint = isUuid
|
||||
? `${API_BASE_URL}/live-blogs/${slugOrId}`
|
||||
: `${API_BASE_URL}/live-blogs/slug/${slugOrId}`;
|
||||
const response = await authFetch(endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogById(id: string): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogUpdates(
|
||||
liveBlogId: string,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<LiveBlogUpdatesResponse> {
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog updates');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/recent`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recent live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchHeroArticle(): Promise<Article | null> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/hero`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch hero article');
|
||||
}
|
||||
const article = await response.json();
|
||||
return article || null;
|
||||
}
|
||||
|
||||
export async function fetchLatestArticles(limit = 12): Promise<ArticlesResponse> {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles?status=published&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch latest articles');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pinned live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/active`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch active live blogs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Admin functions
|
||||
export async function createLiveBlogUpdate(
|
||||
liveBlogId: string,
|
||||
dto: CreateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create live blog update');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateLiveBlogUpdate(
|
||||
liveBlogId: string,
|
||||
updateId: string,
|
||||
dto: UpdateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update live blog update');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete live blog update');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete live blog');
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to archive live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to publish live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
import type { User, LoginDto, RegisterDto, AuthResponse } from '@/types';
|
||||
|
||||
// Auth API Functions
|
||||
export async function login(dto: LoginDto): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Login failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function register(dto: RegisterDto): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Registration failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<User> {
|
||||
const response = await authFetch(`${API_BASE_URL}/users/profile`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profile');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
// Comment Types
|
||||
export interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId: string | null;
|
||||
liveBlogId: string | null;
|
||||
parentCommentId: string | null;
|
||||
userId: string;
|
||||
isVisible: boolean;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
reactions?: {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
};
|
||||
replies?: Comment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentDto {
|
||||
content: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentCommentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCommentDto {
|
||||
content?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface FindCommentsParams {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentCommentId?: string;
|
||||
parentId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
data: Comment[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ReactionCounts {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
}
|
||||
|
||||
export interface CreateReactionDto {
|
||||
type: 'like' | 'dislike';
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
// Comment API Functions
|
||||
// Interface for backend comment response
|
||||
interface BackendComment {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId: string | null;
|
||||
liveBlogId: string | null;
|
||||
parentId: string | null;
|
||||
userId: string;
|
||||
likeCount: number;
|
||||
dislikeCount: number;
|
||||
isVisible: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
replies?: BackendComment[];
|
||||
}
|
||||
|
||||
// Recursive function to map comment and its replies
|
||||
function mapBackendComment(comment: BackendComment): Comment {
|
||||
const mappedComment: Comment = {
|
||||
id: comment.id,
|
||||
content: comment.content,
|
||||
articleId: comment.articleId,
|
||||
liveBlogId: comment.liveBlogId,
|
||||
parentCommentId: comment.parentId,
|
||||
userId: comment.userId,
|
||||
isVisible: comment.isVisible,
|
||||
reactions: {
|
||||
likes: comment.likeCount || 0,
|
||||
dislikes: comment.dislikeCount || 0,
|
||||
},
|
||||
replies: comment.replies?.map(mapBackendComment) || [],
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
return mappedComment;
|
||||
}
|
||||
|
||||
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Map parentCommentId to parentId for backend compatibility
|
||||
const backendParams = { ...params };
|
||||
if (backendParams.parentCommentId) {
|
||||
backendParams.parentId = backendParams.parentCommentId;
|
||||
delete backendParams.parentCommentId;
|
||||
}
|
||||
|
||||
Object.entries(backendParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
searchParams.append(key, value.toString());
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = await authFetch(`${API_BASE_URL}/comments?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch comments');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const mappedData = (data as BackendComment[]).map(mapBackendComment);
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
total: mappedData.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
||||
// Map parentCommentId to parentId for backend compatibility
|
||||
const backendDto = {
|
||||
content: dto.content,
|
||||
articleId: dto.articleId,
|
||||
liveBlogId: dto.liveBlogId,
|
||||
parentId: dto.parentCommentId,
|
||||
};
|
||||
|
||||
const response = await authFetch(`${API_BASE_URL}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(backendDto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create comment');
|
||||
}
|
||||
|
||||
const comment = await response.json() as BackendComment;
|
||||
|
||||
// Map backend response to frontend interface
|
||||
return mapBackendComment(comment);
|
||||
}
|
||||
|
||||
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update comment');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete comment');
|
||||
}
|
||||
}
|
||||
|
||||
export async function addReaction(dto: CreateReactionDto): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add reaction');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReactionCounts(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
): Promise<ReactionCounts> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (articleId) searchParams.append('articleId', articleId);
|
||||
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
|
||||
if (commentId) searchParams.append('commentId', commentId);
|
||||
|
||||
const url = `${API_BASE_URL}/comments/reactions/counts${searchParams.toString() ? `?${searchParams}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch reaction counts');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getUserReaction(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
): Promise<{ type: string | null }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (articleId) searchParams.append('articleId', articleId);
|
||||
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
|
||||
if (commentId) searchParams.append('commentId', commentId);
|
||||
|
||||
const url = `${API_BASE_URL}/comments/reactions/user${searchParams.toString() ? `?${searchParams}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { type: null };
|
||||
}
|
||||
throw new Error('Failed to fetch user reaction');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
124
pwa/src/lib/social-utils.ts
Normal file
@ -0,0 +1,124 @@
|
||||
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
|
||||
|
||||
export interface ShareData {
|
||||
title: string;
|
||||
url: string;
|
||||
excerpt?: string;
|
||||
image?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const getShareUrl = (
|
||||
platform: Exclude<SharePlatform, 'link'>,
|
||||
data: ShareData
|
||||
): string => {
|
||||
const { title, url, excerpt } = data;
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
|
||||
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
|
||||
case 'twitter':
|
||||
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
|
||||
case 'whatsapp':
|
||||
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
|
||||
case 'telegram':
|
||||
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
|
||||
case 'email':
|
||||
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
|
||||
default:
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return success;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatformIcon = (platform: SharePlatform): string => {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'Facebook';
|
||||
case 'twitter':
|
||||
return 'Twitter';
|
||||
case 'whatsapp':
|
||||
return 'MessageCircle';
|
||||
case 'telegram':
|
||||
return 'Send';
|
||||
case 'email':
|
||||
return 'Mail';
|
||||
case 'link':
|
||||
return 'Link';
|
||||
default:
|
||||
return 'Share2';
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatformLabel = (platform: SharePlatform): string => {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'Facebook';
|
||||
case 'twitter':
|
||||
return 'Twitter';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'telegram':
|
||||
return 'Telegram';
|
||||
case 'email':
|
||||
return 'Email';
|
||||
case 'link':
|
||||
return 'Copy Link';
|
||||
default:
|
||||
return 'Share';
|
||||
}
|
||||
};
|
||||
|
||||
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
|
||||
const { title, excerpt, image, url } = data;
|
||||
|
||||
// Default values if not provided
|
||||
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
|
||||
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
|
||||
const ogImage = image || '/placeholder-image.jpg';
|
||||
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
|
||||
return {
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage,
|
||||
ogUrl,
|
||||
ogType: 'article' as const,
|
||||
twitterCard: 'summary_large_image' as const,
|
||||
twitterTitle: ogTitle,
|
||||
twitterDescription: ogDescription,
|
||||
twitterImage: ogImage,
|
||||
};
|
||||
};
|
||||
|
||||
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
};
|
||||
79
pwa/src/lib/theme.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Theme utilities for dark/light mode
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Get the current theme from localStorage or system preference
|
||||
*/
|
||||
export function getInitialTheme(): Theme {
|
||||
// Check localStorage first
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
// Default to light
|
||||
return 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to document
|
||||
*/
|
||||
export function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme on page load
|
||||
*/
|
||||
export function initializeTheme(): void {
|
||||
const theme = getInitialTheme();
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
export function toggleTheme(): Theme {
|
||||
const currentTheme = getInitialTheme();
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
applyTheme(newTheme);
|
||||
return newTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for system theme changes
|
||||
*/
|
||||
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
// Only update if user hasn't set a preference in localStorage
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const theme = e.matches ? 'dark' : 'light';
|
||||
callback(theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
// Return cleanup function
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
6
pwa/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
157
pwa/src/lib/video-utils.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Video utilities for handling YouTube embeds and video URLs
|
||||
*/
|
||||
|
||||
export type VideoPosition = 'top' | 'inline' | 'bottom' | 'none';
|
||||
export type VideoPlatform = 'youtube' | 'vimeo' | 'dailymotion' | 'twitch' | 'uploaded' | 'external';
|
||||
|
||||
/**
|
||||
* Extract YouTube video ID from various URL formats
|
||||
* Supported formats:
|
||||
* - https://www.youtube.com/watch?v=VIDEO_ID
|
||||
* - https://youtu.be/VIDEO_ID
|
||||
* - https://www.youtube.com/embed/VIDEO_ID
|
||||
* - https://www.youtube.com/v/VIDEO_ID
|
||||
* - https://www.youtube.com/shorts/VIDEO_ID
|
||||
*/
|
||||
export function extractYouTubeVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
const patterns = [
|
||||
// Standard watch URL
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||
// Just the video ID
|
||||
/^([a-zA-Z0-9_-]{11})$/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid YouTube URL
|
||||
*/
|
||||
export function isValidYouTubeUrl(url: string): boolean {
|
||||
return extractYouTubeVideoId(url) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YouTube thumbnail URL for a video ID
|
||||
* Quality options: default, mq (medium), hq (high), sd (standard), maxres (maximum resolution)
|
||||
*/
|
||||
export function getYouTubeThumbnail(
|
||||
videoId: string,
|
||||
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
|
||||
): string {
|
||||
const qualityMap = {
|
||||
default: 'default.jpg',
|
||||
mq: 'mqdefault.jpg',
|
||||
hq: 'hqdefault.jpg',
|
||||
sd: 'sddefault.jpg',
|
||||
maxres: 'maxresdefault.jpg',
|
||||
};
|
||||
|
||||
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect video platform from URL
|
||||
*/
|
||||
export function detectVideoPlatform(url: string): VideoPlatform {
|
||||
if (!url) return 'external';
|
||||
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||
if (url.includes('vimeo.com')) return 'vimeo';
|
||||
if (url.includes('dailymotion.com')) return 'dailymotion';
|
||||
if (url.includes('twitch.tv')) return 'twitch';
|
||||
if (url.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)$/i)) return 'uploaded';
|
||||
|
||||
return 'external';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate YouTube embed URL with privacy-enhanced mode and parameters
|
||||
*/
|
||||
export function generateYouTubeEmbedUrl(
|
||||
videoId: string,
|
||||
options: {
|
||||
autoplay?: boolean;
|
||||
controls?: boolean;
|
||||
modestbranding?: boolean;
|
||||
rel?: boolean;
|
||||
playsinline?: boolean;
|
||||
start?: number;
|
||||
end?: number;
|
||||
} = {}
|
||||
): string {
|
||||
const {
|
||||
autoplay = false,
|
||||
controls = true,
|
||||
modestbranding = true,
|
||||
rel = false,
|
||||
playsinline = true,
|
||||
start,
|
||||
end,
|
||||
} = options;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
autoplay: autoplay ? '1' : '0',
|
||||
controls: controls ? '1' : '0',
|
||||
modestbranding: modestbranding ? '1' : '0',
|
||||
rel: rel ? '1' : '0',
|
||||
playsinline: playsinline ? '1' : '0',
|
||||
});
|
||||
|
||||
if (start !== undefined) params.set('start', start.toString());
|
||||
if (end !== undefined) params.set('end', end.toString());
|
||||
|
||||
// Use privacy-enhanced mode (youtube-nocookie.com)
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes for video positioning
|
||||
*/
|
||||
export function getVideoPositionClasses(position: VideoPosition): string {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'mb-8';
|
||||
case 'inline':
|
||||
return 'my-6';
|
||||
case 'bottom':
|
||||
return 'mt-8';
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
return 'my-6';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video URL
|
||||
*/
|
||||
export function validateVideoUrl(url: string): { isValid: boolean; platform: VideoPlatform; error?: string } {
|
||||
if (!url) {
|
||||
return { isValid: false, platform: 'external', error: 'Video URL is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return { isValid: false, platform: 'external', error: 'Invalid URL format' };
|
||||
}
|
||||
|
||||
const platform = detectVideoPlatform(url);
|
||||
|
||||
if (platform === 'youtube' && !isValidYouTubeUrl(url)) {
|
||||
return { isValid: false, platform: 'youtube', error: 'Invalid YouTube URL' };
|
||||
}
|
||||
|
||||
return { isValid: true, platform };
|
||||
}
|
||||
22
pwa/src/main.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { router } from './routes'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { initializeTheme } from './lib/theme'
|
||||
import 'virtual:pwa-register'
|
||||
|
||||
initializeTheme()
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
90
pwa/src/queries/articles.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function useArticles(params: api.FindArticlesParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['articles', params],
|
||||
queryFn: () => api.fetchArticles(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useArticle(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['article', slug],
|
||||
queryFn: () => api.fetchArticleBySlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useArticleById(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['article', id],
|
||||
queryFn: () => api.fetchArticleById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createArticle,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
|
||||
api.updateArticle(id, dto),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.deleteArticle,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
// Also invalidate any specific article queries
|
||||
queryClient.removeQueries({ queryKey: ['article', variables] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.archiveArticle,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
|
||||
api.publishArticle(id, status),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
49
pwa/src/queries/auth.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ username, password }: { username: string; password: string }) =>
|
||||
api.login({ username, password }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'user'], data.user);
|
||||
queryClient.setQueryData(['auth', 'token'], data.access_token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ username, email, password }: { username: string; email: string; password: string }) =>
|
||||
api.register({ username, email, password }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'user'], data.user);
|
||||
queryClient.setQueryData(['auth', 'token'], data.access_token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'user'],
|
||||
queryFn: api.getProfile,
|
||||
enabled: false, // We'll manually trigger this when needed
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.removeQueries({ queryKey: ['auth'] });
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
98
pwa/src/queries/comments.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function useComments(params: api.FindCommentsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['comments', params],
|
||||
queryFn: () => api.fetchComments(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createComment,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['comments', { articleId: variables.articleId }]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['comments', { liveBlogId: variables.liveBlogId }]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateCommentDto }) =>
|
||||
api.updateComment(id, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.deleteComment,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReactionCounts(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['reactions', 'counts', { articleId, liveBlogId, commentId }],
|
||||
queryFn: () => api.getReactionCounts(articleId, liveBlogId, commentId),
|
||||
enabled: !!articleId || !!liveBlogId || !!commentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserReaction(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['reactions', 'user', { articleId, liveBlogId, commentId }],
|
||||
queryFn: () => api.getUserReaction(articleId, liveBlogId, commentId),
|
||||
enabled: !!articleId || !!liveBlogId || !!commentId,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddReaction() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.addReaction,
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate both counts and user reaction queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['reactions', 'counts', {
|
||||
articleId: variables.articleId,
|
||||
liveBlogId: variables.liveBlogId,
|
||||
commentId: variables.commentId
|
||||
}]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['reactions', 'user', {
|
||||
articleId: variables.articleId,
|
||||
liveBlogId: variables.liveBlogId,
|
||||
commentId: variables.commentId
|
||||
}]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
185
pwa/src/queries/live-blogs.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
// Live Blog Queries
|
||||
export function useLiveBlogs(params: api.FindLiveBlogsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['liveBlogs', params],
|
||||
queryFn: () => api.fetchLiveBlogs(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLiveBlog(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['liveBlog', slug],
|
||||
queryFn: () => api.fetchLiveBlogBySlug(slug),
|
||||
enabled: !!slug,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLiveBlogById(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['liveBlog', id],
|
||||
queryFn: () => api.fetchLiveBlogById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLiveBlogUpdates(liveBlogId: string, page = 1, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['liveBlogUpdates', liveBlogId, page, limit],
|
||||
queryFn: () => api.fetchLiveBlogUpdates(liveBlogId, page, limit),
|
||||
enabled: !!liveBlogId,
|
||||
refetchInterval: 10000, // Poll every 10 seconds as fallback
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentLiveBlogs() {
|
||||
return useQuery({
|
||||
queryKey: ['recentLiveBlogs'],
|
||||
queryFn: () => api.fetchRecentLiveBlogs(),
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function usePinnedLiveBlogs() {
|
||||
return useQuery({
|
||||
queryKey: ['pinnedLiveBlogs'],
|
||||
queryFn: () => api.fetchPinnedLiveBlogs(),
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveLiveBlogs() {
|
||||
return useQuery({
|
||||
queryKey: ['activeLiveBlogs'],
|
||||
queryFn: () => api.fetchActiveLiveBlogs(),
|
||||
refetchInterval: 10000, // Refetch every 10 seconds for ticker
|
||||
});
|
||||
}
|
||||
|
||||
// Live Blog Mutations (Admin)
|
||||
export function useCreateLiveBlogUpdate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
|
||||
api.createLiveBlogUpdate(liveBlogId, dto),
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate live blog queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLiveBlogUpdate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ liveBlogId, updateId, dto }: {
|
||||
liveBlogId: string;
|
||||
updateId: string;
|
||||
dto: api.UpdateLiveBlogUpdateDto
|
||||
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate live blog queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLiveBlogUpdate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
|
||||
api.deleteLiveBlogUpdate(liveBlogId, updateId),
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate live blog queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Live Blog CRUD Mutations
|
||||
export function useCreateLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: api.CreateLiveBlogDto) => api.createLiveBlog(dto),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) =>
|
||||
api.updateLiveBlog(id, dto),
|
||||
onSuccess: () => {
|
||||
// Force immediate refetch of all live blog queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['liveBlog'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['liveBlogs'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['recentLiveBlogs'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteLiveBlog(id),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.archiveLiveBlog(id),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
|
||||
api.publishLiveBlog(id, status),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
326
pwa/src/routes.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
|
||||
import { ArticleTicker } from './components/ArticleTicker'
|
||||
import { ArchiveComponent } from './components/routes/ArchiveComponent'
|
||||
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
|
||||
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
|
||||
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
|
||||
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
||||
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
||||
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
|
||||
import { AuthPage } from './components/routes/AuthPage'
|
||||
import { SportComponent } from './components/routes/SportComponent'
|
||||
import { ArtComponent } from './components/routes/ArtComponent'
|
||||
import { ScienceComponent } from './components/routes/ScienceComponent'
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||
import { Header } from './components/layout/Header'
|
||||
import { HeroArticle } from './components/home/HeroArticle'
|
||||
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
|
||||
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
|
||||
import { Button } from './components/ui/button'
|
||||
import { Zap, Search, Users } from 'lucide-react'
|
||||
import './styles.css'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: 'Placebo.mk - Сатирични вести од Македонија',
|
||||
description: 'Latest news and articles from Macedonia with a sarcastic twist',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<Header />
|
||||
<ArticleTicker />
|
||||
|
||||
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
|
||||
<p className="font-body text-sm text-background/70">
|
||||
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Категории</h4>
|
||||
<ul className="space-y-2 font-body text-sm">
|
||||
<li><Link to="/sport" className="hover:text-accent transition-colors">Спорт</Link></li>
|
||||
<li><Link to="/art" className="hover:text-accent transition-colors">Уметност</Link></li>
|
||||
<li><Link to="/science" className="hover:text-accent transition-colors">Наука</Link></li>
|
||||
<li><Link to="/archive" className="hover:text-accent transition-colors">Архива</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Следете не</h4>
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Zap className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Search className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Users className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
|
||||
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<div>
|
||||
<div className="py-8 md:py-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
<div className="lg:col-span-2">
|
||||
<HeroArticle />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LatestArticlesGrid />
|
||||
|
||||
<div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/archive">
|
||||
<Button variant="brutalAccent" className="gap-2">
|
||||
Прелистај архива
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
|
||||
<Zap className="w-4 h-4" />
|
||||
Live Блогови
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-16">
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Најнови вести</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Свежо подготвена сатира за тековни настани, политика и сè помеѓу тоа.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-2">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<span className="font-display text-2xl">⚠</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-3">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const archiveRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/archive',
|
||||
component: ArchiveComponent,
|
||||
})
|
||||
|
||||
const sportRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/sport',
|
||||
component: SportComponent,
|
||||
})
|
||||
|
||||
const artRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/art',
|
||||
component: ArtComponent,
|
||||
})
|
||||
|
||||
const scienceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/science',
|
||||
component: ScienceComponent,
|
||||
})
|
||||
|
||||
const articleDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/articles/$id',
|
||||
component: () => {
|
||||
const { id } = articleDetailRoute.useParams()
|
||||
return <ArticleDetailComponent id={id} />
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
|
||||
if (!response.ok) {
|
||||
return { article: null }
|
||||
}
|
||||
const data = await response.json()
|
||||
return { article: data.data }
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const article = loaderData?.article
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
meta: [
|
||||
{ title: 'Article Not Found - Placebo.mk' },
|
||||
{ name: 'description', content: 'Article not found' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const ogTitle = article.ogTitle || article.title
|
||||
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk'
|
||||
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg'
|
||||
const twitterTitle = article.twitterTitle || article.title
|
||||
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk'
|
||||
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg'
|
||||
|
||||
const metaTags = [
|
||||
{ title: `${article.title} - Placebo.mk` },
|
||||
{ name: 'description', content: ogDescription },
|
||||
{ property: 'og:title', content: ogTitle },
|
||||
{ property: 'og:description', content: ogDescription },
|
||||
{ property: 'og:image', content: ogImage },
|
||||
{ property: 'og:url', content: typeof window !== 'undefined' ? window.location.href : '' },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:locale', content: 'mk_MK' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: twitterTitle },
|
||||
{ name: 'twitter:description', content: twitterDescription },
|
||||
{ name: 'twitter:image', content: twitterImage },
|
||||
{ property: 'article:published_time', content: article.createdAt },
|
||||
{ property: 'article:modified_time', content: article.updatedAt },
|
||||
]
|
||||
|
||||
if (article.author?.name) {
|
||||
metaTags.push({ property: 'article:author', content: article.author.name })
|
||||
}
|
||||
|
||||
if (article.tags && article.tags.length > 0) {
|
||||
article.tags.forEach((tag: string) => {
|
||||
metaTags.push({ property: 'article:tag', content: tag })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
meta: metaTags,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const liveBlogsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/live-blogs',
|
||||
component: LiveBlogsComponent,
|
||||
})
|
||||
|
||||
const liveBlogDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/live-blogs/$slug',
|
||||
component: () => {
|
||||
const { slug } = liveBlogDetailRoute.useParams()
|
||||
return <LiveBlogDetailComponent slug={slug} />
|
||||
},
|
||||
})
|
||||
|
||||
const authRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/auth',
|
||||
component: AuthPage,
|
||||
})
|
||||
|
||||
const liveBlogAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/$slug',
|
||||
component: () => {
|
||||
const { slug } = liveBlogAdminRoute.useParams()
|
||||
return (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<LiveBlogAdminComponent slug={slug} />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const createLiveBlogRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/create',
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<CreateLiveBlogComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const adminDashboardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin',
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<AdminDashboardComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
archiveRoute,
|
||||
sportRoute,
|
||||
artRoute,
|
||||
scienceRoute,
|
||||
articleDetailRoute,
|
||||
liveBlogsRoute,
|
||||
liveBlogDetailRoute,
|
||||
authRoute,
|
||||
liveBlogAdminRoute,
|
||||
createLiveBlogRoute,
|
||||
adminDashboardRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
351
pwa/src/routes.tsx.backup
Normal file
@ -0,0 +1,351 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as api from './lib/api'
|
||||
import './styles.css'
|
||||
|
||||
function ArticleTicker() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['ticker-articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
|
||||
})
|
||||
|
||||
const articles = data?.data.slice(0, 10) || []
|
||||
|
||||
if (articles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden bg-muted/50 border-y">
|
||||
<div className="container mx-auto max-w-6xl px-4">
|
||||
<div className="py-2 flex items-center gap-4">
|
||||
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
||||
Latest:
|
||||
</span>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex animate-marquee">
|
||||
{articles.map((article) => (
|
||||
<div key={article.id} className="whitespace-nowrap px-6 border-r">
|
||||
<Link
|
||||
to={`/articles/${article.id}` as any}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: 'Placebo.mk - Sarcastic News from Macedonia',
|
||||
description: 'Latest news and articles from Macedonia with a sarcastic twist',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
</h1>
|
||||
<nav className="flex gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t mt-12">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
© 2025 Placebo.mk. Sarcastic news from Macedonia.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<div>
|
||||
<ArticleTicker />
|
||||
<div className="py-12 md:py-20">
|
||||
<div className="max-w-4xl mx-auto text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
Placebo<span className="text-primary">.mk</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Community</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Join thousands of readers who appreciate the finer art of Macedonian sarcasm.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<Link
|
||||
to="/articles"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Articles
|
||||
<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">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const articlesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/articles',
|
||||
component: () => {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['articles'],
|
||||
queryFn: () => api.fetchArticles({ status: 'published' }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading articles...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading articles</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Articles</h1>
|
||||
<p className="text-muted-foreground">Latest news and articles</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data?.data.map((article) => (
|
||||
<Link
|
||||
key={article.id}
|
||||
to={`/articles/${article.id}` as any}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No articles published yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const articleDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/articles/$id',
|
||||
component: () => {
|
||||
const { id } = articleDetailRoute.useParams()
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['article', id],
|
||||
queryFn: () => api.fetchArticleById(id),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading article...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Error loading article</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Article not found</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto">
|
||||
<Link
|
||||
to="/articles"
|
||||
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">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
<path d="M19 6H5" />
|
||||
</svg>
|
||||
Back to articles
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
|
||||
<span>
|
||||
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{data.views} views</span>
|
||||
{data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>By {data.author.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.featuredImage && (
|
||||
<img
|
||||
src={data.featuredImage}
|
||||
alt={data.title}
|
||||
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
|
||||
</div>
|
||||
|
||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute, articleDetailRoute])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
355
pwa/src/styles.css
Normal file
@ -0,0 +1,355 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: oklch(0.08 0 0);
|
||||
--color-primary-foreground: oklch(0.98 0 0);
|
||||
--color-secondary: oklch(0.93 0.02 50);
|
||||
--color-secondary-foreground: oklch(0.08 0 0);
|
||||
--color-muted: oklch(0.91 0.01 50);
|
||||
--color-muted-foreground: oklch(0.45 0.01 50);
|
||||
--color-accent: oklch(0.93 0.8 100);
|
||||
--color-accent-foreground: oklch(0.08 0 0);
|
||||
--color-destructive: oklch(0.55 0.22 25);
|
||||
--color-destructive-foreground: oklch(0.98 0 0);
|
||||
--color-border: oklch(0.08 0 0);
|
||||
--color-input: oklch(0.08 0 0);
|
||||
--color-ring: oklch(0.93 0.8 100);
|
||||
--radius: 0px;
|
||||
--font-display: "Bebas Neue", sans-serif;
|
||||
--font-body: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--radius: 0px;
|
||||
|
||||
--shadow-brutal: 4px 4px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-sm: 2px 2px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-lg: 6px 6px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-accent: 4px 4px 0px 0px oklch(0.93 0.8 100);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 17% 22%;
|
||||
--foreground: 220 17% 92%;
|
||||
--card: 220 17% 27%;
|
||||
--card-foreground: 220 17% 92%;
|
||||
--popover: 220 17% 25%;
|
||||
--popover-foreground: 220 17% 96%;
|
||||
--primary: 220 17% 92%;
|
||||
--primary-foreground: 220 17% 22%;
|
||||
--secondary: 220 17% 32%;
|
||||
--secondary-foreground: 220 17% 88%;
|
||||
--muted: 220 17% 36%;
|
||||
--muted-foreground: 220 17% 70%;
|
||||
--accent: 193 48% 67%;
|
||||
--accent-foreground: 220 17% 22%;
|
||||
--destructive: 354 46% 56%;
|
||||
--destructive-foreground: 220 17% 96%;
|
||||
--border: 220 17% 36%;
|
||||
--input: 220 17% 36%;
|
||||
--ring: 193 48% 67%;
|
||||
|
||||
--shadow-brutal: 4px 4px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-sm: 2px 2px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-lg: 6px 6px 0px 0px hsl(220 17% 36%);
|
||||
--shadow-brutal-accent: 4px 4px 0px 0px hsl(193 48% 67%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-body);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
50% {
|
||||
border-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch {
|
||||
0% {
|
||||
clip-path: inset(40% 0 61% 0);
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
20% {
|
||||
clip-path: inset(92% 0 1% 0);
|
||||
transform: translate(1px, -1px);
|
||||
}
|
||||
40% {
|
||||
clip-path: inset(43% 0 1% 0);
|
||||
transform: translate(-1px, 2px);
|
||||
}
|
||||
60% {
|
||||
clip-path: inset(25% 0 58% 0);
|
||||
transform: translate(2px, 1px);
|
||||
}
|
||||
80% {
|
||||
clip-path: inset(54% 0 7% 0);
|
||||
transform: translate(-2px, -1px);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(58% 0 43% 0);
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: flex;
|
||||
animation: marquee 25s linear infinite;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
.stagger-7 { animation-delay: 0.35s; }
|
||||
.stagger-8 { animation-delay: 0.4s; }
|
||||
.stagger-9 { animation-delay: 0.45s; }
|
||||
.stagger-10 { animation-delay: 0.5s; }
|
||||
.stagger-11 { animation-delay: 0.55s; }
|
||||
.stagger-12 { animation-delay: 0.6s; }
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.font-body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.border-brutal {
|
||||
border: 3px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-brutal-sm {
|
||||
border: 2px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-brutal-accent {
|
||||
border: 3px solid hsl(var(--accent));
|
||||
border-left-width: 6px;
|
||||
}
|
||||
|
||||
.shadow-brutal {
|
||||
box-shadow: var(--shadow-brutal);
|
||||
}
|
||||
|
||||
.shadow-brutal-sm {
|
||||
box-shadow: var(--shadow-brutal-sm);
|
||||
}
|
||||
|
||||
.shadow-brutal-lg {
|
||||
box-shadow: var(--shadow-brutal-lg);
|
||||
}
|
||||
|
||||
.shadow-brutal-accent {
|
||||
box-shadow: var(--shadow-brutal-accent);
|
||||
}
|
||||
|
||||
.hover\:shadow-brutal:hover {
|
||||
box-shadow: var(--shadow-brutal);
|
||||
transform: translate(-2px, -2px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.hover\:shadow-brutal-accent:hover {
|
||||
box-shadow: var(--shadow-brutal-accent);
|
||||
transform: translate(-2px, -2px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bg-noise::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.text-outline {
|
||||
-webkit-text-stroke: 1px hsl(var(--foreground));
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.text-outline-hover:hover {
|
||||
-webkit-text-stroke: 1px hsl(var(--foreground));
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
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 {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border: 2px solid hsl(var(--secondary));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
59
pwa/src/sw.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.includes('/api/'),
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-cache',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
self.addEventListener('push', (event: PushEvent) => {
|
||||
const data = event.data?.json() ?? {}
|
||||
const title = data.title ?? 'Placebo.mk'
|
||||
const options = {
|
||||
body: data.body ?? 'New update available',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
data: data.url ?? '/',
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
event.notification.close()
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
const url = event.notification.data
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export {}
|
||||
32
pwa/src/types/auth.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export type UserRole = 'admin' | 'contributor' | 'user';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterDto {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
error?: string;
|
||||
}
|
||||
1
pwa/src/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { User, UserRole, LoginDto, RegisterDto, AuthResponse, ApiError } from './auth';
|
||||
13
pwa/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean
|
||||
onNeedRefresh?: () => void
|
||||
onOfflineReady?: () => void
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
|
||||
onRegisterError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
34
pwa/tsconfig.app.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
pwa/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
pwa/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
64
pwa/vite.config.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icons/*.svg'],
|
||||
manifest: {
|
||||
name: 'Placebo.mk',
|
||||
short_name: 'Placebo',
|
||||
description: 'Сатирични вести од Македонија',
|
||||
lang: 'mk',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
preserveSymlinks: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom', 'react-markdown'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||