Разделение компонента админской страницы на несколько компонентов. Прочие доработки.

This commit is contained in:
anibilag 2025-04-01 13:33:50 +03:00
parent dc00bca390
commit a08eb412e4
17 changed files with 987 additions and 748 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

BIN
public/images/gpt_film.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

View File

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

View File

@ -0,0 +1,311 @@
import React, { useState, useEffect } from 'react';
import { TipTapEditor } from './TipTapEditor.tsx';
import { CoverImageUpload } from './ImageUpload/CoverImageUpload.tsx';
import { ImageUploader } from './ImageUpload/ImageUploader.tsx';
import { GalleryManager } from './GalleryManager';
import { useGallery } from '../hooks/useGallery';
import { CategoryTitles, CityTitles, Author, GalleryImage, ArticleData } from '../types';
import { useAuthStore } from '../stores/authStore';
interface FormState {
title: string;
excerpt: string;
categoryId: number;
cityId: number;
coverImage: string;
readTime: number;
content: string;
authorId: string;
galleryImages: GalleryImage[];
}
interface ArticleFormProps {
editingId: string | null;
articleId: string;
initialFormState: FormState | null;
onSubmit: (articleData: ArticleData, closeForm: boolean) => Promise<void>;
onCancel: () => void;
authors: Author[];
availableCategoryIds: number[];
availableCityIds: number[];
}
export function ArticleForm({
editingId,
articleId,
initialFormState,
onSubmit,
onCancel,
authors,
availableCategoryIds,
availableCityIds,
}: ArticleFormProps) {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
const [categoryId, setCategoryId] = useState(availableCategoryIds[0] || 1);
const [cityId, setCityId] = useState(availableCityIds[0] || 1);
const [coverImage, setCoverImage] = useState('/images/cover-placeholder.webp');
const [readTime, setReadTime] = useState(5);
const [content, setContent] = useState('');
const [authorId, setAuthorId] = useState<string>(authors[0]?.id || '');
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
const [formNewImageUrl, setFormNewImageUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
useEffect(() => {
if (editingId) {
setDisplayedImages(galleryImages);
} else {
setDisplayedImages([]);
}
}, [editingId, galleryImages]);
useEffect(() => {
if (initialFormState) {
setTitle(initialFormState.title);
setExcerpt(initialFormState.excerpt);
setCategoryId(initialFormState.categoryId);
setCityId(initialFormState.cityId);
setCoverImage(initialFormState.coverImage);
setReadTime(initialFormState.readTime);
setContent(initialFormState.content);
setAuthorId(initialFormState.authorId);
setDisplayedImages(initialFormState.galleryImages || []);
}
}, [initialFormState]);
useEffect(() => {
if (!initialFormState) return;
const currentState: FormState = { title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, galleryImages: displayedImages };
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
const hasFormChanges = Object.keys(initialFormState).some(key => {
if (key === 'galleryImages') {
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
if (isInitialLoad && isDifferent) return false;
return isDifferent;
}
if (key === 'content') {
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
return isDifferent;
}
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
const isDifferent = currentValue !== initialValue;
return isDifferent;
});
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
if (isInitialLoad) {
setIsInitialLoad(false);
}
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, displayedImages, initialFormState, isInitialLoad]);
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
e.preventDefault();
if (!title.trim() || !excerpt.trim()) {
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
return;
}
if (!hasChanges) return;
const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user;
const articleData = {
title,
excerpt,
categoryId,
cityId,
coverImage,
readTime,
gallery: displayedImages,
content: content || '',
importId: 0,
isActive: false,
author: selectedAuthor,
};
await onSubmit(articleData, closeForm);
};
const handleApply = (e: React.FormEvent) => {
handleSubmit(e, false);
};
if (editingId && galleryLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{editingId ? 'Редактировать статью' : 'Создать новую статью'}</h1>
{(error || galleryError) && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error || galleryError}</div>
)}
<form onSubmit={(e) => handleSubmit(e, true)} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Заголовок</span> <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Краткое описание</span> <span className="text-red-500">*</span>
</label>
<textarea
id="excerpt"
rows={3}
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Категория</span></label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCategoryIds.map(cat => <option key={cat} value={cat}>{CategoryTitles[cat]}</option>)}
</select>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Столица</span></label>
<select
id="city"
value={cityId}
onChange={(e) => setCityId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCityIds.map(c => <option key={c} value={c}>{CityTitles[c]}</option>)}
</select>
</div>
<div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Время чтения (минуты)</span></label>
<input
type="number"
id="readTime"
min="1"
value={readTime}
onChange={(e) => setReadTime(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
/>
</div>
{editingId && isAdmin && (
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Автор</span></label>
<select
id="author"
value={authorId}
onChange={(e) => setAuthorId(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{authors.map(author => <option key={author.id} value={author.id}>{author.displayName}</option>)}
</select>
</div>
)}
</div>
<CoverImageUpload
coverImage={coverImage}
articleId={articleId}
onImageUpload={setCoverImage}
onError={setError}
allowUpload={!!editingId}
/>
<div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Статья</span></label>
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Галерея</span></label>
{editingId ? (
<>
<ImageUploader onUploadComplete={(imageUrl) => { setFormNewImageUrl(imageUrl); }} articleId={articleId} />
<GalleryManager
images={displayedImages}
imageUrl={formNewImageUrl}
setImageUrl={setFormNewImageUrl}
onAddImage={async (imageData) => {
console.log('Добавляем изображение в форме:', imageData);
const newImage = await addGalleryImage(imageData);
setFormNewImageUrl('');
setDisplayedImages(prev => [...prev, newImage]);
}}
onReorder={(images) => {
console.log('Переупорядочиваем изображения в форме:', images);
reorderGalleryImages(images.map(img => img.id));
setDisplayedImages(images);
}}
onDelete={(id) => {
console.log('Удаляем изображение в форме:', id);
deleteGalleryImage(id);
setDisplayedImages(prev => prev.filter(img => img.id !== id));
}}
onEdit={(image) => {
console.log('Редактируем изображение в форме:', image);
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
console.error('Ошибка при редактировании изображения в форме:', err);
setError('Не удалось обновить изображение');
});
}}
/>
</>
) : (
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
)}
</div>
<div className="flex justify-end gap-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Отмена
</button>
{!editingId && (
<button
type="button"
onClick={handleApply}
disabled={!hasChanges}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
>
Применить
</button>
)}
<button
type="submit"
disabled={!hasChanges}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
{editingId ? 'Изменить' : 'Сохранить черновик'}
</button>
</div>
</form>
</div>
);
}

View File

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

View File

@ -10,51 +10,55 @@ interface GalleryGridProps {
export function GalleryGrid({ images, onEdit, onDelete, onReorder }: GalleryGridProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((image, index) => (
<div
key={image.id}
className="relative group border rounded-lg overflow-hidden"
draggable
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
onReorder(dragIndex, index);
}}
>
<img
src={image.url}
alt={image.alt}
className="w-full h-48 object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity duration-200">
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="flex space-x-2">
<button
onClick={() => onEdit(image)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
>
<Pencil size={16} />
</button>
<button
onClick={() => onDelete(image.id)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
>
<Trash2 size={16} />
</button>
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
<Move size={16} />
</button>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((image, index) => (
<div
key={image.id}
className="relative group border rounded-lg overflow-hidden"
draggable
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
onReorder(dragIndex, index);
}}
>
<img
src={image.url}
alt={image.alt}
className="w-full h-48 object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity duration-200">
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
console.log('Клик по кнопке редактирования:', image);
onEdit(image);
}}
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
>
<Pencil size={16} />
</button>
<button
onClick={() => onDelete(image.id)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
>
<Trash2 size={16} />
</button>
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
<Move size={16} />
</button>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
{image.caption}
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
{image.caption}
</div>
</div>
))}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { GalleryManager } from './index';
import { useGallery } from '../../hooks/useGallery';
import { GalleryImage } from '../../types';
interface GalleryModalProps {
articleId: string | null;
onClose: () => void;
}
export function GalleryModal({ articleId, onClose }: GalleryModalProps) {
const { images: modalGalleryImages, loading: modalGalleryLoading, error: modalGalleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(articleId || '');
const [modalDisplayedImages, setModalDisplayedImages] = useState<GalleryImage[]>([]);
const [newImageUrl, setNewImageUrl] = useState('');
useEffect(() => {
if (articleId) {
setModalDisplayedImages(modalGalleryImages);
} else {
setModalDisplayedImages([]);
}
}, [articleId, modalGalleryImages]);
if (!articleId) return null;
return (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-lg w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Управление галереей</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500"><X size={20} /></button>
</div>
{modalGalleryLoading ? (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : modalGalleryError ? (
<div className="text-red-700 p-4 rounded-md bg-red-50">Ошибка загрузки галереи: {modalGalleryError}</div>
) : (
<GalleryManager
images={modalDisplayedImages}
imageUrl={newImageUrl}
setImageUrl={setNewImageUrl}
onAddImage={async (imageData) => {
console.log('Добавляем изображение в модальном окне:', imageData);
const newImage = await addGalleryImage(imageData);
setNewImageUrl('');
setModalDisplayedImages(prev => [...prev, newImage]);
}}
onReorder={(images) => {
console.log('Переупорядочиваем изображения в модальном окне:', images);
reorderGalleryImages(images.map(img => img.id));
setModalDisplayedImages(images);
}}
onDelete={(id) => {
console.log('Удаляем изображение в модальном окне:', id);
deleteGalleryImage(id);
setModalDisplayedImages(prev => prev.filter(img => img.id !== id));
}}
onEdit={(image) => {
console.log('Редактируем изображение в модальном окне:', image);
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
console.error('Ошибка при редактировании изображения в модальном окне:', err);
});
}}
/>
)}
</div>
</div>
);
}

View File

@ -3,12 +3,10 @@ import { GalleryImage } from '../../types';
import { GalleryGrid } from './GalleryGrid';
import { ImageForm } from './ImageForm';
interface GalleryManagerProps {
images: GalleryImage[];
// setImages: (images: GalleryImage[]) => void; // Добавляем setter для изображений
imageUrl: string;
setImageUrl: (url: string) => void; // Добавил setImageUrl для обновления
setImageUrl: (url: string) => void;
onAddImage: (imageData: Omit<GalleryImage, 'id'>) => void;
onReorder: (images: GalleryImage[]) => void;
onDelete: (id: string) => void;
@ -16,18 +14,29 @@ interface GalleryManagerProps {
}
export function GalleryManager({
images,
//setImages,
imageUrl,
setImageUrl,
onAddImage,
onReorder,
onDelete,
onEdit
}: GalleryManagerProps) {
images,
imageUrl,
setImageUrl,
onAddImage,
onReorder,
onDelete,
onEdit,
}: GalleryManagerProps) {
const [newImageUrl, setNewImageUrl] = useState('');
const [newImageCaption, setNewImageCaption] = useState('');
const [newImageAlt, setNewImageAlt] = useState('');
const [showEditModal, setShowEditModal] = useState<string | null>(null);
const [editAltText, setEditAltText] = useState<string>('');
// Отладка передачи images в GalleryGrid
useEffect(() => {
console.log('Images, переданные в GalleryGrid:', images);
}, [images]);
// Отладка изменения состояния showEditModal
useEffect(() => {
console.log('Текущее значение showEditModal:', showEditModal);
}, [showEditModal]);
// Следим за изменением imageUrl и обновляем newImageUrl
useEffect(() => {
@ -43,17 +52,34 @@ export function GalleryManager({
url: newImageUrl,
caption: newImageCaption,
alt: newImageAlt || newImageCaption,
width: 800, // Примерное значение, можно заменить на динамическое
width: 800, // Примерное значение, можно заменить на динамическое
height: 600,
size: 100000, // Примерное значение
size: 100000, // Примерное значение
format: 'webp', // Формат по умолчанию
};
console.log('Добавляем новое изображение:', newImage);
onAddImage(newImage); // Вызываем функцию добавления
setNewImageUrl('');
setNewImageCaption('');
setNewImageAlt('');
setImageUrl(''); // Очищаем после добавления
setImageUrl(''); // Очищаем после добавления
};
const handleEditClick = (image: GalleryImage) => {
console.log('Открываем модальное окно редактирования для изображения:', image);
console.log('ID изображения:', image.id);
setShowEditModal(image.id);
setEditAltText(image.alt || '');
console.log('Новое значение showEditModal:', image.id);
};
const handleEditSubmit = (image: GalleryImage) => {
const updatedImage = { ...image, alt: editAltText };
console.log('Сохраняем изменения для изображения:', updatedImage);
onEdit(updatedImage);
setShowEditModal(null);
setEditAltText('');
};
return (
@ -76,17 +102,59 @@ export function GalleryManager({
<h3 className="text-lg font-medium text-gray-900 mb-4">Изображения галереи</h3>
<GalleryGrid
images={images}
onEdit={(img) => onEdit(img)}
onDelete={(id) => onDelete(id)}
onEdit={(img) => {
console.log('Вызываем onEdit из GalleryGrid:', img);
handleEditClick(img);
}}
onDelete={(id) => {
console.log('Вызываем onDelete из GalleryGrid:', id);
onDelete(id);
}}
onReorder={(dragIndex, dropIndex) => {
console.log('Переупорядочиваем изображения: dragIndex=', dragIndex, 'dropIndex=', dropIndex);
const reorderedImages = [...images];
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
reorderedImages.splice(dropIndex, 0, draggedImage);
onReorder(reorderedImages);
// setImages(reorderedImages); // Обновляем состояние изображений
}}
/>
</div>
{/* Модальное окно редактирования */}
{showEditModal && (
console.log('Рендерим модальное окно с showEditModal:', showEditModal),
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Редактировать изображение</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">Alt-текст</label>
<input
type="text"
value={editAltText}
onChange={(e) => setEditAltText(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-4">
<button
onClick={() => setShowEditModal(null)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Отмена
</button>
<button
onClick={() => {
const image = images.find(img => img.id === showEditModal);
if (image) handleEditSubmit(image);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Сохранить
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,8 +1,8 @@
export const BackgroundImages: Record<number, string> = {
1: '/images/film-bg.avif?auto=format&fit=crop&q=80&w=2070',
2: '/images/main-bg-1.webp?auto=format&fit=crop&q=80&w=2070',
3: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
4: 'https://images.unsplash.com/photo-1461896836934-ffe607ba8211?auto=format&fit=crop&q=80&w=2070',
1: '/images/gpt_film.webp?auto=format&fit=crop&q=80&w=2070',
2: '/images/gpt_theatre.webp?auto=format&fit=crop&q=80&w=2070',
3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070',
4: '/images/bg-sport.webp?auto=format&fit=crop&q=80&w=2070',
5: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
6: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070',
7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { GalleryImage } from '../types';
import { galleryService } from '../services/galleryService';
export function useGallery(articleId: string) {
const [images, setImages] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(true);
@ -48,11 +47,14 @@ export function useGallery(articleId: string) {
const updateImage = async (id: string, updates: Partial<GalleryImage>) => {
try {
console.log('Обновляем изображение с ID:', id, 'Обновления:', updates);
const updatedImage = await galleryService.updateImage(id, updates);
setImages(images.map(img => img.id === id ? updatedImage : img));
console.log('Изображение успешно обновлено:', updatedImage);
setImages(images.map(img => (img.id === id ? updatedImage : img)));
return updatedImage;
} catch (err) {
console.error('Ошибка изменения изображения:', err);
setError('Не удалось обновить изображение');
throw err;
}
};
@ -63,6 +65,7 @@ export function useGallery(articleId: string) {
setImages(images.filter(img => img.id !== id));
} catch (err) {
console.error('Ошибка удаления изображения:', err);
setError('Не удалось удалить изображение');
throw err;
}
};
@ -70,12 +73,11 @@ export function useGallery(articleId: string) {
const reorderImages = async (imageIds: string[]) => {
try {
await galleryService.reorderImages(articleId, imageIds);
const reorderedImages = imageIds.map(id =>
images.find(img => img.id === id)!
);
const reorderedImages = imageIds.map(id => images.find(img => img.id === id)!);
setImages(reorderedImages);
} catch (err) {
console.error('Ошибка реорганизации изображений:', err);
setError('Не удалось изменить порядок изображений');
throw err;
}
};
@ -88,6 +90,6 @@ export function useGallery(articleId: string) {
updateImage,
deleteImage,
reorderImages,
refresh: loadGallery
refresh: loadGallery,
};
}

View File

@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { useAuthStore } from '../stores/authStore';
import { CategoryIds, CityIds } from '../types';
const allCategoryIds: number[] = CategoryIds;
const allCityIds: number[] = CityIds;
export function usePermissions() {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const availableCategoryIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCategoryIds;
return allCategoryIds.filter(cat => user.permissions.categories[cat]?.create || user.permissions.categories[cat]?.edit);
}, [user, isAdmin]);
const availableCityIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCityIds;
return user.permissions.cities;
}, [user, isAdmin]);
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
return { availableCategoryIds, availableCityIds, hasNoPermissions, isAdmin };
}

View File

@ -1,189 +1,91 @@
import React, { useState, useEffect, useMemo } from 'react';
import axios from "axios";
import MinutesWord from '../components/MinutesWord';
import { TipTapEditor } from '../components/TipTapEditor';
import {useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { Header } from '../components/Header';
import { GalleryManager } from '../components/GalleryManager';
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
import { useGallery } from '../hooks/useGallery';
import { CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author, GalleryImage } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { ArticleList } from '../components/ArticleList';
import { ArticleForm } from '../components/ArticleForm';
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
import { Article, Author, GalleryImage, ArticleData } from '../types';
import { usePermissions } from '../hooks/usePermissions';
interface FormState {
title: string;
excerpt: string;
categoryId: number;
cityId: number;
coverImage: string;
readTime: number;
content: string;
authorId: string;
galleryImages: GalleryImage[];
}
const allCategoryIds: number[] = CategoryIds;
const allCityIds: number[] = CityIds;
// Обложка по умоланию для новых статей
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
export function AdminPage() {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
const [showForm, setShowForm] = useState(false);
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(DEFAULT_COVER_IMAGE);
const [readTime, setReadTime] = useState(5);
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
const [initialFormState, setInitialFormState] = useState<FormState | null>(null);
const [articles, setArticles] = useState<Article[]>([]);
const [refreshArticles, setRefreshArticles] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [filterCategoryId, setFilterCategoryId] = useState(0);
const [filterCityId, setFilterCityId] = useState(0);
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
const [showGalleryModal, setShowGalleryModal] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [content, setContent] = useState('');
const [showDraftOnly, setShowDraftOnly] = useState(false);
const [authors, setAuthors] = useState<Author[]>([]);
const [authorId, setAuthorId] = useState<string>('');
const [newImageUrl, setNewImageUrl] = useState('');
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
// Инициализация хука useGallery с текущим значением articleId (editingId)
const {
images: galleryImages,
loading: galleryLoading,
error: galleryError,
addImage: addGalleryImage,
updateImage: updateGalleryImage,
deleteImage: deleteGalleryImage,
reorderImages: reorderGalleryImages,
// refresh: refreshGallery
} = useGallery(editingId || '');
// Синхронизируем displayedImages с galleryImages при изменении galleryImages
/*
useEffect(() => {
setDisplayedImages(galleryImages);
}, [galleryImages]);
*/
// Добавляем useEffect для инициализации displayedImages при изменении editingId
useEffect(() => {
if (editingId) {
setDisplayedImages(galleryImages);
} else {
setDisplayedImages([]); // Очищаем, если editingId сбрасывается
}
}, [editingId, galleryImages]); // Зависимость от galleryImages нужна только для инициализации
// Загрузка списка авторов
useEffect(() => {
const fetchAuthors = async () => {
try {
const response = await axios.get('/api/users/' , {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
const response = await axios.get('/api/users/', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setAuthors(response.data);
} catch (err) {
console.error('Ошибка загрузки авторов:', err);
setError('Не удалось загрузить список авторов.');
}
};
fetchAuthors();
}, []);
// Загрузка статей
useEffect(() => {
const fetchArticles = async () => {
try {
const response = await axios.get('/api/articles/', {
params: {
page: currentPage,
categoryId: filterCategoryId,
cityId: filterCityId,
isDraft: showDraftOnly,
userId: user?.id,
isAdmin: isAdmin
}
});
setArticles(response.data.articles);
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
} catch (error) {
setError('Не удалось загрузить статьи');
console.error(error);
}
};
const handleEdit = useCallback(
(id: string) => {
const article = articles.find(a => a.id === id);
if (article) {
setEditingId(id);
setInitialFormState({
title: article.title,
excerpt: article.excerpt,
categoryId: article.categoryId,
cityId: article.cityId,
coverImage: article.coverImage,
readTime: article.readTime,
content: article.content,
authorId: article.author.id,
galleryImages: [],
});
setArticleId(article.id);
setShowForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
setError('Статья не найдена');
}
},
[articles]
);
fetchArticles();
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshArticles]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Фильтр для категорий, основанный на разрешениях пользователя
const availableCategoryIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCategoryIds;
return allCategoryIds.filter(categoryId =>
user.permissions.categories[categoryId] &&
(user.permissions.categories[categoryId].create || user.permissions.categories[categoryId].edit)
);
}, [user, isAdmin]);
// Фильтр для городов, основанный на разрешениях пользователя
const availableCityIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCityIds;
return user.permissions.cities;
}, [user, isAdmin]);
// Установка категории и города по умолчанию на основе доступных параметров
useEffect(() => {
if (availableCategoryIds.length > 0 && !availableCategoryIds.includes(categoryId)) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0 && !availableCityIds.includes(cityId)) {
setCityId(availableCityIds[0]);
}
}, [availableCategoryIds, availableCityIds, categoryId, cityId]);
// Редактирование статьи - загрузка данных
const handleEdit = (id: string) => {
const article = articles.find(a => a.id === id);
if (article) {
setArticleId(article.id);
setTitle(article.title);
setExcerpt(article.excerpt);
setCategoryId(article.categoryId);
setCityId(article.cityId);
setCoverImage(article.coverImage);
setReadTime(article.readTime);
setAuthorId(article.author.id);
setContent(article.content);
setEditingId(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// Удаление статьи
const handleDelete = async (id: string) => {
try {
await axios.delete(`/api/article/${id}`, {
await axios.delete(`/api/articles/${id}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticles(prev => ({
...prev,
articles: prev.filter(article => article.id !== id),
}));
setArticles(prev => prev.filter(article => article.id !== id));
setRefreshArticles(prev => prev + 1);
} catch (error) {
setError('Не удалось удалить статью');
@ -192,549 +94,120 @@ export function AdminPage() {
setShowDeleteModal(null);
};
// Перевод статьи в черновик и обратно
const handleToggleActive = async (id: string) => {
const article = articles.find(a => a.id === id);
if (article) {
const articleData = {
isActive: article.isActive,
};
const handleSubmit = async (articleData: ArticleData, closeForm: boolean = true) => {
try {
let response;
try {
await axios.put(`/api/articles/active/${id}`, articleData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticles(prev => prev.map(article =>
article.id === id ? {...article, isActive: !article.isActive} : article
));
} catch (error) {
setError('Не переключить статью в актив');
console.error(error);
}
}
};
// Создание новой статьи, сохранение существующей
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let selectedAuthor;
if (editingId) {
if (isAdmin) {
selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
} else {
// Пользователь без административных прав не может менять автора
const originalArticle = articles.find(a => a.id === editingId);
selectedAuthor = originalArticle?.author || authors[0];
}
} else {
// Для новой статьи всегда использовать текущего пользователя
selectedAuthor = user;
}
const articleData = {
title,
excerpt,
categoryId,
cityId,
coverImage,
readTime,
gallery: galleryImages,
content: content || '',
importId: 0,
isActive: false,
author: selectedAuthor
};
if (editingId) {
// Редактирование существующей статьи
try {
if (editingId) {
const response = await axios.put(`/api/articles/${editingId}`, articleData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticles(prev => prev.map(article => article.id === editingId ? response.data : article));
} catch (error) {
setError('Не удалось обновить статью');
console.error(error);
}
setEditingId(null);
}
else {
// Создание новой статьи
try {
const response = await axios.post('/api/articles', articleData, {
const updatedArticle: Article = response.data;
setArticles(prev => prev.map(a => (a.id === editingId ? updatedArticle : a)));
} else {
response = await axios.post('/api/articles', articleData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const newArticle: Article = response.data;
setArticles(prev => [...prev, newArticle]);
setArticles(prev => [...prev, response.data]);
} catch (error) {
setError('Не удалось создать статью');
console.error(error);
if (!closeForm) {
const newArticleId = newArticle.id;
setEditingId(newArticleId);
setArticleId(newArticleId);
setInitialFormState({
title: articleData.title,
excerpt: articleData.excerpt,
categoryId: articleData.categoryId,
cityId: articleData.cityId,
coverImage: articleData.coverImage,
readTime: articleData.readTime,
content: articleData.content,
authorId: articleData.author.id,
galleryImages: articleData.gallery || [],
});
}
}
setRefreshArticles(prev => prev + 1);
if (closeForm) {
resetForm();
}
} catch (error) {
setError(`Не удалось ${editingId ? 'обновить' : 'создать'} статью`);
console.error(error);
}
setRefreshArticles(prev => prev + 1);
// Очистка формы статьи
setArticleId('');
setTitle('');
setExcerpt('');
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setContent('');
setEditingId(null);
};
// Проверка прав пользователя
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
const resetForm = () => {
setShowForm(false);
setEditingId(null);
setArticleId('');
setInitialFormState(null);
setError(null);
};
// Show loading state while gallery is loading
if (editingId && galleryLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const handleNewArticle = () => {
setInitialFormState({
title: '',
excerpt: '',
categoryId: availableCategoryIds[0] || 1,
cityId: availableCityIds[0] || 1,
coverImage: DEFAULT_COVER_IMAGE,
readTime: 5,
content: '',
authorId: authors[0]?.id || '',
galleryImages: [],
});
setArticleId('');
setEditingId(null);
setShowForm(true);
};
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{hasNoPermissions ? (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Недостаточно прав
</h1>
<p className="text-gray-600 mb-4">
У вас нет прав на создание и редактирование статей в любой категории и городе.
Свяжитесь с администраторм чтобы получить права.
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
</h1>
{(error || galleryError) && isAdmin && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
{error || galleryError}
</div>
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{error && isAdmin && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Заголовок</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
<ArticleList
articles={articles}
setArticles={setArticles}
onEdit={handleEdit}
onDelete={(id) => setShowDeleteModal(id)}
onShowGallery={(id) => setShowGalleryModal(id)}
onNewArticle={handleNewArticle}
refreshTrigger={refreshArticles}
/>
{showForm && (
<ArticleForm
editingId={editingId}
articleId={articleId}
initialFormState={initialFormState}
onSubmit={handleSubmit}
onCancel={resetForm}
authors={authors}
availableCategoryIds={availableCategoryIds}
availableCityIds={availableCityIds}
/>
</div>
<div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Краткое описание</span>
</label>
<textarea
id="excerpt"
rows={3}
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span>
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCategoryIds.map((cat) => (
<option key={cat} value={cat}>
{CategoryTitles[cat]}
</option>
))}
</select>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Столица</span>
</label>
<select
id="city"
value={cityId}
onChange={(e) => setCityId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{availableCityIds.map((c) => (
<option key={c} value={c}>
{CityTitles[c]}
</option>
))}
</select>
</div>
<div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Время чтения (минуты)</span>
</label>
<input
type="number"
id="readTime"
min="1"
value={readTime}
onChange={(e) => setReadTime(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
/>
</div>
{editingId && isAdmin && (
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Автор</span>
</label>
<select
id="author"
value={authorId}
onChange={(e) => setAuthorId(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{authors.map((author) => (
<option key={author.id} value={author.id}>
{author.displayName}
</option>
))}
</select>
</div>
)}
</div>
<CoverImageUpload
coverImage={coverImage}
articleId={articleId}
onImageUpload={setCoverImage}
onError={setError}
allowUpload={!!editingId}
/>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Статья</span>
</label>
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId}/>
</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>
{editingId && (
<button
type="button"
onClick={() => setShowGalleryUploader(true)}
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
images={displayedImages} // Используем displayedImages вместо galleryImages
imageUrl={newImageUrl}
setImageUrl={setNewImageUrl}
onAddImage={async (imageData) => {
const newImage = await addGalleryImage(imageData);
setNewImageUrl('');
setDisplayedImages((prev) => [...prev, newImage]);
}}
onReorder={(images) => {
reorderGalleryImages(images.map(img => img.id));
setDisplayedImages(images);
}}
onDelete={(id) => {
deleteGalleryImage(id);
setDisplayedImages((prev) => prev.filter(img => img.id !== id));
}}
onEdit={(image) => {
updateGalleryImage(image.id, image);
setDisplayedImages((prev) => prev.map(img => (img.id === image.id ? image : img)));
}}
/>
</div>
<div className="flex justify-end gap-4">
{editingId && (
<button
type="button"
onClick={() => {
setEditingId(null);
setTitle('');
setExcerpt('');
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setContent('');
setDisplayedImages([]); // Очищаем отображаемые изображения
setNewImageUrl(''); // Очищаем URL нового изображения
}}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Отмена
</button>
)}
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{editingId ? 'Изменить' : 'Сохранить черновик'}
</button>
</div>
</form>
</div>
)}
{/* Модальная загрузка изображений галереи */}
{showGalleryUploader && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-lg w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
Загрузка изображений в галерею
</h3>
<button
onClick={() => setShowGalleryUploader(false)}
className="text-gray-400 hover:text-gray-500"
>
<X size={20} />
</button>
</div>
<ImageUploader onUploadComplete={(imageUrl) => {
setNewImageUrl(imageUrl);
setShowGalleryUploader(false);
}}
articleId={articleId}
/>
</div>
</div>
)}
{/* Список статей */}
<div className="bg-white rounded-lg shadow-sm">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
<div className="flex flex-wrap gap-4">
<select
value={filterCategoryId}
onChange={(e) => {
setFilterCategoryId(Number(e.target.value));
setCurrentPage(1);
}}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="">Все категории</option>
{CategoryIds.map((cat) => (
<option key={cat} value={cat}>
{CategoryTitles[cat]}
</option>
))}
</select>
<select
value={filterCityId}
onChange={(e) => {
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"
>
<option value="">Все столицы</option>
{CityIds.map((c) => (
<option key={c} value={c}>
{CityTitles[c]}
</option>
))}
</select>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={showDraftOnly}
onChange={(e) => setShowDraftOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Только черновики</span>
</label>
</div>
</div>
</div>
<ul className="divide-y divide-gray-200">
{articles.map((article) => (
<li
key={article.id}
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate">
{article.title}
</h3>
<p className="text-sm text-gray-500">
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
</p>
<div className="flex items-center text-xs text-gray-500 mt-1">
<img
src={article.author.avatarUrl}
alt={article.author.displayName}
className="h-6 w-6 rounded-full mr-1"
/>
<span className="italic font-bold"> {article.author.displayName}</span>
</div>
</div>
<div className="flex items-center gap-4 ml-4">
<button
onClick={() => handleToggleActive(article.id)}
className={`p-2 rounded-full hover:bg-gray-100 ${
article.isActive ? 'text-green-600' : 'text-gray-400'
}`}
title={article.isActive ? 'Set as draft' : 'Publish article'}
>
{article.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
</button>
<button
onClick={() => handleEdit(article.id)}
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
>
<Pencil size={18} />
</button>
<button
onClick={() => setShowDeleteModal(article.id)}
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50"
>
<Trash2 size={18} />
</button>
</div>
</div>
</li>
))}
</ul>
{/* Pagination */}
{totalPages > 1 && (
<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">
Показано {articles.length} статей
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={20} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`px-4 py-2 text-sm font-medium rounded-md ${
currentPage === page
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={20} />
</button>
</div>
</div>
</div>
)}
</div>
</main>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Delete Article
</h3>
<p className="text-sm text-gray-500 mb-6">
Are you sure you want to delete this article? This action cannot be undone.
</p>
<div className="flex justify-end gap-4">
<button
onClick={() => setShowDeleteModal(null)}
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"
>
Cancel
</button>
<button
onClick={() => handleDelete(showDeleteModal)}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
{showGalleryModal && (
<GalleryModal
articleId={showGalleryModal}
onClose={() => setShowGalleryModal(null)}
/>
)}
{showDeleteModal && (
<ArticleDeleteModal
onConfirm={() => handleDelete(showDeleteModal)}
onCancel={() => setShowDeleteModal(null)}
/>
)}
</main>
</div>
);
}

View File

@ -17,6 +17,21 @@ export interface Article {
isActive: boolean;
}
// Определяем тип ArticleData для редактрования
export interface ArticleData {
title: string;
excerpt: string;
categoryId: number;
cityId: number;
coverImage: string;
readTime: number;
gallery: GalleryImage[];
content: string;
importId: number;
isActive: boolean;
author: Author;
}
// Структура ответа на список статей
export interface ArticlesResponse {
articles: Article[];