Работает создание, редактирование статьи. Сейчас проблема с редактором - вставка изображения.
This commit is contained in:
parent
5cc46da09d
commit
1c4a7f2384
4693
package-lock.json
generated
4693
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,9 @@
|
||||
"@aws-sdk/client-s3": "^3.525.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
||||
"@prisma/client": "^6.2.1",
|
||||
"@tiptap/extension-highlight": "^2.11.5",
|
||||
"@tiptap/extension-image": "^2.11.5",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/pm": "^2.2.4",
|
||||
"@tiptap/react": "^2.2.4",
|
||||
"@tiptap/starter-kit": "^2.2.4",
|
||||
@ -51,7 +54,7 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
@ -62,6 +65,6 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "6.0.9"
|
||||
"vite": "^5.4.14"
|
||||
}
|
||||
}
|
||||
|
BIN
public/images/avatar.jpg
Normal file
BIN
public/images/avatar.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
@ -1,8 +1,7 @@
|
||||
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Article } from '../types';
|
||||
import { getCategoryName } from '../utils/categories';
|
||||
import MinutesWord from './MinutesWord.tsx';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import MinutesWord from './MinutesWord';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
@ -10,7 +9,7 @@ interface ArticleCardProps {
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
const categoryName = getCategoryName(article.categoryId);
|
||||
const categoryName = CategoryTitles[article.categoryId];
|
||||
|
||||
return (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
@ -28,7 +27,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
</span>
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
||||
<MapPin size={14} className="mr-1" />
|
||||
{article.city}
|
||||
{CityTitles[article.cityId]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -36,15 +35,20 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img
|
||||
src={article.author.avatar}
|
||||
alt={article.author.name}
|
||||
src={article.author.avatarUrl}
|
||||
alt={article.author.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{article.author.name}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{article.readTime} <MinutesWord minutes={article.readTime} /> на чтение
|
||||
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
|
||||
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
66
src/components/ArticleContent.tsx
Normal file
66
src/components/ArticleContent.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Blockquote from '@tiptap/extension-blockquote';
|
||||
import Image from "@tiptap/extension-image";
|
||||
|
||||
interface ArticleContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ResizableImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
||||
},
|
||||
width: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function ArticleContent({ content }: ArticleContentProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false, // Отключаем дефолтный
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
ResizableImage
|
||||
],
|
||||
content,
|
||||
editable: false, // Контент только для чтения
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="prose prose-lg max-w-none mb-8">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { Category } from '../types';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
requiredPermissions?: {
|
||||
category?: Category;
|
||||
categoryId?: number;
|
||||
action: 'create' | 'edit' | 'delete';
|
||||
};
|
||||
}
|
||||
@ -28,16 +27,16 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
|
||||
}
|
||||
|
||||
if (requiredPermissions) {
|
||||
const { category, action } = requiredPermissions;
|
||||
const { categoryId, action } = requiredPermissions;
|
||||
|
||||
if (!user.permissions.isAdmin) {
|
||||
if (category && !user.permissions.categories[category.name]?.[action]) {
|
||||
if (categoryId && !user.permissions.categories[categoryId]?.[action]) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600">
|
||||
You don't have permission to {action} articles in the {category.name} category.
|
||||
You don't have permission to {action} articles in the {categoryId} category.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
138
src/components/ContentEditor.tsx
Normal file
138
src/components/ContentEditor.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useEditor, EditorContent, Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
|
||||
export interface ContentEditorRef {
|
||||
setContent: (content: string) => void;
|
||||
getEditor: () => Editor | null;
|
||||
}
|
||||
|
||||
const ContentEditor = forwardRef<ContentEditorRef>((_, ref) => {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const ResizableImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
||||
},
|
||||
width: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false, // Отключаем дефолтный
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
ResizableImage,
|
||||
Highlight,
|
||||
],
|
||||
content: '',
|
||||
onUpdate: ({ editor }) => {
|
||||
const pos = editor.state.selection.$anchor.pos;
|
||||
const resolvedPos = editor.state.doc.resolve(pos);
|
||||
const parentNode = resolvedPos.parent;
|
||||
|
||||
if (parentNode.type.name === 'image') {
|
||||
setSelectedImage(parentNode.attrs.src); // Сохраняем URL изображения
|
||||
} else {
|
||||
setSelectedImage(null); // Очищаем выбор, если это не изображение
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Позволяем родительскому компоненту использовать методы редактора
|
||||
useImperativeHandle(ref, () => ({
|
||||
setContent: (content: string) => {
|
||||
editor?.commands.setContent(content);
|
||||
},
|
||||
getEditor: () => editor,
|
||||
}));
|
||||
|
||||
// Функция изменения размера изображения
|
||||
const updateImageSize = (delta: number) => {
|
||||
if (selectedImage) {
|
||||
const attrs = editor?.getAttributes('image');
|
||||
const newWidth = Math.max((parseInt(attrs?.width) || 100) + delta, 50);
|
||||
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{selectedImage && (
|
||||
<div
|
||||
className="absolute z-10 flex space-x-2 bg-white border p-1 rounded shadow-lg"
|
||||
style={{
|
||||
top: editor?.view?.dom
|
||||
? `${editor.view.dom.getBoundingClientRect().top + window.scrollY + 50}px`
|
||||
: '0px',
|
||||
left: editor?.view?.dom
|
||||
? `${editor.view.dom.getBoundingClientRect().left + window.scrollX + 200}px`
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => updateImageSize(-20)}
|
||||
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
|
||||
>
|
||||
Уменьшить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateImageSize(20)}
|
||||
className="px-2 py-1 bg-gray-200 rounded hover:bg-gray-300"
|
||||
>
|
||||
Увеличить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ContentEditor.displayName = 'ContentEditor';
|
||||
export default ContentEditor;
|
@ -1,34 +1,63 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ArticleCard } from './ArticleCard';
|
||||
import { Pagination } from './Pagination';
|
||||
import { articles } from '../data/mock';
|
||||
import { getCategoryId } from '../utils/categories';
|
||||
import { CategoryName } from '../types';
|
||||
import {Article, CategoryTitles, CityTitles} from '../types';
|
||||
import axios from "axios";
|
||||
|
||||
const ARTICLES_PER_PAGE = 6;
|
||||
|
||||
export function FeaturedSection() {
|
||||
const location = useLocation();
|
||||
// const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const categoryParam = searchParams.get('category') as CategoryName | null;
|
||||
const category = searchParams.get('category');
|
||||
const city = searchParams.get('city');
|
||||
const currentPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка статей с backend
|
||||
useEffect(() => {
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/articles/', {
|
||||
params: {
|
||||
page: currentPage,
|
||||
categoryId: category || undefined,
|
||||
cityId: city || undefined,
|
||||
},
|
||||
});
|
||||
console.log('Загруженные данные:', response.data);
|
||||
setArticles(response.data.articles);
|
||||
} catch (error) {
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchArticles();
|
||||
}, [category, city, currentPage]);
|
||||
|
||||
// Фильтрация статей
|
||||
const filteredArticles = useMemo(() => {
|
||||
return articles.filter(article => {
|
||||
if (categoryParam && city) {
|
||||
return article.categoryId === getCategoryId(categoryParam) && article.city === city;
|
||||
|
||||
if (category && city) {
|
||||
return article.categoryId === Number(category) && article.cityId === Number(city);
|
||||
}
|
||||
if (categoryParam) {
|
||||
return article.categoryId === getCategoryId(categoryParam);
|
||||
|
||||
if (category) {
|
||||
return article.categoryId === Number(category);
|
||||
}
|
||||
|
||||
if (city) {
|
||||
return article.city === city;
|
||||
return article.cityId === Number(city);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [categoryParam, city]);
|
||||
}, [articles, category, city]);
|
||||
|
||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
||||
|
||||
@ -62,20 +91,26 @@ export function FeaturedSection() {
|
||||
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{city ? `${city} ` : ''}
|
||||
{categoryParam ? `${categoryParam} Статьи` : 'Тематические статьи'}
|
||||
{city ? `${CityTitles[Number(city)]} ` : ''}
|
||||
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Показано {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} из {filteredArticles.length} статей
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{currentArticles.map((article, index) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
featured={currentPage === 1 && index === 0 && !categoryParam && !city}
|
||||
featured={currentPage === 1 && index === 0 && !category && !city}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -53,39 +53,39 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
|
||||
URL изображения
|
||||
<span className="italic font-bold">URL изображения</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="imageUrl"
|
||||
value={newImageUrl}
|
||||
onChange={(e) => setNewImageUrl(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
|
||||
Заголовок
|
||||
<span className="italic font-bold">Заголовок</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageCaption"
|
||||
value={newImageCaption}
|
||||
onChange={(e) => setNewImageCaption(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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 htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
|
||||
Текст при наведении
|
||||
<span className="italic font-bold">Текст при наведении на изображение</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageAlt"
|
||||
value={newImageAlt}
|
||||
onChange={(e) => setNewImageAlt(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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>
|
||||
<button
|
||||
|
@ -1,10 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Menu, Search, X, ChevronDown } from 'lucide-react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Category, City } from '../types';
|
||||
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from '../types';
|
||||
|
||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
|
||||
export function Header() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@ -66,10 +64,10 @@ export function Header() {
|
||||
onChange={handleCityChange}
|
||||
className="appearance-none bg-white pl-3 pr-8 py-2 text-sm font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((city) => (
|
||||
<option value="">Все столицы</option>
|
||||
{CityIds.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
{CityTitles[city]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -92,19 +90,19 @@ export function Header() {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
All Categories
|
||||
Все категории
|
||||
</Link>
|
||||
{categories.map((category) => (
|
||||
{CategoryIds.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
currentCategory === category
|
||||
Number(currentCategory) === category
|
||||
? 'text-blue-600 hover:text-blue-800'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{CategoryTitles[category]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@ -117,7 +115,7 @@ export function Header() {
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
onKeyDown={handleSearchKeyPress}
|
||||
className="w-40 lg:w-60 pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
@ -145,10 +143,10 @@ export function Header() {
|
||||
onChange={handleCityChange}
|
||||
className="w-full bg-white px-3 py-2 text-base font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((city) => (
|
||||
<option value="">Все столицы</option>
|
||||
{CityIds.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
{CityTitles[city]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -165,19 +163,19 @@ export function Header() {
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
All Categories
|
||||
Все категории
|
||||
</Link>
|
||||
{categories.map((category) => (
|
||||
{CategoryIds.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
currentCategory === category
|
||||
Number(currentCategory) === category
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{CategoryTitles[category]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
@ -27,14 +27,11 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
||||
formData.append('resolutionId', resolution.id);
|
||||
formData.append('folder', 'articles/1');
|
||||
|
||||
// Получение токена из локального хранилища
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Отправка запроса на сервер
|
||||
const response = await axios.post('/api/images/upload-url', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': `Bearer ${token}`, // Передача токена аутентификации
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
|
||||
},
|
||||
});
|
||||
|
||||
@ -88,7 +85,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
||||
id="coverImage"
|
||||
value={coverImage}
|
||||
readOnly
|
||||
className="flex-1 rounded-l-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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="URL изображения будет здесь после загрузки"
|
||||
/>
|
||||
<label
|
||||
@ -96,7 +93,7 @@ export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverIm
|
||||
className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-r-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={18} className="mr-2" />
|
||||
Выбор
|
||||
Загрузка
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
|
||||
@ -32,13 +32,13 @@ export function ImageDropzone({ onDrop, disabled = false }: ImageDropzoneProps)
|
||||
<ImagePlus className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{isDragActive ? (
|
||||
'Drop the image here'
|
||||
'Перетащите изображение сюда'
|
||||
) : (
|
||||
'Drag & drop an image here, or click to select'
|
||||
'Перетащите изображение сюда или нажмите для выбора'
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PNG, JPG, JPEG or WebP up to 10MB
|
||||
PNG, JPG, JPEG или WebP до 10MB
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { ImageResolution } from '../../types/image';
|
||||
|
||||
interface ResolutionSelectProps {
|
||||
@ -17,7 +16,7 @@ export function ResolutionSelect({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Select Resolution
|
||||
Выбор разрешения
|
||||
</label>
|
||||
<select
|
||||
value={selectedResolution}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { ImageUploadProgress } from '../../types/image';
|
||||
import { CheckCircle, AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
|
@ -5,24 +5,24 @@ export const imageResolutions: ImageResolution[] = [
|
||||
id: 'thumbnail',
|
||||
width: 300,
|
||||
height: 300,
|
||||
label: 'Thumbnail (300x300)'
|
||||
label: 'Миниатюра (300x300)'
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
width: 800,
|
||||
height: 600,
|
||||
label: 'Medium (800x600)'
|
||||
label: 'Среднее (800x600)'
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
label: 'Large (1920x1080)'
|
||||
label: 'Большое (1920x1080)'
|
||||
},
|
||||
{
|
||||
id: 'original',
|
||||
width: 0, // 0 means keep original dimensions
|
||||
height: 0,
|
||||
label: 'Original Size'
|
||||
label: 'Оригинальный размер'
|
||||
}
|
||||
];
|
168
src/data/mock.ts
168
src/data/mock.ts
@ -1,168 +0,0 @@
|
||||
import {Article, Author, CategoryName} from '../types';
|
||||
|
||||
export const authors: Author[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Елена Маркова',
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=150&h=150',
|
||||
bio: 'Cultural critic and art historian based in Barcelona',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Илья Золкин',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150',
|
||||
bio: 'Music journalist and classical pianist',
|
||||
},
|
||||
];
|
||||
|
||||
export const articles: Article[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'The Renaissance of Independent Theater',
|
||||
excerpt: 'How small theater companies are revolutionizing modern storytelling through innovative approaches and community engagement.',
|
||||
content: 'In the heart of urban artistic communities, independent theater companies are crafting innovative narratives that challenge traditional storytelling methods.',
|
||||
categoryId: 2, // Theater
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [
|
||||
{
|
||||
id: '1',
|
||||
url: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Behind the scenes at the rehearsal',
|
||||
alt: 'Theater rehearsal in progress'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
url: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Stage design and lighting setup',
|
||||
alt: 'Theater stage with dramatic lighting'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
url: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Opening night performance',
|
||||
alt: 'Actors performing on stage'
|
||||
}
|
||||
],
|
||||
publishedAt: '2024-03-15T10:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 124,
|
||||
dislikes: 8,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Evolution of Digital Art Museums',
|
||||
excerpt: 'Exploring the intersection of technology and traditional art spaces in the modern digital age.',
|
||||
content: 'As we venture further into the digital age, museums are adapting to new ways of presenting art that blend traditional curation with cutting-edge technology.',
|
||||
categoryId: 5, // Art
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-14T09:00:00Z',
|
||||
readTime: 4,
|
||||
likes: 89,
|
||||
dislikes: 3,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'The Future of Classical Music',
|
||||
excerpt: 'Contemporary composers bridging the gap between traditional and modern musical expressions.',
|
||||
content: 'Classical music is experiencing a remarkable transformation as new composers blend traditional orchestral elements with contemporary influences.',
|
||||
categoryId: 3, // Music
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-13T10:00:00Z',
|
||||
readTime: 6,
|
||||
likes: 156,
|
||||
dislikes: 12,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Modern Literature in the Digital Age',
|
||||
excerpt: 'How e-books and digital platforms are changing the way we consume and create literature.',
|
||||
content: 'The digital revolution has transformed the literary landscape, offering new ways for authors to reach readers and for stories to be told.',
|
||||
categoryId: 5, // Art
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-12T09:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 78,
|
||||
dislikes: 4,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Rise of Immersive Art Installations',
|
||||
excerpt: 'How interactive and immersive art is transforming gallery spaces worldwide.',
|
||||
content: 'Interactive art installations are revolutionizing the way we experience and engage with contemporary art.',
|
||||
categoryId: 5, // Art
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-11T10:00:00Z',
|
||||
readTime: 7,
|
||||
likes: 201,
|
||||
dislikes: 15,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Film Festivals in the Post-Pandemic Era',
|
||||
excerpt: 'How film festivals are adapting to hybrid formats and reaching wider audiences.',
|
||||
content: 'Film festivals are embracing digital platforms while maintaining the magic of in-person screenings.',
|
||||
categoryId: 1, // Film
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-10T09:00:00Z',
|
||||
readTime: 4,
|
||||
likes: 167,
|
||||
dislikes: 9,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Street Art: From Vandalism to Validation',
|
||||
excerpt: 'The evolution of street art and its acceptance in the contemporary art world.',
|
||||
content: 'Street art has transformed from a controversial form of expression to a celebrated art movement.',
|
||||
categoryId: 5, // Art
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-09T10:00:00Z',
|
||||
readTime: 6,
|
||||
likes: 145,
|
||||
dislikes: 7,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'The New Wave of Theater Technology',
|
||||
excerpt: 'How digital innovations are enhancing live theater performances.',
|
||||
content: 'Modern theater productions are incorporating cutting-edge technology to create immersive experiences.',
|
||||
categoryId: 2, // Theater
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-08T09:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 134,
|
||||
dislikes: 6,
|
||||
userReaction: null,
|
||||
},
|
||||
|
||||
// ... rest of the articles array remains the same
|
||||
];
|
@ -1,58 +0,0 @@
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
|
||||
const categories: Category[] = [
|
||||
{id : 1, name : 'Film'},
|
||||
{id : 2, name : 'Theater'},
|
||||
{id : 3, name : 'Music'},
|
||||
{id : 4, name : 'Sports'},
|
||||
{id : 5, name : 'Art'},
|
||||
{id : 6, name : 'Legends'},
|
||||
{id : 7, name : 'Anniversaries'},
|
||||
{id : 8, name : 'Memory'}
|
||||
];
|
||||
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
|
||||
export const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@culturescope.com',
|
||||
displayName: 'Admin User',
|
||||
permissions: {
|
||||
categories: categories.reduce((acc, category) => ({
|
||||
...acc,
|
||||
[category.id]: { create: true, edit: true, delete: true }
|
||||
}), {}),
|
||||
cities: cities,
|
||||
isAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'editor@culturescope.com',
|
||||
displayName: 'Content Editor',
|
||||
permissions: {
|
||||
categories: {
|
||||
'Art': { create: true, edit: true, delete: false },
|
||||
'Music': { create: true, edit: true, delete: false },
|
||||
'Film': { create: true, edit: true, delete: false }
|
||||
},
|
||||
cities: ['New York'],
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'writer@culturescope.com',
|
||||
displayName: 'Staff Writer',
|
||||
permissions: {
|
||||
categories: {
|
||||
'Theater': { create: true, edit: false, delete: false },
|
||||
'Sports': { create: true, edit: false, delete: false }
|
||||
},
|
||||
cities: ['London'],
|
||||
isAdmin: false
|
||||
}
|
||||
}
|
||||
];
|
@ -1,17 +1,12 @@
|
||||
import { CategoryName } from '../types';
|
||||
|
||||
const backgroundImages = {
|
||||
Film: '/images/film-bg.avif?auto=format&fit=crop&q=80&w=2070',
|
||||
Theater: '/images/main-bg-1.webp?auto=format&fit=crop&q=80&w=2070',
|
||||
Music: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
|
||||
Sports: 'https://images.unsplash.com/photo-1461896836934-ffe607ba8211?auto=format&fit=crop&q=80&w=2070',
|
||||
Art: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
|
||||
Legends: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070',
|
||||
Anniversaries: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',
|
||||
Memory: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070',
|
||||
default: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070'
|
||||
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',
|
||||
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',
|
||||
8: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070',
|
||||
0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070'
|
||||
};
|
||||
|
||||
export function useBackgroundImage(name?: CategoryName | null) {
|
||||
return name ? backgroundImages[name] : backgroundImages.default;
|
||||
}
|
66
src/hooks/useContentEditor.ts
Normal file
66
src/hooks/useContentEditor.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Blockquote from '@tiptap/extension-blockquote';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Image from '@tiptap/extension-image';
|
||||
|
||||
export const useContentEditor = (content = '') => {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false, // Отключаем дефолтный
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Highlight,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
HTMLAttributes: {
|
||||
class: 'resizable-image',
|
||||
},
|
||||
}),
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
let isImageSelected = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type.name === 'image') {
|
||||
setSelectedImage(node.attrs.src);
|
||||
isImageSelected = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isImageSelected) {
|
||||
setSelectedImage(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateImageSize = (delta: number) => {
|
||||
if (selectedImage) {
|
||||
const attrs = editor?.getAttributes('image');
|
||||
if (!attrs) return;
|
||||
const newWidth = Math.max((parseInt(attrs.width) || 100) + delta, 50);
|
||||
editor?.chain().focus().updateAttributes('image', { width: newWidth }).run();
|
||||
}
|
||||
};
|
||||
|
||||
return { editor, selectedImage, updateImageSize };
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
import { User, UserFormData } from '../types/auth';
|
||||
import { userService } from '../services/userService';
|
||||
|
||||
|
||||
export function useUserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
@ -10,25 +10,25 @@ export function useUserManagement() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedUsers = await userService.getUsers();
|
||||
setUsers(fetchedUsers);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch users');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedUsers = await userService.getUsers();
|
||||
setUsers(fetchedUsers);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch users');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermissionChange = async (
|
||||
category: Category,
|
||||
categoryId: number,
|
||||
action: 'create' | 'edit' | 'delete',
|
||||
value: boolean
|
||||
) => {
|
||||
@ -39,8 +39,8 @@ export function useUserManagement() {
|
||||
...selectedUser.permissions,
|
||||
categories: {
|
||||
...selectedUser.permissions.categories,
|
||||
[category.name]: {
|
||||
...selectedUser.permissions.categories[category.name],
|
||||
[categoryId]: {
|
||||
...selectedUser.permissions.categories[categoryId],
|
||||
[action]: value,
|
||||
},
|
||||
},
|
||||
@ -62,7 +62,7 @@ export function useUserManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCityChange = async (city: City, checked: boolean) => {
|
||||
const handleCityChange = async (city: number, checked: boolean) => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
@ -91,6 +91,42 @@ export function useUserManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const createUser = async (formData: UserFormData) => {
|
||||
try {
|
||||
const newUser = await userService.createUser(formData);
|
||||
setUsers([...users, newUser]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to create user');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (userId: string, formData: UserFormData) => {
|
||||
try {
|
||||
const updatedUser = await userService.updateUser(userId, formData);
|
||||
setUsers(users.map(user => user.id === userId ? updatedUser : user));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to update user');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
try {
|
||||
await userService.deleteUser(userId);
|
||||
setUsers(users.filter(user => user.id !== userId));
|
||||
if (selectedUser?.id === userId) {
|
||||
setSelectedUser(null);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to delete user');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
selectedUser,
|
||||
@ -98,6 +134,9 @@ export function useUserManagement() {
|
||||
error,
|
||||
setSelectedUser,
|
||||
handlePermissionChange,
|
||||
handleCityChange
|
||||
handleCityChange,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
};
|
||||
}
|
@ -1,46 +1,102 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import axios from "axios";
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Blockquote from '@tiptap/extension-blockquote';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Header } from '../components/Header';
|
||||
import { GalleryManager } from '../components/GalleryManager';
|
||||
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
||||
import {Category, City, Article, GalleryImage, CategoryMap} from '../types';
|
||||
import { Bold, Italic, List, ListOrdered, Quote, Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { articles } from '../data/mock';
|
||||
|
||||
//const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const categories: Category[] = ['Кино', 'Театр', 'Музыка', 'Спорт', 'Искусство', 'Легенды', 'Юбилеи', 'Память'];
|
||||
|
||||
/*
|
||||
const categorieIds: Category[] = CategoryNames.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
}));
|
||||
*/
|
||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
||||
import MinutesWord from "../components/MinutesWord";
|
||||
import { ArticlesResponse, GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds } from '../types';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Blocks,
|
||||
ImagePlus,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
const ARTICLES_PER_PAGE = 5;
|
||||
const ARTICLES_PER_PAGE = 6;
|
||||
|
||||
export function AdminPage() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [category, setCategory] = useState<Category>('Искусство');
|
||||
const [city, setCity] = useState<City>('New York');
|
||||
const [categoryId, setCategoryId] = useState(1);
|
||||
const [cityId, setCityId] = useState(1);
|
||||
const [coverImage, setCoverImage] = useState('');
|
||||
const [readTime, setReadTime] = useState(5);
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||
const [articlesList, setArticlesList] = useState(articles);
|
||||
const [articlesList, setArticlesList] = useState<ArticlesResponse>({articles: [], totalPages: 1, currentPage: 1});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [filterCategory, setFilterCategory] = useState<Category | ''>('');
|
||||
const [filterCity, setFilterCity] = useState<City | ''>('');
|
||||
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
||||
const [filterCityId, setFilterCity] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ResizableImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
default: 'max-w-full h-auto', // Добавлено для адаптивности
|
||||
},
|
||||
width: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('width') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 'auto',
|
||||
parseHTML: (element) => element.getAttribute('height') || 'auto',
|
||||
renderHTML: (attributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false, // Отключаем дефолтный
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
ResizableImage,
|
||||
Highlight
|
||||
],
|
||||
content: '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@ -49,57 +105,64 @@ export function AdminPage() {
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
// Загрузка статей
|
||||
useEffect(() => {
|
||||
async function fetchArticles() {
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/articles', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
const response = await axios.get('/api/articles/', {
|
||||
params: {
|
||||
page: currentPage,
|
||||
categoryId: filterCategoryId,
|
||||
cityId: filterCityId
|
||||
}
|
||||
});
|
||||
setArticlesList(response.data);
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error(err);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
fetchArticles();
|
||||
}, []);
|
||||
*/
|
||||
};
|
||||
|
||||
fetchArticles();
|
||||
}, [currentPage, filterCategoryId, filterCityId]);
|
||||
|
||||
// Фильтрация статей по категории и городу
|
||||
const filteredArticles = useMemo(() => {
|
||||
return articlesList.filter(article => {
|
||||
if (filterCategory && filterCity) {
|
||||
return article.category === filterCategory && article.city === filterCity;
|
||||
return articlesList.articles.filter(article => {
|
||||
if (filterCategoryId && filterCityId) {
|
||||
return article.categoryId === filterCategoryId && article.cityId === filterCityId;
|
||||
}
|
||||
if (filterCategory) {
|
||||
return article.category === filterCategory;
|
||||
if (filterCategoryId) {
|
||||
return article.categoryId === filterCategoryId;
|
||||
}
|
||||
if (filterCity) {
|
||||
return article.city === filterCity;
|
||||
if (filterCityId) {
|
||||
return article.cityId === filterCityId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [articlesList, filterCategory, filterCity]);
|
||||
}, [articlesList, filterCategoryId, filterCityId]);
|
||||
|
||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
||||
|
||||
/*
|
||||
const paginatedArticles = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
||||
return filteredArticles.slice(startIndex, startIndex + ARTICLES_PER_PAGE);
|
||||
}, [filteredArticles, currentPage]);
|
||||
*/
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Редактирование статьи - загрузка данных
|
||||
const handleEdit = (id: string) => {
|
||||
const article = articlesList.find(a => a.id === id);
|
||||
const article = articlesList.articles.find(a => a.id === id);
|
||||
if (article) {
|
||||
setTitle(article.title);
|
||||
setExcerpt(article.excerpt);
|
||||
setCategory(article.category);
|
||||
setCity(article.city);
|
||||
setCategoryId(article.categoryId);
|
||||
setCityId(article.cityId);
|
||||
setCoverImage(article.coverImage);
|
||||
setReadTime(article.readTime);
|
||||
setGallery(article.gallery || []);
|
||||
@ -109,25 +172,34 @@ export function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setArticlesList(prev => prev.filter(article => article.id !== id));
|
||||
// Удаление статьи
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`/api/article/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
setArticlesList(prev => ({
|
||||
...prev,
|
||||
articles: prev.articles.filter(article => article.id !== id),
|
||||
}));
|
||||
} catch (error) {
|
||||
setError('Не удалось удалить статью');
|
||||
console.error(error);
|
||||
}
|
||||
setShowDeleteModal(null);
|
||||
};
|
||||
|
||||
// Создание новой статьи, сохранение существующей
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const categoryId : number = CategoryMap[category];
|
||||
if (!categoryId) {
|
||||
console.error('Invalid category name.');
|
||||
return;
|
||||
}
|
||||
|
||||
const articleData = {
|
||||
title,
|
||||
excerpt,
|
||||
categoryId,
|
||||
city,
|
||||
cityId,
|
||||
coverImage,
|
||||
readTime,
|
||||
gallery,
|
||||
@ -135,38 +207,62 @@ export function AdminPage() {
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
setArticlesList(prev =>
|
||||
prev.map(article =>
|
||||
article.id === editingId
|
||||
? {...article, ...articleData}
|
||||
: article
|
||||
)
|
||||
);
|
||||
try {
|
||||
const response = await axios.put(`/api/articles/${editingId}`, articleData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
setArticlesList(prev => ({
|
||||
...prev,
|
||||
articles: prev.articles.map(article =>
|
||||
article.id === editingId ? response.data : article
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
setError('Не удалось обновить статью');
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Создание новой статьи
|
||||
const response = await axios.post('/api/articles', articleData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
setArticlesList(prev => [...prev, response.data]);
|
||||
setArticlesList(prev => ({
|
||||
...prev,
|
||||
articles: [...prev.articles, response.data],
|
||||
}));
|
||||
|
||||
// In a real app, this would send data to an API
|
||||
console.log('Creating new article:', articleData);
|
||||
console.log('Создание новой статьи:', articleData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setExcerpt('');
|
||||
setCategory('Art');
|
||||
setCity('New York');
|
||||
setCategoryId(1);
|
||||
setCityId(1);
|
||||
setCoverImage('');
|
||||
setReadTime(5);
|
||||
setGallery([]);
|
||||
editor?.commands.setContent('');
|
||||
};
|
||||
|
||||
const handleGalleryImageUpload = (imageUrl: string) => {
|
||||
const newImage: GalleryImage = {
|
||||
id: Date.now().toString(),
|
||||
url: imageUrl,
|
||||
caption: '',
|
||||
alt: ''
|
||||
};
|
||||
setGallery(prev => [...prev, newImage]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
@ -185,28 +281,28 @@ export function AdminPage() {
|
||||
<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="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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>
|
||||
</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
rows={3}
|
||||
value={excerpt}
|
||||
onChange={(e) => setExcerpt(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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>
|
||||
@ -214,17 +310,17 @@ export function AdminPage() {
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||
Категория
|
||||
<span className="italic font-bold">Категория</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as Category)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
{CategoryIds.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
{CategoryTitles[cat]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -232,17 +328,17 @@ export function AdminPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||||
Город
|
||||
<span className="italic font-bold">Столица</span>
|
||||
</label>
|
||||
<select
|
||||
id="city"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value as City)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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"
|
||||
>
|
||||
{cities.map((c) => (
|
||||
{CityIds.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
{CityTitles[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -250,7 +346,7 @@ export function AdminPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
|
||||
Время чтения (минуты)
|
||||
<span className="italic font-bold">Время чтения (минуты)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@ -258,7 +354,7 @@ export function AdminPage() {
|
||||
min="1"
|
||||
value={readTime}
|
||||
onChange={(e) => setReadTime(Number(e.target.value))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
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>
|
||||
@ -271,7 +367,7 @@ export function AdminPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Статья
|
||||
<span className="italic font-bold">Статья</span>
|
||||
</label>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
@ -294,6 +390,15 @@ export function AdminPage() {
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Blocks size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
@ -314,13 +419,45 @@ export function AdminPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
onClick={() => {
|
||||
console.log('Quote button clicked');
|
||||
editor?.chain().focus().toggleBlockquote().run()}}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Quote size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = prompt('Введите URL изображения');
|
||||
if (url) {
|
||||
editor?.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
🖼️ Изображение
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newWidth = (parseInt(editor?.getAttributes('image').width) || 100) + 20;
|
||||
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
||||
}}
|
||||
>
|
||||
Увеличить
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newWidth = Math.max((parseInt(editor?.getAttributes('image').width) || 100) - 20, 50);
|
||||
editor?.chain().focus().updateAttributes('image', { width: `${newWidth}px` }).run();
|
||||
}}
|
||||
>
|
||||
Уменьшить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
@ -330,9 +467,19 @@ export function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Фото Галерея
|
||||
</label>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Фото галерея
|
||||
</label>
|
||||
<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={gallery}
|
||||
onChange={setGallery}
|
||||
@ -347,8 +494,8 @@ export function AdminPage() {
|
||||
setEditingId(null);
|
||||
setTitle('');
|
||||
setExcerpt('');
|
||||
setCategory('Искусство');
|
||||
setCity('New York');
|
||||
setCategoryId(1);
|
||||
setCityId(1);
|
||||
setCoverImage('');
|
||||
setReadTime(5);
|
||||
setGallery([]);
|
||||
@ -369,39 +516,62 @@ export function AdminPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Articles List */}
|
||||
{/* Модальная загрузка изображений галереи */}
|
||||
{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) => {
|
||||
handleGalleryImageUpload(imageUrl);
|
||||
setShowGalleryUploader(false);
|
||||
}} />
|
||||
</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 gap-4">
|
||||
<select
|
||||
value={filterCategory}
|
||||
value={filterCategoryId}
|
||||
onChange={(e) => {
|
||||
setFilterCategory(e.target.value as Category | '');
|
||||
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>
|
||||
{categories.map((cat) => (
|
||||
{CategoryIds.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
{CategoryTitles[cat]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterCity}
|
||||
value={filterCityId}
|
||||
onChange={(e) => {
|
||||
setFilterCity(e.target.value as City | '');
|
||||
setFilterCity(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>
|
||||
{cities.map((c) => (
|
||||
{CityIds.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
{CityTitles[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -410,7 +580,7 @@ export function AdminPage() {
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{paginatedArticles.map((article) => (
|
||||
{articlesList.articles.map((article) => (
|
||||
<li key={article.id} className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -418,7 +588,7 @@ export function AdminPage() {
|
||||
{article.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{article.category} · {article.city} · {article.readTime} min read
|
||||
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-4">
|
||||
|
@ -4,15 +4,36 @@ import { ArrowLeft, Clock, Share2, Bookmark } from 'lucide-react';
|
||||
import { Header } from '../components/Header';
|
||||
import { ReactionButtons } from '../components/ReactionButtons';
|
||||
import { PhotoGallery } from '../components/PhotoGallery';
|
||||
import { articles } from '../data/mock';
|
||||
import { Article } from '../types';
|
||||
//import { articles } from '../data/mock';
|
||||
import {Article, CategoryTitles} from '../types';
|
||||
import { ArticleContent } from '../components/ArticleContent';
|
||||
import MinutesWord from '../components/MinutesWord';
|
||||
import axios from "axios";
|
||||
|
||||
export function ArticlePage() {
|
||||
const { id } = useParams();
|
||||
const [articleData, setArticleData] = useState<Article | null>(null);
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/*
|
||||
const [articleData, setArticleData] = useState<Article | undefined>(
|
||||
articles.find(a => a.id === id)
|
||||
);
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArticle = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/articles/${id}`);
|
||||
setArticleData(response.data);
|
||||
} catch (error) {
|
||||
//setError('Не удалось загрузить статью');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchArticle();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
@ -24,7 +45,7 @@ export function ArticlePage() {
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Article not found</h2>
|
||||
<Link to="/" className="text-blue-600 hover:text-blue-800">
|
||||
Return to homepage
|
||||
Назад на главную
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,12 +90,12 @@ export function ArticlePage() {
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={articleData.author.avatar}
|
||||
alt={articleData.author.name}
|
||||
src={articleData.author.avatarUrl}
|
||||
alt={articleData.author.displayName}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{articleData.author.name}</p>
|
||||
<p className="font-medium text-gray-900">{articleData.author.displayName}</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{articleData.readTime} <MinutesWord minutes={articleData.readTime}/> на чтение ·{' '}
|
||||
@ -92,7 +113,7 @@ export function ArticlePage() {
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<span className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
{articleData.category}
|
||||
{CategoryTitles[articleData.categoryId]}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100">
|
||||
@ -114,20 +135,8 @@ export function ArticlePage() {
|
||||
{/* Article Content */}
|
||||
<div className="prose prose-lg max-w-none mb-8">
|
||||
<div className="text-gray-800 leading-relaxed">
|
||||
{articleData.content}
|
||||
<ArticleContent content={articleData.content} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-800 leading-relaxed mt-6">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
|
||||
<blockquote className="border-l-4 border-blue-500 pl-4 my-8 italic text-gray-700">
|
||||
"Art is not what you see, but what you make others see." - Edgar Degas
|
||||
</blockquote>
|
||||
|
||||
<p className="text-gray-800 leading-relaxed">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
|
@ -1,73 +1,31 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { FeaturedSection } from '../components/FeaturedSection';
|
||||
import { useBackgroundImage } from '../hooks/useBackgroundImage';
|
||||
import { CategoryName } from '../types';
|
||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||
|
||||
export function HomePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const categoryName = searchParams.get('category') as CategoryName;
|
||||
const backgroundImage = useBackgroundImage(categoryName);
|
||||
const categoryId = searchParams.get('category');
|
||||
const backgroundImage= BackgroundImages[Number(categoryId)];
|
||||
|
||||
console.log(categoryName)
|
||||
console.log(categoryId)
|
||||
|
||||
const getHeroTitle = () => {
|
||||
if (categoryName) {
|
||||
if (categoryId) {
|
||||
return {
|
||||
main: getCategoryTitle(categoryName),
|
||||
sub: getCategoryDescription(categoryName),
|
||||
description: getCategoryText(categoryName)
|
||||
main: CategoryTitles[Number(categoryId)],
|
||||
sub: CategoryDescription[Number(categoryId)],
|
||||
description: CategoryText[Number(categoryId)]
|
||||
};
|
||||
}
|
||||
return {
|
||||
main: 'Discover the World of',
|
||||
main: 'Волшебный мир искуства',
|
||||
sub: 'Все о культуре Москвы и Петербурга',
|
||||
description: 'Следите за событиями культурной жизни столиц: концерты, выставки, спектакли и многое другое в одном удобном формате.'
|
||||
};
|
||||
};
|
||||
|
||||
const getCategoryTitle = (name: CategoryName): string => {
|
||||
const title = {
|
||||
Film: 'Кино',
|
||||
Theater: 'Театр',
|
||||
Music: 'Музыка',
|
||||
Sports: 'Спорт',
|
||||
Art: 'Искусство',
|
||||
Legends: 'Легенды',
|
||||
Anniversaries: 'Юбилеи',
|
||||
Memory: 'Память'
|
||||
};
|
||||
return title[name];
|
||||
};
|
||||
|
||||
const getCategoryDescription = (name: CategoryName): string => {
|
||||
const descriptions = {
|
||||
Film: 'Свет, камера, действие! Магия кино',
|
||||
Theater: 'Гармония актёра и зрителя',
|
||||
Music: 'Мелодии двух столиц',
|
||||
Sports: 'Сила, движение, победа',
|
||||
Art: 'Шедевры говорят с нами',
|
||||
Legends: 'Истории, которые вдохновляют',
|
||||
Anniversaries: 'Вехи истории и великие даты',
|
||||
Memory: 'Память о великом и наследие'
|
||||
};
|
||||
return descriptions[name];
|
||||
};
|
||||
|
||||
const getCategoryText = (name: CategoryName): string => {
|
||||
const subtexts = {
|
||||
Film: 'Узнайте о кино-премьерах, фестивалях и знаковых фильмах Москвы и Петербурга. Мир кино открывается для вас.',
|
||||
Theater: 'Откройте для себя театральные премьеры и закулисье лучших сцен Москвы и Петербурга.',
|
||||
Music: 'Исследуйте богатый музыкальный мир двух столиц. Узнайте о лучших исполнителях и событиях.',
|
||||
Sports: 'Спорт — это не только движение, но и культура. Откройте для себя спортивные события двух столиц.',
|
||||
Art: 'Откройте богатство искусства Москвы и Петербурга: от классики до современного авангарда.',
|
||||
Legends: 'Узнайте о великих личностях Москвы и Петербурга. Легенды, которые формируют культуру.',
|
||||
Anniversaries: 'Погрузитесь в исторические события и юбилеи, которые оставляют след в культуре двух столиц.',
|
||||
Memory: 'Сохраняем культурные традиции и память о великих событиях и людях.'
|
||||
};
|
||||
return subtexts[name];
|
||||
};
|
||||
|
||||
const { main, sub, description } = getHeroTitle();
|
||||
|
||||
return (
|
||||
|
@ -53,11 +53,11 @@ export function SearchPage() {
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{query ? `Search Results for "${query}"` : 'Search Articles'}
|
||||
{query ? `Результаты поиска "${query}"` : 'Search Articles'}
|
||||
</h1>
|
||||
{articles.length > 0 && (
|
||||
<p className="mt-2 text-gray-600">
|
||||
Found {articles.length} articles
|
||||
Найдено {articles.length} статей
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -84,10 +84,10 @@ export function SearchPage() {
|
||||
) : query ? (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">
|
||||
No articles found
|
||||
Не найдено ни одной статьи
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Try adjusting your search terms or browse our categories instead
|
||||
Попытайтесь изменить сроку поиска или browse our categories instead
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { Header } from '../components/Header';
|
||||
import { AuthGuard } from '../components/AuthGuard';
|
||||
import { Category, City } from '../types';
|
||||
import { UserFormData } from '../types/auth';
|
||||
import { useUserManagement } from '../hooks/useUserManagement';
|
||||
import { ImagePlus, X, UserPlus, Pencil } from 'lucide-react';
|
||||
import { uploadImage } from '../services/imageService';
|
||||
import { imageResolutions } from '../config/imageResolutions';
|
||||
import {CategoryIds, CategoryTitles, CityIds} from "../types";
|
||||
|
||||
|
||||
/*
|
||||
const categories: Category[] = [
|
||||
{id : 1, name : 'Film'},
|
||||
{id : 2, name : 'Theater'},
|
||||
@ -15,6 +22,14 @@ const categories: Category[] = [
|
||||
];
|
||||
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
*/
|
||||
|
||||
const initialFormData: UserFormData = {
|
||||
email: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
avatarUrl: ''
|
||||
};
|
||||
|
||||
export function UserManagementPage() {
|
||||
const {
|
||||
@ -24,9 +39,51 @@ export function UserManagementPage() {
|
||||
error,
|
||||
setSelectedUser,
|
||||
handlePermissionChange,
|
||||
handleCityChange
|
||||
handleCityChange,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
} = useUserManagement();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [formData, setFormData] = useState<UserFormData>(initialFormData);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 uploadedImage = await uploadImage(file, resolution);
|
||||
setFormData(prev => ({ ...prev, avatarUrl: uploadedImage.url }));
|
||||
} catch (error) {
|
||||
setFormError('Failed to upload avatar. Please try again.');
|
||||
console.error('Upload error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
if (showCreateModal) {
|
||||
await createUser(formData);
|
||||
setShowCreateModal(false);
|
||||
} else if (showEditModal && selectedUser) {
|
||||
await updateUser(selectedUser.id, formData);
|
||||
setShowEditModal(false);
|
||||
}
|
||||
setFormData(initialFormData);
|
||||
} catch (error) {
|
||||
setFormError(error instanceof Error ? error.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -45,12 +102,20 @@ export function UserManagementPage() {
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Управление пользователями
|
||||
</h1>
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 px-4 py-2 rounded-md text-sm">
|
||||
<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" />
|
||||
Add New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Users List */}
|
||||
@ -59,17 +124,38 @@ export function UserManagementPage() {
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className={`w-full text-left px-4 py-2 rounded-md ${
|
||||
selectedUser?.id === user.id
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
<div
|
||||
className={`flex items-center p-3 rounded-md ${
|
||||
selectedUser?.id === user.id
|
||||
? 'bg-blue-50'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</button>
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="w-10 h-10 rounded-full mr-3"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
email: user.email,
|
||||
password: '',
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -89,9 +175,9 @@ export function UserManagementPage() {
|
||||
Права по категориям
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="border rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">{category.name}</h4>
|
||||
{CategoryIds.map((category) => (
|
||||
<div key={category} className="border rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">{CategoryTitles[category]}</h4>
|
||||
<div className="space-y-2">
|
||||
{(['create', 'edit', 'delete'] as const).map((action) => (
|
||||
<label
|
||||
@ -101,7 +187,7 @@ export function UserManagementPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedUser.permissions.categories[category.name]?.[action] ?? false
|
||||
selectedUser.permissions.categories[CategoryTitles[category]]?.[action] ?? false
|
||||
}
|
||||
onChange={(e) =>
|
||||
handlePermissionChange(
|
||||
@ -129,7 +215,7 @@ export function UserManagementPage() {
|
||||
Разрешения для города
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{cities.map((city) => (
|
||||
{CityIds.map((city) => (
|
||||
<label
|
||||
key={city}
|
||||
className="flex items-center space-x-2"
|
||||
@ -153,6 +239,121 @@ export function UserManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Create/Edit User Modal */}
|
||||
{(showCreateModal || showEditModal) && (
|
||||
<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 ? 'Create New User' : 'Edit User'}
|
||||
</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-4 bg-red-50 text-red-700 p-3 rounded-md text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Avatar
|
||||
</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="User 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" />
|
||||
Change Avatar
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Password {showEditModal && '(leave blank to keep current)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required={!showEditModal}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
Cancel
|
||||
</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 ? 'Create User' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
32
src/services/imageService.ts
Normal file
32
src/services/imageService.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import axios from '../utils/api';
|
||||
import { ImageResolution, UploadedImage } from '../types/image';
|
||||
|
||||
export async function uploadImage(
|
||||
file: File,
|
||||
resolution: ImageResolution,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<UploadedImage> {
|
||||
// Get pre-signed URL for S3 upload
|
||||
const { data: { uploadUrl, imageId } } = await axios.post('/images/upload-url', {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
resolution: resolution.id
|
||||
});
|
||||
|
||||
// Upload to S3
|
||||
await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress?.(progress);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get the processed image details
|
||||
const { data: image } = await axios.get(`/images/${imageId}`);
|
||||
return image;
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
import { mockUsers } from '../data/mockUsers';
|
||||
|
||||
// In-memory storage
|
||||
let users = [...mockUsers];
|
||||
|
||||
export const mockUserService = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(users);
|
||||
}, 500); // Simulate network delay
|
||||
});
|
||||
},
|
||||
|
||||
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const userIndex = users.findIndex(user => user.id === userId);
|
||||
if (userIndex === -1) {
|
||||
reject(new Error('User not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...users[userIndex],
|
||||
permissions: {
|
||||
...permissions,
|
||||
// Ensure admin status cannot be changed through this method
|
||||
isAdmin: users[userIndex].permissions.isAdmin
|
||||
}
|
||||
};
|
||||
|
||||
users = [
|
||||
...users.slice(0, userIndex),
|
||||
updatedUser,
|
||||
...users.slice(userIndex + 1)
|
||||
];
|
||||
|
||||
resolve(updatedUser);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import axios from '../utils/api';
|
||||
import { User } from '../types/auth';
|
||||
import { User, UserFormData } from '../types/auth';
|
||||
|
||||
export const userService = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
@ -12,6 +12,26 @@ export const userService = {
|
||||
}
|
||||
},
|
||||
|
||||
createUser: async (userData: UserFormData): Promise<User> => {
|
||||
try {
|
||||
const response = await axios.post('/users', userData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
},
|
||||
|
||||
updateUser: async (userId: string, userData: Partial<UserFormData>): Promise<User> => {
|
||||
try {
|
||||
const response = await axios.put(`/users/${userId}`, userData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
},
|
||||
|
||||
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
|
||||
try {
|
||||
const response = await axios.put(`/users/${userId}/permissions`, { permissions });
|
||||
@ -20,5 +40,14 @@ export const userService = {
|
||||
console.error('Error updating user permissions:', error);
|
||||
throw new Error('Failed to update user permissions');
|
||||
}
|
||||
},
|
||||
|
||||
deleteUser: async (userId: string): Promise<void> => {
|
||||
try {
|
||||
await axios.delete(`/users/${userId}`);
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
}
|
||||
};
|
@ -1,14 +1,8 @@
|
||||
import { CategoryName, City } from './index';
|
||||
|
||||
export interface UserPermissions {
|
||||
categories: {
|
||||
[key in CategoryName]?: {
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
[key: string]: { edit: boolean; create: boolean; delete: boolean };
|
||||
};
|
||||
cities: City[];
|
||||
cities: number[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
@ -16,5 +10,13 @@ export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
permissions: UserPermissions;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
@ -3,9 +3,8 @@ export interface Article {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
category: Category;
|
||||
categoryId: number;
|
||||
city: City;
|
||||
cityId: number;
|
||||
author: Author;
|
||||
coverImage: string;
|
||||
gallery?: GalleryImage[];
|
||||
@ -16,6 +15,13 @@ export interface Article {
|
||||
userReaction?: 'like' | 'dislike' | null;
|
||||
}
|
||||
|
||||
// Структура ответа на список статей
|
||||
export interface ArticlesResponse {
|
||||
articles: Article[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
id: string;
|
||||
url: string;
|
||||
@ -25,30 +31,40 @@ export interface GalleryImage {
|
||||
|
||||
export interface Author {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
bio: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: CategoryName;
|
||||
}
|
||||
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];
|
||||
export const CityIds: number[] = [1 , 2];
|
||||
|
||||
export type CategoryName = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory';
|
||||
export type City = 'New York' | 'London';
|
||||
|
||||
export const CategoryNames: CategoryName[] = [
|
||||
'Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'
|
||||
];
|
||||
|
||||
export const CategoryMap: Record<string, number> = {
|
||||
Film: 1,
|
||||
Theater: 2,
|
||||
Music: 3,
|
||||
Sports: 4,
|
||||
Art: 5,
|
||||
Legends: 6,
|
||||
Anniversaries: 7,
|
||||
Memory: 8,
|
||||
export const CategoryTitles: Record<number, string> = {
|
||||
1: 'Кино', 2: 'Театр', 3: 'Музыка', 4: 'Спорт', 5: 'Искусство', 6: 'Легенды', 7: 'Юбилеи', 8: 'Память'
|
||||
};
|
||||
|
||||
export const CityTitles: Record<number, string> = {
|
||||
1: 'Москва', 2: 'Санкт-Петербург'
|
||||
};
|
||||
|
||||
export const CategoryDescription: Record<number, string> = {
|
||||
1: 'Свет, камера, действие! Магия кино',
|
||||
2: 'Гармония актёра и зрителя',
|
||||
3: 'Мелодии двух столиц',
|
||||
4: 'Сила, движение, победа',
|
||||
5: 'Шедевры говорят с нами',
|
||||
6: 'Истории, которые вдохновляют',
|
||||
7: 'Вехи истории и великие даты',
|
||||
8: 'Память о великом и наследие'
|
||||
};
|
||||
|
||||
export const CategoryText: Record<number, string> = {
|
||||
1: 'Узнайте о кино-премьерах, фестивалях и знаковых фильмах Москвы и Петербурга. Мир кино открывается для вас.',
|
||||
2: 'Откройте для себя театральные премьеры и закулисье лучших сцен Москвы и Петербурга.',
|
||||
3: 'Исследуйте богатый музыкальный мир двух столиц. Узнайте о лучших исполнителях и событиях.',
|
||||
4: 'Спорт — это не только движение, но и культура. Откройте для себя спортивные события двух столиц.',
|
||||
5: 'Откройте богатство искусства Москвы и Петербурга: от классики до современного авангарда.',
|
||||
6: 'Узнайте о великих личностях Москвы и Петербурга. Легенды, которые формируют культуру.',
|
||||
7: 'Погрузитесь в исторические события и юбилеи, которые оставляют след в культуре двух столиц.',
|
||||
8: 'Сохраняем культурные традиции и память о великих событиях и людях.'
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user