diff --git a/package-lock.json b/package-lock.json index 0e46d34..1bec3ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e620bce..10a8687 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..fb20b4e --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,9 @@ +User-agent: * +Allow: / + +# Disallow admin routes +Disallow: /admin/ +Disallow: /admin/* + +# Sitemap +Sitemap: https://russcult.anibilag.ru/sitemap.xml \ No newline at end of file diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js new file mode 100644 index 0000000..fb9dac7 --- /dev/null +++ b/scripts/generate-sitemap.js @@ -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); \ No newline at end of file diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index fba8330..e75117c 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -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.title}
@@ -51,7 +52,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
-

+

{article.title}

{article.excerpt}

@@ -59,6 +60,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
Читать → diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index ce1a8f5..aed66fc 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -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, // Контент только для чтения diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index 9320a9d..1568ef5 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -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'; diff --git a/src/components/AuthorsSection.tsx b/src/components/AuthorsSection.tsx index 9b8b6f0..2043dea 100644 --- a/src/components/AuthorsSection.tsx +++ b/src/components/AuthorsSection.tsx @@ -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'; diff --git a/src/components/CustomImageExtension.tsx b/src/components/CustomImageExtension.tsx index e7b76e4..e3ba487 100644 --- a/src/components/CustomImageExtension.tsx +++ b/src/components/CustomImageExtension.tsx @@ -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 = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => { @@ -29,7 +30,7 @@ const ImageNodeView: React.FC = ({ 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 = ({ 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 ( - + // + + {/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
= ({ node, updateAttributes, editor
)}
+ {caption && ( +

+ {caption} +

+ )} ); }; @@ -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() { diff --git a/src/components/Editor/Indent.ts b/src/components/Editor/Indent.ts new file mode 100644 index 0000000..f2fa5e6 --- /dev/null +++ b/src/components/Editor/Indent.ts @@ -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 + }, + } + }, +}) diff --git a/src/components/Editor/IndentedParagraph.ts b/src/components/Editor/IndentedParagraph.ts new file mode 100644 index 0000000..15d6b93 --- /dev/null +++ b/src/components/Editor/IndentedParagraph.ts @@ -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 не пустой, добавляем его к тегу

+ if (attributes.class) { + return { class: attributes.class }; + } + // Иначе не добавляем атрибут class вообще + return {}; + }, + }, + }; + }, +}); diff --git a/src/components/Editor/TipTapEditor.css b/src/components/Editor/TipTapEditor.css new file mode 100644 index 0000000..07ee893 --- /dev/null +++ b/src/components/Editor/TipTapEditor.css @@ -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%; +} \ No newline at end of file diff --git a/src/components/TipTapEditor.tsx b/src/components/Editor/TipTapEditor.tsx similarity index 73% rename from src/components/TipTapEditor.tsx rename to src/components/Editor/TipTapEditor.tsx index b4c928b..7cfd407 100644 --- a/src/components/TipTapEditor.tsx +++ b/src/components/Editor/TipTapEditor.tsx @@ -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(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 > + + + {/* Добавляем кнопку для вставки ссылки */} + {editor?.isActive('link') && ( // Показываем кнопку удаления ссылки, если выделенный текст — ссылка + + )} )} + ); } \ No newline at end of file diff --git a/src/components/TipTapImageUploadButton.tsx b/src/components/Editor/TipTapImageUploadButton.tsx similarity index 88% rename from src/components/TipTapImageUploadButton.tsx rename to src/components/Editor/TipTapImageUploadButton.tsx index a8ed242..5e3ee95 100644 --- a/src/components/TipTapImageUploadButton.tsx +++ b/src/components/Editor/TipTapImageUploadButton.tsx @@ -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 = ({ 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 = ({ 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('Не удалось загрузить изображение. Проверьте консоль для деталей.'); } diff --git a/src/components/Editor/TipTapWithCaptionButton.tsx b/src/components/Editor/TipTapWithCaptionButton.tsx new file mode 100644 index 0000000..ad7cb26 --- /dev/null +++ b/src/components/Editor/TipTapWithCaptionButton.tsx @@ -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 = ({ 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 ( + <> + + setIsModalOpen(false)} + /> + + ); +}; + +export default TipTapImageUploadButton; \ No newline at end of file diff --git a/src/components/FeaturedSection.tsx b/src/components/FeaturedSection.tsx index a6589a4..9d633a5 100644 --- a/src/components/FeaturedSection.tsx +++ b/src/components/FeaturedSection.tsx @@ -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 (

@@ -83,12 +85,12 @@ export function FeaturedSection() {
)} -
+
{articles.map((article, index) => ( ))}
diff --git a/src/components/ImageUpload/CoverImageUpload.tsx b/src/components/ImageUpload/CoverImageUpload.tsx index f4cb0d1..9657ed9 100644 --- a/src/components/ImageUpload/CoverImageUpload.tsx +++ b/src/components/ImageUpload/CoverImageUpload.tsx @@ -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; diff --git a/src/components/ImageUploadModal.tsx b/src/components/ImageUploadModal.tsx new file mode 100644 index 0000000..0996592 --- /dev/null +++ b/src/components/ImageUploadModal.tsx @@ -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 = ({ isOpen, onConfirm, onCancel }) => { + const [file, setFile] = useState(null); + const [caption, setCaption] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) { + setFile(null); + setCaption(''); + setError(null); + } + }, [isOpen]); + + const handleFileChange = (e: React.ChangeEvent) => { + 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) => { + if (e.key === 'Enter') { + handleApply(); + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+

Добавить изображение

+
+ + 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 &&

{error}

} +
+ + +
+
+
+
+ ); +}; + +export default ImageUploadModal; \ No newline at end of file diff --git a/src/components/LinkModal.tsx b/src/components/LinkModal.tsx new file mode 100644 index 0000000..b28282b --- /dev/null +++ b/src/components/LinkModal.tsx @@ -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 = ({ isOpen, initialUrl, onConfirm, onCancel }) => { + const [url, setUrl] = useState(initialUrl); + const [error, setError] = useState(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) => { + if (e.key === 'Enter') { + handleApply(); + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + 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 ( +
+
+

+ {initialUrl ? 'Редактировать ссылку' : 'Добавить ссылку'} +

+
+ { + 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 ? ( +

{error}

+ ) : ( +

+ {url.trim() === '' ? 'Введите URL или удалите ссылку' : 'Нажмите "Применить", чтобы сохранить изменения'} +

+ )} +
+ {initialUrl && ( + + )} + + +
+
+
+
+ ); +}; + +export default LinkModal; \ No newline at end of file diff --git a/src/components/SEO.tsx b/src/components/SEO.tsx new file mode 100644 index 0000000..bbdbfd0 --- /dev/null +++ b/src/components/SEO.tsx @@ -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 ( + + {/* Basic meta tags */} + {siteTitle} + + + + {/* OpenGraph meta tags */} + + + + + + + + {/* Twitter meta tags */} + + + + + + {/* Additional meta tags */} + + + + + + ); +} \ No newline at end of file diff --git a/src/hooks/useBackgroundImage.ts b/src/hooks/useBackgroundImage.ts index 2c0580c..f633314 100644 --- a/src/hooks/useBackgroundImage.ts +++ b/src/hooks/useBackgroundImage.ts @@ -1,12 +1,12 @@ export const BackgroundImages: Record = { 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' }; diff --git a/src/index.css b/src/index.css index db96007..c24eaa6 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } } \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index ea9e363..273c1dc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + ); diff --git a/src/pages/ArticlePage.tsx b/src/pages/ArticlePage.tsx index e0402c9..a483ef1 100644 --- a/src/pages/ArticlePage.tsx +++ b/src/pages/ArticlePage.tsx @@ -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
(null); const [error, setError] = useState(null); + // Получть предыдущее состояние местоположения или создать путь по умолчанию + const backTo = location.state?.from || '/'; + useEffect(() => { const fetchArticle = async () => { try { @@ -40,7 +45,7 @@ export function ArticlePage() {

Статья не найдена

- + Назад на главную
@@ -83,10 +88,17 @@ export function ArticlePage() { return (
+
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 25b612b..814d227 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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 (
+
diff --git a/src/pages/ImportArticlesPage.tsx b/src/pages/ImportArticlesPage.tsx index 667130c..43d6271 100644 --- a/src/pages/ImportArticlesPage.tsx +++ b/src/pages/ImportArticlesPage.tsx @@ -231,8 +231,8 @@ export function ImportArticlesPage() { ) : (
-

No articles imported

-

Get started by selecting a JSON file.

+

Еще нет статей для импорта

+

Начните с выбора JSON файла.

)}
diff --git a/src/types/index.ts b/src/types/index.ts index 759d7b8..41ae462 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export interface Article { cityId: number; author: Author; coverImage: string; + images?: string[]; gallery?: GalleryImage[]; publishedAt: string; readTime: number;