Compare commits
No commits in common. "main" and "BRANCH-1" have entirely different histories.
4
.env
@ -1 +1,3 @@
|
||||
VITE_API_URL="http://localhost:5000"
|
||||
DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
|
||||
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
|
||||
PORT=5000
|
@ -1,2 +0,0 @@
|
||||
VITE_API_URL=https://russcult.anibilag.ru
|
||||
VITE_APP_ENV=production
|
@ -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()
|
@ -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;
|
||||
}
|
@ -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
|
21
deploy.sh
@ -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
39
package.json
@ -8,43 +8,28 @@
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"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"
|
||||
"server": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.525.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@prisma/client": "^6.2.1",
|
||||
"@tiptap/extension-highlight": "^2.11.5",
|
||||
"@tiptap/extension-image": "^2.11.5",
|
||||
"@tiptap/extension-link": "^2.11.7",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@tiptap/pm": "^2.2.4",
|
||||
"@tiptap/react": "^2.2.4",
|
||||
"@tiptap/starter-kit": "^2.2.4",
|
||||
"axios": "^1.6.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sitemap": "^8.0.0",
|
||||
"sharp": "^0.33.2",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
@ -52,28 +37,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/node": "^22.10.7",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.2.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"prisma": "^5.10.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.14"
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,6 @@
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
{
|
||||
postcssPlugin: 'fix-from-warning',
|
||||
Once(root, { result }) {
|
||||
if (typeof result.opts.from === 'undefined') {
|
||||
result.opts.from = 'unknown.css';
|
||||
}
|
||||
},
|
||||
},
|
||||
tailwindcss,
|
||||
autoprefixer,
|
||||
],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
@ -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;
|
@ -20,50 +20,43 @@ model User {
|
||||
}
|
||||
|
||||
model Article {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
excerpt String
|
||||
content String
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int
|
||||
category String
|
||||
city String
|
||||
coverImage String
|
||||
readTime Int
|
||||
likes Int @default(0)
|
||||
dislikes Int @default(0)
|
||||
publishedAt DateTime @default(now())
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
likes Int @default(0)
|
||||
dislikes Int @default(0)
|
||||
publishedAt DateTime @default(now())
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
authorId String
|
||||
gallery GalleryImage[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id
|
||||
name String @unique
|
||||
articles Article[]
|
||||
}
|
||||
|
||||
model GalleryImage {
|
||||
id String @id @default(uuid())
|
||||
url String
|
||||
caption String
|
||||
alt String
|
||||
width Int
|
||||
height Int
|
||||
size Int
|
||||
format String
|
||||
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||
articleId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
order Int @default(0)
|
||||
id String @id @default(uuid())
|
||||
url String
|
||||
caption String
|
||||
alt String
|
||||
width Int
|
||||
height Int
|
||||
size Int
|
||||
format String
|
||||
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||
articleId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
order Int @default(0)
|
||||
}
|
||||
|
||||
model UserReaction {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
articleId String
|
||||
reaction String // 'like' or 'dislike'
|
||||
reaction String // 'like' or 'dislike'
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, articleId])
|
||||
|
@ -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();
|
@ -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();
|
||||
});
|
Before Width: | Height: | Size: 142 KiB |
Before Width: | Height: | Size: 182 KiB |
Before Width: | Height: | Size: 903 KiB |
Before Width: | Height: | Size: 951 KiB |
Before Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 612 KiB |
Before Width: | Height: | Size: 79 KiB |
Before Width: | Height: | Size: 586 KiB |
Before Width: | Height: | Size: 321 KiB |
Before Width: | Height: | Size: 141 KiB |
Before Width: | Height: | Size: 194 KiB |
Before Width: | Height: | Size: 429 KiB |
Before Width: | Height: | Size: 467 KiB |
Before Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 548 KiB |
Before Width: | Height: | Size: 435 KiB |
Before Width: | Height: | Size: 790 KiB |
Before Width: | Height: | Size: 338 KiB |
Before Width: | Height: | Size: 651 KiB |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
Before Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 207 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 49 KiB |
@ -1,9 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Disallow admin routes
|
||||
Disallow: /admin/
|
||||
Disallow: /admin/*
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://russcult.anibilag.ru/sitemap.xml
|
@ -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>
|
@ -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
78
server/config/logger.ts
Normal 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
@ -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
@ -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);
|
||||
});
|
29
server/middleware/auth/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
11
server/middleware/auth/extractToken.ts
Normal 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;
|
||||
}
|
22
server/middleware/auth/getUser.ts
Normal 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;
|
||||
}
|
||||
}
|
5
server/middleware/auth/index.ts
Normal 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';
|
12
server/middleware/auth/types.ts
Normal 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;
|
||||
}
|
10
server/middleware/auth/validateToken.ts
Normal 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;
|
||||
}
|
||||
}
|
20
server/middleware/error/errorHandler.ts
Normal 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
|
||||
});
|
||||
}
|
27
server/middleware/error/errorLogger.ts
Normal 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);
|
||||
};
|
21
server/middleware/logging/requestLogger.ts
Normal 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();
|
||||
};
|
17
server/middleware/validation/validateRequest.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
}
|
123
server/routes/articles/controllers/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
41
server/routes/articles/controllers/list.ts
Normal 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' });
|
||||
}
|
||||
}
|
44
server/routes/articles/controllers/search.ts
Normal 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' });
|
||||
}
|
||||
}
|
93
server/routes/articles/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
19
server/routes/articles/index.ts
Normal 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;
|
41
server/routes/articles/list.ts
Normal 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' });
|
||||
}
|
||||
}
|
44
server/routes/articles/search.ts
Normal 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
@ -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;
|
36
server/routes/auth/controllers/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
12
server/routes/auth/index.ts
Normal 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;
|
8
server/routes/auth/validation/authSchemas.ts
Normal 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)
|
||||
})
|
||||
});
|
81
server/routes/gallery/controllers/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
19
server/routes/gallery/index.ts
Normal 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;
|
58
server/routes/images/index.ts
Normal 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
@ -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;
|
29
server/routes/users/controllers/users.ts
Normal 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' });
|
||||
}
|
||||
}
|
10
server/routes/users/index.ts
Normal 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;
|
95
server/services/authService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
92
server/services/galleryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
81
server/services/s3Service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
44
server/services/userService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
16
server/utils/permissions.ts
Normal 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);
|
||||
};
|
23
src/App.tsx
@ -7,15 +7,10 @@ import { ArticlePage } from './pages/ArticlePage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { UserManagementPage } from './pages/UserManagementPage';
|
||||
import { AuthorManagementPage } from './pages/AuthorManagementPage';
|
||||
import { SearchPage } from './pages/SearchPage';
|
||||
import { BookmarksPage } from './pages/BookmarksPage';
|
||||
import { Footer } from './components/Footer';
|
||||
import { AuthGuard } from './components/AuthGuard';
|
||||
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
||||
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||
|
||||
function App() {
|
||||
const { setUser, setLoading } = useAuthStore();
|
||||
@ -24,7 +19,7 @@ function App() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
setLoading(true);
|
||||
axios.get(`${API_URL}/api/auth/me`, {
|
||||
axios.get('http://localhost:5000/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(response => {
|
||||
@ -68,22 +63,6 @@ function App() {
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/authors"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<AuthorManagementPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/import"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<ImportArticlesPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
|
Before Width: | Height: | Size: 429 KiB |
@ -1,8 +1,6 @@
|
||||
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import MinutesWord from './Words/MinutesWord';
|
||||
import { useScrollStore } from '../stores/scrollStore';
|
||||
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Article } from '../types';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
@ -10,92 +8,58 @@ interface 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 (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||
} flex flex-col`}>
|
||||
<div className="relative pt-7">
|
||||
featured ? 'col-span-2 row-span-2' : ''
|
||||
}`}>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={article.coverImage}
|
||||
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">
|
||||
<span className="text-white text-sm md:text-base font-medium">
|
||||
{CategoryTitles[article.categoryId]}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
||||
{article.category}
|
||||
</span>
|
||||
<span className="text-white text-sm md:text-base font-medium flex items-center">
|
||||
<MapPin size={14} className="mr-1 text-white" />
|
||||
{CityTitles[article.cityId]}
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
||||
<MapPin size={14} className="mr-1" />
|
||||
{article.city}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col flex-grow">
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{writerAuthors.map((authorLink) => (
|
||||
<img
|
||||
key={authorLink.author.id}
|
||||
src={authorLink.author.avatarUrl}
|
||||
alt={authorLink.author.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
src={article.author.avatar}
|
||||
alt={article.author.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<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>
|
||||
<p className="text-sm font-medium text-gray-900">{article.author.name}</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
|
||||
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{article.readTime} min read
|
||||
</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}
|
||||
</h2>
|
||||
<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
|
||||
to={`/article/${article.id}`}
|
||||
state={{ from: location.pathname + location.search }}
|
||||
onClick={handleArticleClick}
|
||||
className="text-blue-600 font-medium hover:text-blue-800"
|
||||
>
|
||||
Читать →
|
||||
Read More →
|
||||
</Link>
|
||||
<div className="flex items-center text-gray-500">
|
||||
<ThumbsUp size={16} className="mr-1" />
|
||||
<span>{article.likes}</span>
|
||||
<span className="ml-2">
|
||||
<ThumbsDown size={16} className="mr-1" />
|
||||
</span>
|
||||
<span>{article.dislikes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
});
|
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { Category } from '../types';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
requiredPermissions?: {
|
||||
categoryId?: number;
|
||||
category?: Category;
|
||||
action: 'create' | 'edit' | 'delete';
|
||||
};
|
||||
}
|
||||
@ -27,16 +28,16 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
|
||||
}
|
||||
|
||||
if (requiredPermissions) {
|
||||
const { categoryId, action } = requiredPermissions;
|
||||
const { category, action } = requiredPermissions;
|
||||
|
||||
if (!user.permissions.isAdmin) {
|
||||
if (categoryId && !user.permissions.categories[categoryId]?.[action]) {
|
||||
if (category && !user.permissions.categories[category]?.[action]) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600">
|
||||
У вас нет прав на {action} статьи в разделе {categoryId}.
|
||||
You don't have permission to {action} articles in the {category} category.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { useBookmarkStore } from '../stores/bookmarkStore';
|
||||
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">
|
||||
{bookmarked ? 'Убрать закладку' : 'Добавить закладку'}
|
||||
{bookmarked ? 'Remove bookmark' : 'Add bookmark'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
},
|
||||
});
|