Работает создание, редактирование статьи. Сейчас проблема с редактором - вставка изображения.

This commit is contained in:
anibilag 2025-02-17 23:07:47 +03:00
parent 5cc46da09d
commit 1c4a7f2384
30 changed files with 3391 additions and 3020 deletions

4693
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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>

View 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>
);
}

View File

@ -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>

View 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;

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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}

View File

@ -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>
);

View File

@ -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}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { ImageUploadProgress } from '../../types/image';
import { CheckCircle, AlertCircle, Loader } from 'lucide-react';

View File

@ -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: 'Оригинальный размер'
}
];

View File

@ -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
];

View File

@ -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
}
}
];

View File

@ -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;
}

View 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 };
};

View File

@ -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
};
}

View File

@ -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 />
@ -175,38 +271,38 @@ export function AdminPage() {
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
</h1>
{error && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Заголовок
<span className="italic font-bold">Заголовок</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="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">

View File

@ -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 */}

View File

@ -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 (

View File

@ -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}

View File

@ -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>
);

View 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;
}

View File

@ -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);
});
}
};

View File

@ -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');
}
}
};

View File

@ -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;
}

View File

@ -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 CategoryTitles: Record<number, string> = {
1: 'Кино', 2: 'Театр', 3: 'Музыка', 4: 'Спорт', 5: 'Искусство', 6: 'Легенды', 7: 'Юбилеи', 8: 'Память'
};
export const CategoryNames: CategoryName[] = [
'Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'
];
export const CityTitles: Record<number, string> = {
1: 'Москва', 2: 'Санкт-Петербург'
};
export const CategoryMap: Record<string, number> = {
Film: 1,
Theater: 2,
Music: 3,
Sports: 4,
Art: 5,
Legends: 6,
Anniversaries: 7,
Memory: 8,
};
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: 'Сохраняем культурные традиции и память о великих событиях и людях.'
};