Переделана пагинация на backend, поэтому убрана фильтрация по frontend. Реализован импорт статей из json файла, пока без загрузки обложки статьи в хранилище S3
This commit is contained in:
parent
1c4a7f2384
commit
be59f4418a
BIN
public/images/empty-cover.png
Normal file
BIN
public/images/empty-cover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 194 KiB |
@ -11,6 +11,7 @@ import { SearchPage } from './pages/SearchPage';
|
|||||||
import { BookmarksPage } from './pages/BookmarksPage';
|
import { BookmarksPage } from './pages/BookmarksPage';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
import { AuthGuard } from './components/AuthGuard';
|
import { AuthGuard } from './components/AuthGuard';
|
||||||
|
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { setUser, setLoading } = useAuthStore();
|
const { setUser, setLoading } = useAuthStore();
|
||||||
@ -63,6 +64,14 @@ function App() {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/import"
|
||||||
|
element={
|
||||||
|
<AuthGuard>
|
||||||
|
<ImportArticlesPage />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
@ -1,28 +1,21 @@
|
|||||||
|
//import { ResizableImage } from './TipTapEditor';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Blockquote from '@tiptap/extension-blockquote';
|
import Blockquote from '@tiptap/extension-blockquote';
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from '@tiptap/extension-blockquote';
|
||||||
|
|
||||||
|
|
||||||
interface ArticleContentProps {
|
interface ArticleContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResizableImage = Image.extend({
|
const ResizableImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
src: {
|
src: { default: null, },
|
||||||
default: null,
|
alt: { default: null, },
|
||||||
},
|
title: { default: null, },
|
||||||
alt: {
|
class: { default: 'max-w-full h-auto', },
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
|
||||||
},
|
|
||||||
width: {
|
width: {
|
||||||
default: 'auto',
|
default: 'auto',
|
||||||
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||||
@ -41,6 +34,7 @@ const ResizableImage = Image.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Показ контента статьи в редакторе, в режиме "только чтение"
|
||||||
export function ArticleContent({ content }: ArticleContentProps) {
|
export function ArticleContent({ content }: ArticleContentProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ArticleCard } from './ArticleCard';
|
import { ArticleCard } from './ArticleCard';
|
||||||
import { Pagination } from './Pagination';
|
import { Pagination } from './Pagination';
|
||||||
import {Article, CategoryTitles, CityTitles} from '../types';
|
import {Article, CategoryTitles, CityTitles} from '../types';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const ARTICLES_PER_PAGE = 6;
|
|
||||||
|
|
||||||
export function FeaturedSection() {
|
export function FeaturedSection() {
|
||||||
// const location = useLocation();
|
// const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
const city = searchParams.get('city');
|
const city = searchParams.get('city');
|
||||||
const currentPage = parseInt(searchParams.get('page') || '1', 10);
|
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
|
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Загрузка статей с backend
|
// Загрузка статей с backend
|
||||||
@ -30,6 +29,7 @@ export function FeaturedSection() {
|
|||||||
});
|
});
|
||||||
console.log('Загруженные данные:', response.data);
|
console.log('Загруженные данные:', response.data);
|
||||||
setArticles(response.data.articles);
|
setArticles(response.data.articles);
|
||||||
|
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Не удалось загрузить статьи');
|
setError('Не удалось загрузить статьи');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -39,40 +39,13 @@ export function FeaturedSection() {
|
|||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, [category, city, currentPage]);
|
}, [category, city, currentPage]);
|
||||||
|
|
||||||
// Фильтрация статей
|
|
||||||
const filteredArticles = useMemo(() => {
|
|
||||||
return articles.filter(article => {
|
|
||||||
|
|
||||||
if (category && city) {
|
|
||||||
return article.categoryId === Number(category) && article.cityId === Number(city);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category) {
|
|
||||||
return article.categoryId === Number(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (city) {
|
|
||||||
return article.cityId === Number(city);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [articles, category, city]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
|
||||||
|
|
||||||
const currentArticles = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
|
||||||
return filteredArticles.slice(startIndex, startIndex + ARTICLES_PER_PAGE);
|
|
||||||
}, [filteredArticles, currentPage]);
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
searchParams.set('page', page.toString());
|
searchParams.set('page', page.toString());
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filteredArticles.length === 0) {
|
if (articles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@ -95,7 +68,7 @@ export function FeaturedSection() {
|
|||||||
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Показано {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} из {filteredArticles.length} статей
|
Показано {articles.length} статей
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -106,7 +79,7 @@ export function FeaturedSection() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{currentArticles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
key={article.id}
|
key={article.id}
|
||||||
article={article}
|
article={article}
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { ImagePlus, X } from 'lucide-react';
|
import { ImagePlus, X } from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { imageResolutions } from '../../config/imageResolutions';
|
import { imageResolutions } from '../../config/imageResolutions.ts';
|
||||||
|
|
||||||
interface CoverImageUploadProps {
|
interface CoverImageUploadProps {
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
|
articleId: string;
|
||||||
onImageUpload: (url: string) => void;
|
onImageUpload: (url: string) => void;
|
||||||
onError: (error: string) => void;
|
onError: (error: string) => void;
|
||||||
|
allowUpload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverImageUploadProps) {
|
export function CoverImageUpload({ coverImage, articleId, onImageUpload, onError, allowUpload = true }: CoverImageUploadProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -25,7 +27,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('resolutionId', resolution.id);
|
formData.append('resolutionId', resolution.id);
|
||||||
formData.append('folder', 'articles/1');
|
formData.append('folder', 'articles/' + articleId);
|
||||||
|
|
||||||
// Отправка запроса на сервер
|
// Отправка запроса на сервер
|
||||||
const response = await axios.post('/api/images/upload-url', formData, {
|
const response = await axios.post('/api/images/upload-url', formData, {
|
||||||
@ -39,7 +41,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
|||||||
onImageUpload(response.data.fileUrl); // Передача URL загруженного изображения
|
onImageUpload(response.data.fileUrl); // Передача URL загруженного изображения
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError('Failed to upload image. Please try again.');
|
onError('Ошибка загрузки изображения. Попробуйте еще раз.');
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,13 +71,15 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
|||||||
alt="Cover preview"
|
alt="Cover preview"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<button
|
{allowUpload && (
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearImage}
|
onClick={handleClearImage}
|
||||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<X size={16} className="text-gray-600" />
|
<X size={16} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all" />
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -88,13 +92,15 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
|||||||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||||
placeholder="URL изображения будет здесь после загрузки"
|
placeholder="URL изображения будет здесь после загрузки"
|
||||||
/>
|
/>
|
||||||
<label
|
{allowUpload && (
|
||||||
|
<label
|
||||||
htmlFor="coverImageUpload"
|
htmlFor="coverImageUpload"
|
||||||
className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-r-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-r-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<ImagePlus size={18} className="mr-2" />
|
<ImagePlus size={18} className="mr-2" />
|
||||||
Загрузка
|
Загрузка
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
342
src/components/TipTapEditor.tsx
Normal file
342
src/components/TipTapEditor.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
|
import Blockquote from '@tiptap/extension-blockquote';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Bold, Italic, List, ListOrdered, Quote, Redo, Undo, AlignLeft, AlignCenter, Plus, Minus, Text } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
interface TipTapEditorProps {
|
||||||
|
initialContent: string;
|
||||||
|
onContentChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
const ResizableImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
src: { default: null, },
|
||||||
|
alt: { default: null, },
|
||||||
|
title: { default: null, },
|
||||||
|
class: { default: 'max-w-full h-auto', },
|
||||||
|
width: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
width: attributes.width,
|
||||||
|
class: selectedImage === attributes.src ? 'selected-image' : '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorProps) {
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ResizableImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
src: { default: null, },
|
||||||
|
alt: { default: null, },
|
||||||
|
title: { default: null, },
|
||||||
|
class: { default: 'max-w-full h-auto', },
|
||||||
|
width: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
width: attributes.width,
|
||||||
|
class: selectedImage === attributes.src ? 'selected-image' : '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: 'auto',
|
||||||
|
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
blockquote: false, // Отключаем дефолтный
|
||||||
|
}),
|
||||||
|
Blockquote.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
|
ResizableImage,
|
||||||
|
Highlight,
|
||||||
|
],
|
||||||
|
content: initialContent || '',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
let foundImage = false;
|
||||||
|
|
||||||
|
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||||
|
if (node.type.name === 'image') {
|
||||||
|
setSelectedImage(node.attrs.src);
|
||||||
|
editor.commands.updateAttributes('image', { class: 'selected-image' });
|
||||||
|
foundImage = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundImage) {
|
||||||
|
setSelectedImage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onContentChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление контента при изменении initialContent
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && editor.getHTML() !== initialContent) {
|
||||||
|
editor.commands.setContent(initialContent);
|
||||||
|
}
|
||||||
|
}, [initialContent, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!editor?.view.dom.contains(event.target as Node)) {
|
||||||
|
setSelectedImage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const resizeImage = (delta: number) => {
|
||||||
|
if (!editor.isActive('image')) return;
|
||||||
|
|
||||||
|
const attrs = editor?.getAttributes('image');
|
||||||
|
const newWidth = Math.max((parseInt(attrs?.width, 10) || 100) + delta, 50);
|
||||||
|
|
||||||
|
editor.chain().focus().updateAttributes('image', {width: `${newWidth}px`}).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="border-b bg-gray-50 px-4 py-2">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${
|
||||||
|
editor?.isActive('bold') ? 'bg-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bold size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const { state, dispatch } = editor.view;
|
||||||
|
const tr = state.tr;
|
||||||
|
let inSentence = false;
|
||||||
|
let isNewParagraph = false;
|
||||||
|
|
||||||
|
state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'paragraph') {
|
||||||
|
isNewParagraph = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.isText) {
|
||||||
|
const text = node.text || "";
|
||||||
|
const currentPos = pos;
|
||||||
|
let wordStart = -1;
|
||||||
|
let hasUpperCase = false;
|
||||||
|
let sentenceStart = true;
|
||||||
|
|
||||||
|
// Сброс контекста для нового параграфа
|
||||||
|
if (isNewParagraph) {
|
||||||
|
inSentence = false;
|
||||||
|
sentenceStart = true;
|
||||||
|
isNewParagraph = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
// Обновление статуса предложения при обнаружении пунктуации
|
||||||
|
if (/[.!?]/.test(char) && (i === text.length - 1 || /\s/.test(text[i + 1]))) {
|
||||||
|
sentenceStart = true;
|
||||||
|
inSentence = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordStart === -1 && /\S/.test(char)) {
|
||||||
|
wordStart = i;
|
||||||
|
hasUpperCase = /^[A-ZА-ЯЁ]/u.test(char);
|
||||||
|
|
||||||
|
// Первое слово в предложении
|
||||||
|
if (sentenceStart && hasUpperCase) {
|
||||||
|
sentenceStart = false;
|
||||||
|
inSentence = true;
|
||||||
|
continue; // Пропускаем выделение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordStart !== -1 && (/\s/.test(char) || i === text.length - 1)) {
|
||||||
|
const wordEnd = char === ' ' ? i : i + 1;
|
||||||
|
|
||||||
|
// Выделяем только слова внутри предложения, не первые
|
||||||
|
if (hasUpperCase && inSentence && !sentenceStart) {
|
||||||
|
tr.addMark(
|
||||||
|
currentPos + wordStart,
|
||||||
|
currentPos + wordEnd,
|
||||||
|
state.schema.marks.bold.create()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
wordStart = -1;
|
||||||
|
hasUpperCase = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(tr);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
<Text size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${
|
||||||
|
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Italic size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
|
||||||
|
>
|
||||||
|
<AlignLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().undo().run()}
|
||||||
|
disabled={!editor?.can().chain().focus().undo().run()}
|
||||||
|
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Undo size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().redo().run()}
|
||||||
|
disabled={!editor?.can().chain().focus().redo().run()}
|
||||||
|
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Redo size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${
|
||||||
|
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${
|
||||||
|
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListOrdered size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
editor?.chain().focus().toggleBlockquote().run()}}
|
||||||
|
className={`p-1 rounded hover:bg-gray-200 ${
|
||||||
|
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlignCenter size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const url = prompt('Введите URL изображения');
|
||||||
|
if (url) {
|
||||||
|
editor?.chain().focus().setImage({ src: url }).run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🖼️ Фото
|
||||||
|
</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' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🏛 Центр
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resizeImage(20)} className="p-1 rounded hover:bg-gray-200">
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resizeImage(-20)} className="p-1 rounded hover:bg-gray-200">
|
||||||
|
<Minus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -16,6 +16,10 @@
|
|||||||
.prose {
|
.prose {
|
||||||
@apply max-w-none;
|
@apply max-w-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-image {
|
||||||
|
@apply border-4 border-blue-500 shadow-md;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
@ -1,109 +1,37 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { TipTapEditor } from '../components/TipTapEditor';
|
||||||
import Highlight from '@tiptap/extension-highlight'
|
|
||||||
import TextAlign from '@tiptap/extension-text-align'
|
|
||||||
import Blockquote from '@tiptap/extension-blockquote';
|
|
||||||
import Image from '@tiptap/extension-image';
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { GalleryManager } from '../components/GalleryManager';
|
import { GalleryManager } from '../components/GalleryManager';
|
||||||
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload.tsx';
|
||||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
||||||
import MinutesWord from "../components/MinutesWord";
|
import MinutesWord from '../components/MinutesWord';
|
||||||
import { ArticlesResponse, GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds } from '../types';
|
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
|
||||||
import {
|
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X } from 'lucide-react';
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Quote,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Blocks,
|
|
||||||
ImagePlus,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
|
|
||||||
const ARTICLES_PER_PAGE = 6;
|
// Default cover image for new articles
|
||||||
|
const DEFAULT_COVER_IMAGE = '/images/empty-cover.png';
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
|
const [articleId, setArticleId] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [excerpt, setExcerpt] = useState('');
|
const [excerpt, setExcerpt] = useState('');
|
||||||
const [categoryId, setCategoryId] = useState(1);
|
const [categoryId, setCategoryId] = useState(1);
|
||||||
const [cityId, setCityId] = useState(1);
|
const [cityId, setCityId] = useState(1);
|
||||||
const [coverImage, setCoverImage] = useState('');
|
const [coverImage, setCoverImage] = useState(DEFAULT_COVER_IMAGE);
|
||||||
const [readTime, setReadTime] = useState(5);
|
const [readTime, setReadTime] = useState(5);
|
||||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||||
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
|
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||||
const [articlesList, setArticlesList] = useState<ArticlesResponse>({articles: [], totalPages: 1, currentPage: 1});
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
||||||
const [filterCityId, setFilterCity] = useState(0);
|
const [filterCityId, setFilterCityId] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [content, setContent] = useState('');
|
||||||
const ResizableImage = Image.extend({
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
src: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
alt: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
default: 'auto',
|
|
||||||
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
|
||||||
renderHTML: (attributes) => ({
|
|
||||||
width: attributes.width,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
default: 'auto',
|
|
||||||
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
|
||||||
renderHTML: (attributes) => ({
|
|
||||||
height: attributes.height,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
blockquote: false, // Отключаем дефолтный
|
|
||||||
}),
|
|
||||||
Blockquote.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TextAlign.configure({
|
|
||||||
types: ['heading', 'paragraph'],
|
|
||||||
}),
|
|
||||||
ResizableImage,
|
|
||||||
Highlight
|
|
||||||
],
|
|
||||||
content: '',
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Загрузка статей
|
// Загрузка статей
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -116,7 +44,8 @@ export function AdminPage() {
|
|||||||
cityId: filterCityId
|
cityId: filterCityId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setArticlesList(response.data);
|
setArticles(response.data.articles);
|
||||||
|
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Не удалось загрузить статьи');
|
setError('Не удалось загрузить статьи');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -126,39 +55,15 @@ export function AdminPage() {
|
|||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, [currentPage, filterCategoryId, filterCityId]);
|
}, [currentPage, filterCategoryId, filterCityId]);
|
||||||
|
|
||||||
// Фильтрация статей по категории и городу
|
|
||||||
const filteredArticles = useMemo(() => {
|
|
||||||
return articlesList.articles.filter(article => {
|
|
||||||
if (filterCategoryId && filterCityId) {
|
|
||||||
return article.categoryId === filterCategoryId && article.cityId === filterCityId;
|
|
||||||
}
|
|
||||||
if (filterCategoryId) {
|
|
||||||
return article.categoryId === filterCategoryId;
|
|
||||||
}
|
|
||||||
if (filterCityId) {
|
|
||||||
return article.cityId === filterCityId;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [articlesList, filterCategoryId, filterCityId]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
|
||||||
|
|
||||||
/*
|
|
||||||
const paginatedArticles = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
|
||||||
return filteredArticles.slice(startIndex, startIndex + ARTICLES_PER_PAGE);
|
|
||||||
}, [filteredArticles, currentPage]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Редактирование статьи - загрузка данных
|
// Редактирование статьи - загрузка данных
|
||||||
const handleEdit = (id: string) => {
|
const handleEdit = (id: string) => {
|
||||||
const article = articlesList.articles.find(a => a.id === id);
|
const article = articles.find(a => a.id === id);
|
||||||
if (article) {
|
if (article) {
|
||||||
|
setArticleId(article.id);
|
||||||
setTitle(article.title);
|
setTitle(article.title);
|
||||||
setExcerpt(article.excerpt);
|
setExcerpt(article.excerpt);
|
||||||
setCategoryId(article.categoryId);
|
setCategoryId(article.categoryId);
|
||||||
@ -166,7 +71,7 @@ export function AdminPage() {
|
|||||||
setCoverImage(article.coverImage);
|
setCoverImage(article.coverImage);
|
||||||
setReadTime(article.readTime);
|
setReadTime(article.readTime);
|
||||||
setGallery(article.gallery || []);
|
setGallery(article.gallery || []);
|
||||||
editor?.commands.setContent(article.content);
|
setContent(article.content); // Устанавливаем содержимое редактора
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@ -180,9 +85,9 @@ export function AdminPage() {
|
|||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setArticlesList(prev => ({
|
setArticles(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
articles: prev.articles.filter(article => article.id !== id),
|
articles: prev.filter(article => article.id !== id),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Не удалось удалить статью');
|
setError('Не удалось удалить статью');
|
||||||
@ -203,7 +108,7 @@ export function AdminPage() {
|
|||||||
coverImage,
|
coverImage,
|
||||||
readTime,
|
readTime,
|
||||||
gallery,
|
gallery,
|
||||||
content: editor?.getHTML() || '',
|
content: content || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@ -214,9 +119,9 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setArticlesList(prev => ({
|
setArticles(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
articles: prev.articles.map(article =>
|
articles: prev.map(article =>
|
||||||
article.id === editingId ? response.data : article
|
article.id === editingId ? response.data : article
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@ -234,23 +139,24 @@ export function AdminPage() {
|
|||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setArticlesList(prev => ({
|
setArticles(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
articles: [...prev.articles, response.data],
|
articles: [...prev, response.data],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('Создание новой статьи:', articleData);
|
console.log('Создание новой статьи:', articleData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form
|
// Очистка формы статьи
|
||||||
|
setArticleId('');
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setExcerpt('');
|
setExcerpt('');
|
||||||
setCategoryId(1);
|
setCategoryId(1);
|
||||||
setCityId(1);
|
setCityId(1);
|
||||||
setCoverImage('');
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
setGallery([]);
|
setGallery([]);
|
||||||
editor?.commands.setContent('');
|
setContent(''); // Очищаем содержимое редактора
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGalleryImageUpload = (imageUrl: string) => {
|
const handleGalleryImageUpload = (imageUrl: string) => {
|
||||||
@ -361,115 +267,22 @@ export function AdminPage() {
|
|||||||
|
|
||||||
<CoverImageUpload
|
<CoverImageUpload
|
||||||
coverImage={coverImage}
|
coverImage={coverImage}
|
||||||
|
articleId={articleId}
|
||||||
onImageUpload={setCoverImage}
|
onImageUpload={setCoverImage}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
|
allowUpload={!!editingId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
<span className="italic font-bold">Статья</span>
|
<span className="italic font-bold">Статья</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<TipTapEditor initialContent={content} onContentChange={setContent} />
|
||||||
<div className="border-b bg-gray-50 px-4 py-2">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('bold') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Bold size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Italic size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Blocks size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<List size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ListOrdered size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
console.log('Quote button clicked');
|
|
||||||
editor?.chain().focus().toggleBlockquote().run()}}
|
|
||||||
className={`p-1 rounded hover:bg-gray-200 ${
|
|
||||||
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Quote size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const url = prompt('Введите URL изображения');
|
|
||||||
if (url) {
|
|
||||||
editor?.chain().focus().setImage({ src: url }).run();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🖼️ Изображение
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const newWidth = (parseInt(editor?.getAttributes('image').width) || 100) + 20;
|
|
||||||
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Увеличить
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const newWidth = Math.max((parseInt(editor?.getAttributes('image').width) || 100) - 20, 50);
|
|
||||||
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Уменьшить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Фото галерея
|
<span className="italic font-bold">Фото галерея</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -477,7 +290,7 @@ export function AdminPage() {
|
|||||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<ImagePlus size={16} className="mr-2" />
|
<ImagePlus size={16} className="mr-2" />
|
||||||
Добавить изображение
|
Загрузить фото
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<GalleryManager
|
<GalleryManager
|
||||||
@ -496,10 +309,10 @@ export function AdminPage() {
|
|||||||
setExcerpt('');
|
setExcerpt('');
|
||||||
setCategoryId(1);
|
setCategoryId(1);
|
||||||
setCityId(1);
|
setCityId(1);
|
||||||
setCoverImage('');
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
setGallery([]);
|
setGallery([]);
|
||||||
editor?.commands.setContent('');
|
setContent('');
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
@ -563,7 +376,7 @@ export function AdminPage() {
|
|||||||
<select
|
<select
|
||||||
value={filterCityId}
|
value={filterCityId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFilterCity(Number(e.target.value));
|
setFilterCityId(Number(e.target.value));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
@ -580,7 +393,7 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="divide-y divide-gray-200">
|
<ul className="divide-y divide-gray-200">
|
||||||
{articlesList.articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<li key={article.id} className="px-6 py-4">
|
<li key={article.id} className="px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -615,7 +428,7 @@ export function AdminPage() {
|
|||||||
<div className="px-6 py-4 border-t border-gray-200">
|
<div className="px-6 py-4 border-t border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Showing {((currentPage - 1) * ARTICLES_PER_PAGE) + 1} to {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} of {filteredArticles.length} articles
|
Показано {articles.length} статей
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
|
243
src/pages/ImportArticlesPage.tsx
Normal file
243
src/pages/ImportArticlesPage.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { AuthGuard } from '../components/AuthGuard';
|
||||||
|
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||||
|
import { FileJson, Save, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export function ImportArticlesPage() {
|
||||||
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [editingArticle, setEditingArticle] = useState<Article | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(articles.length / ARTICLES_PER_PAGE);
|
||||||
|
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ARTICLES_PER_PAGE;
|
||||||
|
const currentArticles = articles.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const text = e.target?.result;
|
||||||
|
if (!text) throw new Error('Файл пустой или не читается');
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(text as string);
|
||||||
|
if (Array.isArray(jsonData)) {
|
||||||
|
setArticles(jsonData);
|
||||||
|
setCurrentPage(1); // Сброс на первую страницу
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
throw new Error('Неправильный формат JSON. Ожидается массив статей.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Ошибка разбора JSON файла. Проверьте формат файла.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditField = (articleId: string, field: keyof Article, value: any) => {
|
||||||
|
setArticles(prev => prev.map(article =>
|
||||||
|
article.id === articleId ? { ...article, [field]: value } : article
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveToBackend = useCallback(async () => {
|
||||||
|
if (articles.length === 0) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/articles/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(articles),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ошибка сервера: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
alert('Статьи успешно сохранены');
|
||||||
|
} catch {
|
||||||
|
setError('Не удалось сохранить статьи. Попробуйте снова.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Импорт статей
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<FileJson className="h-5 w-5 mr-2" />
|
||||||
|
Выбор JSON файла
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{articles.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveToBackend}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Save className="h-5 w-5 mr-2" />
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить на сервер'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{articles.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{currentArticles.map((article) => (
|
||||||
|
<li key={article.id} className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={article.title}
|
||||||
|
onChange={(e) => handleEditField(article.id, 'title', e.target.value)}
|
||||||
|
className="block w-full text-sm font-medium text-gray-900 border-0 focus:ring-0 p-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingArticle(article)}
|
||||||
|
className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={article.excerpt}
|
||||||
|
onChange={(e) => handleEditField(article.id, 'excerpt', e.target.value)}
|
||||||
|
className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center text-sm text-gray-500">
|
||||||
|
<span className="truncate">
|
||||||
|
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing <span className="font-medium">{startIndex + 1}</span> to{' '}
|
||||||
|
<span className="font-medium">{Math.min(endIndex, articles.length)}</span> of{' '}
|
||||||
|
<span className="font-medium">{articles.length}</span> articles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous</span>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }).map((_, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setCurrentPage(idx + 1)}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
|
currentPage === idx + 1
|
||||||
|
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||||
|
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next</span>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
@ -9,21 +9,6 @@ import { imageResolutions } from '../config/imageResolutions';
|
|||||||
import {CategoryIds, CategoryTitles, CityIds} from "../types";
|
import {CategoryIds, CategoryTitles, CityIds} from "../types";
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
const categories: Category[] = [
|
|
||||||
{id : 1, name : 'Film'},
|
|
||||||
{id : 2, name : 'Theater'},
|
|
||||||
{id : 3, name : 'Music'},
|
|
||||||
{id : 4, name : 'Sports'},
|
|
||||||
{id : 5, name : 'Art'},
|
|
||||||
{id : 6, name : 'Legends'},
|
|
||||||
{id : 7, name : 'Anniversaries'},
|
|
||||||
{id : 8, name : 'Memory'}
|
|
||||||
];
|
|
||||||
|
|
||||||
const cities: City[] = ['New York', 'London'];
|
|
||||||
*/
|
|
||||||
|
|
||||||
const initialFormData: UserFormData = {
|
const initialFormData: UserFormData = {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@ -274,7 +259,7 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex items-center space-x-4">
|
<div className="mt-1 flex items-center space-x-4">
|
||||||
<img
|
<img
|
||||||
src={formData.avatarUrl || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150'}
|
src={formData.avatarUrl || '/images/avatar.jpg'}
|
||||||
alt="User avatar"
|
alt="User avatar"
|
||||||
className="h-12 w-12 rounded-full object-cover"
|
className="h-12 w-12 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user