Дополнительный комит для тестовой установки.
This commit is contained in:
parent
9ca069c49b
commit
51f112a2e0
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TipTapEditor } from './Editor/TipTapEditor';
|
||||
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
|
||||
import { ImageUploader } from './ImageUpload/ImageUploader';
|
||||
import { GalleryManager } from './GalleryManager';
|
||||
import { useGallery } from '../hooks/useGallery';
|
||||
import { ArticleData, Author, AuthorLink, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||||
import {
|
||||
ArticleData,
|
||||
Author,
|
||||
AuthorLink,
|
||||
AuthorRole,
|
||||
CategoryTitles,
|
||||
CityTitles,
|
||||
GalleryImage,
|
||||
ViewMode
|
||||
} from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import RolesWord from "./Words/RolesWord";
|
||||
import { Trash2, UserPlus } from "lucide-react";
|
||||
import { ArrowLeft, Trash2, UserPlus } from "lucide-react";
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { NewGalleryManager } from "./Gallery";
|
||||
|
||||
|
||||
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
||||
@ -36,6 +46,7 @@ interface FormState {
|
||||
|
||||
interface ArticleFormProps {
|
||||
editingId: string | null;
|
||||
viewMode: ViewMode;
|
||||
articleId: string;
|
||||
initialFormState: FormState | null;
|
||||
onSubmit: (articleData: ArticleData, closeForm: boolean) => Promise<void>;
|
||||
@ -47,6 +58,7 @@ interface ArticleFormProps {
|
||||
|
||||
export function ArticleForm({
|
||||
editingId,
|
||||
viewMode,
|
||||
articleId,
|
||||
initialFormState,
|
||||
onSubmit,
|
||||
@ -149,7 +161,7 @@ export function ArticleForm({
|
||||
readTime,
|
||||
content,
|
||||
authors: selectedAuthors,
|
||||
galleryImages: showGallery ? displayedImages : initialFormState.galleryImages,
|
||||
galleryImages: displayedImages,
|
||||
};
|
||||
|
||||
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
||||
@ -158,7 +170,7 @@ export function ArticleForm({
|
||||
if (!formReady) return false;
|
||||
|
||||
if (key === 'galleryImages') {
|
||||
if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
|
||||
//if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
|
||||
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||
if (isInitialLoad && isDifferent) return false;
|
||||
return isDifferent;
|
||||
@ -271,7 +283,7 @@ export function ArticleForm({
|
||||
);
|
||||
};
|
||||
|
||||
if (editingId && galleryLoading) {
|
||||
if (viewMode === 'edit' && 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>
|
||||
@ -281,10 +293,25 @@ export function ArticleForm({
|
||||
|
||||
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>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="mr-4 p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{viewMode === 'edit' ? 'Редактирование' : 'Новая статья'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(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">
|
||||
@ -408,6 +435,12 @@ export function ArticleForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewGalleryManager
|
||||
articleId={articleId}
|
||||
images={displayedImages}
|
||||
onChange={setDisplayedImages}
|
||||
/>
|
||||
|
||||
{showGallery && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
|
||||
@ -255,6 +255,7 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
<span className="ml-2 text-sm text-gray-700"></span>
|
||||
</>
|
||||
)}
|
||||
{error && isAdmin && (
|
||||
|
||||
232
src/components/Gallery/GalleryModal.tsx
Normal file
232
src/components/Gallery/GalleryModal.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Save } from 'lucide-react';
|
||||
import { GalleryImage } from '../../types';
|
||||
import { ImageUploader } from '../ImageUpload/ImageUploader';
|
||||
|
||||
interface GalleryModalProps {
|
||||
articleId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddImages: (images: GalleryImage[]) => void;
|
||||
editingImage?: GalleryImage | null;
|
||||
onUpdateImage?: (image: GalleryImage) => void;
|
||||
}
|
||||
|
||||
export function GalleryModal({
|
||||
articleId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddImages,
|
||||
editingImage,
|
||||
onUpdateImage
|
||||
}: GalleryModalProps) {
|
||||
const [uploadedImages, setUploadedImages] = useState<GalleryImage[]>([]);
|
||||
const [editForm, setEditForm] = useState({
|
||||
caption: editingImage?.caption || '',
|
||||
alt: editingImage?.alt || ''
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editingImage) {
|
||||
setEditForm({
|
||||
caption: editingImage.caption,
|
||||
alt: editingImage.alt
|
||||
});
|
||||
}
|
||||
}, [editingImage]);
|
||||
|
||||
const handleImageUpload = (imageUrl: string) => {
|
||||
const newImage: GalleryImage = {
|
||||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
||||
url: imageUrl,
|
||||
caption: '',
|
||||
alt: '',
|
||||
width: 800, // Примерное значение, можно заменить на динамическое
|
||||
height: 600,
|
||||
size: 100000, // Примерное значение
|
||||
format: 'webp', // Формат по умолчанию
|
||||
};
|
||||
setUploadedImages(prev => [...prev, newImage]);
|
||||
};
|
||||
|
||||
const handleUpdateImageDetails = (imageId: string, field: 'caption' | 'alt', value: string) => {
|
||||
setUploadedImages(prev => prev.map(img =>
|
||||
img.id === imageId ? { ...img, [field]: value } : img
|
||||
));
|
||||
};
|
||||
|
||||
const handleRemoveUploadedImage = (imageId: string) => {
|
||||
setUploadedImages(prev => prev.filter(img => img.id !== imageId));
|
||||
};
|
||||
|
||||
const handleSaveImages = () => {
|
||||
if (editingImage && onUpdateImage) {
|
||||
// Update existing image
|
||||
onUpdateImage({
|
||||
...editingImage,
|
||||
caption: editForm.caption,
|
||||
alt: editForm.alt
|
||||
});
|
||||
} else {
|
||||
// Add new images
|
||||
onAddImages(uploadedImages);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setUploadedImages([]);
|
||||
setEditForm({ caption: '', alt: '' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setUploadedImages([]);
|
||||
setEditForm({ caption: '', alt: '' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) 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-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{editingImage ? 'Редактирование данных изображения' : 'Добавление изображений в галерею'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{editingImage ? (
|
||||
// Edit existing image form
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={editingImage.url}
|
||||
alt={editingImage.alt}
|
||||
className="max-w-md max-h-64 object-contain rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Подпись
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.caption}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, caption: 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"
|
||||
placeholder="Введите подпись под изображением"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Альтернативный текст
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.alt}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, alt: 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"
|
||||
placeholder="Введите альтернативный текст"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Add new images interface
|
||||
<div className="space-y-6">
|
||||
<ImageUploader
|
||||
onUploadComplete={handleImageUpload}
|
||||
articleId={articleId}
|
||||
/>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-md font-medium text-gray-900">
|
||||
Загруженные изображения ({uploadedImages.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{uploadedImages.map((image) => (
|
||||
<div key={image.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-24 h-24 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Подпись
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={image.caption}
|
||||
onChange={(e) => handleUpdateImageDetails(image.id, 'caption', 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"
|
||||
placeholder="Введите подпись под изображением"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Альтернативный текст
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={image.alt}
|
||||
onChange={(e) => handleUpdateImageDetails(image.id, 'alt', 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"
|
||||
placeholder="Введите альтернативный текст"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveUploadedImage(image.id)}
|
||||
className="text-red-600 hover:text-red-800 p-2"
|
||||
title="Remove image"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
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
|
||||
onClick={handleSaveImages}
|
||||
disabled={editingImage ? false : uploadedImages.length === 0}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
{editingImage ? 'Сохранить изменения' : `Добавить ${uploadedImages.length} фото${uploadedImages.length !== 1 ? '' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/Gallery/GalleryPreview.tsx
Normal file
103
src/components/Gallery/GalleryPreview.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { ImagePlus, Pencil, Trash2, Move } from 'lucide-react';
|
||||
import { GalleryImage } from '../../types';
|
||||
|
||||
interface GalleryPreviewProps {
|
||||
images: GalleryImage[];
|
||||
onAddImage: () => void;
|
||||
onEditImage: (image: GalleryImage) => void;
|
||||
onDeleteImage: (id: string) => void;
|
||||
onReorderImages: (dragIndex: number, dropIndex: number) => void;
|
||||
}
|
||||
|
||||
export function GalleryPreview({
|
||||
images,
|
||||
onAddImage,
|
||||
onEditImage,
|
||||
onDeleteImage,
|
||||
onReorderImages
|
||||
}: GalleryPreviewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900">Фото галерея</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddImage}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<ImagePlus size={16} className="mr-2" />
|
||||
Добавить фото
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{images.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group border rounded-lg overflow-hidden bg-gray-50"
|
||||
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'));
|
||||
onReorderImages(dragIndex, index);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
|
||||
{/* Overlay with controls */}
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => onEditImage(image)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600 shadow-md"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteImage(image.id)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600 shadow-md"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-white rounded-full text-gray-700 cursor-move shadow-md"
|
||||
title="Перетащить"
|
||||
>
|
||||
<Move size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caption overlay */}
|
||||
{image.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white p-2 text-xs truncate">
|
||||
{image.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<ImagePlus className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">В галерее еще нет изображений</p>
|
||||
<p className="text-xs text-gray-500">Нажмите "Добавить изображения" чтобы начать.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/Gallery/index.tsx
Normal file
68
src/components/Gallery/index.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { GalleryImage } from '../../types';
|
||||
import { GalleryPreview } from './GalleryPreview';
|
||||
import { GalleryModal } from './GalleryModal';
|
||||
|
||||
interface GalleryManagerProps {
|
||||
articleId: string;
|
||||
images: GalleryImage[];
|
||||
onChange: (images: GalleryImage[]) => void;
|
||||
}
|
||||
|
||||
export function NewGalleryManager({ articleId, images, onChange }: GalleryManagerProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
|
||||
|
||||
const handleAddImages = (newImages: GalleryImage[]) => {
|
||||
onChange([...images, ...newImages]);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleEditImage = (image: GalleryImage) => {
|
||||
setEditingImage(image);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleUpdateImage = (updatedImage: GalleryImage) => {
|
||||
onChange(images.map(img => img.id === updatedImage.id ? updatedImage : img));
|
||||
setEditingImage(null);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteImage = (id: string) => {
|
||||
onChange(images.filter(img => img.id !== id));
|
||||
};
|
||||
|
||||
const handleReorderImages = (dragIndex: number, dropIndex: number) => {
|
||||
const reorderedImages = [...images];
|
||||
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
|
||||
reorderedImages.splice(dropIndex, 0, draggedImage);
|
||||
onChange(reorderedImages);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingImage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GalleryPreview
|
||||
images={images}
|
||||
onAddImage={() => setShowModal(true)}
|
||||
onEditImage={handleEditImage}
|
||||
onDeleteImage={handleDeleteImage}
|
||||
onReorderImages={handleReorderImages}
|
||||
/>
|
||||
|
||||
<GalleryModal
|
||||
articleId={articleId}
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
onAddImages={handleAddImages}
|
||||
editingImage={editingImage}
|
||||
onUpdateImage={handleUpdateImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -89,7 +89,7 @@ export function CoverImageUpload({ coverImage, articleId, onImageUpload, onError
|
||||
id="coverImage"
|
||||
value={coverImage}
|
||||
readOnly
|
||||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||
className="hidden px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||
placeholder="URL изображения будет здесь после загрузки"
|
||||
/>
|
||||
{allowUpload && (
|
||||
|
||||
@ -22,7 +22,7 @@ export function ResolutionSelect({
|
||||
value={selectedResolution}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50"
|
||||
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"
|
||||
>
|
||||
{resolutions.map((resolution) => (
|
||||
<option key={resolution.id} value={resolution.id}>
|
||||
|
||||
@ -62,7 +62,7 @@ const ImageUploadModal: React.FC<ImageUploadModalProps> = ({ isOpen, onConfirm,
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full transform transition-all duration-300 ease-in-out scale-100 opacity-100">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Добавить изображение</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Добавить фото</h3>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@ -107,7 +107,7 @@ export function Pagination({ currentPage, totalPages, onPageChange }: Pagination
|
||||
</nav>
|
||||
|
||||
{/* Поле быстрого перехода */}
|
||||
<div className="flex items-center space-x-2 justify-center sm:justify-end flex-1">
|
||||
<div className="flex items-center space-x-2 justify-center sm:justify-end flex-1 px-2">
|
||||
<span className="text-sm text-gray-600">Страница:</span>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@ -12,6 +12,7 @@ export function useAuthorManagement() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthors(selectedRole, currentPage);
|
||||
@ -21,8 +22,9 @@ export function useAuthorManagement() {
|
||||
const fetchAuthors = async (role: string, page: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedAuthors = await authorService.getAuthors(role, page);
|
||||
setAuthors(fetchedAuthors);
|
||||
const response = await authorService.getAuthors(role, page);
|
||||
setAuthors(response.authors);
|
||||
setTotalPages(response.totalPages);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка получения списка авторов');
|
||||
@ -129,6 +131,7 @@ export function useAuthorManagement() {
|
||||
error,
|
||||
selectedRole,
|
||||
currentPage,
|
||||
totalPages,
|
||||
setSelectedAuthor,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
|
||||
@ -5,7 +5,7 @@ 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, AuthorRole, AuthorLink } from '../types';
|
||||
import { Article, Author, GalleryImage, ArticleData, AuthorRole, AuthorLink, ViewMode } from '../types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { FileJson, Users, UserSquare2 } from "lucide-react";
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -28,16 +28,26 @@ const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
||||
export function AdminPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||||
|
||||
// View state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
// Form state
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [articleId, setArticleId] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [initialFormState, setInitialFormState] = useState<FormState | null>(null);
|
||||
|
||||
// List state
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [refreshArticles, setRefreshArticles] = useState(0);
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
|
||||
// UI state
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||
const [showGalleryModal, setShowGalleryModal] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthors = async () => {
|
||||
@ -46,9 +56,8 @@ export function AdminPage() {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
// params: { role: 'WRITER' },
|
||||
});
|
||||
setAuthors(response.data);
|
||||
setAuthors(response.data.authors);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки авторов:', err);
|
||||
setError('Не удалось загрузить список авторов.');
|
||||
@ -71,6 +80,7 @@ export function AdminPage() {
|
||||
|
||||
if (article) {
|
||||
setEditingId(id);
|
||||
setViewMode('edit');
|
||||
setInitialFormState({
|
||||
title: article.title,
|
||||
excerpt: article.excerpt,
|
||||
@ -159,6 +169,7 @@ export function AdminPage() {
|
||||
const resetForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setViewMode('list');
|
||||
setArticleId('');
|
||||
setInitialFormState(null);
|
||||
setError(null);
|
||||
@ -189,6 +200,7 @@ export function AdminPage() {
|
||||
});
|
||||
setArticleId('');
|
||||
setEditingId(null);
|
||||
setViewMode('create');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@ -196,8 +208,9 @@ export function AdminPage() {
|
||||
<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">
|
||||
{/* Admin Navigation */}
|
||||
{isAdmin && (
|
||||
|
||||
{/* Блок кнопок навигации */}
|
||||
{viewMode === 'list' && isAdmin && (
|
||||
<div className="flex gap-4 mb-8">
|
||||
<Link
|
||||
to="/admin/users"
|
||||
@ -226,18 +239,21 @@ export function AdminPage() {
|
||||
{error && isAdmin && (
|
||||
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error}</div>
|
||||
)}
|
||||
<ArticleList
|
||||
articles={articles}
|
||||
setArticles={setArticles}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(id) => setShowDeleteModal(id)}
|
||||
onShowGallery={(id) => setShowGalleryModal(id)}
|
||||
onNewArticle={handleNewArticle}
|
||||
refreshTrigger={refreshArticles}
|
||||
/>
|
||||
{showForm && (
|
||||
{viewMode === 'list' && (
|
||||
<ArticleList
|
||||
articles={articles}
|
||||
setArticles={setArticles}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(id) => setShowDeleteModal(id)}
|
||||
onShowGallery={(id) => setShowGalleryModal(id)}
|
||||
onNewArticle={handleNewArticle}
|
||||
refreshTrigger={refreshArticles}
|
||||
/>
|
||||
)}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && showForm && (
|
||||
<ArticleForm
|
||||
editingId={editingId}
|
||||
viewMode={viewMode}
|
||||
articleId={articleId}
|
||||
initialFormState={initialFormState}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@ -20,6 +20,7 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import RolesWord from "../components/Words/RolesWord";
|
||||
import axios from "axios";
|
||||
import {AuthorRole} from "../types";
|
||||
import {Pagination} from "../components/Pagination.tsx";
|
||||
|
||||
|
||||
const roleIcons: Record<string, JSX.Element> = {
|
||||
@ -63,6 +64,7 @@ export function AuthorManagementPage() {
|
||||
error,
|
||||
selectedRole,
|
||||
currentPage,
|
||||
totalPages,
|
||||
setSelectedAuthor,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
@ -185,6 +187,11 @@ export function AuthorManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -367,7 +374,7 @@ export function AuthorManagementPage() {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/*
|
||||
<div className="mt-4 flex justify-center items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
@ -384,8 +391,17 @@ export function AuthorManagementPage() {
|
||||
Вперёд →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
*/}
|
||||
</ul>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
<span className="ml-2 text-sm text-gray-700"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -13,8 +13,15 @@ interface AuthorFormData {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorsResponse {
|
||||
authors: Author[];
|
||||
totalPages: number,
|
||||
currentPage: number,
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const authorService = {
|
||||
getAuthors: async (role: string, page: number): Promise<Author[]> => {
|
||||
getAuthors: async (role: string, page: number): Promise<AuthorsResponse> => {
|
||||
try {
|
||||
const response = await axios.get('/authors', {
|
||||
params: {
|
||||
|
||||
@ -85,6 +85,8 @@ export interface AuthorLink {
|
||||
author: Author;
|
||||
}
|
||||
|
||||
export type ViewMode = 'list' | 'create' | 'edit';
|
||||
|
||||
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];
|
||||
export const CityIds: number[] = [1 , 2];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user