Переделана пагинация на backend, поэтому убрана фильтрация по frontend. Реализован импорт статей из json файла, пока без загрузки обложки статьи в хранилище S3

This commit is contained in:
anibilag 2025-02-25 13:29:46 +03:00
parent 1c4a7f2384
commit be59f4418a
10 changed files with 673 additions and 304 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

@ -11,6 +11,7 @@ import { SearchPage } from './pages/SearchPage';
import { BookmarksPage } from './pages/BookmarksPage';
import { Footer } from './components/Footer';
import { AuthGuard } from './components/AuthGuard';
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
function App() {
const { setUser, setLoading } = useAuthStore();
@ -63,6 +64,14 @@ function App() {
</AuthGuard>
}
/>
<Route
path="/admin/import"
element={
<AuthGuard>
<ImportArticlesPage />
</AuthGuard>
}
/>
</Routes>
</div>
<Footer />

View File

@ -1,28 +1,21 @@
//import { ResizableImage } from './TipTapEditor';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Blockquote from '@tiptap/extension-blockquote';
import Image from "@tiptap/extension-image";
import Image from '@tiptap/extension-blockquote';
interface ArticleContentProps {
content: string;
}
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
class: {
default: 'max-w-full h-auto', // Добавлено для адаптивности
},
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',
@ -41,6 +34,7 @@ const ResizableImage = Image.extend({
},
});
// Показ контента статьи в редакторе, в режиме "только чтение"
export function ArticleContent({ content }: ArticleContentProps) {
const editor = useEditor({
extensions: [

View File

@ -1,20 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ArticleCard } from './ArticleCard';
import { Pagination } from './Pagination';
import {Article, CategoryTitles, CityTitles} from '../types';
import axios from "axios";
const ARTICLES_PER_PAGE = 6;
export function FeaturedSection() {
// const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category');
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 [totalPages, setTotalPages] = useState(1);
const [error, setError] = useState<string | null>(null);
// Загрузка статей с backend
@ -30,6 +29,7 @@ export function FeaturedSection() {
});
console.log('Загруженные данные:', response.data);
setArticles(response.data.articles);
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
} catch (error) {
setError('Не удалось загрузить статьи');
console.error(error);
@ -39,40 +39,13 @@ export function FeaturedSection() {
fetchArticles();
}, [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) => {
searchParams.set('page', page.toString());
setSearchParams(searchParams);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (filteredArticles.length === 0) {
if (articles.length === 0) {
return (
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="text-center py-12">
@ -95,7 +68,7 @@ export function FeaturedSection() {
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
</h2>
<p className="text-gray-600">
Показано {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} из {filteredArticles.length} статей
Показано {articles.length} статей
</p>
</div>
@ -106,7 +79,7 @@ export function FeaturedSection() {
)}
<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
key={article.id}
article={article}

View File

@ -1,15 +1,17 @@
import React, { useRef } from 'react';
import { ImagePlus, X } from 'lucide-react';
import axios from 'axios';
import { imageResolutions } from '../../config/imageResolutions';
import { imageResolutions } from '../../config/imageResolutions.ts';
interface CoverImageUploadProps {
coverImage: string;
articleId: string;
onImageUpload: (url: 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 handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -25,7 +27,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
const formData = new FormData();
formData.append('file', file);
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, {
@ -39,7 +41,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
onImageUpload(response.data.fileUrl); // Передача URL загруженного изображения
}
} catch (error) {
onError('Failed to upload image. Please try again.');
onError('Ошибка загрузки изображения. Попробуйте еще раз.');
console.error('Upload error:', error);
}
@ -69,6 +71,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
alt="Cover preview"
className="w-full h-full object-cover"
/>
{allowUpload && (
<button
type="button"
onClick={handleClearImage}
@ -76,6 +79,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
>
<X size={16} className="text-gray-600" />
</button>
)}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all" />
</div>
)}
@ -88,6 +92,7 @@ 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"
placeholder="URL изображения будет здесь после загрузки"
/>
{allowUpload && (
<label
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"
@ -95,6 +100,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
<ImagePlus size={18} className="mr-2" />
Загрузка
</label>
)}
<input
ref={fileInputRef}
type="file"

View 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>
);
}

View File

@ -16,6 +16,10 @@
.prose {
@apply max-w-none;
}
.selected-image {
@apply border-4 border-blue-500 shadow-md;
}
}
@layer utilities {

View File

@ -1,109 +1,37 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import axios from "axios";
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 { TipTapEditor } from '../components/TipTapEditor';
import { Header } from '../components/Header';
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 MinutesWord from "../components/MinutesWord";
import { ArticlesResponse, GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds } from '../types';
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Pencil,
Trash2,
ChevronLeft,
ChevronRight,
Blocks,
ImagePlus,
X
} from 'lucide-react';
import MinutesWord from '../components/MinutesWord';
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, 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() {
const [articleId, setArticleId] = useState('');
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
const [categoryId, setCategoryId] = useState(1);
const [cityId, setCityId] = useState(1);
const [coverImage, setCoverImage] = useState('');
const [coverImage, setCoverImage] = useState(DEFAULT_COVER_IMAGE);
const [readTime, setReadTime] = useState(5);
const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
const [editingId, setEditingId] = 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 [filterCategoryId, setFilterCategoryId] = useState(0);
const [filterCityId, setFilterCity] = useState(0);
const [filterCityId, setFilterCityId] = useState(0);
const [error, setError] = useState<string | null>(null);
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
class: {
default: 'max-w-full h-auto', // Добавлено для адаптивности
},
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false, // Отключаем дефолтный
}),
Blockquote.configure({
HTMLAttributes: {
class: 'border-l-4 border-gray-300 pl-4 italic',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
ResizableImage,
Highlight
],
content: '',
editorProps: {
attributes: {
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
},
},
});
const [content, setContent] = useState('');
// Загрузка статей
useEffect(() => {
@ -116,7 +44,8 @@ export function AdminPage() {
cityId: filterCityId
}
});
setArticlesList(response.data);
setArticles(response.data.articles);
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
} catch (error) {
setError('Не удалось загрузить статьи');
console.error(error);
@ -126,39 +55,15 @@ export function AdminPage() {
fetchArticles();
}, [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) => {
setCurrentPage(page);
};
// Редактирование статьи - загрузка данных
const handleEdit = (id: string) => {
const article = articlesList.articles.find(a => a.id === id);
const article = articles.find(a => a.id === id);
if (article) {
setArticleId(article.id);
setTitle(article.title);
setExcerpt(article.excerpt);
setCategoryId(article.categoryId);
@ -166,7 +71,7 @@ export function AdminPage() {
setCoverImage(article.coverImage);
setReadTime(article.readTime);
setGallery(article.gallery || []);
editor?.commands.setContent(article.content);
setContent(article.content); // Устанавливаем содержимое редактора
setEditingId(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
@ -180,9 +85,9 @@ export function AdminPage() {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticlesList(prev => ({
setArticles(prev => ({
...prev,
articles: prev.articles.filter(article => article.id !== id),
articles: prev.filter(article => article.id !== id),
}));
} catch (error) {
setError('Не удалось удалить статью');
@ -203,7 +108,7 @@ export function AdminPage() {
coverImage,
readTime,
gallery,
content: editor?.getHTML() || '',
content: content || '',
};
if (editingId) {
@ -214,9 +119,9 @@ export function AdminPage() {
},
});
setArticlesList(prev => ({
setArticles(prev => ({
...prev,
articles: prev.articles.map(article =>
articles: prev.map(article =>
article.id === editingId ? response.data : article
),
}));
@ -234,23 +139,24 @@ export function AdminPage() {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticlesList(prev => ({
setArticles(prev => ({
...prev,
articles: [...prev.articles, response.data],
articles: [...prev, response.data],
}));
console.log('Создание новой статьи:', articleData);
}
// Reset form
// Очистка формы статьи
setArticleId('');
setTitle('');
setExcerpt('');
setCategoryId(1);
setCityId(1);
setCoverImage('');
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setGallery([]);
editor?.commands.setContent('');
setContent(''); // Очищаем содержимое редактора
};
const handleGalleryImageUpload = (imageUrl: string) => {
@ -361,115 +267,22 @@ export function AdminPage() {
<CoverImageUpload
coverImage={coverImage}
articleId={articleId}
onImageUpload={setCoverImage}
onError={setError}
allowUpload={!!editingId}
/>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Статья</span>
</label>
<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={() => 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>
<TipTapEditor initialContent={content} onContentChange={setContent} />
</div>
</div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-4">
<label className="block text-sm font-medium text-gray-700">
Фото галерея
<span className="italic font-bold">Фото галерея</span>
</label>
<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"
>
<ImagePlus size={16} className="mr-2" />
Добавить изображение
Загрузить фото
</button>
</div>
<GalleryManager
@ -496,10 +309,10 @@ export function AdminPage() {
setExcerpt('');
setCategoryId(1);
setCityId(1);
setCoverImage('');
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
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"
>
@ -563,7 +376,7 @@ export function AdminPage() {
<select
value={filterCityId}
onChange={(e) => {
setFilterCity(Number(e.target.value));
setFilterCityId(Number(e.target.value));
setCurrentPage(1);
}}
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>
<ul className="divide-y divide-gray-200">
{articlesList.articles.map((article) => (
{articles.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">
@ -615,7 +428,7 @@ export function AdminPage() {
<div className="px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<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 className="flex items-center space-x-2">
<button

View 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>
);
}

View File

@ -9,21 +9,6 @@ import { imageResolutions } from '../config/imageResolutions';
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 = {
email: '',
password: '',
@ -274,7 +259,7 @@ export function UserManagementPage() {
</label>
<div className="mt-1 flex items-center space-x-4">
<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"
className="h-12 w-12 rounded-full object-cover"
/>