583 lines
26 KiB
TypeScript
583 lines
26 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Header } from '../components/Header';
|
||
import { AuthGuard } from '../components/AuthGuard';
|
||
import { AuthorFormData } from '../types/auth';
|
||
import { useAuthorManagement } from '../hooks/useAuthorManagement';
|
||
import {
|
||
ImagePlus,
|
||
X,
|
||
UserPlus,
|
||
Pencil,
|
||
Link as LinkIcon,
|
||
Link2Off as LinkOff,
|
||
ToggleLeft,
|
||
ToggleRight,
|
||
Trash2, ChevronDown, ChevronUp
|
||
} from 'lucide-react';
|
||
import { imageResolutions } from '../config/imageResolutions';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import axios from "axios";
|
||
|
||
|
||
const initialFormData: AuthorFormData = {
|
||
id: '',
|
||
displayName: '',
|
||
email: '',
|
||
bio: '',
|
||
avatarUrl: '/images/avatar.jpg',
|
||
order: 0,
|
||
okUrl: '',
|
||
vkUrl: '',
|
||
websiteUrl: '',
|
||
articlesCount: 0,
|
||
isActive: true
|
||
};
|
||
|
||
export function AuthorManagementPage() {
|
||
const {
|
||
authors,
|
||
users,
|
||
selectedAuthor,
|
||
loading,
|
||
error,
|
||
setSelectedAuthor,
|
||
createAuthor,
|
||
updateAuthor,
|
||
linkUser,
|
||
unlinkUser,
|
||
orderMoveUp,
|
||
orderMoveDown,
|
||
toggleActive,
|
||
deleteAuthor,
|
||
fetchAuthors,
|
||
fetchUsers,
|
||
} = useAuthorManagement();
|
||
|
||
const { user } = useAuthStore();
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [showLinkUserModal, setShowLinkUserModal] = useState(false);
|
||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||
const [formData, setFormData] = useState<AuthorFormData>(initialFormData);
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||
|
||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>, authorId: string) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
const resolution = imageResolutions.find(r => r.id === 'thumbnail');
|
||
if (!resolution) throw new Error('Invalid resolution');
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('resolutionId', resolution.id);
|
||
formData.append('folder', 'authors/' + authorId);
|
||
|
||
const response = await axios.post('/api/images/upload-url', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
|
||
},
|
||
});
|
||
|
||
setFormData(prev => ({ ...prev, avatarUrl: response.data?.fileUrl }));
|
||
} catch (error) {
|
||
setFormError('Ошибка загрузки аватара. Повторите попытку.');
|
||
console.error('Ошибка загрузки:', error);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setFormError(null);
|
||
|
||
try {
|
||
if (showCreateModal) {
|
||
await createAuthor(formData);
|
||
setShowCreateModal(false);
|
||
} else if (showEditModal && selectedAuthor) {
|
||
await updateAuthor(selectedAuthor.id, formData);
|
||
setShowEditModal(false);
|
||
await fetchAuthors();
|
||
}
|
||
setFormData(initialFormData);
|
||
} catch (error) {
|
||
setFormError(error instanceof Error ? error.message : 'An error occurred');
|
||
}
|
||
};
|
||
|
||
const handleLinkUser = async (authorId: string, userId: string) => {
|
||
if (!selectedAuthor || !selectedUserId) return;
|
||
|
||
try {
|
||
await linkUser(authorId, userId);
|
||
setShowLinkUserModal(false);
|
||
setSelectedUserId('');
|
||
await fetchAuthors();
|
||
await fetchUsers();
|
||
} catch {
|
||
setFormError('Failed to link user to author');
|
||
}
|
||
};
|
||
|
||
const handleUnlinkUser = async (authorId: string) => {
|
||
try {
|
||
await unlinkUser(authorId);
|
||
await fetchAuthors();
|
||
await fetchUsers();
|
||
} catch {
|
||
setFormError('Failed to unlink user from author');
|
||
}
|
||
};
|
||
|
||
const handleMoveUp = async (authorId: string) => {
|
||
await orderMoveUp(authorId);
|
||
fetchAuthors();
|
||
};
|
||
|
||
const handleMoveDown = async (authorId: string) => {
|
||
await orderMoveDown(authorId);
|
||
fetchAuthors();
|
||
};
|
||
|
||
const handleToggleActive = async (authorId: string, isActive: boolean) => {
|
||
try {
|
||
await toggleActive(authorId, isActive);
|
||
await fetchAuthors();
|
||
} catch {
|
||
setFormError('Failed to toggle author status');
|
||
}
|
||
};
|
||
|
||
const handleDeleteAuthor = async (authorId: string) => {
|
||
try {
|
||
await deleteAuthor(authorId);
|
||
setShowDeleteModal(null)
|
||
} catch {
|
||
setFormError('Failed to delete author');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
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 (
|
||
<AuthGuard>
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Header />
|
||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||
<div className="px-4 py-6 sm:px-0">
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h1 className="text-2xl font-bold text-gray-900">
|
||
Управление авторами
|
||
</h1>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
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"
|
||
>
|
||
<UserPlus className="h-5 w-5 mr-2" />
|
||
Добавить автора
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||
<ul className="divide-y divide-gray-200">
|
||
{authors.map((author, index) => (
|
||
<li key={author.id} className="px-6 py-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center">
|
||
<img
|
||
src={author.avatarUrl || '/images/avatar.jpg'}
|
||
alt={author.displayName}
|
||
className="h-10 w-10 rounded-full"
|
||
/>
|
||
<div className="ml-4">
|
||
<h2 className="text-lg font-medium text-gray-900">
|
||
{author.displayName} {author.userId ? `=> пользователь ${author.displayName}` : ``}
|
||
</h2>
|
||
<p className="text-sm text-gray-500">{author.bio}</p>
|
||
<div className="flex mt-1 space-x-4">
|
||
{author.okUrl && (
|
||
<a
|
||
href={author.okUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-sm text-gray-500 hover:text-blue-500"
|
||
>
|
||
Одноклассники
|
||
</a>
|
||
)}
|
||
{author.vkUrl && (
|
||
<a
|
||
href={author.vkUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-sm text-gray-500 hover:text-pink-500"
|
||
>
|
||
Вконтакте
|
||
</a>
|
||
)}
|
||
{author.websiteUrl && (
|
||
<a
|
||
href={author.websiteUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-sm text-gray-500 hover:text-gray-700"
|
||
>
|
||
Website
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex flex-col items-center space-y-1">
|
||
<button
|
||
onClick={() => handleMoveUp(author.id)}
|
||
disabled={index === 0}
|
||
className="p-1 text-gray-400 hover:text-blue-500 disabled:opacity-30"
|
||
title="Переместить вверх"
|
||
>
|
||
<ChevronUp size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => handleMoveDown(author.id)}
|
||
disabled={index === authors.length - 1}
|
||
className="p-1 text-gray-400 hover:text-blue-500 disabled:opacity-30"
|
||
title="Переместить вниз"
|
||
>
|
||
<ChevronDown size={18} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={() => handleToggleActive(author.id, !author.isActive)}
|
||
className={`p-2 rounded-full hover:bg-gray-100 ${
|
||
author.isActive ? 'text-green-600' : 'text-gray-400'
|
||
}`}
|
||
title={author.isActive ? 'Деактивировать автора' : 'Активировать автора'}
|
||
>
|
||
{author.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
|
||
</button>
|
||
{author.userId ? (
|
||
<button
|
||
onClick={() => handleUnlinkUser(author.id)}
|
||
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
|
||
>
|
||
<LinkOff size={20} />
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedAuthor(author);
|
||
setShowLinkUserModal(true);
|
||
}}
|
||
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
|
||
>
|
||
<LinkIcon size={20} />
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
setSelectedAuthor(author);
|
||
setFormData({
|
||
id: author.id,
|
||
displayName: author.displayName,
|
||
email: author.email || '',
|
||
bio: author.bio || '',
|
||
avatarUrl: author.avatarUrl || '',
|
||
order: author.order,
|
||
okUrl: author.okUrl || '',
|
||
vkUrl: author.vkUrl || '',
|
||
websiteUrl: author.websiteUrl || '',
|
||
articlesCount: author.articlesCount,
|
||
isActive: author.isActive || true
|
||
});
|
||
setShowEditModal(true);
|
||
}}
|
||
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
|
||
>
|
||
<Pencil size={20} />
|
||
</button>
|
||
<button
|
||
onClick={() => setShowDeleteModal(author.id)}
|
||
className="p-2 rounded-full hover:bg-gray-100 text-red-400"
|
||
>
|
||
<Trash2 size={20} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Create/Edit Author Modal */}
|
||
{((showCreateModal || showEditModal) && user?.permissions.isAdmin ) && (
|
||
<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">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-medium text-gray-900">
|
||
{showCreateModal ? 'Новый автор' : 'Изменить автора'}
|
||
</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateModal(false);
|
||
setShowEditModal(false);
|
||
setFormData(initialFormData);
|
||
setFormError(null);
|
||
}}
|
||
className="text-gray-400 hover:text-gray-500"
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
{formError && (
|
||
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
|
||
{formError}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Аватар
|
||
</label>
|
||
<div className="mt-1 flex items-center space-x-4">
|
||
<img
|
||
src={formData.avatarUrl || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150'}
|
||
alt="Author avatar"
|
||
className="h-12 w-12 rounded-full object-cover"
|
||
/>
|
||
<label className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||
<ImagePlus size={16} className="mr-2" />
|
||
Сменить Аватар
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
accept="image/*"
|
||
onChange={(e) => handleAvatarUpload(e, formData.id)} // Передаем authorId
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label 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"
|
||
value={formData.displayName}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, displayName: 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 className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Биографические данные</span>
|
||
</label>
|
||
<textarea
|
||
value={formData.bio}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
|
||
rows={3}
|
||
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>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Одноклассники URL</span>
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.okUrl}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, okUrl: 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="https://twitter.com/username"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">E-mail</span>
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={formData.email}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, email: 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="author@email.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Вконтакте URL</span>
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.vkUrl}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, vkUrl: 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="https://instagram.com/username"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Сайт URL</span>
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.websiteUrl}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, websiteUrl: 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="https://example.com"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end space-x-3 mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateModal(false);
|
||
setShowEditModal(false);
|
||
setFormData(initialFormData);
|
||
setFormError(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"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
{showCreateModal ? 'Создать' : 'Сохранить'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Link User Modal */}
|
||
{showLinkUserModal && (
|
||
<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">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-medium text-gray-900">
|
||
Связать автора с пользователем
|
||
</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowLinkUserModal(false);
|
||
setSelectedUserId('');
|
||
}}
|
||
className="text-gray-400 hover:text-gray-500"
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<select
|
||
value={selectedUserId}
|
||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||
>
|
||
<option value="">Выберите пользователя...</option>
|
||
{users
|
||
.filter(u => !u.isLinkedToAuthor) // Only show users not already linked to an author
|
||
.map(user => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.displayName})
|
||
</option>
|
||
))
|
||
}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex justify-end space-x-3 mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowLinkUserModal(false);
|
||
setSelectedUserId('');
|
||
}}
|
||
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
|
||
type="button"
|
||
onClick={() => {
|
||
if (selectedAuthor && selectedUserId) {
|
||
handleLinkUser(selectedAuthor.id, selectedUserId);
|
||
}
|
||
}}
|
||
disabled={!selectedUserId}
|
||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Связать
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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">
|
||
Удалить автора
|
||
</h3>
|
||
|
||
<p className="text-sm text-gray-500 mb-6">
|
||
Вы уверены, что хотите удалить этого автора? Это действие невозможно отменить.
|
||
Все статьи, связанные с этим автором, останутся, но у них больше не будет активного автора.
|
||
</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"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteAuthor(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"
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</AuthGuard>
|
||
);
|
||
} |