Разделение компонента админской страницы на несколько компонентов. Прочие доработки.
This commit is contained in:
parent
dc00bca390
commit
a08eb412e4
BIN
public/images/bg-film.webp
Normal file
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
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
BIN
public/images/bg-sport.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 586 KiB |
BIN
public/images/bg-theatre.webp
Normal file
BIN
public/images/bg-theatre.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 321 KiB |
BIN
public/images/gpt_film.webp
Normal file
BIN
public/images/gpt_film.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 467 KiB |
BIN
public/images/gpt_theatre.webp
Normal file
BIN
public/images/gpt_theatre.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 548 KiB |
29
src/components/ArticleDeleteModal.tsx
Normal file
29
src/components/ArticleDeleteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
311
src/components/ArticleForm.tsx
Normal file
311
src/components/ArticleForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
238
src/components/ArticleList.tsx
Normal file
238
src/components/ArticleList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -10,51 +10,55 @@ interface GalleryGridProps {
|
|||||||
|
|
||||||
export function GalleryGrid({ images, onEdit, onDelete, onReorder }: GalleryGridProps) {
|
export function GalleryGrid({ images, onEdit, onDelete, onReorder }: GalleryGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
key={image.id}
|
||||||
className="relative group border rounded-lg overflow-hidden"
|
className="relative group border rounded-lg overflow-hidden"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
|
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||||
onReorder(dragIndex, index);
|
onReorder(dragIndex, index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
className="w-full h-48 object-cover"
|
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 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="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(image)}
|
onClick={(e) => {
|
||||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
|
e.stopPropagation();
|
||||||
>
|
console.log('Клик по кнопке редактирования:', image);
|
||||||
<Pencil size={16} />
|
onEdit(image);
|
||||||
</button>
|
}}
|
||||||
<button
|
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
|
||||||
onClick={() => onDelete(image.id)}
|
>
|
||||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
|
<Pencil size={16} />
|
||||||
>
|
</button>
|
||||||
<Trash2 size={16} />
|
<button
|
||||||
</button>
|
onClick={() => onDelete(image.id)}
|
||||||
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
|
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
|
||||||
<Move size={16} />
|
>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
))}
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
|
</div>
|
||||||
{image.caption}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
72
src/components/GalleryManager/GalleryModal.tsx
Normal file
72
src/components/GalleryManager/GalleryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -3,12 +3,10 @@ import { GalleryImage } from '../../types';
|
|||||||
import { GalleryGrid } from './GalleryGrid';
|
import { GalleryGrid } from './GalleryGrid';
|
||||||
import { ImageForm } from './ImageForm';
|
import { ImageForm } from './ImageForm';
|
||||||
|
|
||||||
|
|
||||||
interface GalleryManagerProps {
|
interface GalleryManagerProps {
|
||||||
images: GalleryImage[];
|
images: GalleryImage[];
|
||||||
// setImages: (images: GalleryImage[]) => void; // Добавляем setter для изображений
|
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
setImageUrl: (url: string) => void; // Добавил setImageUrl для обновления
|
setImageUrl: (url: string) => void;
|
||||||
onAddImage: (imageData: Omit<GalleryImage, 'id'>) => void;
|
onAddImage: (imageData: Omit<GalleryImage, 'id'>) => void;
|
||||||
onReorder: (images: GalleryImage[]) => void;
|
onReorder: (images: GalleryImage[]) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
@ -16,18 +14,29 @@ interface GalleryManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GalleryManager({
|
export function GalleryManager({
|
||||||
images,
|
images,
|
||||||
//setImages,
|
imageUrl,
|
||||||
imageUrl,
|
setImageUrl,
|
||||||
setImageUrl,
|
onAddImage,
|
||||||
onAddImage,
|
onReorder,
|
||||||
onReorder,
|
onDelete,
|
||||||
onDelete,
|
onEdit,
|
||||||
onEdit
|
}: GalleryManagerProps) {
|
||||||
}: GalleryManagerProps) {
|
|
||||||
const [newImageUrl, setNewImageUrl] = useState('');
|
const [newImageUrl, setNewImageUrl] = useState('');
|
||||||
const [newImageCaption, setNewImageCaption] = useState('');
|
const [newImageCaption, setNewImageCaption] = useState('');
|
||||||
const [newImageAlt, setNewImageAlt] = 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
|
// Следим за изменением imageUrl и обновляем newImageUrl
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,17 +52,34 @@ export function GalleryManager({
|
|||||||
url: newImageUrl,
|
url: newImageUrl,
|
||||||
caption: newImageCaption,
|
caption: newImageCaption,
|
||||||
alt: newImageAlt || newImageCaption,
|
alt: newImageAlt || newImageCaption,
|
||||||
width: 800, // Примерное значение, можно заменить на динамическое
|
width: 800, // Примерное значение, можно заменить на динамическое
|
||||||
height: 600,
|
height: 600,
|
||||||
size: 100000, // Примерное значение
|
size: 100000, // Примерное значение
|
||||||
format: 'webp', // Формат по умолчанию
|
format: 'webp', // Формат по умолчанию
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('Добавляем новое изображение:', newImage);
|
||||||
onAddImage(newImage); // Вызываем функцию добавления
|
onAddImage(newImage); // Вызываем функцию добавления
|
||||||
setNewImageUrl('');
|
setNewImageUrl('');
|
||||||
setNewImageCaption('');
|
setNewImageCaption('');
|
||||||
setNewImageAlt('');
|
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 (
|
return (
|
||||||
@ -76,17 +102,59 @@ export function GalleryManager({
|
|||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Изображения галереи</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Изображения галереи</h3>
|
||||||
<GalleryGrid
|
<GalleryGrid
|
||||||
images={images}
|
images={images}
|
||||||
onEdit={(img) => onEdit(img)}
|
onEdit={(img) => {
|
||||||
onDelete={(id) => onDelete(id)}
|
console.log('Вызываем onEdit из GalleryGrid:', img);
|
||||||
|
handleEditClick(img);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => {
|
||||||
|
console.log('Вызываем onDelete из GalleryGrid:', id);
|
||||||
|
onDelete(id);
|
||||||
|
}}
|
||||||
onReorder={(dragIndex, dropIndex) => {
|
onReorder={(dragIndex, dropIndex) => {
|
||||||
|
console.log('Переупорядочиваем изображения: dragIndex=', dragIndex, 'dropIndex=', dropIndex);
|
||||||
const reorderedImages = [...images];
|
const reorderedImages = [...images];
|
||||||
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
|
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
|
||||||
reorderedImages.splice(dropIndex, 0, draggedImage);
|
reorderedImages.splice(dropIndex, 0, draggedImage);
|
||||||
onReorder(reorderedImages);
|
onReorder(reorderedImages);
|
||||||
// setImages(reorderedImages); // Обновляем состояние изображений
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,8 +1,8 @@
|
|||||||
export const BackgroundImages: Record<number, string> = {
|
export const BackgroundImages: Record<number, string> = {
|
||||||
1: '/images/film-bg.avif?auto=format&fit=crop&q=80&w=2070',
|
1: '/images/gpt_film.webp?auto=format&fit=crop&q=80&w=2070',
|
||||||
2: '/images/main-bg-1.webp?auto=format&fit=crop&q=80&w=2070',
|
2: '/images/gpt_theatre.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',
|
3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070',
|
||||||
4: 'https://images.unsplash.com/photo-1461896836934-ffe607ba8211?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',
|
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',
|
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',
|
7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',
|
||||||
|
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
|||||||
import { GalleryImage } from '../types';
|
import { GalleryImage } from '../types';
|
||||||
import { galleryService } from '../services/galleryService';
|
import { galleryService } from '../services/galleryService';
|
||||||
|
|
||||||
|
|
||||||
export function useGallery(articleId: string) {
|
export function useGallery(articleId: string) {
|
||||||
const [images, setImages] = useState<GalleryImage[]>([]);
|
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -48,11 +47,14 @@ export function useGallery(articleId: string) {
|
|||||||
|
|
||||||
const updateImage = async (id: string, updates: Partial<GalleryImage>) => {
|
const updateImage = async (id: string, updates: Partial<GalleryImage>) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('Обновляем изображение с ID:', id, 'Обновления:', updates);
|
||||||
const updatedImage = await galleryService.updateImage(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;
|
return updatedImage;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка изменения изображения:', err);
|
console.error('Ошибка изменения изображения:', err);
|
||||||
|
setError('Не удалось обновить изображение');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -63,6 +65,7 @@ export function useGallery(articleId: string) {
|
|||||||
setImages(images.filter(img => img.id !== id));
|
setImages(images.filter(img => img.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка удаления изображения:', err);
|
console.error('Ошибка удаления изображения:', err);
|
||||||
|
setError('Не удалось удалить изображение');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -70,12 +73,11 @@ export function useGallery(articleId: string) {
|
|||||||
const reorderImages = async (imageIds: string[]) => {
|
const reorderImages = async (imageIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await galleryService.reorderImages(articleId, imageIds);
|
await galleryService.reorderImages(articleId, imageIds);
|
||||||
const reorderedImages = imageIds.map(id =>
|
const reorderedImages = imageIds.map(id => images.find(img => img.id === id)!);
|
||||||
images.find(img => img.id === id)!
|
|
||||||
);
|
|
||||||
setImages(reorderedImages);
|
setImages(reorderedImages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка реорганизации изображений:', err);
|
console.error('Ошибка реорганизации изображений:', err);
|
||||||
|
setError('Не удалось изменить порядок изображений');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -88,6 +90,6 @@ export function useGallery(articleId: string) {
|
|||||||
updateImage,
|
updateImage,
|
||||||
deleteImage,
|
deleteImage,
|
||||||
reorderImages,
|
reorderImages,
|
||||||
refresh: loadGallery
|
refresh: loadGallery,
|
||||||
};
|
};
|
||||||
}
|
}
|
27
src/hooks/usePermissions.ts
Normal file
27
src/hooks/usePermissions.ts
Normal 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 };
|
||||||
|
}
|
@ -1,189 +1,91 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import {useState, useEffect, useCallback } from 'react';
|
||||||
import axios from "axios";
|
import axios from 'axios';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
|
||||||
import { TipTapEditor } from '../components/TipTapEditor';
|
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { GalleryManager } from '../components/GalleryManager';
|
import { ArticleList } from '../components/ArticleList';
|
||||||
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
import { ArticleForm } from '../components/ArticleForm';
|
||||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
||||||
import { useGallery } from '../hooks/useGallery';
|
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
||||||
import { CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author, GalleryImage } from '../types';
|
import { Article, Author, GalleryImage, ArticleData } from '../types';
|
||||||
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight } from 'lucide-react';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
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[];
|
||||||
|
}
|
||||||
|
|
||||||
const allCategoryIds: number[] = CategoryIds;
|
|
||||||
const allCityIds: number[] = CityIds;
|
|
||||||
|
|
||||||
// Обложка по умоланию для новых статей
|
|
||||||
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const { user } = useAuthStore();
|
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||||||
const isAdmin = user?.permissions.isAdmin || false;
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const [articleId, setArticleId] = useState('');
|
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 [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 [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [refreshArticles, setRefreshArticles] = useState(0);
|
const [refreshArticles, setRefreshArticles] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [showGalleryModal, setShowGalleryModal] = useState<string | null>(null);
|
||||||
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
|
||||||
const [filterCityId, setFilterCityId] = useState(0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
|
||||||
const [authors, setAuthors] = useState<Author[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchAuthors = async () => {
|
const fetchAuthors = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/users/' , {
|
const response = await axios.get('/api/users/', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setAuthors(response.data);
|
setAuthors(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка загрузки авторов:', err);
|
console.error('Ошибка загрузки авторов:', err);
|
||||||
setError('Не удалось загрузить список авторов.');
|
setError('Не удалось загрузить список авторов.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAuthors();
|
fetchAuthors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Загрузка статей
|
const handleEdit = useCallback(
|
||||||
useEffect(() => {
|
(id: string) => {
|
||||||
const fetchArticles = async () => {
|
const article = articles.find(a => a.id === id);
|
||||||
try {
|
if (article) {
|
||||||
const response = await axios.get('/api/articles/', {
|
setEditingId(id);
|
||||||
params: {
|
setInitialFormState({
|
||||||
page: currentPage,
|
title: article.title,
|
||||||
categoryId: filterCategoryId,
|
excerpt: article.excerpt,
|
||||||
cityId: filterCityId,
|
categoryId: article.categoryId,
|
||||||
isDraft: showDraftOnly,
|
cityId: article.cityId,
|
||||||
userId: user?.id,
|
coverImage: article.coverImage,
|
||||||
isAdmin: isAdmin
|
readTime: article.readTime,
|
||||||
}
|
content: article.content,
|
||||||
});
|
authorId: article.author.id,
|
||||||
setArticles(response.data.articles);
|
galleryImages: [],
|
||||||
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
|
});
|
||||||
} catch (error) {
|
setArticleId(article.id);
|
||||||
setError('Не удалось загрузить статьи');
|
setShowForm(true);
|
||||||
console.error(error);
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/article/${id}`, {
|
await axios.delete(`/api/articles/${id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setArticles(prev => ({
|
setArticles(prev => prev.filter(article => article.id !== id));
|
||||||
...prev,
|
|
||||||
articles: prev.filter(article => article.id !== id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setRefreshArticles(prev => prev + 1);
|
setRefreshArticles(prev => prev + 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Не удалось удалить статью');
|
setError('Не удалось удалить статью');
|
||||||
@ -192,549 +94,120 @@ export function AdminPage() {
|
|||||||
setShowDeleteModal(null);
|
setShowDeleteModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Перевод статьи в черновик и обратно
|
const handleSubmit = async (articleData: ArticleData, closeForm: boolean = true) => {
|
||||||
const handleToggleActive = async (id: string) => {
|
try {
|
||||||
const article = articles.find(a => a.id === id);
|
let response;
|
||||||
if (article) {
|
|
||||||
const articleData = {
|
|
||||||
isActive: article.isActive,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
if (editingId) {
|
||||||
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 {
|
|
||||||
const response = await axios.put(`/api/articles/${editingId}`, articleData, {
|
const response = await axios.put(`/api/articles/${editingId}`, articleData, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const updatedArticle: Article = response.data;
|
||||||
setArticles(prev => prev.map(article => article.id === editingId ? response.data : article));
|
setArticles(prev => prev.map(a => (a.id === editingId ? updatedArticle : a)));
|
||||||
} catch (error) {
|
} else {
|
||||||
setError('Не удалось обновить статью');
|
response = await axios.post('/api/articles', articleData, {
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingId(null);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Создание новой статьи
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/api/articles', articleData, {
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const newArticle: Article = response.data;
|
||||||
|
setArticles(prev => [...prev, newArticle]);
|
||||||
|
|
||||||
setArticles(prev => [...prev, response.data]);
|
if (!closeForm) {
|
||||||
} catch (error) {
|
const newArticleId = newArticle.id;
|
||||||
setError('Не удалось создать статью');
|
setEditingId(newArticleId);
|
||||||
console.error(error);
|
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 resetForm = () => {
|
||||||
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setArticleId('');
|
||||||
|
setInitialFormState(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading state while gallery is loading
|
const handleNewArticle = () => {
|
||||||
if (editingId && galleryLoading) {
|
setInitialFormState({
|
||||||
return (
|
title: '',
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
excerpt: '',
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
categoryId: availableCategoryIds[0] || 1,
|
||||||
</div>
|
cityId: availableCityIds[0] || 1,
|
||||||
);
|
coverImage: DEFAULT_COVER_IMAGE,
|
||||||
}
|
readTime: 5,
|
||||||
|
content: '',
|
||||||
|
authorId: authors[0]?.id || '',
|
||||||
|
galleryImages: [],
|
||||||
|
});
|
||||||
|
setArticleId('');
|
||||||
|
setEditingId(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
{hasNoPermissions ? (
|
{error && isAdmin && (
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-8 text-center">
|
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error}</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
<ArticleList
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
articles={articles}
|
||||||
<div>
|
setArticles={setArticles}
|
||||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
onEdit={handleEdit}
|
||||||
<span className="italic font-bold">Заголовок</span>
|
onDelete={(id) => setShowDeleteModal(id)}
|
||||||
</label>
|
onShowGallery={(id) => setShowGalleryModal(id)}
|
||||||
<input
|
onNewArticle={handleNewArticle}
|
||||||
type="text"
|
refreshTrigger={refreshArticles}
|
||||||
id="title"
|
/>
|
||||||
value={title}
|
{showForm && (
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
<ArticleForm
|
||||||
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"
|
editingId={editingId}
|
||||||
required
|
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>
|
{showGalleryModal && (
|
||||||
</main>
|
<GalleryModal
|
||||||
|
articleId={showGalleryModal}
|
||||||
{/* Delete Confirmation Modal */}
|
onClose={() => setShowGalleryModal(null)}
|
||||||
{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">
|
{showDeleteModal && (
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<ArticleDeleteModal
|
||||||
Delete Article
|
onConfirm={() => handleDelete(showDeleteModal)}
|
||||||
</h3>
|
onCancel={() => setShowDeleteModal(null)}
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
/>
|
||||||
Are you sure you want to delete this article? This action cannot be undone.
|
)}
|
||||||
</p>
|
</main>
|
||||||
<div className="flex justify-end gap-4">
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -17,6 +17,21 @@ export interface Article {
|
|||||||
isActive: boolean;
|
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 {
|
export interface ArticlesResponse {
|
||||||
articles: Article[];
|
articles: Article[];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user