Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
9ca069c49b | |||
933d82e3ac | |||
23bd232504 | |||
0a1a5d65d5 | |||
6bb71bff44 | |||
7e5f29eb40 | |||
7dbfb0323c | |||
4663e0a300 | |||
12b0011d57 | |||
5a2bc6dbd3 | |||
a08eb412e4 | |||
dc00bca390 | |||
9ba64a453c | |||
e4d5029e72 | |||
7fb5daf210 | |||
7afbcf27f8 | |||
00376c124f | |||
e254ef0dc0 | |||
1d09dbadf3 | |||
ae5e789f1b | |||
be59f4418a | |||
1c4a7f2384 | |||
5cc46da09d | |||
2a3f179f54 |
4
.env
@ -1,3 +1 @@
|
|||||||
DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
|
VITE_API_URL="http://localhost:5000"
|
||||||
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
|
|
||||||
PORT=5000
|
|
||||||
|
2
.env.production
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://russcult.anibilag.ru
|
||||||
|
VITE_APP_ENV=production
|
107
.import/mysql_json_convert.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import json
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
# Файл дампа
|
||||||
|
input_directory = "D:/__TEMP/____RUSS_CULT/__convert"
|
||||||
|
json_file = "1-2025-p"
|
||||||
|
output_file = "1-2025-p-convert.json"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
f = io.open(input_directory + '/' + json_file + '.json', encoding='utf-8')
|
||||||
|
records = json.loads(f.read())
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Ошибка: Входной JSON файл не найден.")
|
||||||
|
return
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Ошибка декодирования JSON: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
articles = list()
|
||||||
|
|
||||||
|
for item in records:
|
||||||
|
try:
|
||||||
|
article = dict()
|
||||||
|
|
||||||
|
pre = item['pre']
|
||||||
|
full = item['full']
|
||||||
|
|
||||||
|
article['id'] = item['id']
|
||||||
|
article['title'] = item['title']
|
||||||
|
article['content'] = full
|
||||||
|
article['categoryId'] = item['cat_id']
|
||||||
|
article['cityId'] = item['city_id']
|
||||||
|
|
||||||
|
soup = BeautifulSoup(pre, "html.parser")
|
||||||
|
|
||||||
|
# Извлекаем URL изображения
|
||||||
|
img_tag = soup.find("img")
|
||||||
|
img_url = img_tag["src"] if img_tag else None
|
||||||
|
|
||||||
|
# Удаляем тег <img> из HTML
|
||||||
|
if img_tag:
|
||||||
|
img_tag.decompose()
|
||||||
|
|
||||||
|
# Удаляем пустые <p> (с пробелами или полностью пустые)
|
||||||
|
for p in soup.find_all("p"):
|
||||||
|
if not p.get_text(strip=True): # strip=True убирает пробелы и невидимые символы
|
||||||
|
p.decompose()
|
||||||
|
|
||||||
|
# Извлекаем текст из оставшихся <p>
|
||||||
|
text_content = " ".join(p.get_text(strip=True) for p in soup.find_all("p"))
|
||||||
|
|
||||||
|
if not text_content:
|
||||||
|
# Находим первый тег <p> и извлекаем текст
|
||||||
|
soup = BeautifulSoup(full, "html.parser")
|
||||||
|
first_p = soup.find("p")
|
||||||
|
text_content = first_p.get_text(strip=True) if first_p else ""
|
||||||
|
|
||||||
|
article['excerpt'] = text_content
|
||||||
|
article['coverImage'] = img_url
|
||||||
|
|
||||||
|
article['readTime'] = 2
|
||||||
|
article['likes'] = 0
|
||||||
|
article['dislikes'] = 0
|
||||||
|
|
||||||
|
article['gallery'] = []
|
||||||
|
|
||||||
|
# Разбираем строку в объект datetime
|
||||||
|
date_obj = datetime.strptime(item['date'], "%d.%m.%Y")
|
||||||
|
|
||||||
|
# Преобразуем в нужный формат
|
||||||
|
formatted_date = date_obj.strftime("%Y-%m-%dT00:00:00Z")
|
||||||
|
|
||||||
|
article['publishedAt'] = formatted_date
|
||||||
|
|
||||||
|
author = dict()
|
||||||
|
author['id'] = '41e09d9a-f9c1-44a7-97f4-0be694371e7e'
|
||||||
|
|
||||||
|
article['author'] = author
|
||||||
|
|
||||||
|
articles.append(article)
|
||||||
|
|
||||||
|
except KeyError as e:
|
||||||
|
print(f"Потерян ключ в записи: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
save_to_json_file(output_file, articles, 'w')
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_file(path, data, mode):
|
||||||
|
f = open(input_directory + '/' + path, mode)
|
||||||
|
f.write(data)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def save_to_json_file(path, data, mode):
|
||||||
|
f = io.open(input_directory + '/' + path, encoding='utf-8', mode=mode)
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
#def create_article:
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
48
.nginx/nginx.conf
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
root /var/www/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
server_name russcult.anibilag.ru;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_buffers 16 8k;
|
||||||
|
gzip_http_version 1.1;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://192.168.1.67:5000/api/; # 👈 сохраняем /api/ в пути
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /index.html;
|
||||||
|
}
|
9
Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20 AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
# Переход на stage со статикой
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
21
deploy.sh
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Backup current deployment
|
||||||
|
sudo cp -r /var/www/dist /var/www/dist.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# Deploy new build
|
||||||
|
sudo rm -rf /var/www/dist/*
|
||||||
|
sudo cp -r dist/* /var/www/dist/
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
sudo chown -R www-data:www-data /var/www/dist
|
||||||
|
sudo chmod -R 755 /var/www/dist
|
||||||
|
|
||||||
|
# Test nginx config and reload
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
|
echo "Deployment completed successfully!"
|
4929
package-lock.json
generated
39
package.json
@ -8,28 +8,43 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"server": "node server/index.js"
|
"server": "node server/index.js",
|
||||||
|
"prisma-seed": "npx prisma db seed",
|
||||||
|
"db:seed": "node prisma/seed.js",
|
||||||
|
"generate-sitemap": "node scripts/generate-sitemap.js"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx ts-node --project tsconfig.node.json --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.525.0",
|
"@aws-sdk/client-s3": "^3.525.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
||||||
"@prisma/client": "^5.10.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
|
"@prisma/client": "^6.2.1",
|
||||||
|
"@tiptap/extension-highlight": "^2.11.5",
|
||||||
|
"@tiptap/extension-image": "^2.11.5",
|
||||||
|
"@tiptap/extension-link": "^2.11.7",
|
||||||
|
"@tiptap/extension-text-align": "^2.11.5",
|
||||||
"@tiptap/pm": "^2.2.4",
|
"@tiptap/pm": "^2.2.4",
|
||||||
"@tiptap/react": "^2.2.4",
|
"@tiptap/react": "^2.2.4",
|
||||||
"@tiptap/starter-kit": "^2.2.4",
|
"@tiptap/starter-kit": "^2.2.4",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"multer": "1.4.5-lts.1",
|
"multer": "1.4.5-lts.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"sharp": "^0.33.2",
|
"sitemap": "^8.0.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
@ -37,22 +52,28 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"@types/winston": "^2.4.4",
|
"@types/winston": "^2.4.4",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.49",
|
||||||
"prisma": "^5.10.2",
|
"prisma": "^6.2.1",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: [
|
||||||
tailwindcss: {},
|
{
|
||||||
autoprefixer: {},
|
postcssPlugin: 'fix-from-warning',
|
||||||
},
|
Once(root, { result }) {
|
||||||
|
if (typeof result.opts.from === 'undefined') {
|
||||||
|
result.opts.from = 'unknown.css';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tailwindcss,
|
||||||
|
autoprefixer,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
45
prisma/migrations/20250108142452_category_id/migration.sql
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `category` on the `Article` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `categoryId` to the `Article` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Article" DROP COLUMN "category",
|
||||||
|
ADD COLUMN "categoryId" INTEGER NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GalleryImage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"caption" TEXT NOT NULL,
|
||||||
|
"alt" TEXT NOT NULL,
|
||||||
|
"width" INTEGER NOT NULL,
|
||||||
|
"height" INTEGER NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"format" TEXT NOT NULL,
|
||||||
|
"articleId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "GalleryImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Article" ADD CONSTRAINT "Article_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GalleryImage" ADD CONSTRAINT "GalleryImage_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -20,44 +20,51 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Article {
|
model Article {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
excerpt String
|
excerpt String
|
||||||
content String
|
content String
|
||||||
category String
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
categoryId Int
|
||||||
city String
|
city String
|
||||||
coverImage String
|
coverImage String
|
||||||
readTime Int
|
readTime Int
|
||||||
likes Int @default(0)
|
likes Int @default(0)
|
||||||
dislikes Int @default(0)
|
dislikes Int @default(0)
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
author User @relation(fields: [authorId], references: [id])
|
author User @relation(fields: [authorId], references: [id])
|
||||||
authorId String
|
authorId String
|
||||||
gallery GalleryImage[]
|
gallery GalleryImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id Int @id
|
||||||
|
name String @unique
|
||||||
|
articles Article[]
|
||||||
|
}
|
||||||
|
|
||||||
model GalleryImage {
|
model GalleryImage {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
url String
|
url String
|
||||||
caption String
|
caption String
|
||||||
alt String
|
alt String
|
||||||
width Int
|
width Int
|
||||||
height Int
|
height Int
|
||||||
size Int
|
size Int
|
||||||
format String
|
format String
|
||||||
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||||
articleId String
|
articleId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserReaction {
|
model UserReaction {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
articleId String
|
articleId String
|
||||||
reaction String // 'like' or 'dislike'
|
reaction String // 'like' or 'dislike'
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([userId, articleId])
|
@@unique([userId, articleId])
|
||||||
}
|
}
|
||||||
|
16
prisma/seed.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
|
||||||
|
const execPromise = util.promisify(exec);
|
||||||
|
|
||||||
|
async function runSeed() {
|
||||||
|
try {
|
||||||
|
await execPromise('npx ts-node prisma/seed.ts');
|
||||||
|
console.log('Seeding completed successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing the seed script:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runSeed();
|
36
prisma/seed.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Данные для заполнения
|
||||||
|
const categories = [
|
||||||
|
{ id: 1, name: "Film" },
|
||||||
|
{ id: 2, name: "Theater" },
|
||||||
|
{ id: 3, name: "Music" },
|
||||||
|
{ id: 4, name: "Sports" },
|
||||||
|
{ id: 5, name: "Art" },
|
||||||
|
{ id: 6, name: "Legends" },
|
||||||
|
{ id: 7, name: "Anniversaries" },
|
||||||
|
{ id: 8, name: "Memory" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Заполнение данных
|
||||||
|
for (const category of categories) {
|
||||||
|
await prisma.category.upsert({
|
||||||
|
where: { id: category.id },
|
||||||
|
update: {},
|
||||||
|
create: category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Данные успешно добавлены!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
BIN
public/images/Logo-1.webp
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
public/images/Logo-2.webp
Normal file
After Width: | Height: | Size: 182 KiB |
BIN
public/images/Logo-3.png
Normal file
After Width: | Height: | Size: 903 KiB |
BIN
public/images/Logo-4.png
Normal file
After Width: | Height: | Size: 951 KiB |
BIN
public/images/avatar-man.webp
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/images/avatar-woman.webp
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
public/images/avatar.jpg
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
public/images/bg-film.webp
Normal file
After Width: | Height: | Size: 612 KiB |
BIN
public/images/bg-music.webp
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
public/images/bg-sport.webp
Normal file
After Width: | Height: | Size: 586 KiB |
BIN
public/images/bg-theatre.webp
Normal file
After Width: | Height: | Size: 321 KiB |
BIN
public/images/cover-placeholder.webp
Normal file
After Width: | Height: | Size: 141 KiB |
BIN
public/images/empty-cover.png
Normal file
After Width: | Height: | Size: 194 KiB |
BIN
public/images/film-bg.avif
Normal file
After Width: | Height: | Size: 429 KiB |
BIN
public/images/gpt_film.webp
Normal file
After Width: | Height: | Size: 467 KiB |
BIN
public/images/gpt_main-bg.webp
Normal file
After Width: | Height: | Size: 499 KiB |
BIN
public/images/gpt_theatre.webp
Normal file
After Width: | Height: | Size: 548 KiB |
BIN
public/images/main-bg-1.webp
Normal file
After Width: | Height: | Size: 435 KiB |
BIN
public/images/main-bg.jpg
Normal file
After Width: | Height: | Size: 790 KiB |
BIN
public/images/main-bg.png
Normal file
After Width: | Height: | Size: 338 KiB |
BIN
public/images/main-bg.webp
Normal file
After Width: | Height: | Size: 651 KiB |
23
public/images/odnoklassniki-blk.svg
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 94 94" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
|
||||||
|
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"/>
|
||||||
|
<path d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
|
||||||
|
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
|
||||||
|
C27.722,17.365,36.4,8.734,47.08,8.766z M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416
|
||||||
|
c-2.991,1.156-6.268,1.742-9.512,2.13c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27
|
||||||
|
c1.494,1.511,1.81,3.386,0.985,5.145c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582
|
||||||
|
c-3.324-3.345-6.711-6.627-9.965-10.031c-0.947-0.992-1.403-0.807-2.241,0.056c-3.343,3.442-6.738,6.831-10.155,10.2
|
||||||
|
c-1.535,1.514-3.36,1.785-5.143,0.922c-1.892-0.917-3.094-2.848-3.001-4.791c0.064-1.312,0.71-2.314,1.611-3.214
|
||||||
|
c4.356-4.351,8.702-8.713,13.05-13.072c0.289-0.288,0.557-0.597,0.976-1.045c-5.929-0.619-11.275-2.077-15.85-5.657
|
||||||
|
c-0.567-0.445-1.154-0.875-1.674-1.373c-2.002-1.924-2.203-4.125-0.618-6.396c1.354-1.942,3.632-2.464,5.997-1.349
|
||||||
|
c0.459,0.215,0.895,0.486,1.313,0.775c8.528,5.86,20.245,6.023,28.806,0.266c0.847-0.647,1.754-1.183,2.806-1.449
|
||||||
|
c2.045-0.525,3.947,0.224,5.045,2.012C70.314,51.496,70.297,53.488,68.753,55.072z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
24
public/images/odnoklassniki.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px"
|
||||||
|
height="800px" viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
|
||||||
|
<g id="7935ec95c421cee6d86eb22ecd128789">
|
||||||
|
|
||||||
|
<path style="display: inline;" d="M256.018,259.156c71.423,0,129.31-57.899,129.31-129.334C385.327,58.387,327.44,0.5,256.018,0.5
|
||||||
|
c-71.448,0-129.359,57.887-129.359,129.322C126.658,201.257,184.57,259.156,256.018,259.156z M256.018,66.196
|
||||||
|
c35.131,0,63.612,28.482,63.612,63.625c0,35.144-28.481,63.625-63.612,63.625c-35.168,0-63.638-28.481-63.638-63.625
|
||||||
|
C192.38,94.678,220.849,66.196,256.018,66.196z M405.075,274.938c-7.285-14.671-27.508-26.872-54.394-5.701
|
||||||
|
c-36.341,28.619-94.664,28.619-94.664,28.619s-58.361,0-94.702-28.619c-26.873-21.171-47.083-8.97-54.381,5.701
|
||||||
|
c-12.75,25.563,1.634,37.926,34.096,58.761c27.721,17.803,65.821,24.452,90.411,26.935l-20.535,20.535
|
||||||
|
c-28.918,28.905-56.826,56.838-76.201,76.213c-11.59,11.577-11.59,30.354,0,41.931l3.48,3.506c11.59,11.577,30.354,11.577,41.943,0
|
||||||
|
l76.201-76.214c28.943,28.919,56.851,56.839,76.225,76.214c11.59,11.577,30.354,11.577,41.943,0l3.48-3.506
|
||||||
|
c11.59-11.59,11.59-30.354,0-41.943l-76.201-76.2l-20.584-20.598c24.614-2.545,62.29-9.22,89.786-26.872
|
||||||
|
C403.441,312.863,417.801,300.5,405.075,274.938z">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
37
public/images/ok-11.svg
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
fill="black"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
>
|
||||||
|
<!-- Ãîëîâà -->
|
||||||
|
<circle cx="50" cy="30" r="10" fill="black" />
|
||||||
|
|
||||||
|
<!-- Ðóêè -->
|
||||||
|
<path
|
||||||
|
d="M30 50 Q50 70, 70 50"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Òåëî -->
|
||||||
|
<path
|
||||||
|
d="M50 40 L50 60"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Íîãè -->
|
||||||
|
<path
|
||||||
|
d="M40 75 L50 60 L60 75"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 609 B |
33
public/images/ok.svg
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<!-- Âíóòðåííèé êðóã -->
|
||||||
|
<path
|
||||||
|
d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
|
||||||
|
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"
|
||||||
|
fill="#FF9900"
|
||||||
|
/>
|
||||||
|
<!-- Âíåøíèé êðóã -->
|
||||||
|
<path
|
||||||
|
d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
|
||||||
|
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
|
||||||
|
C27.722,17.365,36.4,8.734,47.08,8.766z"
|
||||||
|
fill="#FF6600"
|
||||||
|
/>
|
||||||
|
<!-- Ëèíèè è òî÷êè -->
|
||||||
|
<path
|
||||||
|
d="M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416c-2.991,1.156-6.268,1.742-9.512,2.13
|
||||||
|
c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27c1.494,1.511,1.81,3.386,0.985,5.145
|
||||||
|
c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582c-3.324-3.345-6.711-6.627-9.965-10.031
|
||||||
|
c-0.947-0.992-1.403-0.841..."
|
||||||
|
fill="#FF3300"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/images/pack/anniversary-bn.webp
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
public/images/pack/art-bn.webp
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
public/images/pack/film-bn.webp
Normal file
After Width: | Height: | Size: 207 KiB |
BIN
public/images/pack/legend-bn.webp
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
public/images/pack/memory-bn.webp
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
public/images/pack/music-bn.webp
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
public/images/pack/sport-bn.webp
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
public/images/pack/theatre-bn.webp
Normal file
After Width: | Height: | Size: 49 KiB |
9
public/robots.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Disallow admin routes
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /admin/*
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: https://russcult.anibilag.ru/sitemap.xml
|
1
public/sitemap.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://russcult.anibilag.ru/</loc><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>https://russcult.anibilag.ru/search</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://russcult.anibilag.ru/article/054af0fc-25e4-4169-88f4-38952742eb64</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0d854fd6-f846-4b33-a27f-01a49198fc6e</loc><lastmod>2025-02-12T08:31:07.750Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0fa77c25-df32-482c-8e55-90cc5a9798fb</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/141303a4-8932-4d5a-b7d9-0600f6eea996</loc><lastmod>2025-02-13T20:08:04.036Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/1431c796-bcac-4fc9-9482-705268629a0e</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/22f74530-d68f-4d11-880e-f1beb42ddee8</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/248c18f7-6252-415f-baae-96ad215a6c03</loc><lastmod>2025-02-18T10:21:12.746Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/2ba5bceb-95b1-45e4-ab49-2c84ff181d7c</loc><lastmod>2025-02-19T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5093e84f-f3c3-4809-abe9-00b477e9efa9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5453d8a9-2af8-49f1-bbca-cb362908d975</loc><lastmod>2025-02-12T20:12:40.391Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/58da4b5a-be98-4433-b77e-413540813688</loc><lastmod>2025-02-12T20:21:38.395Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/92f695f6-0492-42d7-b14e-c5c270ab6c0a</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/ce8252e4-1e3f-4d10-9a48-334e48df29e9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/?category=Film</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Theater</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Music</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Sports</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Art</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Legends</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Anniversaries</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Memory</loc><changefreq>daily</changefreq><priority>0.9</priority></url></urlset>
|
62
scripts/generate-sitemap.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { SitemapStream, streamToPromise } from 'sitemap';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
async function generateSitemap() {
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000';
|
||||||
|
|
||||||
|
async function fetchArticlePaths() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/articles/sitemap/`);
|
||||||
|
return response.data.articles;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении путей статей:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = 'https://russcult.anibilag.ru'; // Replace with your actual domain
|
||||||
|
|
||||||
|
// Create a stream to write to
|
||||||
|
const stream = new SitemapStream({ hostname: baseUrl });
|
||||||
|
|
||||||
|
// Add static routes
|
||||||
|
const staticRoutes = [
|
||||||
|
{ url: '/', changefreq: 'daily', priority: 1.0 },
|
||||||
|
{ url: '/search', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const articles = await fetchArticlePaths();
|
||||||
|
|
||||||
|
// Add dynamic article routes
|
||||||
|
const articleRoutes = articles.map(article => ({
|
||||||
|
url: `/article/${article.id}`,
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.7,
|
||||||
|
lastmod: article.publishedAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add category routes
|
||||||
|
const categories = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||||
|
const categoryRoutes = categories.map(category => ({
|
||||||
|
url: `/?category=${category}`,
|
||||||
|
changefreq: 'daily',
|
||||||
|
priority: 0.9
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Combine all routes
|
||||||
|
const links = [...staticRoutes, ...articleRoutes, ...categoryRoutes];
|
||||||
|
|
||||||
|
// Create sitemap from routes
|
||||||
|
const sitemap = await streamToPromise(
|
||||||
|
Readable.from(links).pipe(stream)
|
||||||
|
).then(data => data.toString());
|
||||||
|
|
||||||
|
// Write sitemap to public directory
|
||||||
|
writeFileSync('./public/sitemap.xml', sitemap);
|
||||||
|
console.log('Sitemap generated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSitemap().catch(console.error);
|
BIN
server.zip
@ -1,78 +0,0 @@
|
|||||||
import * as winston from 'winston';
|
|
||||||
import 'winston-daily-rotate-file';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Define log levels
|
|
||||||
const levels = {
|
|
||||||
error: 0,
|
|
||||||
warn: 1,
|
|
||||||
info: 2,
|
|
||||||
http: 3,
|
|
||||||
debug: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define log level based on environment
|
|
||||||
const level = () => {
|
|
||||||
const env = process.env.NODE_ENV || 'development';
|
|
||||||
return env === 'development' ? 'debug' : 'warn';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define colors for each level
|
|
||||||
const colors = {
|
|
||||||
error: 'red',
|
|
||||||
warn: 'yellow',
|
|
||||||
info: 'green',
|
|
||||||
http: 'magenta',
|
|
||||||
debug: 'blue',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add colors to winston
|
|
||||||
winston.addColors(colors);
|
|
||||||
|
|
||||||
// Custom format for logging
|
|
||||||
const format = winston.format.combine(
|
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
|
||||||
winston.format.colorize({ all: true }),
|
|
||||||
winston.format.printf(
|
|
||||||
(info) => `${info.timestamp} ${info.level}: ${info.message}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Define file transport options
|
|
||||||
const fileRotateTransport = new winston.transports.DailyRotateFile({
|
|
||||||
filename: path.join('logs', '%DATE%-server.log'),
|
|
||||||
datePattern: 'YYYY-MM-DD',
|
|
||||||
zippedArchive: true,
|
|
||||||
maxSize: '20m',
|
|
||||||
maxFiles: '14d',
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.uncolorize(),
|
|
||||||
winston.format.timestamp(),
|
|
||||||
winston.format.json()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the logger
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: level(),
|
|
||||||
levels,
|
|
||||||
format,
|
|
||||||
transports: [
|
|
||||||
new winston.transports.Console({
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.colorize(),
|
|
||||||
winston.format.simple()
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
fileRotateTransport,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a stream object for Morgan middleware
|
|
||||||
const stream = {
|
|
||||||
write: (message: string) => {
|
|
||||||
logger.http(message.trim());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export { logger, stream };
|
|
@ -1,26 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import authRoutes from './routes/auth.js';
|
|
||||||
import articleRoutes from './routes/articles';
|
|
||||||
import userRoutes from './routes/users.js';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/articles', articleRoutes);
|
|
||||||
app.use('/api/users', userRoutes);
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5000;
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
@ -1,45 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { logger, stream } from './config/logger.js';
|
|
||||||
import { requestLogger } from './middleware/logging/requestLogger.js';
|
|
||||||
import { errorLogger } from './middleware/error/errorLogger.js';
|
|
||||||
import authRoutes from './routes/auth/index.js';
|
|
||||||
import articleRoutes from './routes/articles/index.js';
|
|
||||||
import userRoutes from './routes/users/index.js';
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(requestLogger);
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/articles', articleRoutes);
|
|
||||||
app.use('/api/users', userRoutes);
|
|
||||||
|
|
||||||
// Error handling
|
|
||||||
app.use(errorLogger);
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5000;
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
logger.info(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
logger.error('Uncaught Exception:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle unhandled promise rejections
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
@ -1,29 +0,0 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
|
||||||
import { AuthRequest } from './types.js';
|
|
||||||
import { extractToken } from './extractToken.js';
|
|
||||||
import { validateToken } from './validateToken.js';
|
|
||||||
import { getUser } from './getUser.js';
|
|
||||||
|
|
||||||
export async function auth(req: AuthRequest, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = validateToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(payload.id);
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = user;
|
|
||||||
next();
|
|
||||||
} catch {
|
|
||||||
res.status(401).json({ error: 'Authentication failed' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
export function extractToken(req: Request): string | null {
|
|
||||||
const authHeader = req.header('Authorization');
|
|
||||||
if (!authHeader) return null;
|
|
||||||
|
|
||||||
const [bearer, token] = authHeader.split(' ');
|
|
||||||
if (bearer !== 'Bearer' || !token) return null;
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { User } from '../../../src/types/auth.js';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function getUser(userId: string): Promise<User | null> {
|
|
||||||
try {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
displayName: true,
|
|
||||||
permissions: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return user as User | null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export { auth } from './auth.js';
|
|
||||||
export { extractToken } from './extractToken.js';
|
|
||||||
export { validateToken } from './validateToken.js';
|
|
||||||
export { getUser } from './getUser.js';
|
|
||||||
export * from './types.js';
|
|
@ -1,12 +0,0 @@
|
|||||||
import { Request } from 'express';
|
|
||||||
import { User } from '../../../src/types/auth.js';
|
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
|
||||||
id: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import { JwtPayload } from './types.js';
|
|
||||||
|
|
||||||
export function validateToken(token: string): JwtPayload | null {
|
|
||||||
try {
|
|
||||||
return jwt.verify(token, process.env.JWT_SECRET || '') as JwtPayload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
|
|
||||||
export interface AppError extends Error {
|
|
||||||
statusCode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorHandler(
|
|
||||||
err: AppError,
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
const statusCode = err.statusCode || 500;
|
|
||||||
const message = err.message || 'Internal Server Error';
|
|
||||||
|
|
||||||
res.status(statusCode).json({
|
|
||||||
error: message,
|
|
||||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import { logger } from '../../config/logger.js';
|
|
||||||
|
|
||||||
export interface AppError extends Error {
|
|
||||||
statusCode?: number;
|
|
||||||
details?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const errorLogger = (
|
|
||||||
err: AppError,
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) => {
|
|
||||||
const errorDetails = {
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
statusCode: err.statusCode || 500,
|
|
||||||
details: err.details,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.error('Application error:', errorDetails);
|
|
||||||
next(err);
|
|
||||||
};
|
|
@ -1,21 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import { logger } from '../../config/logger.js';
|
|
||||||
|
|
||||||
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
|
|
||||||
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
logger.error(message);
|
|
||||||
} else if (res.statusCode >= 400) {
|
|
||||||
logger.warn(message);
|
|
||||||
} else {
|
|
||||||
logger.info(message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
@ -1,17 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import { Schema } from 'zod';
|
|
||||||
|
|
||||||
export function validateRequest(schema: Schema) {
|
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
await schema.parseAsync({
|
|
||||||
body: req.body,
|
|
||||||
query: req.query,
|
|
||||||
params: req.params
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({ error: 'Invalid request data' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,123 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../../src/lib/prisma';
|
|
||||||
import { AuthRequest } from '../../../middleware/auth';
|
|
||||||
import { checkPermission } from '../../../utils/permissions.js';
|
|
||||||
|
|
||||||
export async function getArticle(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const article = await prisma.article.findUnique({
|
|
||||||
where: { id: req.params.id },
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return res.status(404).json({ error: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(article);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createArticle(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
|
|
||||||
|
|
||||||
if (!req.user || !checkPermission(req.user, category, 'create')) {
|
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const article = await prisma.article.create({
|
|
||||||
data: {
|
|
||||||
title,
|
|
||||||
excerpt,
|
|
||||||
content,
|
|
||||||
category,
|
|
||||||
city,
|
|
||||||
coverImage,
|
|
||||||
readTime,
|
|
||||||
authorId: req.user.id
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(article);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateArticle(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const article = await prisma.article.findUnique({
|
|
||||||
where: { id: req.params.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return res.status(404).json({ error: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.user || !checkPermission(req.user, article.category, 'edit')) {
|
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedArticle = await prisma.article.update({
|
|
||||||
where: { id: req.params.id },
|
|
||||||
data: req.body,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(updatedArticle);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteArticle(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const article = await prisma.article.findUnique({
|
|
||||||
where: { id: req.params.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return res.status(404).json({ error: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.user || !checkPermission(req.user, article.category, 'delete')) {
|
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.article.delete({
|
|
||||||
where: { id: req.params.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Article deleted successfully' });
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../../src/lib/prisma';
|
|
||||||
|
|
||||||
export async function listArticles(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { page = 1, category, city } = req.query;
|
|
||||||
const perPage = 6;
|
|
||||||
|
|
||||||
const where = {
|
|
||||||
...(category && { category: category as string }),
|
|
||||||
...(city && { city: city as string })
|
|
||||||
};
|
|
||||||
|
|
||||||
const [articles, total] = await Promise.all([
|
|
||||||
prisma.article.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
skip: ((page as number) - 1) * perPage,
|
|
||||||
take: perPage,
|
|
||||||
orderBy: { publishedAt: 'desc' }
|
|
||||||
}),
|
|
||||||
prisma.article.count({ where })
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
articles,
|
|
||||||
totalPages: Math.ceil(total / perPage),
|
|
||||||
currentPage: parseInt(page as string)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../../src/lib/prisma';
|
|
||||||
|
|
||||||
export async function searchArticles(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { q, page = 1, limit = 9 } = req.query;
|
|
||||||
const skip = ((page as number) - 1) * (limit as number);
|
|
||||||
|
|
||||||
const where = {
|
|
||||||
OR: [
|
|
||||||
{ title: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
{ excerpt: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
{ content: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const [articles, total] = await Promise.all([
|
|
||||||
prisma.article.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
skip,
|
|
||||||
take: parseInt(limit as string),
|
|
||||||
orderBy: { publishedAt: 'desc' }
|
|
||||||
}),
|
|
||||||
prisma.article.count({ where })
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
articles,
|
|
||||||
totalPages: Math.ceil(total / (limit as number)),
|
|
||||||
currentPage: parseInt(page as string)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../src/lib/prisma';
|
|
||||||
|
|
||||||
export async function getArticle(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const article = await prisma.article.findUnique({
|
|
||||||
where: { id: req.params.id },
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return res.status(404).json({ error: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(article);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createArticle(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
|
|
||||||
|
|
||||||
const article = await prisma.article.create({
|
|
||||||
data: {
|
|
||||||
title,
|
|
||||||
excerpt,
|
|
||||||
content,
|
|
||||||
category,
|
|
||||||
city,
|
|
||||||
coverImage,
|
|
||||||
readTime,
|
|
||||||
authorId: req.user!.id
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(article);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateArticle(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const article = await prisma.article.update({
|
|
||||||
where: { id: req.params.id },
|
|
||||||
data: req.body,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(article);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteArticle(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
await prisma.article.delete({
|
|
||||||
where: { id: req.params.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Article deleted successfully' });
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { auth } from '../../middleware/auth';
|
|
||||||
import { searchArticles } from './controllers/search.js';
|
|
||||||
import { listArticles } from './controllers/list.js';
|
|
||||||
import { getArticle, createArticle, updateArticle, deleteArticle } from './controllers/crud.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Search and list routes
|
|
||||||
router.get('/search', searchArticles);
|
|
||||||
router.get('/', listArticles);
|
|
||||||
|
|
||||||
// CRUD routes
|
|
||||||
router.get('/:id', getArticle);
|
|
||||||
router.post('/', auth, createArticle);
|
|
||||||
router.put('/:id', auth, updateArticle);
|
|
||||||
router.delete('/:id', auth, deleteArticle);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,41 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../src/lib/prisma';
|
|
||||||
|
|
||||||
export async function listArticles(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { page = 1, category, city } = req.query;
|
|
||||||
const perPage = 6;
|
|
||||||
|
|
||||||
const where = {
|
|
||||||
...(category && { category: category as string }),
|
|
||||||
...(city && { city: city as string })
|
|
||||||
};
|
|
||||||
|
|
||||||
const [articles, total] = await Promise.all([
|
|
||||||
prisma.article.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
skip: ((page as number) - 1) * perPage,
|
|
||||||
take: perPage,
|
|
||||||
orderBy: { publishedAt: 'desc' }
|
|
||||||
}),
|
|
||||||
prisma.article.count({ where })
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
articles,
|
|
||||||
totalPages: Math.ceil(total / perPage),
|
|
||||||
currentPage: parseInt(page as string)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { prisma } from '../../../src/lib/prisma';
|
|
||||||
|
|
||||||
export async function searchArticles(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { q, page = 1, limit = 9 } = req.query;
|
|
||||||
const skip = ((page as number) - 1) * (limit as number);
|
|
||||||
|
|
||||||
const where = {
|
|
||||||
OR: [
|
|
||||||
{ title: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
{ excerpt: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
{ content: { contains: q as string, mode: 'insensitive' } },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const [articles, total] = await Promise.all([
|
|
||||||
prisma.article.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
skip,
|
|
||||||
take: parseInt(limit as string),
|
|
||||||
orderBy: { publishedAt: 'desc' }
|
|
||||||
}),
|
|
||||||
prisma.article.count({ where })
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
articles,
|
|
||||||
totalPages: Math.ceil(total / (limit as number)),
|
|
||||||
currentPage: parseInt(page as string)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { authService } from '../services/authService.js';
|
|
||||||
import { auth } from '../middleware/auth';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Login route
|
|
||||||
router.post('/login', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
const { user, token } = await authService.login(email, password);
|
|
||||||
res.json({ user, token });
|
|
||||||
} catch {
|
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get current user route
|
|
||||||
router.get('/me', auth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
res.json(req.user);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,36 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { AuthRequest } from '../../../middleware/auth';
|
|
||||||
import { authService } from '../../../services/authService.js';
|
|
||||||
|
|
||||||
export async function login(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
const { user, token } = await authService.login(email, password);
|
|
||||||
res.json({ user, token });
|
|
||||||
} catch {
|
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCurrentUser(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
|
||||||
}
|
|
||||||
res.json(req.user);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshToken(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
|
||||||
}
|
|
||||||
const token = await authService.generateToken(req.user.id);
|
|
||||||
res.json({ token });
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Failed to refresh token' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { auth } from '../../middleware/auth';
|
|
||||||
import { login, getCurrentUser } from './controllers/auth.js';
|
|
||||||
import { validateRequest } from '../../middleware/validation/validateRequest.js';
|
|
||||||
import { loginSchema } from './validation/authSchemas.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post('/login', validateRequest(loginSchema), login);
|
|
||||||
router.get('/me', auth, getCurrentUser);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,8 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
|
||||||
body: z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(6)
|
|
||||||
})
|
|
||||||
});
|
|
@ -1,81 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { AuthRequest } from '../../../middleware/auth/types.js';
|
|
||||||
import { galleryService } from '../../../services/galleryService.js';
|
|
||||||
import { s3Service } from '../../../services/s3Service.js';
|
|
||||||
import { logger } from '../../../config/logger.js';
|
|
||||||
|
|
||||||
export async function createGalleryImage(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const { articleId } = req.params;
|
|
||||||
const { url, caption, alt, width, height, size, format } = req.body;
|
|
||||||
|
|
||||||
const image = await galleryService.createImage({
|
|
||||||
url,
|
|
||||||
caption,
|
|
||||||
alt,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
size,
|
|
||||||
format,
|
|
||||||
articleId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(image);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating gallery image:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to create gallery image' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateGalleryImage(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { caption, alt, order } = req.body;
|
|
||||||
|
|
||||||
const image = await galleryService.updateImage(id, {
|
|
||||||
caption,
|
|
||||||
alt,
|
|
||||||
order
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(image);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating gallery image:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update gallery image' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteGalleryImage(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
await galleryService.deleteImage(id);
|
|
||||||
res.json({ message: 'Gallery image deleted successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error deleting gallery image:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to delete gallery image' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reorderGalleryImages(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const { articleId } = req.params;
|
|
||||||
const { imageIds } = req.body;
|
|
||||||
|
|
||||||
await galleryService.reorderImages(articleId, imageIds);
|
|
||||||
res.json({ message: 'Gallery images reordered successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error reordering gallery images:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to reorder gallery images' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArticleGallery(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { articleId } = req.params;
|
|
||||||
const images = await galleryService.getArticleGallery(articleId);
|
|
||||||
res.json(images);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching article gallery:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch gallery images' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { auth } from '../../middleware/auth';
|
|
||||||
import {
|
|
||||||
createGalleryImage,
|
|
||||||
updateGalleryImage,
|
|
||||||
deleteGalleryImage,
|
|
||||||
reorderGalleryImages,
|
|
||||||
getArticleGallery
|
|
||||||
} from './controllers/crud.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/article/:articleId', getArticleGallery);
|
|
||||||
router.post('/article/:articleId', auth, createGalleryImage);
|
|
||||||
router.put('/:id', auth, updateGalleryImage);
|
|
||||||
router.delete('/:id', auth, deleteGalleryImage);
|
|
||||||
router.post('/article/:articleId/reorder', auth, reorderGalleryImages);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,58 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import multer from 'multer';
|
|
||||||
import { auth } from '../../middleware/auth';
|
|
||||||
import { s3Service } from '../../services/s3Service.js';
|
|
||||||
import { logger } from '../../config/logger.js';
|
|
||||||
import { imageResolutions } from '../../../src/config/imageResolutions.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
const upload = multer();
|
|
||||||
|
|
||||||
router.post('/upload-url', auth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { fileName, fileType, resolution } = req.body;
|
|
||||||
|
|
||||||
const selectedResolution = imageResolutions.find(r => r.id === resolution);
|
|
||||||
if (!selectedResolution) {
|
|
||||||
throw new Error('Invalid resolution');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uploadUrl, imageId, key } = await s3Service.getUploadUrl(fileName, fileType);
|
|
||||||
|
|
||||||
logger.info(`Generated upload URL for image: ${fileName}`);
|
|
||||||
res.json({ uploadUrl, imageId, key });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error generating upload URL:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to generate upload URL' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/process', auth, upload.single('image'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { file } = req;
|
|
||||||
const { resolution } = req.body;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
throw new Error('No file uploaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedResolution = imageResolutions.find(r => r.id === resolution);
|
|
||||||
if (!selectedResolution) {
|
|
||||||
throw new Error('Invalid resolution');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await s3Service.optimizeAndUpload(
|
|
||||||
file.buffer,
|
|
||||||
file.originalname,
|
|
||||||
selectedResolution
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Successfully processed image: ${file.originalname}`);
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error processing image:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to process image' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,35 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { userService } from '../services/userService.js';
|
|
||||||
import { auth } from '../middleware/auth';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
import { User } from '../../src/types/auth.ts';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', auth, async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.user?.permissions.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
const users = await userService.getUsers();
|
|
||||||
res.json(users);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id/permissions', auth, async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.user?.permissions.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
const { id } = req.params;
|
|
||||||
const { permissions } = req.body;
|
|
||||||
const user = await userService.updateUserPermissions(id, permissions);
|
|
||||||
res.json(user);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,29 +0,0 @@
|
|||||||
import { Response } from 'express';
|
|
||||||
import { AuthRequest } from '../../../middleware/auth';
|
|
||||||
import { userService } from '../../../services/userService.js';
|
|
||||||
|
|
||||||
export async function getUsers(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
if (!req.user?.permissions.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
const users = await userService.getUsers();
|
|
||||||
res.json(users);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUserPermissions(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
if (!req.user?.permissions.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
const { id } = req.params;
|
|
||||||
const { permissions } = req.body;
|
|
||||||
const user = await userService.updateUserPermissions(id, permissions);
|
|
||||||
res.json(user);
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { auth } from '../../middleware/auth';
|
|
||||||
import { getUsers, updateUserPermissions } from './controllers/users.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', auth, getUsers);
|
|
||||||
router.put('/:id/permissions', auth, updateUserPermissions);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,95 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import { User } from '../../src/types/auth.js';
|
|
||||||
import { logger } from '../config/logger.js';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export const authService = {
|
|
||||||
login: async (email: string, password: string) => {
|
|
||||||
try {
|
|
||||||
logger.info(`Login attempt for user: ${email}`);
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
password: true,
|
|
||||||
displayName: true,
|
|
||||||
permissions: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(`Login failed: User not found - ${email}`);
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
|
||||||
if (!isValidPassword) {
|
|
||||||
logger.warn(`Login failed: Invalid password for user - ${email}`);
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await authService.generateToken(user.id);
|
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
|
||||||
|
|
||||||
logger.info(`User logged in successfully: ${email}`);
|
|
||||||
return {
|
|
||||||
user: userWithoutPassword as User,
|
|
||||||
token
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Login error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
generateToken: async (userId: string) => {
|
|
||||||
try {
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ id: userId },
|
|
||||||
process.env.JWT_SECRET || '',
|
|
||||||
{ expiresIn: '24h' }
|
|
||||||
);
|
|
||||||
logger.debug(`Generated token for user: ${userId}`);
|
|
||||||
return token;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Token generation error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createUser: async (userData: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
displayName: string;
|
|
||||||
permissions: any;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
logger.info(`Creating new user: ${userData.email}`);
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(userData.password, 10);
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
...userData,
|
|
||||||
password: hashedPassword
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
displayName: true,
|
|
||||||
permissions: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`User created successfully: ${userData.email}`);
|
|
||||||
return user as User;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('User creation error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,92 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { logger } from '../config/logger.js';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export const galleryService = {
|
|
||||||
createImage: async (data: {
|
|
||||||
url: string;
|
|
||||||
caption: string;
|
|
||||||
alt: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
size: number;
|
|
||||||
format: string;
|
|
||||||
articleId: string;
|
|
||||||
order?: number;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const image = await prisma.galleryImage.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
logger.info(`Created gallery image: ${image.id}`);
|
|
||||||
return image;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating gallery image:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateImage: async (
|
|
||||||
id: string,
|
|
||||||
data: {
|
|
||||||
caption?: string;
|
|
||||||
alt?: string;
|
|
||||||
order?: number;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const image = await prisma.galleryImage.update({
|
|
||||||
where: { id },
|
|
||||||
data
|
|
||||||
});
|
|
||||||
logger.info(`Updated gallery image: ${id}`);
|
|
||||||
return image;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error updating gallery image ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteImage: async (id: string) => {
|
|
||||||
try {
|
|
||||||
await prisma.galleryImage.delete({
|
|
||||||
where: { id }
|
|
||||||
});
|
|
||||||
logger.info(`Deleted gallery image: ${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error deleting gallery image ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reorderImages: async (articleId: string, imageIds: string[]) => {
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(
|
|
||||||
imageIds.map((id, index) =>
|
|
||||||
prisma.galleryImage.update({
|
|
||||||
where: { id },
|
|
||||||
data: { order: index }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
logger.info(`Reordered gallery images for article: ${articleId}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error reordering gallery images for article ${articleId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getArticleGallery: async (articleId: string) => {
|
|
||||||
try {
|
|
||||||
const images = await prisma.galleryImage.findMany({
|
|
||||||
where: { articleId },
|
|
||||||
orderBy: { order: 'asc' }
|
|
||||||
});
|
|
||||||
return images;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error fetching gallery for article ${articleId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,81 +0,0 @@
|
|||||||
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import { logger } from '../config/logger.js';
|
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: process.env.AWS_REGION || 'us-east-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
|
|
||||||
|
|
||||||
export const s3Service = {
|
|
||||||
getUploadUrl: async (fileName: string, fileType: string) => {
|
|
||||||
const imageId = uuidv4();
|
|
||||||
const key = `uploads/${imageId}-${fileName}`;
|
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: key,
|
|
||||||
ContentType: fileType
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
|
||||||
logger.info(`Generated pre-signed URL for upload: ${key}`);
|
|
||||||
return { uploadUrl, imageId, key };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error generating pre-signed URL:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
optimizeAndUpload: async (buffer: Buffer, key: string, resolution: { width: number; height: number }) => {
|
|
||||||
try {
|
|
||||||
let sharpInstance = sharp(buffer);
|
|
||||||
|
|
||||||
// Get image metadata
|
|
||||||
const metadata = await sharpInstance.metadata();
|
|
||||||
|
|
||||||
// Resize if resolution is specified
|
|
||||||
if (resolution.width > 0 && resolution.height > 0) {
|
|
||||||
sharpInstance = sharpInstance.resize(resolution.width, resolution.height, {
|
|
||||||
fit: 'inside',
|
|
||||||
withoutEnlargement: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to WebP for better compression
|
|
||||||
const optimizedBuffer = await sharpInstance
|
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
// Upload optimized image
|
|
||||||
const optimizedKey = key.replace(/\.[^/.]+$/, '.webp');
|
|
||||||
await s3Client.send(new PutObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: optimizedKey,
|
|
||||||
Body: optimizedBuffer,
|
|
||||||
ContentType: 'image/webp'
|
|
||||||
}));
|
|
||||||
|
|
||||||
logger.info(`Successfully optimized and uploaded image: ${optimizedKey}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: optimizedKey,
|
|
||||||
width: metadata.width,
|
|
||||||
height: metadata.height,
|
|
||||||
format: 'webp',
|
|
||||||
size: optimizedBuffer.length
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error optimizing and uploading image:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,44 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { User } from '../../src/types/auth';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export const userService = {
|
|
||||||
getUsers: async (): Promise<User[]> => {
|
|
||||||
try {
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
displayName: true,
|
|
||||||
permissions: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return users as User[];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching users:', error);
|
|
||||||
throw new Error('Failed to fetch users');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
|
|
||||||
try {
|
|
||||||
const updatedUser = await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
permissions: permissions as any
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
displayName: true,
|
|
||||||
permissions: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return updatedUser as User;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating user permissions:', error);
|
|
||||||
throw new Error('Failed to update user permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,16 +0,0 @@
|
|||||||
import { Category, City } from '../../src/types';
|
|
||||||
import { User } from '../../src/types/auth';
|
|
||||||
|
|
||||||
export const checkPermission = (
|
|
||||||
user: User,
|
|
||||||
category: Category,
|
|
||||||
action: 'create' | 'edit' | 'delete'
|
|
||||||
): boolean => {
|
|
||||||
if (user.permissions.isAdmin) return true;
|
|
||||||
return !!user.permissions.categories[category]?.[action];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkCityAccess = (user: User, city: City): boolean => {
|
|
||||||
if (user.permissions.isAdmin) return true;
|
|
||||||
return user.permissions.cities.includes(city);
|
|
||||||
};
|
|
23
src/App.tsx
@ -7,10 +7,15 @@ import { ArticlePage } from './pages/ArticlePage';
|
|||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { UserManagementPage } from './pages/UserManagementPage';
|
import { UserManagementPage } from './pages/UserManagementPage';
|
||||||
|
import { AuthorManagementPage } from './pages/AuthorManagementPage';
|
||||||
import { SearchPage } from './pages/SearchPage';
|
import { SearchPage } from './pages/SearchPage';
|
||||||
import { BookmarksPage } from './pages/BookmarksPage';
|
import { BookmarksPage } from './pages/BookmarksPage';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
import { AuthGuard } from './components/AuthGuard';
|
import { AuthGuard } from './components/AuthGuard';
|
||||||
|
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
||||||
|
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { setUser, setLoading } = useAuthStore();
|
const { setUser, setLoading } = useAuthStore();
|
||||||
@ -19,7 +24,7 @@ function App() {
|
|||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios.get('http://localhost:5000/api/auth/me', {
|
axios.get(`${API_URL}/api/auth/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@ -63,6 +68,22 @@ function App() {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/authors"
|
||||||
|
element={
|
||||||
|
<AuthGuard>
|
||||||
|
<AuthorManagementPage />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/import"
|
||||||
|
element={
|
||||||
|
<AuthGuard>
|
||||||
|
<ImportArticlesPage />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
BIN
src/assets/images/film-bg.avif
Normal file
After Width: | Height: | Size: 429 KiB |
@ -1,6 +1,8 @@
|
|||||||
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
|
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Article } from '../types';
|
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||||
|
import MinutesWord from './Words/MinutesWord';
|
||||||
|
import { useScrollStore } from '../stores/scrollStore';
|
||||||
|
|
||||||
interface ArticleCardProps {
|
interface ArticleCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
@ -8,58 +10,92 @@ interface ArticleCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const setHomeScrollPosition = useScrollStore(state => state.setHomeScrollPosition);
|
||||||
|
|
||||||
|
const writerAuthors = article.authors
|
||||||
|
.filter(a => a.role === 'WRITER')
|
||||||
|
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||||
|
|
||||||
|
const handleArticleClick = () => {
|
||||||
|
// Сохранить текущее положение скролинга при переходе к статье
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
setHomeScrollPosition(window.scrollY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||||
featured ? 'col-span-2 row-span-2' : ''
|
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||||
}`}>
|
} flex flex-col`}>
|
||||||
<div className="relative">
|
<div className="relative pt-7">
|
||||||
<img
|
<img
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
className={`w-full object-cover ${featured ? 'h-64 sm:h-96' : 'h-64'}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-4 left-4 flex gap-2">
|
<div className="absolute bottom-0 left-0 w-full flex justify-between items-center z-10 px-4 py-2 bg-gradient-to-t from-black/70 to-transparent">
|
||||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
<span className="text-white text-sm md:text-base font-medium">
|
||||||
{article.category}
|
{CategoryTitles[article.categoryId]}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
<span className="text-white text-sm md:text-base font-medium flex items-center">
|
||||||
<MapPin size={14} className="mr-1" />
|
<MapPin size={14} className="mr-1 text-white" />
|
||||||
{article.city}
|
{CityTitles[article.cityId]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-6 flex flex-col flex-grow">
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<img
|
{writerAuthors.map((authorLink) => (
|
||||||
src={article.author.avatar}
|
<img
|
||||||
alt={article.author.name}
|
key={authorLink.author.id}
|
||||||
className="w-10 h-10 rounded-full"
|
src={authorLink.author.avatarUrl}
|
||||||
/>
|
alt={authorLink.author.displayName}
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{article.author.name}</p>
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{writerAuthors.map((a, i) => (
|
||||||
|
<span key={a.author.id}>
|
||||||
|
{a.author.displayName}
|
||||||
|
{i < writerAuthors.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
<div className="flex items-center text-sm text-gray-500">
|
||||||
<Clock size={14} className="mr-1" />
|
<Clock size={14} className="mr-1" />
|
||||||
{article.readTime} min read
|
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
|
||||||
|
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className={`font-bold text-gray-900 mb-2 ${featured ? 'text-2xl' : 'text-xl'}`}>
|
<h2 className={`font-bold text-gray-900 mb-2 ${featured ? 'text-xl sm:text-2xl' : 'text-xl'}`}>
|
||||||
{article.title}
|
{article.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
|
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="p-4 mt-auto flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to={`/article/${article.id}`}
|
to={`/article/${article.id}`}
|
||||||
|
state={{ from: location.pathname + location.search }}
|
||||||
|
onClick={handleArticleClick}
|
||||||
className="text-blue-600 font-medium hover:text-blue-800"
|
className="text-blue-600 font-medium hover:text-blue-800"
|
||||||
>
|
>
|
||||||
Read More →
|
Читать →
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center text-gray-500">
|
<div className="flex items-center text-gray-500">
|
||||||
<ThumbsUp size={16} className="mr-1" />
|
<ThumbsUp size={16} className="mr-1" />
|
||||||
<span>{article.likes}</span>
|
<span>{article.likes}</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
<ThumbsDown size={16} className="mr-1" />
|
||||||
|
</span>
|
||||||
|
<span>{article.dislikes}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
49
src/components/ArticleContent.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Blockquote from '@tiptap/extension-blockquote';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import { CustomImage } from './CustomImageExtension';
|
||||||
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
|
|
||||||
|
|
||||||
|
interface ArticleContentProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ контента статьи в редакторе, в режиме "только чтение"
|
||||||
|
export function ArticleContent({ content }: ArticleContentProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
blockquote: false, // Отключаем дефолтный
|
||||||
|
}),
|
||||||
|
Blockquote.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
|
CustomImage,
|
||||||
|
Highlight,
|
||||||
|
Link.configure({ // Добавляем расширение Link
|
||||||
|
openOnClick: false, // Отключаем автоматическое открытие ссылок при клике (удобно для редактирования)
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'text-blue-500 underline', // Стили для ссылок
|
||||||
|
target: '_blank', // Открывать в новой вкладке
|
||||||
|
rel: 'noopener noreferrer', // Безопасность
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable: false, // Контент только для чтения
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-lg max-w-none mb-8">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
29
src/components/ArticleDeleteModal.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
interface DeleteModalProps {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleDeleteModal({ onConfirm, onCancel }: DeleteModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Удалить статью</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">Вы уверены? Это действие нельзя отменить.</p>
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
552
src/components/ArticleForm.tsx
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import { TipTapEditor } from './Editor/TipTapEditor';
|
||||||
|
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
|
||||||
|
import { ImageUploader } from './ImageUpload/ImageUploader';
|
||||||
|
import { GalleryManager } from './GalleryManager';
|
||||||
|
import { useGallery } from '../hooks/useGallery';
|
||||||
|
import { ArticleData, Author, AuthorLink, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import RolesWord from "./Words/RolesWord";
|
||||||
|
import { Trash2, UserPlus } from "lucide-react";
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
|
|
||||||
|
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
||||||
|
WRITER: 'Автор статьи',
|
||||||
|
PHOTOGRAPHER: 'Фотограф',
|
||||||
|
EDITOR: 'Редактор',
|
||||||
|
TRANSLATOR: 'Переводчик',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
categoryId: number;
|
||||||
|
cityId: number;
|
||||||
|
coverImage: string;
|
||||||
|
readTime: number;
|
||||||
|
content: string;
|
||||||
|
authors: {
|
||||||
|
role: AuthorRole;
|
||||||
|
author: Author;
|
||||||
|
}[];
|
||||||
|
galleryImages: GalleryImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleFormProps {
|
||||||
|
editingId: string | null;
|
||||||
|
articleId: string;
|
||||||
|
initialFormState: FormState | null;
|
||||||
|
onSubmit: (articleData: ArticleData, closeForm: boolean) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
authors: Author[];
|
||||||
|
availableCategoryIds: number[];
|
||||||
|
availableCityIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleForm({
|
||||||
|
editingId,
|
||||||
|
articleId,
|
||||||
|
initialFormState,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
authors,
|
||||||
|
availableCategoryIds,
|
||||||
|
availableCityIds,
|
||||||
|
}: ArticleFormProps) {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const isAdmin = user?.permissions.isAdmin || false;
|
||||||
|
const showGallery = false;
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [excerpt, setExcerpt] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState(availableCategoryIds[0] || 1);
|
||||||
|
const [cityId, setCityId] = useState(availableCityIds[0] || 1);
|
||||||
|
const [coverImage, setCoverImage] = useState('/images/cover-placeholder.webp');
|
||||||
|
const [readTime, setReadTime] = useState(5);
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
|
||||||
|
const [formNewImageUrl, setFormNewImageUrl] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // Добавляем флаг для отслеживания отправки
|
||||||
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна
|
||||||
|
|
||||||
|
const [newRole, setNewRole] = useState('');
|
||||||
|
const [newAuthorId, setNewAuthorId] = useState('');
|
||||||
|
const [showAddAuthorModal, setShowAddAuthorModal] = useState(false);
|
||||||
|
const [formReady, setFormReady] = useState(false);
|
||||||
|
|
||||||
|
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
|
||||||
|
|
||||||
|
const [selectedAuthors, setSelectedAuthors] = useState<AuthorLink[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialFormState) setFormReady(true);
|
||||||
|
}, [initialFormState]);
|
||||||
|
|
||||||
|
// Добавляем обработку ошибок
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
console.error('Unhandled promise rejection in ArticleForm:', event.reason);
|
||||||
|
event.preventDefault(); // Предотвращаем "всплытие" ошибки
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (event: ErrorEvent) => {
|
||||||
|
console.error('Unhandled error in ArticleForm:', event.error);
|
||||||
|
event.preventDefault(); // Предотвращаем "всплытие" ошибки
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
window.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
window.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingId) {
|
||||||
|
setDisplayedImages(galleryImages);
|
||||||
|
} else {
|
||||||
|
setDisplayedImages([]);
|
||||||
|
}
|
||||||
|
}, [editingId, galleryImages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialFormState) {
|
||||||
|
setTitle(initialFormState.title);
|
||||||
|
setExcerpt(initialFormState.excerpt);
|
||||||
|
setCategoryId(initialFormState.categoryId);
|
||||||
|
setCityId(initialFormState.cityId);
|
||||||
|
setCoverImage(initialFormState.coverImage);
|
||||||
|
setReadTime(initialFormState.readTime);
|
||||||
|
setContent(initialFormState.content);
|
||||||
|
setDisplayedImages(initialFormState.galleryImages || []);
|
||||||
|
setSelectedAuthors(
|
||||||
|
(initialFormState.authors || []).map(a => ({
|
||||||
|
authorId: a.author.id, // 👈 добавить вручную
|
||||||
|
role: a.role,
|
||||||
|
author: a.author
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
// console.log('Содержимое статьи при загрузке:', initialFormState.content);
|
||||||
|
}
|
||||||
|
}, [initialFormState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialFormState || !formReady) return;
|
||||||
|
|
||||||
|
const currentState: FormState = {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
categoryId,
|
||||||
|
cityId,
|
||||||
|
coverImage,
|
||||||
|
readTime,
|
||||||
|
content,
|
||||||
|
authors: selectedAuthors,
|
||||||
|
galleryImages: showGallery ? displayedImages : initialFormState.galleryImages,
|
||||||
|
};
|
||||||
|
|
||||||
|
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
||||||
|
|
||||||
|
const hasFormChanges = Object.keys(initialFormState).some(key => {
|
||||||
|
if (!formReady) return false;
|
||||||
|
|
||||||
|
if (key === 'galleryImages') {
|
||||||
|
if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
|
||||||
|
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||||
|
if (isInitialLoad && isDifferent) return false;
|
||||||
|
return isDifferent;
|
||||||
|
}
|
||||||
|
if (key === 'content') {
|
||||||
|
return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||||
|
}
|
||||||
|
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
|
||||||
|
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
|
||||||
|
|
||||||
|
return !isEqual(currentValue, initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}
|
||||||
|
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, selectedAuthors, displayedImages, initialFormState, isInitialLoad, formReady, showGallery]);
|
||||||
|
|
||||||
|
const filteredAuthors = authors.filter(
|
||||||
|
(a) =>
|
||||||
|
a.roles.includes(newRole as AuthorRole) && // 🔹 автор имеет нужную роль
|
||||||
|
!selectedAuthors.some(sel => sel.authorId === a.id && sel.role === newRole) // 🔹 не выбран уже
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
//console.log('Вызов handleSubmit:', { closeForm });
|
||||||
|
//console.log('Содержимое статьи перед сохранением:', content);
|
||||||
|
|
||||||
|
if (isSubmitting) {
|
||||||
|
//console.log('Форма уже отправляется, игнорируем повторную отправку');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title.trim() || !excerpt.trim()) {
|
||||||
|
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
const articleData: ArticleData = {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
categoryId,
|
||||||
|
cityId,
|
||||||
|
coverImage,
|
||||||
|
readTime,
|
||||||
|
gallery: displayedImages,
|
||||||
|
content: content || '',
|
||||||
|
importId: 0,
|
||||||
|
isActive: false,
|
||||||
|
authors: selectedAuthors,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await onSubmit(articleData, closeForm);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при сохранении статьи:', error);
|
||||||
|
setError('Не удалось сохранить статью. Пожалуйста, попробуйте снова.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = (e: React.FormEvent) => {
|
||||||
|
handleSubmit(e, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
setIsConfirmModalOpen(true); // Открываем модальное окно
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = () => {
|
||||||
|
setIsConfirmModalOpen(false);
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
setNewAuthorId('');
|
||||||
|
setNewRole('WRITER');
|
||||||
|
setShowAddAuthorModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsConfirmModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAuthor = () => {
|
||||||
|
const author = authors.find(a => a.id === newAuthorId);
|
||||||
|
if (author) {
|
||||||
|
setSelectedAuthors([...selectedAuthors, { authorId: author.id, role: newRole as AuthorRole, author }]);
|
||||||
|
setShowAddAuthorModal(false);
|
||||||
|
setNewAuthorId('');
|
||||||
|
setNewRole('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAuthor = (authorId: string, role: AuthorRole) => {
|
||||||
|
setSelectedAuthors(prev =>
|
||||||
|
prev.filter(a => !(a.authorId === authorId && a.role === role))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId && galleryLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">{editingId ? 'Редактировать статью' : 'Создать новую статью'}</h1>
|
||||||
|
{(error || galleryError) && (
|
||||||
|
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error || galleryError}</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={(e) => handleSubmit(e, true)} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Заголовок</span> <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Краткое описание</span> <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="excerpt"
|
||||||
|
rows={3}
|
||||||
|
value={excerpt}
|
||||||
|
onChange={(e) => setExcerpt(e.target.value)}
|
||||||
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Категория</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{availableCategoryIds.map(cat => <option key={cat} value={cat}>{CategoryTitles[cat]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Столица</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="city"
|
||||||
|
value={cityId}
|
||||||
|
onChange={(e) => setCityId(Number(e.target.value))}
|
||||||
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{availableCityIds.map(c => <option key={c} value={c}>{CityTitles[c]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Время чтения (минуты)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="readTime"
|
||||||
|
min="1"
|
||||||
|
value={readTime}
|
||||||
|
onChange={(e) => setReadTime(Number(e.target.value))}
|
||||||
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editingId && isAdmin && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Авторы статьи</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 text-gray-500"
|
||||||
|
>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица выбранных авторов */}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{selectedAuthors.map((a, index) => (
|
||||||
|
<li key={index} className="flex items-center justify-between border p-2 rounded-md">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-semibold">{a.author.displayName}</span>
|
||||||
|
<span className="text-sm text-gray-500">(<RolesWord role={a.role}/>)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveAuthor(a.authorId, a.role)}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CoverImageUpload
|
||||||
|
coverImage={coverImage}
|
||||||
|
articleId={articleId}
|
||||||
|
onImageUpload={setCoverImage}
|
||||||
|
onError={setError}
|
||||||
|
allowUpload={!!editingId}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Статья</span>
|
||||||
|
</label>
|
||||||
|
<TipTapEditor
|
||||||
|
initialContent={content}
|
||||||
|
onContentChange={setContent}
|
||||||
|
articleId={articleId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showGallery && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
<span className="italic font-bold">Галерея</span>
|
||||||
|
</label>
|
||||||
|
{editingId ? (
|
||||||
|
<>
|
||||||
|
<ImageUploader
|
||||||
|
onUploadComplete={(imageUrl) => {
|
||||||
|
setFormNewImageUrl(imageUrl);
|
||||||
|
}}
|
||||||
|
articleId={articleId}
|
||||||
|
/>
|
||||||
|
<GalleryManager
|
||||||
|
images={displayedImages}
|
||||||
|
imageUrl={formNewImageUrl}
|
||||||
|
setImageUrl={setFormNewImageUrl}
|
||||||
|
onAddImage={async (imageData) => {
|
||||||
|
console.log('Добавляем изображение в форме:', imageData);
|
||||||
|
const newImage = await addGalleryImage(imageData);
|
||||||
|
setFormNewImageUrl('');
|
||||||
|
setDisplayedImages(prev => [...prev, newImage]);
|
||||||
|
}}
|
||||||
|
onReorder={(images) => {
|
||||||
|
console.log('Переупорядочиваем изображения в форме:', images);
|
||||||
|
reorderGalleryImages(images.map(img => img.id));
|
||||||
|
setDisplayedImages(images);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => {
|
||||||
|
console.log('Удаляем изображение в форме:', id);
|
||||||
|
deleteGalleryImage(id);
|
||||||
|
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
||||||
|
}}
|
||||||
|
onEdit={(image) => {
|
||||||
|
console.log('Редактируем изображение в форме:', image);
|
||||||
|
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
||||||
|
console.error('Ошибка при редактировании изображения в форме:', err);
|
||||||
|
setError('Не удалось обновить изображение');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
{!editingId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!hasChanges || isSubmitting}
|
||||||
|
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
|
||||||
|
>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges || isSubmitting}
|
||||||
|
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
|
||||||
|
>
|
||||||
|
{editingId ? 'Изменить' : 'Сохранить черновик'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isConfirmModalOpen}
|
||||||
|
onConfirm={handleConfirmCancel}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модальное окно выбора автора */}
|
||||||
|
{showAddAuthorModal && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center">
|
||||||
|
<div className="bg-white p-6 rounded shadow-md max-w-md w-full">
|
||||||
|
<h4 className="text-lg font-bold mb-4">Добавить автора</h4>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="author-role"
|
||||||
|
value={key}
|
||||||
|
checked={newRole === key}
|
||||||
|
onChange={() => {
|
||||||
|
setNewRole(key);
|
||||||
|
setNewAuthorId('');
|
||||||
|
}}
|
||||||
|
className="text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Выбор автора</label>
|
||||||
|
<select
|
||||||
|
value={newAuthorId}
|
||||||
|
onChange={(e) => setNewAuthorId(e.target.value)}
|
||||||
|
className="w-full mb-4 border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">Выберите автора</option>
|
||||||
|
{filteredAuthors.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button onClick={() => setShowAddAuthorModal(false)} className="text-gray-500">Отмена</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddAuthor}
|
||||||
|
disabled={!newAuthorId || !newRole}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
265
src/components/ArticleList.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Article } from '../types';
|
||||||
|
import { CategoryTitles, CityTitles } from '../types';
|
||||||
|
import { Pencil, Trash2, ImagePlus, ToggleLeft, ToggleRight, Plus } from 'lucide-react';
|
||||||
|
import MinutesWord from './Words/MinutesWord';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import {Pagination} from "./Pagination.tsx";
|
||||||
|
import {useSearchParams} from "react-router-dom";
|
||||||
|
|
||||||
|
interface ArticleListProps {
|
||||||
|
articles: Article[];
|
||||||
|
setArticles: React.Dispatch<React.SetStateAction<Article[]>>;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onShowGallery: (id: string) => void;
|
||||||
|
onNewArticle: () => void;
|
||||||
|
refreshTrigger: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE = 6;
|
||||||
|
|
||||||
|
export const ArticleList = React.memo(function ArticleList({
|
||||||
|
articles,
|
||||||
|
setArticles,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onShowGallery,
|
||||||
|
onNewArticle,
|
||||||
|
refreshTrigger,
|
||||||
|
}: ArticleListProps) {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalArticles, setTotalArticles] = useState(0);
|
||||||
|
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
|
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
||||||
|
const [filterCityId, setFilterCityId] = useState(0);
|
||||||
|
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchArticles = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/articles/', {
|
||||||
|
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
|
||||||
|
});
|
||||||
|
setArticles(response.data.articles);
|
||||||
|
setTotalPages(response.data.totalPages);
|
||||||
|
setTotalArticles(response.data.total);
|
||||||
|
} catch (error) {
|
||||||
|
setError('Не удалось загрузить статьи');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchArticles();
|
||||||
|
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshTrigger, user, isAdmin, setArticles]);
|
||||||
|
|
||||||
|
const handleToggleActive = async (id: string) => {
|
||||||
|
const article = articles.find(a => a.id === id);
|
||||||
|
if (article) {
|
||||||
|
try {
|
||||||
|
await axios.put(
|
||||||
|
`/api/articles/active/${id}`,
|
||||||
|
{ isActive: !article.isActive },
|
||||||
|
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||||
|
);
|
||||||
|
setArticles(prev => prev.map(a => (a.id === id ? { ...a, isActive: !a.isActive } : a)));
|
||||||
|
// Обновляем список с сервера после изменения статуса
|
||||||
|
const response = await axios.get('/api/articles/', {
|
||||||
|
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
|
||||||
|
});
|
||||||
|
setArticles(response.data.articles);
|
||||||
|
setTotalPages(response.data.totalPages);
|
||||||
|
} catch (error) {
|
||||||
|
setError('Не удалось переключить статус статьи');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
searchParams.set('page', page.toString());
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm mb-8">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<select
|
||||||
|
value={filterCategoryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterCategoryId(Number(e.target.value));
|
||||||
|
searchParams.set('page', '1');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{availableCategoryIds.map(cat => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{CategoryTitles[cat]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterCityId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterCityId(Number(e.target.value));
|
||||||
|
searchParams.set('page', '1');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Все столицы</option>
|
||||||
|
{availableCityIds.map(c => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{CityTitles[c]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDraftOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShowDraftOnly(e.target.checked)
|
||||||
|
searchParams.set('page', '1');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Только черновики</span>
|
||||||
|
</label>
|
||||||
|
{!hasNoPermissions && (
|
||||||
|
<button
|
||||||
|
onClick={onNewArticle}
|
||||||
|
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="mr-2" /> Новая статья
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<p className="font-bold text-gray-600">
|
||||||
|
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
|
||||||
|
-
|
||||||
|
{Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center p-6">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : hasNoPermissions ? (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Недостаточно прав</h1>
|
||||||
|
<p className="text-gray-600">У вас нет прав на создание и редактирование статей. Свяжитесь с администратором.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{articles.map(article => (
|
||||||
|
<li key={article.id} className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 truncate">{article.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime}{' '}
|
||||||
|
<MinutesWord minutes={article.readTime} /> чтения
|
||||||
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const writerAuthors = article.authors
|
||||||
|
.filter(a => a.role === 'WRITER')
|
||||||
|
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||||
|
if (!writerAuthors) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
|
||||||
|
{writerAuthors.map((authorLink) => (
|
||||||
|
<img
|
||||||
|
key={authorLink.author.id}
|
||||||
|
src={authorLink.author.avatarUrl}
|
||||||
|
alt={authorLink.author.displayName}
|
||||||
|
className="h-6 w-6 rounded-full mr-1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{writerAuthors.map((a, i) => (
|
||||||
|
<span key={a.author.id}>
|
||||||
|
{a.author.displayName}
|
||||||
|
{i < writerAuthors.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(article.id)}
|
||||||
|
className={`p-2 rounded-full hover:bg-gray-100 ${article.isActive ? 'text-green-600' : 'text-gray-400'}`}
|
||||||
|
title={article.isActive ? 'Set as draft' : 'Publish article'}
|
||||||
|
>
|
||||||
|
{article.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(article.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Pencil size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onShowGallery(article.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<ImagePlus size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(article.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && isAdmin && (
|
||||||
|
<div className="p-4 text-red-700 bg-red-50 rounded-md">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { Category } from '../types';
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
interface AuthGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
requiredPermissions?: {
|
requiredPermissions?: {
|
||||||
category?: Category;
|
categoryId?: number;
|
||||||
action: 'create' | 'edit' | 'delete';
|
action: 'create' | 'edit' | 'delete';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -28,16 +27,16 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requiredPermissions) {
|
if (requiredPermissions) {
|
||||||
const { category, action } = requiredPermissions;
|
const { categoryId, action } = requiredPermissions;
|
||||||
|
|
||||||
if (!user.permissions.isAdmin) {
|
if (!user.permissions.isAdmin) {
|
||||||
if (category && !user.permissions.categories[category]?.[action]) {
|
if (categoryId && !user.permissions.categories[categoryId]?.[action]) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
You don't have permission to {action} articles in the {category} category.
|
У вас нет прав на {action} статьи в разделе {categoryId}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
86
src/components/AuthorModal.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Author, User } from "../types/auth.ts";
|
||||||
|
|
||||||
|
|
||||||
|
interface AuthorModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (author: Partial<Author>) => void;
|
||||||
|
author?: Author | null;
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthorModal: React.FC<AuthorModalProps> = ({ open, onClose, onSave, author, users }) => {
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [bio, setBio] = useState("");
|
||||||
|
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (author) {
|
||||||
|
setDisplayName(author.displayName || "");
|
||||||
|
setBio(author.bio || "");
|
||||||
|
setUserId(author.userId);
|
||||||
|
} else {
|
||||||
|
setDisplayName("");
|
||||||
|
setBio("");
|
||||||
|
setUserId(undefined);
|
||||||
|
}
|
||||||
|
}, [author, open]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSave({
|
||||||
|
id: author?.id,
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
userId: userId || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded p-4 w-[400px] shadow-xl">
|
||||||
|
<h2 className="text-lg font-bold mb-2">{author ? "Редактировать" : "Создать"} автора</h2>
|
||||||
|
|
||||||
|
<label className="block text-sm mb-1">Имя</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded px-2 py-1 mb-3"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm mb-1">Биография</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded px-2 py-1 mb-3"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm mb-1">Пользователь</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded px-2 py-1 mb-4"
|
||||||
|
value={userId || ""}
|
||||||
|
onChange={(e) => setUserId(e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">— не связан —</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button className="px-3 py-1 border rounded" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="px-3 py-1 bg-blue-600 text-white rounded" onClick={handleSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthorModal;
|
129
src/components/AuthorsSection.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import {Globe, Mail, ThumbsUp} from 'lucide-react';
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Author } from "../types/auth";
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { VkIcon } from "../icons/custom/VkIcon";
|
||||||
|
import { OkIcon } from "../icons/custom/OkIcon";
|
||||||
|
import ArticlesWord from './Words/ArticlesWord';
|
||||||
|
|
||||||
|
|
||||||
|
export function AuthorsSection() {
|
||||||
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
|
const showAuthors = false;
|
||||||
|
|
||||||
|
// Загрузка авторов
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAuthors = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/authors', {
|
||||||
|
params: { role: 'WRITER' },
|
||||||
|
});
|
||||||
|
setAuthors(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки авторов:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAuthors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||||
|
{showAuthors && (
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900">Наши авторы</h2>
|
||||||
|
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Познакомьтесь с талантливыми писателями и экспертами, работающими для Вас
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAuthors && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{authors
|
||||||
|
.filter(a => a.isActive)
|
||||||
|
.map((author) => (
|
||||||
|
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02] h-full">
|
||||||
|
<div className="p-6 flex flex-col flex-grow min-h-[200px] h-full">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<img
|
||||||
|
src={author.avatarUrl}
|
||||||
|
alt={author.displayName}
|
||||||
|
className="w-14 h-14 rounded-full object-cover mr-4 transition-transform duration-300 hover:scale-150"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{author.displayName}</h3>
|
||||||
|
<div className="flex mt-2 space-x-2">
|
||||||
|
{author.okUrl && (
|
||||||
|
<a
|
||||||
|
href={author.okUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-400 hover:text-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<OkIcon size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{author.vkUrl && (
|
||||||
|
<a
|
||||||
|
href={author.vkUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-400 hover:text-pink-500 transition-colors"
|
||||||
|
>
|
||||||
|
<VkIcon size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{author.websiteUrl && (
|
||||||
|
<a
|
||||||
|
href={author.websiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{author.email && (
|
||||||
|
<a
|
||||||
|
href={author.email}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Mail size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">{author.bio}</p>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
|
||||||
|
<div className="flex items-center text-sm text-gray-500 space-x-2">
|
||||||
|
<span> {author.articlesCount} <ArticlesWord articles={author.articlesCount} /> · </span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ThumbsUp size={16} className="mr-1" />
|
||||||
|
<span>{author.totalLikes}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/search?author=${author.id}&role=WRITER&authorName=${author.displayName}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Статьи автора →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Bookmark } from 'lucide-react';
|
import { Bookmark } from 'lucide-react';
|
||||||
import { useBookmarkStore } from '../stores/bookmarkStore';
|
import { useBookmarkStore } from '../stores/bookmarkStore';
|
||||||
import { Article } from '../types';
|
import { Article } from '../types';
|
||||||
@ -33,7 +32,7 @@ export function BookmarkButton({ article, className = '' }: BookmarkButtonProps)
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
<span className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||||
{bookmarked ? 'Remove bookmark' : 'Add bookmark'}
|
{bookmarked ? 'Убрать закладку' : 'Добавить закладку'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
36
src/components/ConfirmDeleteModal.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ConfirmDeleteModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ isOpen, onConfirm, onCancel }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 w-80 text-center space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">Удалить изображение?</h2>
|
||||||
|
<p className="text-gray-600 text-sm">Вы уверены, что хотите удалить это изображение?</p>
|
||||||
|
<div className="flex justify-center space-x-4 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Да, удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDeleteModal;
|
36
src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, onConfirm, onCancel, message }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||||
|
<p className="text-gray-700 mb-4">{message}</p>
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmModal;
|
138
src/components/ContentEditor.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useState, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import { useEditor, EditorContent, Editor } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import Blockquote from "@tiptap/extension-blockquote";
|
||||||
|
|
||||||
|
export interface ContentEditorRef {
|
||||||
|
setContent: (content: string) => void;
|
||||||
|
getEditor: () => Editor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentEditor = forwardRef<ContentEditorRef>((_, ref) => {
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ResizableImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
width: attributes.width,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
blockquote: false, // Отключаем дефолтный
|
||||||
|
}),
|
||||||
|
Blockquote.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
|
ResizableImage,
|
||||||
|
Highlight,
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const pos = editor.state.selection.$anchor.pos;
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos);
|
||||||
|
const parentNode = resolvedPos.parent;
|
||||||
|
|
||||||
|
if (parentNode.type.name === 'image') {
|
||||||
|
setSelectedImage(parentNode.attrs.src); // Сохраняем URL изображения
|
||||||
|
} else {
|
||||||
|
setSelectedImage(null); // Очищаем выбор, если это не изображение
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Позволяем родительскому компоненту использовать методы редактора
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
setContent: (content: string) => {
|
||||||
|
editor?.commands.setContent(content);
|
||||||
|
},
|
||||||
|
getEditor: () => editor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Функция изменения размера изображения
|
||||||
|
const updateImageSize = (delta: number) => {
|
||||||
|
if (selectedImage) {
|
||||||
|
const attrs = editor?.getAttributes('image');
|
||||||
|
const newWidth = Math.max((parseInt(attrs?.width) || 100) + delta, 50);
|
||||||
|
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="absolute z-10 flex space-x-2 bg-white border p-1 rounded shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: editor?.view?.dom
|
||||||
|
? `${editor.view.dom.getBoundingClientRect().top + window.scrollY + 50}px`
|
||||||
|
: '0px',
|
||||||
|
left: editor?.view?.dom
|
||||||
|
? `${editor.view.dom.getBoundingClientRect().left + window.scrollX + 200}px`
|
||||||
|
: '0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => updateImageSize(-20)}
|
||||||
|
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Уменьшить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateImageSize(20)}
|
||||||
|
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Увеличить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ContentEditor.displayName = 'ContentEditor';
|
||||||
|
export default ContentEditor;
|
252
src/components/CustomImageExtension.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { GripVertical, Trash2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
|
import { Node as ProseMirrorNode, DOMOutputSpec } from 'prosemirror-model';
|
||||||
|
import ConfirmDeleteModal from "./ConfirmDeleteModal";
|
||||||
|
|
||||||
|
interface CustomImageAttributes {
|
||||||
|
src: string | undefined;
|
||||||
|
alt: string | undefined;
|
||||||
|
title: string | undefined;
|
||||||
|
scale: number;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageNodeView: React.FC<NodeViewProps> = ({ node, editor, getPos, selected, deleteNode }) => {
|
||||||
|
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
||||||
|
|
||||||
|
const scale = typedNode.attrs.scale || 1;
|
||||||
|
const caption = typedNode.attrs.caption || '';
|
||||||
|
const isEditable = editor.isEditable;
|
||||||
|
|
||||||
|
const captionRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captionRef.current && caption.trim() !== captionRef.current.innerText.trim()) {
|
||||||
|
captionRef.current.innerText = caption;
|
||||||
|
}
|
||||||
|
}, [caption]);
|
||||||
|
|
||||||
|
const [isConfirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleZoomIn = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
editor.commands.updateAttributes('customImage', {
|
||||||
|
scale: Math.min(scale + 0.1, 2),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
editor.commands.updateAttributes('customImage', {
|
||||||
|
scale: Math.max(scale - 0.1, 0.5),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCaptionBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const newCaption = e.currentTarget.innerText;
|
||||||
|
if (newCaption !== caption) {
|
||||||
|
editor.commands.command(({ tr, state }) => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos == null) return false;
|
||||||
|
|
||||||
|
const nodeAtPos = state.doc.nodeAt(pos);
|
||||||
|
if (!nodeAtPos || nodeAtPos.type.name !== 'customImage') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
caption: newCaption,
|
||||||
|
});
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
|
||||||
|
editor.emit('update', {
|
||||||
|
editor,
|
||||||
|
transaction: tr,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="relative flex flex-col items-center my-4">
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
{isEditable && (
|
||||||
|
<div
|
||||||
|
className="drag-handle cursor-move mr-2 text-gray-400 hover:text-gray-600"
|
||||||
|
data-drag-handle=""
|
||||||
|
title="Перетащить"
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col items-center w-full">
|
||||||
|
<div
|
||||||
|
className="relative flex flex-col items-center"
|
||||||
|
style={{ width: `${scale * 100}%`, transition: 'width 0.2s' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={typedNode.attrs.src}
|
||||||
|
alt={typedNode.attrs.alt}
|
||||||
|
title={typedNode.attrs.title}
|
||||||
|
className={`max-w-full ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
|
||||||
|
data-scale={scale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected && isEditable && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center space-x-2 bg-white bg-opacity-80 p-1 rounded shadow z-10">
|
||||||
|
<button type="button" onClick={handleZoomIn} className="p-1 rounded hover:bg-gray-200" title="Увеличить">
|
||||||
|
<ZoomIn size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleZoomOut} className="p-1 rounded hover:bg-gray-200" title="Уменьшить">
|
||||||
|
<ZoomOut size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleDelete} className="p-1 rounded hover:bg-red-200 text-red-600" title="Удалить изображение">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="text-xs text-gray-500 ml-2">
|
||||||
|
{(scale * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full mt-2">
|
||||||
|
<div
|
||||||
|
ref={captionRef}
|
||||||
|
className="image-caption w-[80%] mx-auto text-base font-bold text-gray-600 italic text-center outline-none relative empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 empty:before:absolute empty:before:top-1/2 empty:before:left-0 empty:before:-translate-y-1/2 empty:before:pl-4 empty:before:text-left empty:before:pointer-events-none empty:before:select-none"
|
||||||
|
contentEditable={isEditable}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onBlur={handleCaptionBlur}
|
||||||
|
data-caption={caption}
|
||||||
|
data-placeholder="Введите подпись..."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
isOpen={isConfirmModalOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteNode();
|
||||||
|
setConfirmModalOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomImage = Node.create({
|
||||||
|
name: 'customImage',
|
||||||
|
group: 'block',
|
||||||
|
inline: false,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: undefined },
|
||||||
|
alt: { default: undefined },
|
||||||
|
title: { default: undefined },
|
||||||
|
scale: {
|
||||||
|
default: 1,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const img = element.querySelector('img');
|
||||||
|
return img ? Number(img.getAttribute('data-scale')) || 1 : 1;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-scale': Number(attributes.scale) || 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const captionElement = element.querySelector('div.image-caption');
|
||||||
|
const img = element.querySelector('img');
|
||||||
|
return captionElement
|
||||||
|
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
|
||||||
|
: img?.getAttribute('data-caption') || '';
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => (attributes.caption ? { 'data-caption': attributes.caption } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div.image-container',
|
||||||
|
getAttrs: (element: HTMLElement) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
|
const img = element.querySelector('img');
|
||||||
|
const captionElement = element.querySelector('div.image-caption');
|
||||||
|
if (!img) return null;
|
||||||
|
return {
|
||||||
|
src: img.getAttribute('src') || undefined,
|
||||||
|
alt: img.getAttribute('alt') || undefined,
|
||||||
|
title: img.getAttribute('title') || undefined,
|
||||||
|
scale: Number(img.getAttribute('data-scale')) || 1,
|
||||||
|
caption: captionElement
|
||||||
|
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
|
||||||
|
: img.getAttribute('data-caption') || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
const { src, alt, title, scale, caption } = node.attrs;
|
||||||
|
const actualScale = Number(scale) || 1;
|
||||||
|
|
||||||
|
const imageElement: DOMOutputSpec = [
|
||||||
|
'img',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
'data-scale': actualScale,
|
||||||
|
style: `width: ${actualScale * 100}%`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const captionElement: DOMOutputSpec = [
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'image-caption w-full text-sm font-bold text-gray-600 italic text-center mt-2',
|
||||||
|
'data-caption': caption,
|
||||||
|
'data-placeholder': 'Введите подпись...',
|
||||||
|
},
|
||||||
|
caption,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
{ class: 'image-container flex flex-col items-center my-4' },
|
||||||
|
imageElement,
|
||||||
|
caption ? captionElement : '',
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ImageNodeView);
|
||||||
|
},
|
||||||
|
});
|