Устранена проблема с пагинацией, добавлено SEO
This commit is contained in:
parent
5a2bc6dbd3
commit
12b0011d57
104
package-lock.json
generated
104
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@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",
|
||||
@ -29,7 +30,9 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sitemap": "^8.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
@ -3320,6 +3323,23 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "2.11.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.7.tgz",
|
||||
"integrity": "sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "2.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.5.tgz",
|
||||
@ -3695,7 +3715,6 @@
|
||||
"version": "22.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
@ -3743,6 +3762,15 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sax": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
|
||||
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
|
||||
@ -4148,7 +4176,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@ -5792,6 +5819,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@ -6110,6 +6146,12 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.2.0.tgz",
|
||||
"integrity": "sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@ -7269,6 +7311,26 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-helmet-async": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"shallowequal": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@ -7507,6 +7569,12 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@ -7595,6 +7663,12 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@ -7712,6 +7786,31 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sitemap": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.0.tgz",
|
||||
"integrity": "sha512-+AbdxhM9kJsHtruUF39bwS/B0Fytw6Fr1o4ZAIAEqA6cke2xcoO2GleBw9Zw7nRzILVEgz7zBM5GiTJjie1G9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^17.0.5",
|
||||
"@types/sax": "^1.2.1",
|
||||
"arg": "^5.0.0",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"sitemap": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0",
|
||||
"npm": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sitemap/node_modules/@types/node": {
|
||||
"version": "17.0.45",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz",
|
||||
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -8147,7 +8246,6 @@
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
@ -10,7 +10,8 @@
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"prisma-seed": "npx prisma db seed",
|
||||
"db:seed": "node prisma/seed.js"
|
||||
"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"
|
||||
@ -21,6 +22,7 @@
|
||||
"@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",
|
||||
@ -37,7 +39,9 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sitemap": "^8.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
|
9
public/robots.txt
Normal file
9
public/robots.txt
Normal file
@ -0,0 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Disallow admin routes
|
||||
Disallow: /admin/
|
||||
Disallow: /admin/*
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://russcult.anibilag.ru/sitemap.xml
|
62
scripts/generate-sitemap.js
Normal file
62
scripts/generate-sitemap.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { SitemapStream, streamToPromise } from 'sitemap';
|
||||
import { Readable } from 'stream';
|
||||
import { writeFileSync } from 'fs';
|
||||
import axios from "axios";
|
||||
|
||||
async function generateSitemap() {
|
||||
|
||||
const API_URL = 'http://localhost:5000';
|
||||
|
||||
async function fetchArticlePaths() {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/articles/sitemap/`);
|
||||
return response.data.articles;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении путей статей:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = 'https://russcult.anibilag.ru'; // Replace with your actual domain
|
||||
|
||||
// Create a stream to write to
|
||||
const stream = new SitemapStream({ hostname: baseUrl });
|
||||
|
||||
// Add static routes
|
||||
const staticRoutes = [
|
||||
{ url: '/', changefreq: 'daily', priority: 1.0 },
|
||||
{ url: '/search', changefreq: 'weekly', priority: 0.8 },
|
||||
];
|
||||
|
||||
const articles = await fetchArticlePaths();
|
||||
|
||||
// Add dynamic article routes
|
||||
const articleRoutes = articles.map(article => ({
|
||||
url: `/article/${article.id}`,
|
||||
changefreq: 'weekly',
|
||||
priority: 0.7,
|
||||
lastmod: article.publishedAt
|
||||
}));
|
||||
|
||||
// Add category routes
|
||||
const categories = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const categoryRoutes = categories.map(category => ({
|
||||
url: `/?category=${category}`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9
|
||||
}));
|
||||
|
||||
// Combine all routes
|
||||
const links = [...staticRoutes, ...articleRoutes, ...categoryRoutes];
|
||||
|
||||
// Create sitemap from routes
|
||||
const sitemap = await streamToPromise(
|
||||
Readable.from(links).pipe(stream)
|
||||
).then(data => data.toString());
|
||||
|
||||
// Write sitemap to public directory
|
||||
writeFileSync('./public/sitemap.xml', sitemap);
|
||||
console.log('Sitemap generated successfully!');
|
||||
}
|
||||
|
||||
generateSitemap().catch(console.error);
|
@ -1,5 +1,5 @@
|
||||
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import MinutesWord from './MinutesWord';
|
||||
|
||||
@ -9,16 +9,17 @@ interface ArticleCardProps {
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
featured ? 'col-span-2 row-span-2' : ''
|
||||
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||
} flex flex-col`}>
|
||||
<div className="relative 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 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">
|
||||
@ -51,7 +52,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
</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>
|
||||
@ -59,6 +60,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
<div className="p-4 mt-auto flex items-center justify-between">
|
||||
<Link
|
||||
to={`/article/${article.id}`}
|
||||
state={{ from: location.pathname + location.search }}
|
||||
className="text-blue-600 font-medium hover:text-blue-800"
|
||||
>
|
||||
Читать →
|
||||
|
@ -1,7 +1,10 @@
|
||||
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 {
|
||||
@ -20,7 +23,19 @@ export function ArticleContent({ content }: ArticleContentProps) {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
CustomImage
|
||||
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, // Контент только для чтения
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {TipTapEditor} from './TipTapEditor.tsx';
|
||||
import {CoverImageUpload} from './ImageUpload/CoverImageUpload.tsx';
|
||||
import {ImageUploader} from './ImageUpload/ImageUploader.tsx';
|
||||
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, CategoryTitles, CityTitles, GalleryImage} from '../types';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Twitter, Instagram, Globe } from 'lucide-react';
|
||||
import {useEffect, useState} from "react";
|
||||
import axios from "axios";
|
||||
import { Author } from "../types/auth.ts";
|
||||
import { Author } from "../types/auth";
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Node, mergeAttributes, NodeViewProps } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ZoomIn, ZoomOut, Trash2 } from 'lucide-react';
|
||||
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
||||
import {mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
|
||||
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react';
|
||||
import {Trash2, ZoomIn, ZoomOut} from 'lucide-react';
|
||||
import {Node as ProseMirrorNode, DOMOutputSpec} from 'prosemirror-model';
|
||||
|
||||
// Определяем тип для атрибутов узла customImage
|
||||
interface CustomImageAttributes {
|
||||
@ -10,6 +10,7 @@ interface CustomImageAttributes {
|
||||
alt: string | undefined;
|
||||
title: string | undefined;
|
||||
scale: number;
|
||||
caption: string; // Добавляем атрибут caption
|
||||
}
|
||||
|
||||
const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
|
||||
@ -29,7 +30,7 @@ const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor
|
||||
}
|
||||
|
||||
// Логируем атрибуты узла при загрузке
|
||||
console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
|
||||
//console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
|
||||
|
||||
const handleZoomIn = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@ -101,19 +102,20 @@ const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
console.log('Перед вызовом deleteNode');
|
||||
deleteNode();
|
||||
console.log('После вызова deleteNode');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении изображения:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const scale = typedNode.attrs.scale || 1;
|
||||
const caption = typedNode.attrs.caption || '';
|
||||
const isEditable = editor.isEditable; // Проверяем, находится ли редактор в режиме редактирования
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="relative flex justify-center my-4">
|
||||
//<NodeViewWrapper className="relative flex justify-center my-4">
|
||||
<NodeViewWrapper className="relative flex flex-col items-center my-4">
|
||||
|
||||
{/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
|
||||
<div
|
||||
className="relative inline-block"
|
||||
@ -166,6 +168,11 @@ const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{caption && (
|
||||
<p className="image-caption mt-2 text-sm text-gray-600 italic text-center">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@ -193,50 +200,81 @@ export const CustomImage = Node.create({
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const scale = Number(element.getAttribute('scale'));
|
||||
const parsedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
||||
console.log('Парсинг scale из HTML:', { scale, parsedScale });
|
||||
return parsedScale;
|
||||
return isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
const scale = Number(attributes.scale);
|
||||
const renderedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
||||
console.log('Рендеринг scale в HTML:', { scale, renderedScale });
|
||||
return { scale: renderedScale };
|
||||
},
|
||||
},
|
||||
caption: { // Добавляем атрибут caption
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-caption') || '',
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.caption) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'data-caption': attributes.caption,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div.image-container',
|
||||
getAttrs: (element: HTMLElement) => {
|
||||
const img = element.querySelector('img');
|
||||
const caption = element.querySelector('p.image-caption')?.textContent || '';
|
||||
return {
|
||||
src: img?.getAttribute('src'),
|
||||
alt: img?.getAttribute('alt'),
|
||||
title: img?.getAttribute('title'),
|
||||
scale: img ? Number(img.getAttribute('scale')) || 1 : 1,
|
||||
caption,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'img[src]',
|
||||
getAttrs: (element) => {
|
||||
const scale = element.getAttribute('scale');
|
||||
const caption = element.getAttribute('data-caption') || '';
|
||||
return {
|
||||
src: element.getAttribute('src'),
|
||||
alt: element.getAttribute('alt'),
|
||||
title: element.getAttribute('title'),
|
||||
scale: scale ? Number(scale) : 1,
|
||||
caption,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { scale, ...rest } = HTMLAttributes;
|
||||
return [
|
||||
'div',
|
||||
{ class: 'flex justify-center my-4' },
|
||||
[
|
||||
'img',
|
||||
mergeAttributes(rest, {
|
||||
scale: scale,
|
||||
style: `transform: scale(${scale})`,
|
||||
}),
|
||||
],
|
||||
renderHTML({ HTMLAttributes }): DOMOutputSpec {
|
||||
const { scale, caption, ...rest } = HTMLAttributes;
|
||||
|
||||
const imgElement: DOMOutputSpec = [
|
||||
'img',
|
||||
mergeAttributes(rest, {
|
||||
scale: scale,
|
||||
'data-caption': caption,
|
||||
style: `transform: scale(${scale})`,
|
||||
}),
|
||||
];
|
||||
|
||||
const children: DOMOutputSpec[] = [imgElement];
|
||||
|
||||
if (caption) {
|
||||
children.push(['p', { class: 'image-caption mt-2 text-sm text-gray-600 italic text-center' }, caption]);
|
||||
}
|
||||
|
||||
return ['div', { class: 'image-container flex flex-col items-center my-4' }, ...children];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
|
71
src/components/Editor/Indent.ts
Normal file
71
src/components/Editor/Indent.ts
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
26
src/components/Editor/IndentedParagraph.ts
Normal file
26
src/components/Editor/IndentedParagraph.ts
Normal 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 {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
20
src/components/Editor/TipTapEditor.css
Normal file
20
src/components/Editor/TipTapEditor.css
Normal 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%;
|
||||
}
|
@ -3,6 +3,7 @@ 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,
|
||||
@ -14,10 +15,15 @@ import {
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
SquareUser,
|
||||
Link as LinkIcon,
|
||||
ArrowRightToLine,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CustomImage } from './CustomImageExtension';
|
||||
import TipTapImageUploadButton from './TipTapImageUploadButton';
|
||||
import { CustomImage } from '../CustomImageExtension';
|
||||
import TipTapImageUploadButton from './TipTapWithCaptionButton';
|
||||
import { IndentedParagraph } from './IndentedParagraph';
|
||||
import LinkModal from '../LinkModal';
|
||||
|
||||
|
||||
interface TipTapEditorProps {
|
||||
initialContent: string;
|
||||
@ -27,12 +33,16 @@ interface TipTapEditorProps {
|
||||
|
||||
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,
|
||||
blockquote: false,
|
||||
paragraph: false,
|
||||
}),
|
||||
IndentedParagraph,
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
@ -43,6 +53,15 @@ export function TipTapEditor({ initialContent, onContentChange, articleId }: Tip
|
||||
}),
|
||||
CustomImage,
|
||||
Highlight,
|
||||
Link.configure({ // Добавляем расширение Link
|
||||
openOnClick: false, // Отключаем автоматическое открытие ссылок при клике (удобно для редактирования)
|
||||
HTMLAttributes: {
|
||||
class: 'text-blue-500 underline', // Стили для ссылок
|
||||
target: '_blank', // Открывать в новой вкладке
|
||||
rel: 'noopener noreferrer', // Безопасность
|
||||
},
|
||||
autolink: true, // Включаем автоматическое распознавание ссылок
|
||||
}),
|
||||
],
|
||||
content: initialContent || '',
|
||||
editorProps: {
|
||||
@ -198,6 +217,35 @@ export function TipTapEditor({ initialContent, onContentChange, articleId }: Tip
|
||||
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 }[],
|
||||
@ -262,6 +310,26 @@ export function TipTapEditor({ initialContent, onContentChange, articleId }: Tip
|
||||
>
|
||||
<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()}
|
||||
@ -304,16 +372,25 @@ export function TipTapEditor({ initialContent, onContentChange, articleId }: Tip
|
||||
>
|
||||
<Quote 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="Выравнивание по центру"
|
||||
onClick={openLinkModal}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('link') ? 'bg-gray-200' : ''}`}
|
||||
title="Добавить или редактировать ссылку"
|
||||
>
|
||||
<AlignCenter size={18} />
|
||||
<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}
|
||||
@ -331,6 +408,12 @@ export function TipTapEditor({ initialContent, onContentChange, articleId }: Tip
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<LinkModal
|
||||
isOpen={isLinkModalOpen}
|
||||
initialUrl={currentLinkUrl}
|
||||
onConfirm={handleLinkConfirm}
|
||||
onCancel={handleLinkCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { imageResolutions } from '../config/imageResolutions';
|
||||
import { imageResolutions } from '../../config/imageResolutions';
|
||||
import axios from 'axios';
|
||||
|
||||
interface TipTapImageUploadButtonProps {
|
||||
@ -22,7 +22,7 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
|
||||
|
||||
try {
|
||||
const resolution = imageResolutions.find((r) => r.id === 'large');
|
||||
if (!resolution) throw new Error('Invalid resolution configuration');
|
||||
if (!resolution) throw new Error('Недопустимое значение разрешения');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@ -55,10 +55,10 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
throw new Error('No file URL returned from server');
|
||||
throw new Error('Сервер не вернул URL файла изображения');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
console.error('Сбой загрузки изображения:', error);
|
||||
alert('Не удалось загрузить изображение. Проверьте консоль для деталей.');
|
||||
}
|
||||
|
84
src/components/Editor/TipTapWithCaptionButton.tsx
Normal file
84
src/components/Editor/TipTapWithCaptionButton.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import ImageUploadModal from '../ImageUploadModal';
|
||||
import {imageResolutions} from "../../config/imageResolutions.ts";
|
||||
import axios from "axios";
|
||||
|
||||
interface TipTapImageUploadButtonProps {
|
||||
editor: Editor;
|
||||
articleId: string;
|
||||
}
|
||||
|
||||
const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ editor, articleId }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleImageUpload = async (file: File | null, caption: string) => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const resolution = imageResolutions.find((r) => r.id === 'large');
|
||||
if (!resolution) throw new Error('Недопустимое значение разрешения');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('resolutionId', resolution.id);
|
||||
formData.append('folder', `articles/${articleId}`);
|
||||
|
||||
const response = await axios.post('/api/images/upload-url', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadedUrl = response.data?.fileUrl;
|
||||
|
||||
if (uploadedUrl) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
const node = editor.schema.nodes.customImage.create({
|
||||
src: uploadedUrl,
|
||||
alt: file.name,
|
||||
scale: 1, // Начальный масштаб
|
||||
caption: caption,
|
||||
});
|
||||
if (dispatch) {
|
||||
tr.insert(editor.state.selection.anchor, node);
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
throw new Error('Сервер не вернул URL файла изображения');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки изображения:', error);
|
||||
} finally {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-2 hover:bg-gray-200 rounded"
|
||||
title="Добавить изображение"
|
||||
>
|
||||
<ImagePlus className="w-5 h-5" />
|
||||
</button>
|
||||
<ImageUploadModal
|
||||
isOpen={isModalOpen}
|
||||
onConfirm={handleImageUpload}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TipTapImageUploadButton;
|
@ -29,7 +29,7 @@ export function FeaturedSection() {
|
||||
cityId: city || undefined,
|
||||
},
|
||||
});
|
||||
//console.log('Загруженные данные:', response.data);
|
||||
|
||||
setArticles(response.data.articles);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalArticles(response.data.total);
|
||||
@ -63,6 +63,8 @@ export function FeaturedSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowFeatured = currentPage === 1 && !category && !city;
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@ -83,12 +85,12 @@ export function FeaturedSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
featured={currentPage === 1 && index === 0 && !category && !city}
|
||||
featured={shouldShowFeatured && index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { ImagePlus, X } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { imageResolutions } from '../../config/imageResolutions.ts';
|
||||
import { imageResolutions } from '../../config/imageResolutions';
|
||||
|
||||
interface CoverImageUploadProps {
|
||||
coverImage: string;
|
||||
|
104
src/components/ImageUploadModal.tsx
Normal file
104
src/components/ImageUploadModal.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ImageUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onConfirm: (file: File | null, caption: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ImageUploadModal: React.FC<ImageUploadModalProps> = ({ isOpen, onConfirm, onCancel }) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [caption, setCaption] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFile(null);
|
||||
setCaption('');
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
if (!selectedFile.type.startsWith('image/')) {
|
||||
setError('Пожалуйста, выберите файл изображения');
|
||||
setFile(null);
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!file) {
|
||||
setError('Пожалуйста, выберите изображение');
|
||||
return;
|
||||
}
|
||||
onConfirm(file, caption);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleApply();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity duration-300 ease-in-out"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full transform transition-all duration-300 ease-in-out scale-100 opacity-100">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Добавить изображение</h3>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full p-2 border rounded-md mb-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Введите подпись (опционально)"
|
||||
className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4 border-gray-300"
|
||||
/>
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="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
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploadModal;
|
133
src/components/LinkModal.tsx
Normal file
133
src/components/LinkModal.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
initialUrl: string;
|
||||
onConfirm: (url: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const LinkModal: React.FC<LinkModalProps> = ({ isOpen, initialUrl, onConfirm, onCancel }) => {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
console.log('Модальное окно открыто, синхронизируем url с initialUrl:', initialUrl);
|
||||
setUrl(initialUrl);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, initialUrl]);
|
||||
|
||||
const validateUrl = (value: string): boolean => {
|
||||
const urlPattern = /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w-./?%&=]*)?$/i;
|
||||
const mailtoPattern = /^mailto:[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/i;
|
||||
const telPattern = /^tel:\+?[0-9-]+$/i;
|
||||
return urlPattern.test(value) || mailtoPattern.test(value) || telPattern.test(value);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!validateUrl(url) && url.trim() !== '') {
|
||||
setError('Пожалуйста, введите корректный URL (например, https://example.com)');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
console.log('handleApply вызван, URL:', url);
|
||||
onConfirm(url);
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
console.log('Удаляем ссылку');
|
||||
onConfirm('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleApply();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity duration-300 ease-in-out"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full transform transition-all duration-300 ease-in-out scale-100 opacity-100">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{initialUrl ? 'Редактировать ссылку' : 'Добавить ссылку'}
|
||||
</h3>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Введите URL (например, https://example.com)"
|
||||
className={`w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4 transition-all duration-300 ${
|
||||
error ? 'border-red-500' : initialUrl ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
autoFocus
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-red-500 text-sm mb-4">{error}</p>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
{url.trim() === '' ? 'Введите URL или удалите ссылку' : 'Нажмите "Применить", чтобы сохранить изменения'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end space-x-3">
|
||||
{initialUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveLink}
|
||||
className="px-4 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="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
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkModal;
|
50
src/components/SEO.tsx
Normal file
50
src/components/SEO.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
interface SEOProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
image?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function SEO({
|
||||
title = 'Культура двух столиц',
|
||||
description = 'Discover the latest in art, music, theater, and cultural events from around the globe.',
|
||||
keywords = ['culture', 'art', 'music', 'theater', 'film'],
|
||||
image = 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
|
||||
url = typeof window !== 'undefined' ? window.location.href : '',
|
||||
type = 'website'
|
||||
}: SEOProps) {
|
||||
const siteTitle = title.includes('CultureScope') ? title : `${title} | CultureScope`;
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Basic meta tags */}
|
||||
<title>{siteTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords.join(', ')} />
|
||||
|
||||
{/* OpenGraph meta tags */}
|
||||
<meta property="og:title" content={siteTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content="CultureScope" />
|
||||
|
||||
{/* Twitter meta tags */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={siteTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
|
||||
{/* Additional meta tags */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta charSet="UTF-8" />
|
||||
<link rel="canonical" href={url} />
|
||||
</Helmet>
|
||||
);
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
export const BackgroundImages: Record<number, string> = {
|
||||
1: '/images/pack/film-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
2: '/images/gpt_theatre.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
2: '/images/pack/theatre-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
3: '/images/pack/music-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
4: '/images/pack/sport-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
5: '/images/pack/art-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
6: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070',
|
||||
7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',
|
||||
8: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070',
|
||||
6: '/images/pack/legend-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
7: '/images/pack/anniversary-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
8: '/images/pack/memory-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8bу?auto=format&fit=crop&q=80&w=2070'
|
||||
};
|
||||
|
||||
|
@ -20,10 +20,26 @@
|
||||
.selected-image {
|
||||
@apply border-4 border-blue-500 shadow-md;
|
||||
}
|
||||
|
||||
div.image-container {
|
||||
@apply flex flex-col items-center my-4;
|
||||
}
|
||||
|
||||
div.image-container img {
|
||||
@apply max-w-full h-auto;
|
||||
}
|
||||
|
||||
p.image-caption {
|
||||
@apply mt-2 text-lg text-gray-600 italic text-center max-w-[80%];
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-shadow {
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.text-indent {
|
||||
text-indent: 2em;
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
</HelmetProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, Clock } from 'lucide-react';
|
||||
import { Header } from '../components/Header';
|
||||
import { ReactionButtons } from '../components/ReactionButtons';
|
||||
import { PhotoGallery } from '../components/PhotoGallery';
|
||||
import { Article, CategoryTitles } from '../types';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import { SEO } from '../components/SEO';
|
||||
import { ArticleContent } from '../components/ArticleContent';
|
||||
import { ShareButton } from '../components/ShareButton';
|
||||
import { BookmarkButton } from '../components/BookmarkButton';
|
||||
@ -14,9 +15,13 @@ import api from "../utils/api";
|
||||
|
||||
export function ArticlePage() {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const [articleData, setArticleData] = useState<Article | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Получть предыдущее состояние местоположения или создать путь по умолчанию
|
||||
const backTo = location.state?.from || '/';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArticle = async () => {
|
||||
try {
|
||||
@ -40,7 +45,7 @@ export function ArticlePage() {
|
||||
<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">Статья не найдена</h2>
|
||||
<Link to="/" className="text-blue-600 hover:text-blue-800">
|
||||
<Link to={backTo} className="text-blue-600 hover:text-blue-800">
|
||||
Назад на главную
|
||||
</Link>
|
||||
</div>
|
||||
@ -83,10 +88,17 @@ export function ArticlePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<SEO
|
||||
title={articleData.title}
|
||||
description={articleData.excerpt}
|
||||
image={articleData.coverImage}
|
||||
type="article"
|
||||
keywords={[CategoryTitles[Number(articleData.categoryId)], 'culture', 'article', CityTitles[Number(articleData.cityId)].toLowerCase()]}
|
||||
/>
|
||||
<Header />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link
|
||||
to="/"
|
||||
to={backTo}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8"
|
||||
>
|
||||
<ArrowLeft size={20} className="mr-2" />
|
||||
|
@ -4,6 +4,8 @@ import { FeaturedSection } from '../components/FeaturedSection';
|
||||
import { AuthorsSection } from '../components/AuthorsSection';
|
||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||
import { SEO } from '../components/SEO';
|
||||
|
||||
|
||||
export function HomePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@ -29,8 +31,21 @@ export function HomePage() {
|
||||
|
||||
const { main, sub, description } = getHeroTitle();
|
||||
|
||||
const getSEODescription = () => {
|
||||
if (categoryId) {
|
||||
return `Explore the latest ${CategoryTitles[Number(categoryId)].toLowerCase()} stories, events, and cultural highlights from around the globe.`;
|
||||
}
|
||||
return 'Discover the latest in art, music, theater, and cultural events from around the globe. Your premier destination for arts and culture coverage.';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/gpt_main-bg.webp')` }}>
|
||||
<SEO
|
||||
title={categoryId ? `${CategoryTitles[Number(categoryId)]} Stories - CultureScope` : undefined}
|
||||
description={getSEODescription()}
|
||||
keywords={categoryId ? [CategoryTitles[Number(categoryId)].toLowerCase(), 'culture', 'arts', 'events'] : undefined}
|
||||
image={backgroundImage}
|
||||
/>
|
||||
<Header />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
|
@ -231,8 +231,8 @@ export function ImportArticlesPage() {
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileJson className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No articles imported</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by selecting a JSON file.</p>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Еще нет статей для импорта</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Начните с выбора JSON файла.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -8,6 +8,7 @@ export interface Article {
|
||||
cityId: number;
|
||||
author: Author;
|
||||
coverImage: string;
|
||||
images?: string[];
|
||||
gallery?: GalleryImage[];
|
||||
publishedAt: string;
|
||||
readTime: number;
|
||||
|
Loading…
x
Reference in New Issue
Block a user