Compare commits

..

No commits in common. "main" and "BRANCH-1" have entirely different histories.

181 changed files with 5382 additions and 9916 deletions

4
.env
View File

@ -1 +1,3 @@
VITE_API_URL="http://localhost:5000" DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
PORT=5000

View File

@ -1,2 +0,0 @@
VITE_API_URL=https://russcult.anibilag.ru
VITE_APP_ENV=production

View File

@ -1,107 +0,0 @@
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()

View File

@ -1,48 +0,0 @@
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;
}

View File

@ -1,9 +0,0 @@
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

View File

@ -1,21 +0,0 @@
#!/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!"

4931
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,43 +8,28 @@
"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",
"@headlessui/react": "^2.2.2", "@prisma/client": "^5.10.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",
"sitemap": "^8.0.0", "sharp": "^0.33.2",
"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",
@ -52,28 +37,22 @@
}, },
"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.4", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.18",
"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.49", "postcss": "^8.4.35",
"prisma": "^6.2.1", "prisma": "^5.10.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.1",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.14" "vite": "^5.4.2"
} }
} }

View File

@ -1,17 +1,6 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default { export default {
plugins: [ plugins: {
{ tailwindcss: {},
postcssPlugin: 'fix-from-warning', autoprefixer: {},
Once(root, { result }) { },
if (typeof result.opts.from === 'undefined') {
result.opts.from = 'unknown.css';
}
},
},
tailwindcss,
autoprefixer,
],
}; };

View File

@ -1,45 +0,0 @@
/*
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;

View File

@ -20,51 +20,44 @@ 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 Category @relation(fields: [categoryId], references: [id]) category String
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])
} }

View File

@ -1,16 +0,0 @@
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();

View File

@ -1,36 +0,0 @@
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();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

View File

@ -1,23 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,24 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,37 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 609 B

View File

@ -1,33 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -1,9 +0,0 @@
User-agent: *
Allow: /
# Disallow admin routes
Disallow: /admin/
Disallow: /admin/*
# Sitemap
Sitemap: https://russcult.anibilag.ru/sitemap.xml

View File

@ -1 +0,0 @@
<?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>

View File

@ -1,62 +0,0 @@
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 Normal file

Binary file not shown.

78
server/config/logger.ts Normal file
View File

@ -0,0 +1,78 @@
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 };

26
server/index.js Normal file
View File

@ -0,0 +1,26 @@
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}`);
});

45
server/index.ts Normal file
View File

@ -0,0 +1,45 @@
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);
});

View File

@ -0,0 +1,29 @@
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' });
}
}

View File

@ -0,0 +1,11 @@
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;
}

View File

@ -0,0 +1,22 @@
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;
}
}

View File

@ -0,0 +1,5 @@
export { auth } from './auth.js';
export { extractToken } from './extractToken.js';
export { validateToken } from './validateToken.js';
export { getUser } from './getUser.js';
export * from './types.js';

View File

@ -0,0 +1,12 @@
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;
}

View File

@ -0,0 +1,10 @@
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;
}
}

View File

@ -0,0 +1,20 @@
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
});
}

View File

@ -0,0 +1,27 @@
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);
};

View File

@ -0,0 +1,21 @@
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();
};

View File

@ -0,0 +1,17 @@
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' });
}
};
}

View File

@ -0,0 +1,123 @@
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' });
}
}

View File

@ -0,0 +1,41 @@
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' });
}
}

View File

@ -0,0 +1,44 @@
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' });
}
}

View File

@ -0,0 +1,93 @@
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' });
}
}

View File

@ -0,0 +1,19 @@
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;

View File

@ -0,0 +1,41 @@
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' });
}
}

View File

@ -0,0 +1,44 @@
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' });
}
}

27
server/routes/auth.ts Normal file
View File

@ -0,0 +1,27 @@
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;

View File

@ -0,0 +1,36 @@
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' });
}
}

View File

@ -0,0 +1,12 @@
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;

View File

@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
body: z.object({
email: z.string().email(),
password: z.string().min(6)
})
});

View File

@ -0,0 +1,81 @@
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' });
}
}

View File

@ -0,0 +1,19 @@
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;

View File

@ -0,0 +1,58 @@
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;

35
server/routes/users.ts Normal file
View File

@ -0,0 +1,35 @@
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;

View File

@ -0,0 +1,29 @@
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' });
}
}

View File

@ -0,0 +1,10 @@
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;

View File

@ -0,0 +1,95 @@
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;
}
}
};

View File

@ -0,0 +1,92 @@
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;
}
}
};

View File

@ -0,0 +1,81 @@
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;
}
}
};

View File

@ -0,0 +1,44 @@
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');
}
}
};

View File

@ -0,0 +1,16 @@
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);
};

View File

@ -7,15 +7,10 @@ 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();
@ -24,7 +19,7 @@ function App() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
setLoading(true); setLoading(true);
axios.get(`${API_URL}/api/auth/me`, { axios.get('http://localhost:5000/api/auth/me', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
.then(response => { .then(response => {
@ -68,22 +63,6 @@ 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 />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

View File

@ -1,8 +1,6 @@
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react'; import { Clock, ThumbsUp, MapPin } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Article, CategoryTitles, CityTitles } from '../types'; import { Article } from '../types';
import MinutesWord from './Words/MinutesWord';
import { useScrollStore } from '../stores/scrollStore';
interface ArticleCardProps { interface ArticleCardProps {
article: Article; article: Article;
@ -10,92 +8,58 @@ 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 ? 'sm:col-span-2 sm:row-span-2' : '' featured ? 'col-span-2 row-span-2' : ''
} flex flex-col`}> }`}>
<div className="relative pt-7"> <div className="relative">
<img <img
src={article.coverImage} src={article.coverImage}
alt={article.title} alt={article.title}
className={`w-full object-cover ${featured ? 'h-64 sm:h-96' : 'h-64'}`} className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
/> />
<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"> <div className="absolute top-4 left-4 flex gap-2">
<span className="text-white text-sm md:text-base font-medium"> <span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
{CategoryTitles[article.categoryId]} {article.category}
</span> </span>
<span className="text-white text-sm md:text-base font-medium flex items-center"> <span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
<MapPin size={14} className="mr-1 text-white" /> <MapPin size={14} className="mr-1" />
{CityTitles[article.cityId]} {article.city}
</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">
{writerAuthors.map((authorLink) => ( <img
<img src={article.author.avatar}
key={authorLink.author.id} alt={article.author.name}
src={authorLink.author.avatarUrl} className="w-10 h-10 rounded-full"
alt={authorLink.author.displayName} />
className="w-10 h-10 rounded-full"
/>
))}
<div> <div>
<p className="text-sm font-medium text-gray-900"> <p className="text-sm font-medium text-gray-900">{article.author.name}</p>
{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} <MinutesWord minutes={article.readTime} /> ·{' '} {article.readTime} min read
{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-xl sm:text-2xl' : 'text-xl'}`}> <h2 className={`font-bold text-gray-900 mb-2 ${featured ? '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="p-4 mt-auto flex items-center justify-between"> <div className="mt-4 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>

View File

@ -1,49 +0,0 @@
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>
);
}

View File

@ -1,29 +0,0 @@
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>
);
}

View File

@ -1,552 +0,0 @@
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>
);
}

View File

@ -1,265 +0,0 @@
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>
);
});

View File

@ -1,11 +1,12 @@
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?: {
categoryId?: number; category?: Category;
action: 'create' | 'edit' | 'delete'; action: 'create' | 'edit' | 'delete';
}; };
} }
@ -27,16 +28,16 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
} }
if (requiredPermissions) { if (requiredPermissions) {
const { categoryId, action } = requiredPermissions; const { category, action } = requiredPermissions;
if (!user.permissions.isAdmin) { if (!user.permissions.isAdmin) {
if (categoryId && !user.permissions.categories[categoryId]?.[action]) { if (category && !user.permissions.categories[category]?.[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">
У вас нет прав на {action} статьи в разделе {categoryId}. You don't have permission to {action} articles in the {category} category.
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,86 +0,0 @@
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;

View File

@ -1,129 +0,0 @@
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>
);
}

View File

@ -1,3 +1,4 @@
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';
@ -32,7 +33,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 ? 'Убрать закладку' : 'Добавить закладку'} {bookmarked ? 'Remove bookmark' : 'Add bookmark'}
</span> </span>
</button> </button>
); );

View File

@ -1,36 +0,0 @@
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;

View File

@ -1,36 +0,0 @@
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;

View File

@ -1,138 +0,0 @@
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;

View File

@ -1,252 +0,0 @@
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);
},
});

Some files were not shown because too many files have changed in this diff Show More