Доработана работа с пользователями - загрузка аватара. Добавлены ограничения по правам на админской странице.
This commit is contained in:
parent
1d09dbadf3
commit
e254ef0dc0
@ -13,6 +13,8 @@ import { Footer } from './components/Footer';
|
|||||||
import { AuthGuard } from './components/AuthGuard';
|
import { AuthGuard } from './components/AuthGuard';
|
||||||
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { setUser, setLoading } = useAuthStore();
|
const { setUser, setLoading } = useAuthStore();
|
||||||
|
|
||||||
@ -20,7 +22,7 @@ function App() {
|
|||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios.get('http://localhost:5000/api/auth/me', {
|
axios.get(`${API_URL}/api/auth/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
@ -15,29 +15,29 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
|||||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||||
featured ? 'col-span-2 row-span-2' : ''
|
featured ? 'col-span-2 row-span-2' : ''
|
||||||
}`}>
|
}`}>
|
||||||
<div className="relative">
|
<div className="relative pt-7">
|
||||||
<img
|
<img
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-4 left-4 flex gap-2">
|
<div className="absolute top-1 left-0 flex gap-2 z-10">
|
||||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium">
|
||||||
{categoryName}
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
||||||
<MapPin size={14} className="mr-1" />
|
<MapPin size={14} className="mr-1" />
|
||||||
{CityTitles[article.cityId]}
|
{CityTitles[article.cityId]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<img
|
<img
|
||||||
src={article.author.avatarUrl}
|
src={article.author.avatarUrl}
|
||||||
alt={article.author.displayName}
|
alt={article.author.displayName}
|
||||||
className="w-10 h-10 rounded-full"
|
className="w-12 h-12 rounded-full"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p>
|
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p>
|
||||||
|
@ -16,7 +16,7 @@ export function FeaturedSection() {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Загрузка статей с backend
|
// Загрузка статей
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -4,7 +4,20 @@ import TextAlign from '@tiptap/extension-text-align';
|
|||||||
import Blockquote from '@tiptap/extension-blockquote';
|
import Blockquote from '@tiptap/extension-blockquote';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import { Bold, Italic, List, ListOrdered, Quote, Redo, Undo, AlignLeft, AlignCenter, Plus, Minus, Text } from 'lucide-react';
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Redo,
|
||||||
|
Undo,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
SquareUser
|
||||||
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
@ -334,7 +347,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="p-1 rounded hover:bg-gray-200"
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
>
|
>
|
||||||
<Text size={18} />
|
<SquareUser size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -1,19 +1,25 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { TipTapEditor } from '../components/TipTapEditor';
|
import { TipTapEditor } from '../components/TipTapEditor';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { GalleryManager } from '../components/GalleryManager';
|
import { GalleryManager } from '../components/GalleryManager';
|
||||||
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload.tsx';
|
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
||||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
import MinutesWord from '../components/MinutesWord';
|
||||||
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
|
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author } from '../types';
|
||||||
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
|
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
const allCategoryIds: number[] = CategoryIds;
|
||||||
|
const allCityIds: number[] = CityIds;
|
||||||
|
|
||||||
// Обложка по умоланию для новых статей
|
// Обложка по умоланию для новых статей
|
||||||
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const isAdmin = user?.permissions.isAdmin || false;
|
||||||
|
|
||||||
const [articleId, setArticleId] = useState('');
|
const [articleId, setArticleId] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [excerpt, setExcerpt] = useState('');
|
const [excerpt, setExcerpt] = useState('');
|
||||||
@ -34,6 +40,28 @@ export function AdminPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
||||||
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
|
const [authorId, setAuthorId] = useState<string>('');
|
||||||
|
|
||||||
|
// Загрузка списка авторов
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAuthors = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users/' , {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthors(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки авторов:', err);
|
||||||
|
setError('Не удалось загрузить список авторов.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAuthors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Загрузка статей
|
// Загрузка статей
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -62,6 +90,35 @@ export function AdminPage() {
|
|||||||
setCurrentPage(page);
|
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 handleEdit = (id: string) => {
|
||||||
const article = articles.find(a => a.id === id);
|
const article = articles.find(a => a.id === id);
|
||||||
@ -73,8 +130,9 @@ export function AdminPage() {
|
|||||||
setCityId(article.cityId);
|
setCityId(article.cityId);
|
||||||
setCoverImage(article.coverImage);
|
setCoverImage(article.coverImage);
|
||||||
setReadTime(article.readTime);
|
setReadTime(article.readTime);
|
||||||
|
setAuthorId(article.author.id);
|
||||||
setGallery(article.gallery || []);
|
setGallery(article.gallery || []);
|
||||||
setContent(article.content); // Устанавливаем содержимое редактора
|
setContent(article.content);
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@ -129,6 +187,22 @@ export function AdminPage() {
|
|||||||
// Создание новой статьи, сохранение существующей
|
// Создание новой статьи, сохранение существующей
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
//const selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
|
||||||
|
|
||||||
|
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 = {
|
const articleData = {
|
||||||
title,
|
title,
|
||||||
@ -141,6 +215,7 @@ export function AdminPage() {
|
|||||||
content: content || '',
|
content: content || '',
|
||||||
importId: 0,
|
importId: 0,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
author: selectedAuthor
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@ -182,16 +257,28 @@ export function AdminPage() {
|
|||||||
setArticleId('');
|
setArticleId('');
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setExcerpt('');
|
setExcerpt('');
|
||||||
|
if (availableCategoryIds.length > 0) {
|
||||||
|
setCategoryId(availableCategoryIds[0]);
|
||||||
|
}
|
||||||
|
if (availableCityIds.length > 0) {
|
||||||
|
setCityId(availableCityIds[0]);
|
||||||
|
}
|
||||||
|
/*
|
||||||
setCategoryId(1);
|
setCategoryId(1);
|
||||||
setCityId(1);
|
setCityId(1);
|
||||||
|
*/
|
||||||
setCoverImage(DEFAULT_COVER_IMAGE);
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
|
setAuthorId(authors[0].id || '');
|
||||||
setGallery([]);
|
setGallery([]);
|
||||||
setContent(''); // Очищаем содержимое редактора
|
setContent(''); // Очищаем содержимое редактора
|
||||||
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Проверка прав пользователя
|
||||||
|
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
||||||
|
|
||||||
const handleGalleryImageUpload = (imageUrl: string) => {
|
const handleGalleryImageUpload = (imageUrl: string) => {
|
||||||
const newImage: GalleryImage = {
|
const newImage: GalleryImage = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
@ -206,6 +293,17 @@ export function AdminPage() {
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{hasNoPermissions ? (
|
||||||
|
<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">
|
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
|
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
|
||||||
@ -246,7 +344,7 @@ export function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||||
<span className="italic font-bold">Категория</span>
|
<span className="italic font-bold">Категория</span>
|
||||||
@ -257,7 +355,7 @@ export function AdminPage() {
|
|||||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{CategoryIds.map((cat) => (
|
{availableCategoryIds.map((cat) => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat} value={cat}>
|
||||||
{CategoryTitles[cat]}
|
{CategoryTitles[cat]}
|
||||||
</option>
|
</option>
|
||||||
@ -275,7 +373,7 @@ export function AdminPage() {
|
|||||||
onChange={(e) => setCityId(Number(e.target.value))}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{CityIds.map((c) => (
|
{availableCityIds.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{CityTitles[c]}
|
{CityTitles[c]}
|
||||||
</option>
|
</option>
|
||||||
@ -296,6 +394,27 @@ export function AdminPage() {
|
|||||||
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="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>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
<CoverImageUpload
|
<CoverImageUpload
|
||||||
@ -317,14 +436,16 @@ export function AdminPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
<span className="italic font-bold">Фото галерея</span>
|
<span className="italic font-bold">Фото галерея</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
{editingId && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowGalleryUploader(true)}
|
type="button"
|
||||||
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"
|
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" />
|
>
|
||||||
Загрузить фото
|
<ImagePlus size={16} className="mr-2" />
|
||||||
</button>
|
Загрузить фото
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<GalleryManager
|
<GalleryManager
|
||||||
images={gallery}
|
images={gallery}
|
||||||
@ -340,10 +461,17 @@ export function AdminPage() {
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setExcerpt('');
|
setExcerpt('');
|
||||||
setCategoryId(1);
|
if (availableCategoryIds.length > 0) {
|
||||||
setCityId(1);
|
setCategoryId(availableCategoryIds[0]);
|
||||||
|
}
|
||||||
|
if (availableCityIds.length > 0) {
|
||||||
|
setCityId(availableCityIds[0]);
|
||||||
|
}
|
||||||
|
//setCategoryId(1);
|
||||||
|
//setCityId(1);
|
||||||
setCoverImage(DEFAULT_COVER_IMAGE);
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
|
setAuthorId(authors[0].id || '');
|
||||||
setGallery([]);
|
setGallery([]);
|
||||||
setContent('');
|
setContent('');
|
||||||
}}
|
}}
|
||||||
@ -356,11 +484,12 @@ export function AdminPage() {
|
|||||||
type="submit"
|
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"
|
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 ? 'Изменить' : 'Созранить черновик'}
|
{editingId ? 'Изменить' : 'Сохранить черновик'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Модальная загрузка изображений галереи */}
|
{/* Модальная загрузка изображений галереи */}
|
||||||
{showGalleryUploader && (
|
{showGalleryUploader && (
|
||||||
@ -438,7 +567,7 @@ export function AdminPage() {
|
|||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<li
|
<li
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-50' : ''}`}
|
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -448,6 +577,15 @@ export function AdminPage() {
|
|||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
|
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
<img
|
||||||
|
src={article.author.avatarUrl}
|
||||||
|
alt={article.author.displayName}
|
||||||
|
className="h-4 w-4 rounded-full mr-1"
|
||||||
|
/>
|
||||||
|
<span className="italic font-bold"> {article.author.displayName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 ml-4">
|
<div className="flex items-center gap-4 ml-4">
|
||||||
<button
|
<button
|
||||||
|
@ -4,12 +4,13 @@ import { AuthGuard } from '../components/AuthGuard';
|
|||||||
import { UserFormData } from '../types/auth';
|
import { UserFormData } from '../types/auth';
|
||||||
import { useUserManagement } from '../hooks/useUserManagement';
|
import { useUserManagement } from '../hooks/useUserManagement';
|
||||||
import { ImagePlus, X, UserPlus, Pencil } from 'lucide-react';
|
import { ImagePlus, X, UserPlus, Pencil } from 'lucide-react';
|
||||||
import { uploadImage } from '../services/imageService';
|
|
||||||
import { imageResolutions } from '../config/imageResolutions';
|
import { imageResolutions } from '../config/imageResolutions';
|
||||||
import {CategoryIds, CategoryTitles, CityIds} from "../types";
|
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
|
||||||
const initialFormData: UserFormData = {
|
const initialFormData: UserFormData = {
|
||||||
|
id: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
@ -27,7 +28,7 @@ export function UserManagementPage() {
|
|||||||
handleCityChange,
|
handleCityChange,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser
|
// deleteUser
|
||||||
} = useUserManagement();
|
} = useUserManagement();
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@ -35,7 +36,7 @@ export function UserManagementPage() {
|
|||||||
const [formData, setFormData] = useState<UserFormData>(initialFormData);
|
const [formData, setFormData] = useState<UserFormData>(initialFormData);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>, userId: string) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@ -43,11 +44,23 @@ export function UserManagementPage() {
|
|||||||
const resolution = imageResolutions.find(r => r.id === 'thumbnail');
|
const resolution = imageResolutions.find(r => r.id === 'thumbnail');
|
||||||
if (!resolution) throw new Error('Invalid resolution');
|
if (!resolution) throw new Error('Invalid resolution');
|
||||||
|
|
||||||
const uploadedImage = await uploadImage(file, resolution);
|
const formData = new FormData();
|
||||||
setFormData(prev => ({ ...prev, avatarUrl: uploadedImage.url }));
|
formData.append('file', file);
|
||||||
|
formData.append('resolutionId', resolution.id);
|
||||||
|
formData.append('folder', 'users/' + userId);
|
||||||
|
|
||||||
|
// Отправка запроса на сервер
|
||||||
|
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) {
|
} catch (error) {
|
||||||
setFormError('Failed to upload avatar. Please try again.');
|
setFormError('Ошибка загрузки аватара. Повторите попытку.');
|
||||||
console.error('Upload error:', error);
|
console.error('Ошибка загрузки:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -129,6 +142,7 @@ export function UserManagementPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setFormData({
|
setFormData({
|
||||||
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password: '',
|
password: '',
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
@ -160,9 +174,9 @@ export function UserManagementPage() {
|
|||||||
Права по категориям
|
Права по категориям
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{CategoryIds.map((category) => (
|
{CategoryIds.map((categoryId) => (
|
||||||
<div key={category} className="border rounded-lg p-4">
|
<div key={categoryId} className="border rounded-lg p-4">
|
||||||
<h4 className="font-medium mb-2">{CategoryTitles[category]}</h4>
|
<h4 className="font-medium mb-2">{CategoryTitles[categoryId]}</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(['create', 'edit', 'delete'] as const).map((action) => (
|
{(['create', 'edit', 'delete'] as const).map((action) => (
|
||||||
<label
|
<label
|
||||||
@ -172,11 +186,11 @@ export function UserManagementPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={
|
||||||
selectedUser.permissions.categories[CategoryTitles[category]]?.[action] ?? false
|
selectedUser.permissions.categories[categoryId]?.[action] ?? false
|
||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePermissionChange(
|
handlePermissionChange(
|
||||||
category,
|
categoryId,
|
||||||
action,
|
action,
|
||||||
e.target.checked
|
e.target.checked
|
||||||
)
|
)
|
||||||
@ -213,7 +227,7 @@ export function UserManagementPage() {
|
|||||||
}
|
}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-700">{city}</span>
|
<span className="text-sm text-gray-700">{CityTitles[city]}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +245,7 @@ export function UserManagementPage() {
|
|||||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
{showCreateModal ? 'Create New User' : 'Edit User'}
|
{showCreateModal ? 'Создание нового' : 'Редактирование'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -255,22 +269,22 @@ export function UserManagementPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Avatar
|
Аватар
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex items-center space-x-4">
|
<div className="mt-1 flex items-center space-x-4">
|
||||||
<img
|
<img
|
||||||
src={formData.avatarUrl || '/images/avatar.jpg'}
|
src={formData.avatarUrl || '/images/avatar.jpg'}
|
||||||
alt="User avatar"
|
alt="Аватар пользователя"
|
||||||
className="h-12 w-12 rounded-full object-cover"
|
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">
|
<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" />
|
<ImagePlus size={16} className="mr-2" />
|
||||||
Change Avatar
|
Сменить аватар
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleAvatarUpload}
|
onChange={(e) => handleAvatarUpload(e, formData.id)} // Передаем userId
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -278,7 +292,7 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Display Name
|
Имя
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -291,7 +305,7 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Email
|
E-mail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -304,7 +318,7 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Password {showEditModal && '(leave blank to keep current)'}
|
Пароль {showEditModal && '(оставить пустым чтобы использовать текущий)'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@ -326,13 +340,13 @@ export function UserManagementPage() {
|
|||||||
}}
|
}}
|
||||||
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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 ? 'Create User' : 'Save Changes'}
|
{showCreateModal ? 'Создать' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -11,12 +11,12 @@ export const galleryService = {
|
|||||||
size: number;
|
size: number;
|
||||||
format: string
|
format: string
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
|
const { data } = await axios.post(`/api/gallery/article/${articleId}`, imageData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
|
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
|
||||||
const { data } = await axios.put(`/gallery/${id}`, updates);
|
const { data } = await axios.put(`/api/gallery/${id}`, updates);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -25,11 +25,11 @@ export const galleryService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reorderImages: async (articleId: string, imageIds: string[]) => {
|
reorderImages: async (articleId: string, imageIds: string[]) => {
|
||||||
await axios.post(`/gallery/article/${articleId}/reorder`, { imageIds });
|
await axios.post(`/api/gallery/article/${articleId}/reorder`, { imageIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
getArticleGallery: async (articleId: string) => {
|
getArticleGallery: async (articleId: string) => {
|
||||||
const { data } = await axios.get(`/gallery/article/${articleId}`);
|
const { data } = await axios.get(`/api/gallery/article/${articleId}`);
|
||||||
return data as GalleryImage[];
|
return data as GalleryImage[];
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -7,8 +7,8 @@ export const userService = {
|
|||||||
const response = await axios.get('/users');
|
const response = await axios.get('/users');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching users:', error);
|
console.error('Ошибка получения списка пользователей:', error);
|
||||||
throw new Error('Failed to fetch users');
|
throw new Error('Ошибка получения списка пользователей');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -6,6 +6,8 @@ export interface UserPermissions {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionAction = 'edit' | 'create' | 'delete';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -15,6 +17,7 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserFormData {
|
export interface UserFormData {
|
||||||
|
id: string
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
@ -1,31 +1,53 @@
|
|||||||
import { Category, City } from '../types';
|
import { PermissionAction, User } from '../types/auth';
|
||||||
import { User } from '../types/auth';
|
|
||||||
|
|
||||||
export const checkPermission = (
|
export const checkPermission = (
|
||||||
user: User,
|
user: User,
|
||||||
category: Category,
|
categoryId: string,
|
||||||
action: 'create' | 'edit' | 'delete'
|
action: PermissionAction
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
// Если пользователь админ — ему разрешено всё
|
||||||
if (user.permissions.isAdmin) return true;
|
if (user.permissions.isAdmin) return true;
|
||||||
return !!user.permissions.categories[category.name]?.[action];
|
|
||||||
|
// Проверяем, есть ли такая категория в permissions
|
||||||
|
const categoryPermissions = user.permissions.categories[categoryId];
|
||||||
|
|
||||||
|
// Если категория отсутствует или в ней нет действия — запрет
|
||||||
|
return categoryPermissions ? categoryPermissions[action] : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkCityAccess = (user: User, city: City): boolean => {
|
export const checkCityAccess = (user: User, cityId: number): boolean => {
|
||||||
if (user.permissions.isAdmin) return true;
|
if (user.permissions.isAdmin) return true;
|
||||||
return user.permissions.cities.includes(city);
|
return user.permissions.cities.includes(cityId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultPermissions = () => ({
|
export const getDefaultPermissions = () => ({
|
||||||
categories: {
|
categories: {
|
||||||
Film: { create: false, edit: false, delete: false },
|
1: { create: false, edit: false, delete: false },
|
||||||
Theater: { create: false, edit: false, delete: false },
|
2: { create: false, edit: false, delete: false },
|
||||||
Music: { create: false, edit: false, delete: false },
|
3: { create: false, edit: false, delete: false },
|
||||||
Sports: { create: false, edit: false, delete: false },
|
4: { create: false, edit: false, delete: false },
|
||||||
Art: { create: false, edit: false, delete: false },
|
5: { create: false, edit: false, delete: false },
|
||||||
Legends: { create: false, edit: false, delete: false },
|
6: { create: false, edit: false, delete: false },
|
||||||
Anniversaries: { create: false, edit: false, delete: false },
|
7: { create: false, edit: false, delete: false },
|
||||||
Memory: { create: false, edit: false, delete: false }
|
8: { create: false, edit: false, delete: false }
|
||||||
},
|
},
|
||||||
cities: [],
|
cities: [],
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getUserAvailableCategories = (user: User): number[] => {
|
||||||
|
if (!user) return [];
|
||||||
|
if (user.permissions.isAdmin) return [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
|
return Object.entries(user.permissions.categories)
|
||||||
|
.filter(([, permissions]) => permissions.create || permissions.edit) // Убрали `_`
|
||||||
|
.map(([categoryId]) => Number(categoryId))
|
||||||
|
.filter((categoryId) => !isNaN(categoryId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserAvailableCities = (user: User): number[] => {
|
||||||
|
if (!user) return [];
|
||||||
|
if (user.permissions.isAdmin) return [1, 2];
|
||||||
|
|
||||||
|
return user.permissions.cities;
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user