Compare commits

..

23 Commits

Author SHA1 Message Date
9ca069c49b Переход от монолитной Header к набору компонентов. Работает поиск статей по дате. Запоминание позиции скролинга при переходе к статье. 2025-06-17 23:26:27 +03:00
933d82e3ac Убран список авторов, добавлена информация о организаторе портала. 2025-06-09 22:41:57 +03:00
23bd232504 Добавлен .env.production для корректной работы на проде. Улучшен nginx.conf Добавлен скрипт деплоя. 2025-06-05 21:46:45 +03:00
0a1a5d65d5 Исправление baseURL для работы на production. Подправлана фоновая надпись (загрузка...) при показе статьи. 2025-06-05 13:49:35 +03:00
6bb71bff44 Фоновое изображение сайта. 2025-06-03 23:09:00 +03:00
7e5f29eb40 Переработана схема привязки авторов к статьям, авторы трех типов. Заложен поиск по дате статьи. 2025-06-03 22:34:32 +03:00
7dbfb0323c Доработан импорт статей, сделано разделение пользователей на собственно пользователей и авторов. Планируется сделать разные типы авторов. 2025-05-21 23:04:36 +03:00
4663e0a300 Доработана вставка изображений с подписью. При импорте статей можно посмотреть их изображения и подписи, можно редактировать подписи. Создание пользователей без права входа в админку. 2025-05-02 23:49:36 +03:00
12b0011d57 Устранена проблема с пагинацией, добавлено SEO 2025-04-15 21:22:43 +03:00
5a2bc6dbd3 Теперь работает вставка изображения в редактор с масштабированием. 2025-04-06 00:20:12 +03:00
a08eb412e4 Разделение компонента админской страницы на несколько компонентов. Прочие доработки. 2025-04-01 13:33:50 +03:00
dc00bca390 Доработка работы с галереей. Работает добавление и изменение порядка изображений. Не работает редактирование. 2025-03-25 22:33:35 +03:00
9ba64a453c Доработана работа с пользователями (ввод пароля и отмена автозаполнения) Добавлен логотип блога. 2025-03-22 23:51:32 +03:00
e4d5029e72 Добавлены bookmarks. Улучшения интерфейса, логотип. Есть дублирующмй код Header в отдельной папке, разбитый на отдельные компоненты. Сейчас используется монолит. 2025-03-19 22:06:42 +03:00
7fb5daf210 Добавлена реакция на статьи (like/dislike) и URL вынесены в .env 2025-03-11 12:47:09 +03:00
7afbcf27f8 Добавлен раздел Наши авторы, поиск по статьям авторов. Добавлено описание bio для автора. 2025-03-06 16:21:22 +03:00
00376c124f Добавлен фильтр списка статей по пользователю. Админ видит все. 2025-03-03 23:18:43 +03:00
e254ef0dc0 Доработана работа с пользователями - загрузка аватара. Добавлены ограничения по правам на админской странице. 2025-03-02 23:35:45 +03:00
1d09dbadf3 Добавлена функциональность черновиков, доработана кнопка TipTap для выделения жирным. 2025-02-26 23:33:29 +03:00
ae5e789f1b Исправлена проблема, когда после сабмита формы не загружался список статей. 2025-02-25 19:37:30 +03:00
be59f4418a Переделана пагинация на backend, поэтому убрана фильтрация по frontend. Реализован импорт статей из json файла, пока без загрузки обложки статьи в хранилище S3 2025-02-25 13:29:46 +03:00
1c4a7f2384 Работает создание, редактирование статьи. Сейчас проблема с редактором - вставка изображения. 2025-02-17 23:07:47 +03:00
5cc46da09d Работает загрузка изображения статьи в S3 и сохранение новой статьи. 2025-02-04 23:07:31 +03:00
177 changed files with 9885 additions and 5388 deletions

4
.env
View File

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

2
.env.production Normal file
View File

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

View File

@ -0,0 +1,107 @@
import json
import io
from datetime import datetime
from bs4 import BeautifulSoup
# Файл дампа
input_directory = "D:/__TEMP/____RUSS_CULT/__convert"
json_file = "1-2025-p"
output_file = "1-2025-p-convert.json"
def main():
try:
f = io.open(input_directory + '/' + json_file + '.json', encoding='utf-8')
records = json.loads(f.read())
except FileNotFoundError:
print("Ошибка: Входной JSON файл не найден.")
return
except json.JSONDecodeError as e:
print(f"Ошибка декодирования JSON: {e}")
return
articles = list()
for item in records:
try:
article = dict()
pre = item['pre']
full = item['full']
article['id'] = item['id']
article['title'] = item['title']
article['content'] = full
article['categoryId'] = item['cat_id']
article['cityId'] = item['city_id']
soup = BeautifulSoup(pre, "html.parser")
# Извлекаем URL изображения
img_tag = soup.find("img")
img_url = img_tag["src"] if img_tag else None
# Удаляем тег <img> из HTML
if img_tag:
img_tag.decompose()
# Удаляем пустые <p> (с пробелами или полностью пустые)
for p in soup.find_all("p"):
if not p.get_text(strip=True): # strip=True убирает пробелы и невидимые символы
p.decompose()
# Извлекаем текст из оставшихся <p>
text_content = " ".join(p.get_text(strip=True) for p in soup.find_all("p"))
if not text_content:
# Находим первый тег <p> и извлекаем текст
soup = BeautifulSoup(full, "html.parser")
first_p = soup.find("p")
text_content = first_p.get_text(strip=True) if first_p else ""
article['excerpt'] = text_content
article['coverImage'] = img_url
article['readTime'] = 2
article['likes'] = 0
article['dislikes'] = 0
article['gallery'] = []
# Разбираем строку в объект datetime
date_obj = datetime.strptime(item['date'], "%d.%m.%Y")
# Преобразуем в нужный формат
formatted_date = date_obj.strftime("%Y-%m-%dT00:00:00Z")
article['publishedAt'] = formatted_date
author = dict()
author['id'] = '41e09d9a-f9c1-44a7-97f4-0be694371e7e'
article['author'] = author
articles.append(article)
except KeyError as e:
print(f"Потерян ключ в записи: {e}")
continue
save_to_json_file(output_file, articles, 'w')
def save_to_file(path, data, mode):
f = open(input_directory + '/' + path, mode)
f.write(data)
f.close()
def save_to_json_file(path, data, mode):
f = io.open(input_directory + '/' + path, encoding='utf-8', mode=mode)
json.dump(data, f, ensure_ascii=False, indent=2)
f.close()
#def create_article:
if __name__ == '__main__':
main()

48
.nginx/nginx.conf Normal file
View File

@ -0,0 +1,48 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/dist;
index index.html;
server_name russcult.anibilag.ru;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location /api/ {
proxy_pass http://192.168.1.67:5000/api/; # 👈 сохраняем /api/ в пути
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
error_page 404 /index.html;
}

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:20 AS build
WORKDIR /app
COPY . .
RUN npm install && npm run build
# Переход на stage со статикой
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html

21
deploy.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# deploy.sh
# Build the application
npm run build
# Backup current deployment
sudo cp -r /var/www/dist /var/www/dist.backup.$(date +%Y%m%d_%H%M%S)
# Deploy new build
sudo rm -rf /var/www/dist/*
sudo cp -r dist/* /var/www/dist/
# Set proper permissions
sudo chown -R www-data:www-data /var/www/dist
sudo chmod -R 755 /var/www/dist
# Test nginx config and reload
sudo nginx -t && sudo systemctl reload nginx
echo "Deployment completed successfully!"

4929
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,28 +8,43 @@
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"server": "node server/index.js"
"server": "node server/index.js",
"prisma-seed": "npx prisma db seed",
"db:seed": "node prisma/seed.js",
"generate-sitemap": "node scripts/generate-sitemap.js"
},
"prisma": {
"seed": "npx ts-node --project tsconfig.node.json --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.525.0",
"@prisma/client": "^5.10.2",
"@headlessui/react": "^2.2.2",
"@prisma/client": "^6.2.1",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-image": "^2.11.5",
"@tiptap/extension-link": "^2.11.7",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/pm": "^2.2.4",
"@tiptap/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",
"sharp": "^0.33.2",
"sitemap": "^8.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
@ -37,22 +52,28 @@
},
"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.1",
"autoprefixer": "^10.4.18",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"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.35",
"prisma": "^5.10.2",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.49",
"prisma": "^6.2.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
"vite": "^5.4.14"
}
}

View File

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

View File

@ -0,0 +1,45 @@
/*
Warnings:
- You are about to drop the column `category` on the `Article` table. All the data in the column will be lost.
- Added the required column `categoryId` to the `Article` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Article" DROP COLUMN "category",
ADD COLUMN "categoryId" INTEGER NOT NULL;
-- CreateTable
CREATE TABLE "Category" (
"id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GalleryImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"caption" TEXT NOT NULL,
"alt" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"size" INTEGER NOT NULL,
"format" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "GalleryImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GalleryImage" ADD CONSTRAINT "GalleryImage_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -20,44 +20,51 @@ model User {
}
model Article {
id String @id @default(uuid())
id String @id @default(uuid())
title String
excerpt String
content String
category String
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
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])
}
}

16
prisma/seed.js Normal file
View File

@ -0,0 +1,16 @@
import { exec } from 'child_process';
import util from 'util';
const execPromise = util.promisify(exec);
async function runSeed() {
try {
await execPromise('npx ts-node prisma/seed.ts');
console.log('Seeding completed successfully.');
} catch (error) {
console.error('Error executing the seed script:', error);
process.exit(1);
}
}
runSeed();

36
prisma/seed.ts Normal file
View File

@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Данные для заполнения
const categories = [
{ id: 1, name: "Film" },
{ id: 2, name: "Theater" },
{ id: 3, name: "Music" },
{ id: 4, name: "Sports" },
{ id: 5, name: "Art" },
{ id: 6, name: "Legends" },
{ id: 7, name: "Anniversaries" },
{ id: 8, name: "Memory" },
];
// Заполнение данных
for (const category of categories) {
await prisma.category.upsert({
where: { id: category.id },
update: {},
create: category,
});
}
console.log('Данные успешно добавлены!');
}
main()
.catch((e) => {
throw e;
})
.finally(async () => {
await prisma.$disconnect();
});

BIN
public/images/Logo-1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
public/images/Logo-2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
public/images/Logo-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

BIN
public/images/Logo-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
public/images/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/images/bg-film.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

BIN
public/images/bg-music.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
public/images/bg-sport.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
public/images/gpt_film.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

BIN
public/images/main-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

BIN
public/images/main-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 94 94" xml:space="preserve">
<g>
<g>
<path d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"/>
<path d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
C27.722,17.365,36.4,8.734,47.08,8.766z M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416
c-2.991,1.156-6.268,1.742-9.512,2.13c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27
c1.494,1.511,1.81,3.386,0.985,5.145c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582
c-3.324-3.345-6.711-6.627-9.965-10.031c-0.947-0.992-1.403-0.807-2.241,0.056c-3.343,3.442-6.738,6.831-10.155,10.2
c-1.535,1.514-3.36,1.785-5.143,0.922c-1.892-0.917-3.094-2.848-3.001-4.791c0.064-1.312,0.71-2.314,1.611-3.214
c4.356-4.351,8.702-8.713,13.05-13.072c0.289-0.288,0.557-0.597,0.976-1.045c-5.929-0.619-11.275-2.077-15.85-5.657
c-0.567-0.445-1.154-0.875-1.674-1.373c-2.002-1.924-2.203-4.125-0.618-6.396c1.354-1.942,3.632-2.464,5.997-1.349
c0.459,0.215,0.895,0.486,1.313,0.775c8.528,5.86,20.245,6.023,28.806,0.266c0.847-0.647,1.754-1.183,2.806-1.449
c2.045-0.525,3.947,0.224,5.045,2.012C70.314,51.496,70.297,53.488,68.753,55.072z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px"
height="800px" viewBox="0 0 512 512" xml:space="preserve">
<g id="7935ec95c421cee6d86eb22ecd128789">
<path style="display: inline;" d="M256.018,259.156c71.423,0,129.31-57.899,129.31-129.334C385.327,58.387,327.44,0.5,256.018,0.5
c-71.448,0-129.359,57.887-129.359,129.322C126.658,201.257,184.57,259.156,256.018,259.156z M256.018,66.196
c35.131,0,63.612,28.482,63.612,63.625c0,35.144-28.481,63.625-63.612,63.625c-35.168,0-63.638-28.481-63.638-63.625
C192.38,94.678,220.849,66.196,256.018,66.196z M405.075,274.938c-7.285-14.671-27.508-26.872-54.394-5.701
c-36.341,28.619-94.664,28.619-94.664,28.619s-58.361,0-94.702-28.619c-26.873-21.171-47.083-8.97-54.381,5.701
c-12.75,25.563,1.634,37.926,34.096,58.761c27.721,17.803,65.821,24.452,90.411,26.935l-20.535,20.535
c-28.918,28.905-56.826,56.838-76.201,76.213c-11.59,11.577-11.59,30.354,0,41.931l3.48,3.506c11.59,11.577,30.354,11.577,41.943,0
l76.201-76.214c28.943,28.919,56.851,56.839,76.225,76.214c11.59,11.577,30.354,11.577,41.943,0l3.48-3.506
c11.59-11.59,11.59-30.354,0-41.943l-76.201-76.2l-20.584-20.598c24.614-2.545,62.29-9.22,89.786-26.872
C403.441,312.863,417.801,300.5,405.075,274.938z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

37
public/images/ok-11.svg Normal file
View File

@ -0,0 +1,37 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
fill="black"
width="64"
height="64"
>
<!-- Ãîëîâà -->
<circle cx="50" cy="30" r="10" fill="black" />
<!-- Ðóêè -->
<path
d="M30 50 Q50 70, 70 50"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
<!-- Òåëî -->
<path
d="M50 40 L50 60"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
<!-- Íîãè -->
<path
d="M40 75 L50 60 L60 75"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

33
public/images/ok.svg Normal file
View File

@ -0,0 +1,33 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width="20"
height="20"
>
<!-- Âíóòðåííèé êðóã -->
<path
d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"
fill="#FF9900"
/>
<!-- Âíåøíèé êðóã -->
<path
d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
C27.722,17.365,36.4,8.734,47.08,8.766z"
fill="#FF6600"
/>
<!-- Ëèíèè è òî÷êè -->
<path
d="M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416c-2.991,1.156-6.268,1.742-9.512,2.13
c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27c1.494,1.511,1.81,3.386,0.985,5.145
c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582c-3.324-3.345-6.711-6.627-9.965-10.031
c-0.947-0.992-1.403-0.841..."
fill="#FF3300"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

9
public/robots.txt Normal file
View File

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

1
public/sitemap.xml Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://russcult.anibilag.ru/</loc><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>https://russcult.anibilag.ru/search</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://russcult.anibilag.ru/article/054af0fc-25e4-4169-88f4-38952742eb64</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0d854fd6-f846-4b33-a27f-01a49198fc6e</loc><lastmod>2025-02-12T08:31:07.750Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0fa77c25-df32-482c-8e55-90cc5a9798fb</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/141303a4-8932-4d5a-b7d9-0600f6eea996</loc><lastmod>2025-02-13T20:08:04.036Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/1431c796-bcac-4fc9-9482-705268629a0e</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/22f74530-d68f-4d11-880e-f1beb42ddee8</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/248c18f7-6252-415f-baae-96ad215a6c03</loc><lastmod>2025-02-18T10:21:12.746Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/2ba5bceb-95b1-45e4-ab49-2c84ff181d7c</loc><lastmod>2025-02-19T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5093e84f-f3c3-4809-abe9-00b477e9efa9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5453d8a9-2af8-49f1-bbca-cb362908d975</loc><lastmod>2025-02-12T20:12:40.391Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/58da4b5a-be98-4433-b77e-413540813688</loc><lastmod>2025-02-12T20:21:38.395Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/92f695f6-0492-42d7-b14e-c5c270ab6c0a</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/ce8252e4-1e3f-4d10-9a48-334e48df29e9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/?category=Film</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Theater</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Music</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Sports</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Art</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Legends</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Anniversaries</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Memory</loc><changefreq>daily</changefreq><priority>0.9</priority></url></urlset>

View File

@ -0,0 +1,62 @@
import { SitemapStream, streamToPromise } from 'sitemap';
import { Readable } from 'stream';
import { writeFileSync } from 'fs';
import axios from "axios";
async function generateSitemap() {
const API_URL = 'http://localhost:5000';
async function fetchArticlePaths() {
try {
const response = await axios.get(`${API_URL}/api/articles/sitemap/`);
return response.data.articles;
} catch (error) {
console.error('Ошибка при получении путей статей:', error.message);
return [];
}
}
const baseUrl = 'https://russcult.anibilag.ru'; // Replace with your actual domain
// Create a stream to write to
const stream = new SitemapStream({ hostname: baseUrl });
// Add static routes
const staticRoutes = [
{ url: '/', changefreq: 'daily', priority: 1.0 },
{ url: '/search', changefreq: 'weekly', priority: 0.8 },
];
const articles = await fetchArticlePaths();
// Add dynamic article routes
const articleRoutes = articles.map(article => ({
url: `/article/${article.id}`,
changefreq: 'weekly',
priority: 0.7,
lastmod: article.publishedAt
}));
// Add category routes
const categories = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
const categoryRoutes = categories.map(category => ({
url: `/?category=${category}`,
changefreq: 'daily',
priority: 0.9
}));
// Combine all routes
const links = [...staticRoutes, ...articleRoutes, ...categoryRoutes];
// Create sitemap from routes
const sitemap = await streamToPromise(
Readable.from(links).pipe(stream)
).then(data => data.toString());
// Write sitemap to public directory
writeFileSync('./public/sitemap.xml', sitemap);
console.log('Sitemap generated successfully!');
}
generateSitemap().catch(console.error);

Binary file not shown.

View File

@ -1,78 +0,0 @@
import * as winston from 'winston';
import 'winston-daily-rotate-file';
import * as path from 'path';
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define log level based on environment
const level = () => {
const env = process.env.NODE_ENV || 'development';
return env === 'development' ? 'debug' : 'warn';
};
// Define colors for each level
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'blue',
};
// Add colors to winston
winston.addColors(colors);
// Custom format for logging
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
);
// Define file transport options
const fileRotateTransport = new winston.transports.DailyRotateFile({
filename: path.join('logs', '%DATE%-server.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: winston.format.combine(
winston.format.uncolorize(),
winston.format.timestamp(),
winston.format.json()
),
});
// Create the logger
const logger = winston.createLogger({
level: level(),
levels,
format,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
fileRotateTransport,
],
});
// Create a stream object for Morgan middleware
const stream = {
write: (message: string) => {
logger.http(message.trim());
},
};
export { logger, stream };

View File

@ -1,26 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { PrismaClient } from '@prisma/client';
import authRoutes from './routes/auth.js';
import articleRoutes from './routes/articles';
import userRoutes from './routes/users.js';
dotenv.config();
const app = express();
const prisma = new PrismaClient();
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/users', userRoutes);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@ -1,45 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { logger, stream } from './config/logger.js';
import { requestLogger } from './middleware/logging/requestLogger.js';
import { errorLogger } from './middleware/error/errorLogger.js';
import authRoutes from './routes/auth/index.js';
import articleRoutes from './routes/articles/index.js';
import userRoutes from './routes/users/index.js';
// Load environment variables
dotenv.config();
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
app.use(requestLogger);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/users', userRoutes);
// Error handling
app.use(errorLogger);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});

View File

@ -1,29 +0,0 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from './types.js';
import { extractToken } from './extractToken.js';
import { validateToken } from './validateToken.js';
import { getUser } from './getUser.js';
export async function auth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const payload = validateToken(token);
if (!payload) {
return res.status(401).json({ error: 'Invalid token' });
}
const user = await getUser(payload.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Authentication failed' });
}
}

View File

@ -1,11 +0,0 @@
import { Request } from 'express';
export function extractToken(req: Request): string | null {
const authHeader = req.header('Authorization');
if (!authHeader) return null;
const [bearer, token] = authHeader.split(' ');
if (bearer !== 'Bearer' || !token) return null;
return token;
}

View File

@ -1,22 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { User } from '../../../src/types/auth.js';
const prisma = new PrismaClient();
export async function getUser(userId: string): Promise<User | null> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return user as User | null;
} catch {
return null;
}
}

View File

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

View File

@ -1,12 +0,0 @@
import { Request } from 'express';
import { User } from '../../../src/types/auth.js';
export interface AuthRequest extends Request {
user?: User;
}
export interface JwtPayload {
id: string;
iat?: number;
exp?: number;
}

View File

@ -1,10 +0,0 @@
import jwt from 'jsonwebtoken';
import { JwtPayload } from './types.js';
export function validateToken(token: string): JwtPayload | null {
try {
return jwt.verify(token, process.env.JWT_SECRET || '') as JwtPayload;
} catch {
return null;
}
}

View File

@ -1,20 +0,0 @@
import { Request, Response, NextFunction } from 'express';
export interface AppError extends Error {
statusCode?: number;
}
export function errorHandler(
err: AppError,
req: Request,
res: Response,
next: NextFunction
) {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
}

View File

@ -1,27 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../config/logger.js';
export interface AppError extends Error {
statusCode?: number;
details?: never;
}
export const errorLogger = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
const errorDetails = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
path: req.path,
method: req.method,
statusCode: err.statusCode || 500,
details: err.details,
};
logger.error('Application error:', errorDetails);
next(err);
};

View File

@ -1,21 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../config/logger.js';
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
if (res.statusCode >= 500) {
logger.error(message);
} else if (res.statusCode >= 400) {
logger.warn(message);
} else {
logger.info(message);
}
});
next();
};

View File

@ -1,17 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { Schema } from 'zod';
export function validateRequest(schema: Schema) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
});
next();
} catch (error) {
res.status(400).json({ error: 'Invalid request data' });
}
};
}

View File

@ -1,123 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
import { AuthRequest } from '../../../middleware/auth';
import { checkPermission } from '../../../utils/permissions.js';
export async function getArticle(req: Request, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function createArticle(req: AuthRequest, res: Response) {
try {
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
if (!req.user || !checkPermission(req.user, category, 'create')) {
return res.status(403).json({ error: 'Permission denied' });
}
const article = await prisma.article.create({
data: {
title,
excerpt,
content,
category,
city,
coverImage,
readTime,
authorId: req.user.id
},
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.status(201).json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateArticle(req: AuthRequest, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id }
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
if (!req.user || !checkPermission(req.user, article.category, 'edit')) {
return res.status(403).json({ error: 'Permission denied' });
}
const updatedArticle = await prisma.article.update({
where: { id: req.params.id },
data: req.body,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.json(updatedArticle);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function deleteArticle(req: AuthRequest, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id }
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
if (!req.user || !checkPermission(req.user, article.category, 'delete')) {
return res.status(403).json({ error: 'Permission denied' });
}
await prisma.article.delete({
where: { id: req.params.id }
});
res.json({ message: 'Article deleted successfully' });
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,41 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
export async function listArticles(req: Request, res: Response) {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const where = {
...(category && { category: category as string }),
...(city && { city: city as string })
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip: ((page as number) - 1) * perPage,
take: perPage,
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / perPage),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,44 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
export async function searchArticles(req: Request, res: Response) {
try {
const { q, page = 1, limit = 9 } = req.query;
const skip = ((page as number) - 1) * (limit as number);
const where = {
OR: [
{ title: { contains: q as string, mode: 'insensitive' } },
{ excerpt: { contains: q as string, mode: 'insensitive' } },
{ content: { contains: q as string, mode: 'insensitive' } },
]
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip,
take: parseInt(limit as string),
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / (limit as number)),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,93 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function getArticle(req: Request, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function createArticle(req: Request, res: Response) {
try {
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
const article = await prisma.article.create({
data: {
title,
excerpt,
content,
category,
city,
coverImage,
readTime,
authorId: req.user!.id
},
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.status(201).json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateArticle(req: Request, res: Response) {
try {
const article = await prisma.article.update({
where: { id: req.params.id },
data: req.body,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function deleteArticle(req: Request, res: Response) {
try {
await prisma.article.delete({
where: { id: req.params.id }
});
res.json({ message: 'Article deleted successfully' });
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,19 +0,0 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { searchArticles } from './controllers/search.js';
import { listArticles } from './controllers/list.js';
import { getArticle, createArticle, updateArticle, deleteArticle } from './controllers/crud.js';
const router = express.Router();
// Search and list routes
router.get('/search', searchArticles);
router.get('/', listArticles);
// CRUD routes
router.get('/:id', getArticle);
router.post('/', auth, createArticle);
router.put('/:id', auth, updateArticle);
router.delete('/:id', auth, deleteArticle);
export default router;

View File

@ -1,41 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function listArticles(req: Request, res: Response) {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const where = {
...(category && { category: category as string }),
...(city && { city: city as string })
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip: ((page as number) - 1) * perPage,
take: perPage,
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / perPage),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,44 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function searchArticles(req: Request, res: Response) {
try {
const { q, page = 1, limit = 9 } = req.query;
const skip = ((page as number) - 1) * (limit as number);
const where = {
OR: [
{ title: { contains: q as string, mode: 'insensitive' } },
{ excerpt: { contains: q as string, mode: 'insensitive' } },
{ content: { contains: q as string, mode: 'insensitive' } },
]
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip,
take: parseInt(limit as string),
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / (limit as number)),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,27 +0,0 @@
import express from 'express';
import { authService } from '../services/authService.js';
import { auth } from '../middleware/auth';
const router = express.Router();
// Login route
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const { user, token } = await authService.login(email, password);
res.json({ user, token });
} catch {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Get current user route
router.get('/me', auth, async (req, res) => {
try {
res.json(req.user);
} catch {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@ -1,36 +0,0 @@
import { Request, Response } from 'express';
import { AuthRequest } from '../../../middleware/auth';
import { authService } from '../../../services/authService.js';
export async function login(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await authService.login(email, password);
res.json({ user, token });
} catch {
res.status(401).json({ error: 'Invalid credentials' });
}
}
export async function getCurrentUser(req: AuthRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json(req.user);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function refreshToken(req: AuthRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const token = await authService.generateToken(req.user.id);
res.json({ token });
} catch {
res.status(500).json({ error: 'Failed to refresh token' });
}
}

View File

@ -1,12 +0,0 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { login, getCurrentUser } from './controllers/auth.js';
import { validateRequest } from '../../middleware/validation/validateRequest.js';
import { loginSchema } from './validation/authSchemas.js';
const router = express.Router();
router.post('/login', validateRequest(loginSchema), login);
router.get('/me', auth, getCurrentUser);
export default router;

View File

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

View File

@ -1,81 +0,0 @@
import { Request, Response } from 'express';
import { AuthRequest } from '../../../middleware/auth/types.js';
import { galleryService } from '../../../services/galleryService.js';
import { s3Service } from '../../../services/s3Service.js';
import { logger } from '../../../config/logger.js';
export async function createGalleryImage(req: AuthRequest, res: Response) {
try {
const { articleId } = req.params;
const { url, caption, alt, width, height, size, format } = req.body;
const image = await galleryService.createImage({
url,
caption,
alt,
width,
height,
size,
format,
articleId
});
res.status(201).json(image);
} catch (error) {
logger.error('Error creating gallery image:', error);
res.status(500).json({ error: 'Failed to create gallery image' });
}
}
export async function updateGalleryImage(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const { caption, alt, order } = req.body;
const image = await galleryService.updateImage(id, {
caption,
alt,
order
});
res.json(image);
} catch (error) {
logger.error('Error updating gallery image:', error);
res.status(500).json({ error: 'Failed to update gallery image' });
}
}
export async function deleteGalleryImage(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
await galleryService.deleteImage(id);
res.json({ message: 'Gallery image deleted successfully' });
} catch (error) {
logger.error('Error deleting gallery image:', error);
res.status(500).json({ error: 'Failed to delete gallery image' });
}
}
export async function reorderGalleryImages(req: AuthRequest, res: Response) {
try {
const { articleId } = req.params;
const { imageIds } = req.body;
await galleryService.reorderImages(articleId, imageIds);
res.json({ message: 'Gallery images reordered successfully' });
} catch (error) {
logger.error('Error reordering gallery images:', error);
res.status(500).json({ error: 'Failed to reorder gallery images' });
}
}
export async function getArticleGallery(req: Request, res: Response) {
try {
const { articleId } = req.params;
const images = await galleryService.getArticleGallery(articleId);
res.json(images);
} catch (error) {
logger.error('Error fetching article gallery:', error);
res.status(500).json({ error: 'Failed to fetch gallery images' });
}
}

View File

@ -1,19 +0,0 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import {
createGalleryImage,
updateGalleryImage,
deleteGalleryImage,
reorderGalleryImages,
getArticleGallery
} from './controllers/crud.js';
const router = express.Router();
router.get('/article/:articleId', getArticleGallery);
router.post('/article/:articleId', auth, createGalleryImage);
router.put('/:id', auth, updateGalleryImage);
router.delete('/:id', auth, deleteGalleryImage);
router.post('/article/:articleId/reorder', auth, reorderGalleryImages);
export default router;

View File

@ -1,58 +0,0 @@
import express from 'express';
import multer from 'multer';
import { auth } from '../../middleware/auth';
import { s3Service } from '../../services/s3Service.js';
import { logger } from '../../config/logger.js';
import { imageResolutions } from '../../../src/config/imageResolutions.js';
const router = express.Router();
const upload = multer();
router.post('/upload-url', auth, async (req, res) => {
try {
const { fileName, fileType, resolution } = req.body;
const selectedResolution = imageResolutions.find(r => r.id === resolution);
if (!selectedResolution) {
throw new Error('Invalid resolution');
}
const { uploadUrl, imageId, key } = await s3Service.getUploadUrl(fileName, fileType);
logger.info(`Generated upload URL for image: ${fileName}`);
res.json({ uploadUrl, imageId, key });
} catch (error) {
logger.error('Error generating upload URL:', error);
res.status(500).json({ error: 'Failed to generate upload URL' });
}
});
router.post('/process', auth, upload.single('image'), async (req, res) => {
try {
const { file } = req;
const { resolution } = req.body;
if (!file) {
throw new Error('No file uploaded');
}
const selectedResolution = imageResolutions.find(r => r.id === resolution);
if (!selectedResolution) {
throw new Error('Invalid resolution');
}
const result = await s3Service.optimizeAndUpload(
file.buffer,
file.originalname,
selectedResolution
);
logger.info(`Successfully processed image: ${file.originalname}`);
res.json(result);
} catch (error) {
logger.error('Error processing image:', error);
res.status(500).json({ error: 'Failed to process image' });
}
});
export default router;

View File

@ -1,35 +0,0 @@
import express from 'express';
import { userService } from '../services/userService.js';
import { auth } from '../middleware/auth';
import { Request, Response } from 'express';
import { User } from '../../src/types/auth.ts';
const router = express.Router();
router.get('/', auth, async (req: Request, res: Response) => {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await userService.getUsers();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
router.put('/:id/permissions', auth, async (req: Request, res: Response) => {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const { permissions } = req.body;
const user = await userService.updateUserPermissions(id, permissions);
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@ -1,29 +0,0 @@
import { Response } from 'express';
import { AuthRequest } from '../../../middleware/auth';
import { userService } from '../../../services/userService.js';
export async function getUsers(req: AuthRequest, res: Response) {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await userService.getUsers();
res.json(users);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateUserPermissions(req: AuthRequest, res: Response) {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const { permissions } = req.body;
const user = await userService.updateUserPermissions(id, permissions);
res.json(user);
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,10 +0,0 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { getUsers, updateUserPermissions } from './controllers/users.js';
const router = express.Router();
router.get('/', auth, getUsers);
router.put('/:id/permissions', auth, updateUserPermissions);
export default router;

View File

@ -1,95 +0,0 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { User } from '../../src/types/auth.js';
import { logger } from '../config/logger.js';
const prisma = new PrismaClient();
export const authService = {
login: async (email: string, password: string) => {
try {
logger.info(`Login attempt for user: ${email}`);
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
displayName: true,
permissions: true
}
});
if (!user) {
logger.warn(`Login failed: User not found - ${email}`);
throw new Error('Invalid credentials');
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
logger.warn(`Login failed: Invalid password for user - ${email}`);
throw new Error('Invalid credentials');
}
const token = await authService.generateToken(user.id);
const { password: _, ...userWithoutPassword } = user;
logger.info(`User logged in successfully: ${email}`);
return {
user: userWithoutPassword as User,
token
};
} catch (error) {
logger.error('Login error:', error);
throw error;
}
},
generateToken: async (userId: string) => {
try {
const token = jwt.sign(
{ id: userId },
process.env.JWT_SECRET || '',
{ expiresIn: '24h' }
);
logger.debug(`Generated token for user: ${userId}`);
return token;
} catch (error) {
logger.error('Token generation error:', error);
throw error;
}
},
createUser: async (userData: {
email: string;
password: string;
displayName: string;
permissions: any;
}) => {
try {
logger.info(`Creating new user: ${userData.email}`);
const hashedPassword = await bcrypt.hash(userData.password, 10);
const user = await prisma.user.create({
data: {
...userData,
password: hashedPassword
},
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
logger.info(`User created successfully: ${userData.email}`);
return user as User;
} catch (error) {
logger.error('User creation error:', error);
throw error;
}
}
};

View File

@ -1,92 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { logger } from '../config/logger.js';
const prisma = new PrismaClient();
export const galleryService = {
createImage: async (data: {
url: string;
caption: string;
alt: string;
width: number;
height: number;
size: number;
format: string;
articleId: string;
order?: number;
}) => {
try {
const image = await prisma.galleryImage.create({
data
});
logger.info(`Created gallery image: ${image.id}`);
return image;
} catch (error) {
logger.error('Error creating gallery image:', error);
throw error;
}
},
updateImage: async (
id: string,
data: {
caption?: string;
alt?: string;
order?: number;
}
) => {
try {
const image = await prisma.galleryImage.update({
where: { id },
data
});
logger.info(`Updated gallery image: ${id}`);
return image;
} catch (error) {
logger.error(`Error updating gallery image ${id}:`, error);
throw error;
}
},
deleteImage: async (id: string) => {
try {
await prisma.galleryImage.delete({
where: { id }
});
logger.info(`Deleted gallery image: ${id}`);
} catch (error) {
logger.error(`Error deleting gallery image ${id}:`, error);
throw error;
}
},
reorderImages: async (articleId: string, imageIds: string[]) => {
try {
await prisma.$transaction(
imageIds.map((id, index) =>
prisma.galleryImage.update({
where: { id },
data: { order: index }
})
)
);
logger.info(`Reordered gallery images for article: ${articleId}`);
} catch (error) {
logger.error(`Error reordering gallery images for article ${articleId}:`, error);
throw error;
}
},
getArticleGallery: async (articleId: string) => {
try {
const images = await prisma.galleryImage.findMany({
where: { articleId },
orderBy: { order: 'asc' }
});
return images;
} catch (error) {
logger.error(`Error fetching gallery for article ${articleId}:`, error);
throw error;
}
}
};

View File

@ -1,81 +0,0 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import { logger } from '../config/logger.js';
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
}
});
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
export const s3Service = {
getUploadUrl: async (fileName: string, fileType: string) => {
const imageId = uuidv4();
const key = `uploads/${imageId}-${fileName}`;
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: fileType
});
try {
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
logger.info(`Generated pre-signed URL for upload: ${key}`);
return { uploadUrl, imageId, key };
} catch (error) {
logger.error('Error generating pre-signed URL:', error);
throw error;
}
},
optimizeAndUpload: async (buffer: Buffer, key: string, resolution: { width: number; height: number }) => {
try {
let sharpInstance = sharp(buffer);
// Get image metadata
const metadata = await sharpInstance.metadata();
// Resize if resolution is specified
if (resolution.width > 0 && resolution.height > 0) {
sharpInstance = sharpInstance.resize(resolution.width, resolution.height, {
fit: 'inside',
withoutEnlargement: true
});
}
// Convert to WebP for better compression
const optimizedBuffer = await sharpInstance
.webp({ quality: 80 })
.toBuffer();
// Upload optimized image
const optimizedKey = key.replace(/\.[^/.]+$/, '.webp');
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: optimizedKey,
Body: optimizedBuffer,
ContentType: 'image/webp'
}));
logger.info(`Successfully optimized and uploaded image: ${optimizedKey}`);
return {
key: optimizedKey,
width: metadata.width,
height: metadata.height,
format: 'webp',
size: optimizedBuffer.length
};
} catch (error) {
logger.error('Error optimizing and uploading image:', error);
throw error;
}
}
};

View File

@ -1,44 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { User } from '../../src/types/auth';
const prisma = new PrismaClient();
export const userService = {
getUsers: async (): Promise<User[]> => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return users as User[];
} catch (error) {
console.error('Error fetching users:', error);
throw new Error('Failed to fetch users');
}
},
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
try {
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
permissions: permissions as any
},
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return updatedUser as User;
} catch (error) {
console.error('Error updating user permissions:', error);
throw new Error('Failed to update user permissions');
}
}
};

View File

@ -1,16 +0,0 @@
import { Category, City } from '../../src/types';
import { User } from '../../src/types/auth';
export const checkPermission = (
user: User,
category: Category,
action: 'create' | 'edit' | 'delete'
): boolean => {
if (user.permissions.isAdmin) return true;
return !!user.permissions.categories[category]?.[action];
};
export const checkCityAccess = (user: User, city: City): boolean => {
if (user.permissions.isAdmin) return true;
return user.permissions.cities.includes(city);
};

View File

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

View File

@ -1,7 +1,8 @@
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Article } from '../types';
import MinutesWord from './MinutesWord.tsx';
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';
interface ArticleCardProps {
article: Article;
@ -9,51 +10,81 @@ 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 ? 'col-span-2 row-span-2' : ''
}`}>
<div className="relative">
featured ? 'sm:col-span-2 sm:row-span-2' : ''
} flex flex-col`}>
<div className="relative pt-7">
<img
src={article.coverImage}
alt={article.title}
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
className={`w-full object-cover ${featured ? 'h-64 sm:h-96' : 'h-64'}`}
/>
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
{article.category}
<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]}
</span>
<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 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>
</div>
</div>
<div className="p-6">
<div className="p-6 flex flex-col flex-grow">
<div className="flex items-center gap-4 mb-4">
<img
src={article.author.avatar}
alt={article.author.name}
className="w-10 h-10 rounded-full"
/>
{writerAuthors.map((authorLink) => (
<img
key={authorLink.author.id}
src={authorLink.author.avatarUrl}
alt={authorLink.author.displayName}
className="w-10 h-10 rounded-full"
/>
))}
<div>
<p className="text-sm font-medium text-gray-900">{article.author.name}</p>
<p className="text-sm font-medium text-gray-900">
{writerAuthors.map((a, i) => (
<span key={a.author.id}>
{a.author.displayName}
{i < writerAuthors.length - 1 ? ', ' : ''}
</span>
))}
</p>
<div className="flex items-center text-sm text-gray-500">
<Clock size={14} className="mr-1" />
{article.readTime} <MinutesWord minutes={article.readTime} /> на чтение
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
<h2 className={`font-bold text-gray-900 mb-2 ${featured ? 'text-2xl' : 'text-xl'}`}>
<h2 className={`font-bold text-gray-900 mb-2 ${featured ? 'text-xl sm:text-2xl' : 'text-xl'}`}>
{article.title}
</h2>
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
<div className="mt-4 flex items-center justify-between">
<div className="p-4 mt-auto flex items-center justify-between">
<Link
to={`/article/${article.id}`}
state={{ from: location.pathname + location.search }}
onClick={handleArticleClick}
className="text-blue-600 font-medium hover:text-blue-800"
>
Читать
@ -61,6 +92,10 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
<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>

View File

@ -0,0 +1,49 @@
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Blockquote from '@tiptap/extension-blockquote';
import Link from '@tiptap/extension-link';
import { CustomImage } from './CustomImageExtension';
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
interface ArticleContentProps {
content: string;
}
// Показ контента статьи в редакторе, в режиме "только чтение"
export function ArticleContent({ content }: ArticleContentProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false, // Отключаем дефолтный
}),
Blockquote.configure({
HTMLAttributes: {
class: 'border-l-4 border-gray-300 pl-4 italic',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
CustomImage,
Highlight,
Link.configure({ // Добавляем расширение Link
openOnClick: false, // Отключаем автоматическое открытие ссылок при клике (удобно для редактирования)
HTMLAttributes: {
class: 'text-blue-500 underline', // Стили для ссылок
target: '_blank', // Открывать в новой вкладке
rel: 'noopener noreferrer', // Безопасность
},
}),
],
content,
editable: false, // Контент только для чтения
});
return (
<div className="prose prose-lg max-w-none mb-8">
<EditorContent editor={editor} />
</div>
);
}

View File

@ -0,0 +1,29 @@
interface DeleteModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export function ArticleDeleteModal({ onConfirm, onCancel }: DeleteModalProps) {
return (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Удалить статью</h3>
<p className="text-sm text-gray-500 mb-6">Вы уверены? Это действие нельзя отменить.</p>
<div className="flex justify-end gap-4">
<button
onClick={onCancel}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Отмена
</button>
<button
onClick={onConfirm}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
>
Удалить
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,552 @@
import React, {useEffect, useState} from 'react';
import { TipTapEditor } from './Editor/TipTapEditor';
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
import { ImageUploader } from './ImageUpload/ImageUploader';
import { GalleryManager } from './GalleryManager';
import { useGallery } from '../hooks/useGallery';
import { ArticleData, Author, AuthorLink, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
import { useAuthStore } from '../stores/authStore';
import ConfirmModal from './ConfirmModal';
import RolesWord from "./Words/RolesWord";
import { Trash2, UserPlus } from "lucide-react";
import isEqual from 'lodash/isEqual';
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
WRITER: 'Автор статьи',
PHOTOGRAPHER: 'Фотограф',
EDITOR: 'Редактор',
TRANSLATOR: 'Переводчик',
};
interface FormState {
title: string;
excerpt: string;
categoryId: number;
cityId: number;
coverImage: string;
readTime: number;
content: string;
authors: {
role: AuthorRole;
author: Author;
}[];
galleryImages: GalleryImage[];
}
interface ArticleFormProps {
editingId: string | null;
articleId: string;
initialFormState: FormState | null;
onSubmit: (articleData: ArticleData, closeForm: boolean) => Promise<void>;
onCancel: () => void;
authors: Author[];
availableCategoryIds: number[];
availableCityIds: number[];
}
export function ArticleForm({
editingId,
articleId,
initialFormState,
onSubmit,
onCancel,
authors,
availableCategoryIds,
availableCityIds,
}: ArticleFormProps) {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const showGallery = false;
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
const [categoryId, setCategoryId] = useState(availableCategoryIds[0] || 1);
const [cityId, setCityId] = useState(availableCityIds[0] || 1);
const [coverImage, setCoverImage] = useState('/images/cover-placeholder.webp');
const [readTime, setReadTime] = useState(5);
const [content, setContent] = useState('');
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
const [formNewImageUrl, setFormNewImageUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // Добавляем флаг для отслеживания отправки
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна
const [newRole, setNewRole] = useState('');
const [newAuthorId, setNewAuthorId] = useState('');
const [showAddAuthorModal, setShowAddAuthorModal] = useState(false);
const [formReady, setFormReady] = useState(false);
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
const [selectedAuthors, setSelectedAuthors] = useState<AuthorLink[]>([]);
useEffect(() => {
if (initialFormState) setFormReady(true);
}, [initialFormState]);
// Добавляем обработку ошибок
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
console.error('Unhandled promise rejection in ArticleForm:', event.reason);
event.preventDefault(); // Предотвращаем "всплытие" ошибки
};
const handleError = (event: ErrorEvent) => {
console.error('Unhandled error in ArticleForm:', event.error);
event.preventDefault(); // Предотвращаем "всплытие" ошибки
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
window.removeEventListener('error', handleError);
};
}, []);
useEffect(() => {
if (editingId) {
setDisplayedImages(galleryImages);
} else {
setDisplayedImages([]);
}
}, [editingId, galleryImages]);
useEffect(() => {
if (initialFormState) {
setTitle(initialFormState.title);
setExcerpt(initialFormState.excerpt);
setCategoryId(initialFormState.categoryId);
setCityId(initialFormState.cityId);
setCoverImage(initialFormState.coverImage);
setReadTime(initialFormState.readTime);
setContent(initialFormState.content);
setDisplayedImages(initialFormState.galleryImages || []);
setSelectedAuthors(
(initialFormState.authors || []).map(a => ({
authorId: a.author.id, // 👈 добавить вручную
role: a.role,
author: a.author
}))
);
// console.log('Содержимое статьи при загрузке:', initialFormState.content);
}
}, [initialFormState]);
useEffect(() => {
if (!initialFormState || !formReady) return;
const currentState: FormState = {
title,
excerpt,
categoryId,
cityId,
coverImage,
readTime,
content,
authors: selectedAuthors,
galleryImages: showGallery ? displayedImages : initialFormState.galleryImages,
};
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
const hasFormChanges = Object.keys(initialFormState).some(key => {
if (!formReady) return false;
if (key === 'galleryImages') {
if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
if (isInitialLoad && isDifferent) return false;
return isDifferent;
}
if (key === 'content') {
return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
}
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
return !isEqual(currentValue, initialValue);
});
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
if (isInitialLoad) {
setIsInitialLoad(false);
}
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, selectedAuthors, displayedImages, initialFormState, isInitialLoad, formReady, showGallery]);
const filteredAuthors = authors.filter(
(a) =>
a.roles.includes(newRole as AuthorRole) && // 🔹 автор имеет нужную роль
!selectedAuthors.some(sel => sel.authorId === a.id && sel.role === newRole) // 🔹 не выбран уже
);
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
e.preventDefault();
//console.log('Вызов handleSubmit:', { closeForm });
//console.log('Содержимое статьи перед сохранением:', content);
if (isSubmitting) {
//console.log('Форма уже отправляется, игнорируем повторную отправку');
return;
}
if (!title.trim() || !excerpt.trim()) {
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
return;
}
if (!hasChanges) return;
const articleData: ArticleData = {
title,
excerpt,
categoryId,
cityId,
coverImage,
readTime,
gallery: displayedImages,
content: content || '',
importId: 0,
isActive: false,
authors: selectedAuthors,
};
try {
setIsSubmitting(true);
await onSubmit(articleData, closeForm);
} catch (error) {
console.error('Ошибка при сохранении статьи:', error);
setError('Не удалось сохранить статью. Пожалуйста, попробуйте снова.');
} finally {
setIsSubmitting(false);
}
};
const handleApply = (e: React.FormEvent) => {
handleSubmit(e, false);
};
const handleCancel = () => {
if (hasChanges) {
setIsConfirmModalOpen(true); // Открываем модальное окно
} else {
onCancel();
}
};
const handleConfirmCancel = () => {
setIsConfirmModalOpen(false);
onCancel();
};
const handleOpenModal = () => {
setNewAuthorId('');
setNewRole('WRITER');
setShowAddAuthorModal(true);
};
const handleCloseModal = () => {
setIsConfirmModalOpen(false);
};
const handleAddAuthor = () => {
const author = authors.find(a => a.id === newAuthorId);
if (author) {
setSelectedAuthors([...selectedAuthors, { authorId: author.id, role: newRole as AuthorRole, author }]);
setShowAddAuthorModal(false);
setNewAuthorId('');
setNewRole('');
}
};
const handleRemoveAuthor = (authorId: string, role: AuthorRole) => {
setSelectedAuthors(prev =>
prev.filter(a => !(a.authorId === authorId && a.role === role))
);
};
if (editingId && galleryLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{editingId ? 'Редактировать статью' : 'Создать новую статью'}</h1>
{(error || galleryError) && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error || galleryError}</div>
)}
<form onSubmit={(e) => handleSubmit(e, true)} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Заголовок</span> <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Краткое описание</span> <span className="text-red-500">*</span>
</label>
<textarea
id="excerpt"
rows={3}
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span>
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCategoryIds.map(cat => <option key={cat} value={cat}>{CategoryTitles[cat]}</option>)}
</select>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Столица</span>
</label>
<select
id="city"
value={cityId}
onChange={(e) => setCityId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCityIds.map(c => <option key={c} value={c}>{CityTitles[c]}</option>)}
</select>
</div>
<div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Время чтения (минуты)</span>
</label>
<input
type="number"
id="readTime"
min="1"
value={readTime}
onChange={(e) => setReadTime(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
/>
</div>
{editingId && isAdmin && (
<div className="space-y-4">
<div className="flex items-center mb-2">
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Авторы статьи</span>
</label>
<button
type="button"
onClick={handleOpenModal}
className="p-2 rounded-full hover:bg-gray-100 text-gray-500"
>
<UserPlus size={18} />
</button>
</div>
{/* Таблица выбранных авторов */}
<ul className="space-y-2">
{selectedAuthors.map((a, index) => (
<li key={index} className="flex items-center justify-between border p-2 rounded-md">
<div className="flex items-center space-x-2">
<span className="font-semibold">{a.author.displayName}</span>
<span className="text-sm text-gray-500">(<RolesWord role={a.role}/>)</span>
</div>
<button
type="button"
onClick={() => handleRemoveAuthor(a.authorId, a.role)}
className="p-2 rounded-full hover:bg-gray-100 text-red-400"
>
<Trash2 size={18} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
<CoverImageUpload
coverImage={coverImage}
articleId={articleId}
onImageUpload={setCoverImage}
onError={setError}
allowUpload={!!editingId}
/>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Статья</span>
</label>
<TipTapEditor
initialContent={content}
onContentChange={setContent}
articleId={articleId}
/>
</div>
{showGallery && (
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Галерея</span>
</label>
{editingId ? (
<>
<ImageUploader
onUploadComplete={(imageUrl) => {
setFormNewImageUrl(imageUrl);
}}
articleId={articleId}
/>
<GalleryManager
images={displayedImages}
imageUrl={formNewImageUrl}
setImageUrl={setFormNewImageUrl}
onAddImage={async (imageData) => {
console.log('Добавляем изображение в форме:', imageData);
const newImage = await addGalleryImage(imageData);
setFormNewImageUrl('');
setDisplayedImages(prev => [...prev, newImage]);
}}
onReorder={(images) => {
console.log('Переупорядочиваем изображения в форме:', images);
reorderGalleryImages(images.map(img => img.id));
setDisplayedImages(images);
}}
onDelete={(id) => {
console.log('Удаляем изображение в форме:', id);
deleteGalleryImage(id);
setDisplayedImages(prev => prev.filter(img => img.id !== id));
}}
onEdit={(image) => {
console.log('Редактируем изображение в форме:', image);
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
console.error('Ошибка при редактировании изображения в форме:', err);
setError('Не удалось обновить изображение');
});
}}
/>
</>
) : (
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
)}
</div>
)}
<div className="flex justify-end gap-4">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Отмена
</button>
{!editingId && (
<button
type="button"
onClick={handleApply}
disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
>
Применить
</button>
)}
<button
type="submit"
disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
{editingId ? 'Изменить' : 'Сохранить черновик'}
</button>
</div>
</form>
<ConfirmModal
isOpen={isConfirmModalOpen}
onConfirm={handleConfirmCancel}
onCancel={handleCloseModal}
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
/>
{/* Модальное окно выбора автора */}
{showAddAuthorModal && (
<>
<div className="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center">
<div className="bg-white p-6 rounded shadow-md max-w-md w-full">
<h4 className="text-lg font-bold mb-4">Добавить автора</h4>
<div className="mb-4">
<div className="space-y-2">
{Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => (
<label key={key} className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="author-role"
value={key}
checked={newRole === key}
onChange={() => {
setNewRole(key);
setNewAuthorId('');
}}
className="text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
))}
</div>
</div>
<label className="block text-sm font-medium text-gray-700 mb-1">Выбор автора</label>
<select
value={newAuthorId}
onChange={(e) => setNewAuthorId(e.target.value)}
className="w-full mb-4 border rounded px-2 py-1"
>
<option value="">Выберите автора</option>
{filteredAuthors.map(a => (
<option key={a.id} value={a.id}>
{a.displayName}
</option>
))}
</select>
<div className="flex justify-end space-x-2">
<button onClick={() => setShowAddAuthorModal(false)} className="text-gray-500">Отмена</button>
<button
onClick={handleAddAuthor}
disabled={!newAuthorId || !newRole}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Добавить
</button>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,265 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Article } from '../types';
import { CategoryTitles, CityTitles } from '../types';
import { Pencil, Trash2, ImagePlus, ToggleLeft, ToggleRight, Plus } from 'lucide-react';
import MinutesWord from './Words/MinutesWord';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import {Pagination} from "./Pagination.tsx";
import {useSearchParams} from "react-router-dom";
interface ArticleListProps {
articles: Article[];
setArticles: React.Dispatch<React.SetStateAction<Article[]>>;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onShowGallery: (id: string) => void;
onNewArticle: () => void;
refreshTrigger: number;
}
const ARTICLES_PER_PAGE = 6;
export const ArticleList = React.memo(function ArticleList({
articles,
setArticles,
onEdit,
onDelete,
onShowGallery,
onNewArticle,
refreshTrigger,
}: ArticleListProps) {
const { user } = useAuthStore();
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
const [totalPages, setTotalPages] = useState(1);
const [totalArticles, setTotalArticles] = useState(0);
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const [filterCategoryId, setFilterCategoryId] = useState(0);
const [filterCityId, setFilterCityId] = useState(0);
const [showDraftOnly, setShowDraftOnly] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchArticles = async () => {
setLoading(true);
try {
const response = await axios.get('/api/articles/', {
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
});
setArticles(response.data.articles);
setTotalPages(response.data.totalPages);
setTotalArticles(response.data.total);
} catch (error) {
setError('Не удалось загрузить статьи');
console.error(error);
} finally {
setLoading(false);
}
};
fetchArticles();
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshTrigger, user, isAdmin, setArticles]);
const handleToggleActive = async (id: string) => {
const article = articles.find(a => a.id === id);
if (article) {
try {
await axios.put(
`/api/articles/active/${id}`,
{ isActive: !article.isActive },
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
);
setArticles(prev => prev.map(a => (a.id === id ? { ...a, isActive: !a.isActive } : a)));
// Обновляем список с сервера после изменения статуса
const response = await axios.get('/api/articles/', {
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
});
setArticles(response.data.articles);
setTotalPages(response.data.totalPages);
} catch (error) {
setError('Не удалось переключить статус статьи');
console.error(error);
}
}
};
const handlePageChange = (page: number) => {
searchParams.set('page', page.toString());
setSearchParams(searchParams);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
return (
<div className="bg-white rounded-lg shadow-sm mb-8">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
<div className="flex flex-wrap gap-4">
<select
value={filterCategoryId}
onChange={(e) => {
setFilterCategoryId(Number(e.target.value));
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="">Все категории</option>
{availableCategoryIds.map(cat => (
<option key={cat} value={cat}>
{CategoryTitles[cat]}
</option>
))}
</select>
<select
value={filterCityId}
onChange={(e) => {
setFilterCityId(Number(e.target.value));
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="">Все столицы</option>
{availableCityIds.map(c => (
<option key={c} value={c}>
{CityTitles[c]}
</option>
))}
</select>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={showDraftOnly}
onChange={(e) => {
setShowDraftOnly(e.target.checked)
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Только черновики</span>
</label>
{!hasNoPermissions && (
<button
onClick={onNewArticle}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<Plus size={16} className="mr-2" /> Новая статья
</button>
)}
</div>
<div className="text-sm text-gray-500">
<p className="font-bold text-gray-600">
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
-
{Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}
</p>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center p-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : hasNoPermissions ? (
<div className="p-6 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Недостаточно прав</h1>
<p className="text-gray-600">У вас нет прав на создание и редактирование статей. Свяжитесь с администратором.</p>
</div>
) : (
<>
<ul className="divide-y divide-gray-200">
{articles.map(article => (
<li key={article.id} className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate">{article.title}</h3>
<p className="text-sm text-gray-500">
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime}{' '}
<MinutesWord minutes={article.readTime} /> чтения
</p>
{(() => {
const writerAuthors = article.authors
.filter(a => a.role === 'WRITER')
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
if (!writerAuthors) return null;
return (
<div className="flex items-center text-xs text-gray-500 mt-1">
{writerAuthors.map((authorLink) => (
<img
key={authorLink.author.id}
src={authorLink.author.avatarUrl}
alt={authorLink.author.displayName}
className="h-6 w-6 rounded-full mr-1"
/>
))}
<div>
<p className="text-sm font-medium text-gray-900">
{writerAuthors.map((a, i) => (
<span key={a.author.id}>
{a.author.displayName}
{i < writerAuthors.length - 1 ? ', ' : ''}
</span>
))}
</p>
</div>
</div>
);
})()}
</div>
<div className="flex items-center gap-4 ml-4">
<button
onClick={() => handleToggleActive(article.id)}
className={`p-2 rounded-full hover:bg-gray-100 ${article.isActive ? 'text-green-600' : 'text-gray-400'}`}
title={article.isActive ? 'Set as draft' : 'Publish article'}
>
{article.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
</button>
<button
onClick={() => onEdit(article.id)}
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
>
<Pencil size={18} />
</button>
<button
onClick={() => onShowGallery(article.id)}
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
>
<ImagePlus size={18} />
</button>
<button
onClick={() => onDelete(article.id)}
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50"
>
<Trash2 size={18} />
</button>
</div>
</div>
</li>
))}
</ul>
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</>
)}
{error && isAdmin && (
<div className="p-4 text-red-700 bg-red-50 rounded-md">{error}</div>
)}
</div>
);
});

View File

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

View File

@ -0,0 +1,86 @@
import React, { useState, useEffect } from "react";
import { Author, User } from "../types/auth.ts";
interface AuthorModalProps {
open: boolean;
onClose: () => void;
onSave: (author: Partial<Author>) => void;
author?: Author | null;
users: User[];
}
const AuthorModal: React.FC<AuthorModalProps> = ({ open, onClose, onSave, author, users }) => {
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [userId, setUserId] = useState<string | undefined>(undefined);
useEffect(() => {
if (author) {
setDisplayName(author.displayName || "");
setBio(author.bio || "");
setUserId(author.userId);
} else {
setDisplayName("");
setBio("");
setUserId(undefined);
}
}, [author, open]);
const handleSubmit = () => {
onSave({
id: author?.id,
displayName,
bio,
userId: userId || undefined,
});
onClose();
};
if (!open) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded p-4 w-[400px] shadow-xl">
<h2 className="text-lg font-bold mb-2">{author ? "Редактировать" : "Создать"} автора</h2>
<label className="block text-sm mb-1">Имя</label>
<input
className="w-full border rounded px-2 py-1 mb-3"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<label className="block text-sm mb-1">Биография</label>
<textarea
className="w-full border rounded px-2 py-1 mb-3"
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<label className="block text-sm mb-1">Пользователь</label>
<select
className="w-full border rounded px-2 py-1 mb-4"
value={userId || ""}
onChange={(e) => setUserId(e.target.value || undefined)}
>
<option value=""> не связан </option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName}
</option>
))}
</select>
<div className="flex justify-end gap-2">
<button className="px-3 py-1 border rounded" onClick={onClose}>Отмена</button>
<button className="px-3 py-1 bg-blue-600 text-white rounded" onClick={handleSubmit}>
Сохранить
</button>
</div>
</div>
</div>
);
};
export default AuthorModal;

View File

@ -0,0 +1,129 @@
import {Globe, Mail, ThumbsUp} from 'lucide-react';
import {useEffect, useState} from "react";
import axios from "axios";
import { Author } from "../types/auth";
import { Link } from 'react-router-dom';
import { VkIcon } from "../icons/custom/VkIcon";
import { OkIcon } from "../icons/custom/OkIcon";
import ArticlesWord from './Words/ArticlesWord';
export function AuthorsSection() {
const [authors, setAuthors] = useState<Author[]>([]);
const showAuthors = false;
// Загрузка авторов
useEffect(() => {
const fetchAuthors = async () => {
try {
const response = await axios.get('/api/authors', {
params: { role: 'WRITER' },
});
setAuthors(response.data);
} catch (error) {
console.error('Ошибка загрузки авторов:', error);
}
};
fetchAuthors();
}, []);
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
{showAuthors && (
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900">Наши авторы</h2>
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
Познакомьтесь с талантливыми писателями и экспертами, работающими для Вас
</p>
</div>
)}
{showAuthors && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{authors
.filter(a => a.isActive)
.map((author) => (
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02] h-full">
<div className="p-6 flex flex-col flex-grow min-h-[200px] h-full">
<div className="flex items-center mb-4">
<img
src={author.avatarUrl}
alt={author.displayName}
className="w-14 h-14 rounded-full object-cover mr-4 transition-transform duration-300 hover:scale-150"
/>
<div>
<h3 className="text-xl font-bold text-gray-900">{author.displayName}</h3>
<div className="flex mt-2 space-x-2">
{author.okUrl && (
<a
href={author.okUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-500 transition-colors"
>
<OkIcon size={18} />
</a>
)}
{author.vkUrl && (
<a
href={author.vkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-pink-500 transition-colors"
>
<VkIcon size={18} />
</a>
)}
{author.websiteUrl && (
<a
href={author.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Globe size={18} />
</a>
)}
{author.email && (
<a
href={author.email}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Mail size={18} />
</a>
)}
</div>
</div>
</div>
<p className="text-gray-600">{author.bio}</p>
<div className="mt-auto pt-6 border-t border-gray-100">
<div className="flex justify-between items-center">
<div className="flex items-center text-sm text-gray-500 space-x-2">
<span> {author.articlesCount} <ArticlesWord articles={author.articlesCount} /> · </span>
<div className="flex items-center">
<ThumbsUp size={16} className="mr-1" />
<span>{author.totalLikes}</span>
</div>
</div>
<Link
to={`/search?author=${author.id}&role=WRITER&authorName=${author.displayName}`}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Статьи автора
</Link>
</div>
</div>
</div>
</div>
))}
</div>
)}
</section>
);
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Bookmark } from 'lucide-react';
import { useBookmarkStore } from '../stores/bookmarkStore';
import { Article } from '../types';
@ -33,7 +32,7 @@ export function BookmarkButton({ article, className = '' }: BookmarkButtonProps)
}`}
/>
<span className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{bookmarked ? 'Remove bookmark' : 'Add bookmark'}
{bookmarked ? 'Убрать закладку' : 'Добавить закладку'}
</span>
</button>
);

View File

@ -0,0 +1,36 @@
import React from 'react';
interface ConfirmDeleteModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ isOpen, onConfirm, onCancel }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow p-6 w-80 text-center space-y-4">
<h2 className="text-lg font-bold">Удалить изображение?</h2>
<p className="text-gray-600 text-sm">Вы уверены, что хотите удалить это изображение?</p>
<div className="flex justify-center space-x-4 mt-4">
<button
onClick={onConfirm}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
Да, удалить
</button>
<button
onClick={onCancel}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
Отмена
</button>
</div>
</div>
</div>
);
};
export default ConfirmDeleteModal;

View File

@ -0,0 +1,36 @@
import React from 'react';
interface ConfirmModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
message: string;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, onConfirm, onCancel, message }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
<p className="text-gray-700 mb-4">{message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
Отмена
</button>
<button
onClick={onConfirm}
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
>
Подтвердить
</button>
</div>
</div>
</div>
);
};
export default ConfirmModal;

View File

@ -0,0 +1,138 @@
import { useState, forwardRef, useImperativeHandle } from 'react';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import Highlight from '@tiptap/extension-highlight';
import Blockquote from "@tiptap/extension-blockquote";
export interface ContentEditorRef {
setContent: (content: string) => void;
getEditor: () => Editor | null;
}
const ContentEditor = forwardRef<ContentEditorRef>((_, ref) => {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
class: {
default: 'max-w-full h-auto', // Добавлено для адаптивности
},
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false, // Отключаем дефолтный
}),
Blockquote.configure({
HTMLAttributes: {
class: 'border-l-4 border-gray-300 pl-4 italic',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
ResizableImage,
Highlight,
],
content: '',
onUpdate: ({ editor }) => {
const pos = editor.state.selection.$anchor.pos;
const resolvedPos = editor.state.doc.resolve(pos);
const parentNode = resolvedPos.parent;
if (parentNode.type.name === 'image') {
setSelectedImage(parentNode.attrs.src); // Сохраняем URL изображения
} else {
setSelectedImage(null); // Очищаем выбор, если это не изображение
}
},
editorProps: {
attributes: {
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
},
},
});
// Позволяем родительскому компоненту использовать методы редактора
useImperativeHandle(ref, () => ({
setContent: (content: string) => {
editor?.commands.setContent(content);
},
getEditor: () => editor,
}));
// Функция изменения размера изображения
const updateImageSize = (delta: number) => {
if (selectedImage) {
const attrs = editor?.getAttributes('image');
const newWidth = Math.max((parseInt(attrs?.width) || 100) + delta, 50);
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
}
};
return (
<div className="relative">
<EditorContent editor={editor} />
{selectedImage && (
<div
className="absolute z-10 flex space-x-2 bg-white border p-1 rounded shadow-lg"
style={{
top: editor?.view?.dom
? `${editor.view.dom.getBoundingClientRect().top + window.scrollY + 50}px`
: '0px',
left: editor?.view?.dom
? `${editor.view.dom.getBoundingClientRect().left + window.scrollX + 200}px`
: '0px',
}}
>
<button
onClick={() => updateImageSize(-20)}
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
>
Уменьшить
</button>
<button
onClick={() => updateImageSize(20)}
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
>
Увеличить
</button>
</div>
)}
</div>
);
});
ContentEditor.displayName = 'ContentEditor';
export default ContentEditor;

View File

@ -0,0 +1,252 @@
import React, { useState, useRef, useEffect } from 'react';
import { mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import { GripVertical, Trash2, ZoomIn, ZoomOut } from 'lucide-react';
import { Node as ProseMirrorNode, DOMOutputSpec } from 'prosemirror-model';
import ConfirmDeleteModal from "./ConfirmDeleteModal";
interface CustomImageAttributes {
src: string | undefined;
alt: string | undefined;
title: string | undefined;
scale: number;
caption: string;
}
const ImageNodeView: React.FC<NodeViewProps> = ({ node, editor, getPos, selected, deleteNode }) => {
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
const scale = typedNode.attrs.scale || 1;
const caption = typedNode.attrs.caption || '';
const isEditable = editor.isEditable;
const captionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (captionRef.current && caption.trim() !== captionRef.current.innerText.trim()) {
captionRef.current.innerText = caption;
}
}, [caption]);
const [isConfirmModalOpen, setConfirmModalOpen] = useState(false);
const handleZoomIn = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
editor.commands.updateAttributes('customImage', {
scale: Math.min(scale + 0.1, 2),
});
};
const handleZoomOut = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
editor.commands.updateAttributes('customImage', {
scale: Math.max(scale - 0.1, 0.5),
});
};
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setConfirmModalOpen(true);
};
const handleCaptionBlur = (e: React.FocusEvent<HTMLDivElement>) => {
const newCaption = e.currentTarget.innerText;
if (newCaption !== caption) {
editor.commands.command(({ tr, state }) => {
const pos = getPos();
if (pos == null) return false;
const nodeAtPos = state.doc.nodeAt(pos);
if (!nodeAtPos || nodeAtPos.type.name !== 'customImage') {
return false;
}
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
caption: newCaption,
});
editor.view.dispatch(tr);
editor.emit('update', {
editor,
transaction: tr,
});
return true;
});
}
};
return (
<NodeViewWrapper className="relative flex flex-col items-center my-4">
<div className="flex w-full items-start">
{isEditable && (
<div
className="drag-handle cursor-move mr-2 text-gray-400 hover:text-gray-600"
data-drag-handle=""
title="Перетащить"
>
<GripVertical size={16} />
</div>
)}
<div className="flex-1">
<div className="flex flex-col items-center w-full">
<div
className="relative flex flex-col items-center"
style={{ width: `${scale * 100}%`, transition: 'width 0.2s' }}
>
<img
src={typedNode.attrs.src}
alt={typedNode.attrs.alt}
title={typedNode.attrs.title}
className={`max-w-full ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
data-scale={scale}
/>
{selected && isEditable && (
<div className="absolute top-2 right-2 flex items-center space-x-2 bg-white bg-opacity-80 p-1 rounded shadow z-10">
<button type="button" onClick={handleZoomIn} className="p-1 rounded hover:bg-gray-200" title="Увеличить">
<ZoomIn size={16} />
</button>
<button type="button" onClick={handleZoomOut} className="p-1 rounded hover:bg-gray-200" title="Уменьшить">
<ZoomOut size={16} />
</button>
<button type="button" onClick={handleDelete} className="p-1 rounded hover:bg-red-200 text-red-600" title="Удалить изображение">
<Trash2 size={16} />
</button>
<div className="text-xs text-gray-500 ml-2">
{(scale * 100).toFixed(0)}%
</div>
</div>
)}
<div className="relative w-full mt-2">
<div
ref={captionRef}
className="image-caption w-[80%] mx-auto text-base font-bold text-gray-600 italic text-center outline-none relative empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 empty:before:absolute empty:before:top-1/2 empty:before:left-0 empty:before:-translate-y-1/2 empty:before:pl-4 empty:before:text-left empty:before:pointer-events-none empty:before:select-none"
contentEditable={isEditable}
suppressContentEditableWarning
onBlur={handleCaptionBlur}
data-caption={caption}
data-placeholder="Введите подпись..."
>
</div>
</div>
</div>
</div>
</div>
</div>
<ConfirmDeleteModal
isOpen={isConfirmModalOpen}
onConfirm={() => {
deleteNode();
setConfirmModalOpen(false);
}}
onCancel={() => setConfirmModalOpen(false)}
/>
</NodeViewWrapper>
);
};
export const CustomImage = Node.create({
name: 'customImage',
group: 'block',
inline: false,
selectable: true,
draggable: true,
atom: true,
addAttributes() {
return {
src: { default: undefined },
alt: { default: undefined },
title: { default: undefined },
scale: {
default: 1,
parseHTML: (element) => {
const img = element.querySelector('img');
return img ? Number(img.getAttribute('data-scale')) || 1 : 1;
},
renderHTML: (attributes) => ({
'data-scale': Number(attributes.scale) || 1,
}),
},
caption: {
default: '',
parseHTML: (element) => {
const captionElement = element.querySelector('div.image-caption');
const img = element.querySelector('img');
return captionElement
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
: img?.getAttribute('data-caption') || '';
},
renderHTML: (attributes) => (attributes.caption ? { 'data-caption': attributes.caption } : {}),
},
};
},
parseHTML() {
return [
{
tag: 'div.image-container',
getAttrs: (element: HTMLElement) => {
if (!(element instanceof HTMLElement)) return null;
const img = element.querySelector('img');
const captionElement = element.querySelector('div.image-caption');
if (!img) return null;
return {
src: img.getAttribute('src') || undefined,
alt: img.getAttribute('alt') || undefined,
title: img.getAttribute('title') || undefined,
scale: Number(img.getAttribute('data-scale')) || 1,
caption: captionElement
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
: img.getAttribute('data-caption') || '',
};
},
},
];
},
renderHTML({ node, HTMLAttributes }) {
const { src, alt, title, scale, caption } = node.attrs;
const actualScale = Number(scale) || 1;
const imageElement: DOMOutputSpec = [
'img',
mergeAttributes(HTMLAttributes, {
src,
alt,
title,
'data-scale': actualScale,
style: `width: ${actualScale * 100}%`,
}),
];
const captionElement: DOMOutputSpec = [
'div',
{
class: 'image-caption w-full text-sm font-bold text-gray-600 italic text-center mt-2',
'data-caption': caption,
'data-placeholder': 'Введите подпись...',
},
caption,
];
return [
'div',
{ class: 'image-container flex flex-col items-center my-4' },
imageElement,
caption ? captionElement : '',
];
},
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView);
},
});

View File

@ -0,0 +1,71 @@
import { Extension, CommandProps } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
const INDENT_CLASS_NAME = 'text-indent'
// Вспомогательная функция для добавления/удаления CSS-класса
const toggleCssClass = (currentClasses: string | null | undefined, className: string): string | null => {
const classes = new Set((currentClasses || '').split(' ').filter(Boolean))
if (classes.has(className)) {
classes.delete(className)
} else {
classes.add(className)
}
return classes.size > 0 ? Array.from(classes).join(' ') : null
}
export const Indent = Extension.create({
name: 'indent',
addCommands() {
return {
toggleIndent:
() =>
({ tr, state, dispatch }: CommandProps): boolean => {
const { selection } = state
const { from, to } = selection
let changed = false
tr.doc.nodesBetween(from, to, (node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'paragraph') {
const currentClasses = node.attrs.class as string | null | undefined
const newClasses = toggleCssClass(currentClasses, INDENT_CLASS_NAME)
if (newClasses !== currentClasses) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
class: newClasses,
})
changed = true
}
}
return false // Не углубляться
})
if (changed && dispatch) {
dispatch(tr.scrollIntoView())
return true
}
return false
},
}
},
// Можно убрать, если не используешь
addStorage() {
return {
isActive: (state: any): boolean => {
const { $from } = state.selection
const node = $from.node($from.depth)
if (node?.type.name === 'paragraph') {
return (node.attrs.class || '').split(' ').includes(INDENT_CLASS_NAME)
}
return false
},
}
},
})

View File

@ -0,0 +1,26 @@
import Paragraph from '@tiptap/extension-paragraph';
export const IndentedParagraph = Paragraph.extend({
addAttributes() {
return {
// Наследуем стандартные атрибуты (если они есть)
...this.parent?.(),
// Добавляем наш атрибут class
class: {
default: null, // Значение по умолчанию - нет класса
// Функция для извлечения значения 'class' из HTML-элемента при загрузке контента
parseHTML: element => element.getAttribute('class'),
// Функция для добавления атрибута 'class' к HTML-элементу при рендеринге
renderHTML: attributes => {
// Если атрибут class не пустой, добавляем его к тегу <p>
if (attributes.class) {
return { class: attributes.class };
}
// Иначе не добавляем атрибут class вообще
return {};
},
},
};
},
});

View File

@ -0,0 +1,20 @@
.image-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 1rem 0;
}
.image-container img {
max-width: 100%;
height: auto;
}
.image-caption {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
font-style: italic;
text-align: center;
max-width: 80%;
}

View File

@ -0,0 +1,419 @@
import { useEditor, EditorContent } from '@tiptap/react';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Blockquote from '@tiptap/extension-blockquote';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo,
Undo,
AlignLeft,
AlignCenter,
SquareUser,
Link as LinkIcon,
ArrowRightToLine,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { CustomImage } from '../CustomImageExtension';
import TipTapImageUploadButton from './TipTapWithCaptionButton';
import { IndentedParagraph } from './IndentedParagraph';
import LinkModal from '../LinkModal';
interface TipTapEditorProps {
initialContent: string;
onContentChange: (content: string) => void;
articleId: string;
}
export function TipTapEditor({ initialContent, onContentChange, articleId }: TipTapEditorProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); // Состояние для модального окна
const [currentLinkUrl, setCurrentLinkUrl] = useState(''); // Текущий URL для редактирования
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false,
paragraph: false,
}),
IndentedParagraph,
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', // Безопасность
},
autolink: true, // Включаем автоматическое распознавание ссылок
}),
],
content: initialContent || '',
editorProps: {
attributes: {
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
},
},
onUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
let foundImage = false;
editor.state.doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'customImage') {
setSelectedImage(node.attrs.src);
foundImage = true;
}
});
if (!foundImage) {
setSelectedImage(null);
}
onContentChange(editor.getHTML());
},
});
useEffect(() => {
if (editor && editor.getHTML() !== initialContent) {
editor.commands.setContent(initialContent);
}
}, [initialContent, editor]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!editor?.view.dom.contains(event.target as Node)) {
setSelectedImage(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [editor]);
if (!editor) return null;
const handleClick = () => {
const { state, dispatch } = editor.view;
const tr = state.tr;
state.doc.forEach((paragraphNode, paragraphOffset) => {
if (paragraphNode.type.name !== 'paragraph') return;
let paragraphText = '';
const textPositions: { start: number; end: number }[] = [];
paragraphNode.content.forEach((textNode, textOffset) => {
if (textNode.isText) {
const nodeText = textNode.text || '';
const start = paragraphOffset + 1 + textOffset;
textPositions.push({
start,
end: start + nodeText.length,
});
paragraphText += nodeText;
}
});
if (!paragraphText) return;
const sentenceRegex = /[^.!?]+[.!?]+/g;
const sentences: { text: string; start: number; end: number }[] = [];
let match;
let lastIndex = 0;
while ((match = sentenceRegex.exec(paragraphText)) !== null) {
sentences.push({
text: match[0],
start: match.index,
end: sentenceRegex.lastIndex,
});
lastIndex = sentenceRegex.lastIndex;
}
if (lastIndex < paragraphText.length) {
sentences.push({
text: paragraphText.slice(lastIndex),
start: lastIndex,
end: paragraphText.length,
});
}
sentences.forEach((sentence) => {
const words = [];
const wordRegex = /\S+/g;
let wordMatch;
while ((wordMatch = wordRegex.exec(sentence.text)) !== null) {
words.push({
word: wordMatch[0],
start: sentence.start + wordMatch.index,
end: sentence.start + wordRegex.lastIndex,
});
}
if (words.length === 0) return;
let isFirstWord = true;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const currentWord = word.word;
if (/^[A-ZА-ЯЁ]/u.test(currentWord)) {
if (isFirstWord) {
isFirstWord = false;
continue;
}
let afterQuote = false;
if (i > 0) {
const prevWord = words[i - 1].word;
if (/[""«»''`*]$/.test(prevWord)) {
afterQuote = true;
} else {
const betweenWords = sentence.text.substring(
words[i - 1].end - sentence.start,
word.start - sentence.start
);
if (/\s[""«»''`]/.test(betweenWords) || /\s\*[«»]\*/.test(betweenWords) || /\s\*/.test(betweenWords)) {
afterQuote = true;
}
}
} else {
const sentencePrefix = sentence.text.substring(0, word.start - sentence.start).trim();
if (/^[""«»''`]/.test(sentencePrefix) || /^\*[«»]\*/.test(sentencePrefix) || /^\*/.test(sentencePrefix)) {
afterQuote = true;
}
}
if (!afterQuote) {
const docPositions = mapWordToDocumentPositions(word, textPositions, paragraphOffset);
if (docPositions) {
tr.addMark(docPositions.start, docPositions.end, state.schema.marks.bold.create());
}
}
}
isFirstWord = false;
}
});
});
dispatch(tr);
};
// Функция для открытия модального окна
const openLinkModal = () => {
const previousUrl = editor.getAttributes('link')?.href || '';
setCurrentLinkUrl(previousUrl);
setIsLinkModalOpen(true);
};
// Функция для применения ссылки
const handleLinkConfirm = (url: string) => {
setIsLinkModalOpen(false);
if (url.trim() === '') {
editor.chain().focus().unsetLink().run();
return;
}
const validUrl = url.match(/^https?:\/\//) ? url : `https://${url}`;
editor.chain().focus().setLink({ href: validUrl }).run();
};
// Функция для закрытия модального окна
const handleLinkCancel = () => {
setIsLinkModalOpen(false);
// Удаляем ссылку только если мы добавляем новую (currentLinkUrl пустой)
if (!currentLinkUrl) {
editor.chain().focus().unsetLink().run();
}
// Если currentLinkUrl не пустой (редактируем существующую ссылку), ничего не делаем
};
const mapWordToDocumentPositions = (
word: { start: number; end: number },
textPositions: { start: number; end: number }[],
paragraphOffset: number
) => {
let docStart = -1;
let docEnd = -1;
for (const pos of textPositions) {
if (docStart === -1 && word.start < pos.end - paragraphOffset - 1) {
const relativeStart = Math.max(0, word.start - (pos.start - paragraphOffset - 1));
docStart = pos.start + relativeStart;
}
if (docStart !== -1 && word.end <= pos.end - paragraphOffset - 1) {
const relativeEnd = Math.min(pos.end - pos.start, word.end - (pos.start - paragraphOffset - 1));
docEnd = pos.start + relativeEnd;
break;
}
}
if (docStart !== -1 && docEnd !== -1) {
return { start: docStart, end: docEnd };
}
return null;
};
return (
<div className="border rounded-lg overflow-hidden">
<div className="border-b bg-gray-50 px-4 py-2">
<div className="flex items-center space-x-4">
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bold') ? 'bg-gray-200' : ''}`}
title="Выделение слов жирным шрифтом"
>
<Bold size={18} />
</button>
<button
type="button"
onClick={handleClick}
className="p-1 rounded hover:bg-gray-200"
title="Автоматическое выделение жирным имен"
>
<SquareUser size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('italic') ? 'bg-gray-200' : ''}`}
title="Выделение слов наклонным шрифтом"
>
<Italic size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().setParagraph().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
title="Выравнивание по левому краю"
>
<AlignLeft size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
title="Выравнивание по центру"
>
<AlignCenter size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setNode('paragraph', { class: 'text-indent' }).run()}
className={`p-2 rounded border ${
editor.isActive('paragraph', { class: 'text-indent' }) ? 'bg-blue-100' : 'hover:bg-gray-100'
}`}
title="Отступ"
>
<ArrowRightToLine size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().undo().run()}
disabled={!editor?.can().chain().focus().undo().run()}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
title="Отменить действие"
>
<Undo size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().redo().run()}
disabled={!editor?.can().chain().focus().redo().run()}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
title="Вернуть действие"
>
<Redo size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bulletList') ? 'bg-gray-200' : ''}`}
title="Создать список"
>
<List size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('orderedList') ? 'bg-gray-200' : ''}`}
title="Создать упорядоченный список"
>
<ListOrdered size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('blockquote') ? 'bg-gray-200' : ''}`}
title="Цитирование (параграф)"
>
<Quote size={18} />
</button>
{/* Добавляем кнопку для вставки ссылки */}
<button
type="button"
onClick={openLinkModal}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('link') ? 'bg-gray-200' : ''}`}
title="Добавить или редактировать ссылку"
>
<LinkIcon size={18} />
</button>
{editor?.isActive('link') && ( // Показываем кнопку удаления ссылки, если выделенный текст — ссылка
<button
type="button"
onClick={() => editor.chain().focus().unsetLink().run()}
className="p-1 rounded hover:bg-gray-200"
title="Удалить ссылку"
>
<LinkIcon size={18} className="text-red-500" />
</button>
)}
<TipTapImageUploadButton
editor={editor}
articleId={articleId}
/>
</div>
</div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
{/* Добавляем отображение URL выбранного изображения */}
{selectedImage && (
<div className="p-4 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Выбранное изображение: <a href={selectedImage} target="_blank" rel="noopener noreferrer" className="text-blue-500">{selectedImage}</a>
</p>
</div>
)}
<LinkModal
isOpen={isLinkModalOpen}
initialUrl={currentLinkUrl}
onConfirm={handleLinkConfirm}
onCancel={handleLinkCancel}
/>
</div>
);
}

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