Разделение компонента админской страницы на несколько компонентов. Прочие доработки.
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) {
|
||||
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>
|
||||
);
|
||||
}
|
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 { 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>
|
||||
);
|
||||
}
|
@ -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',
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
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 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>
|
||||
);
|
||||
}
|
@ -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[];
|
||||
|
Loading…
x
Reference in New Issue
Block a user