Добавлен раздел Наши авторы, поиск по статьям авторов. Добавлено описание bio для автора.
This commit is contained in:
parent
00376c124f
commit
7afbcf27f8
BIN
public/images/main-bg.jpg
Normal file
BIN
public/images/main-bg.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 790 KiB |
BIN
public/images/main-bg.png
Normal file
BIN
public/images/main-bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 338 KiB |
80
src/components/AuthorsSection.tsx
Normal file
80
src/components/AuthorsSection.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Twitter, Instagram, Globe } from 'lucide-react';
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Author } from "../types/auth.ts";
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
export function AuthorsSection() {
|
||||||
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
|
|
||||||
|
// Загрузка авторов
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAuthors = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/authors/');
|
||||||
|
setAuthors(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки авторов:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAuthors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900">Наши авторы</h2>
|
||||||
|
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Познакомьтесь с талантливыми писателями и экспертами, работающими для вас
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{authors.map((author) => (
|
||||||
|
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02]">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<img
|
||||||
|
src={author.avatarUrl}
|
||||||
|
alt={author.displayName}
|
||||||
|
className="w-24 h-24 rounded-full object-cover mr-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{author.displayName}</h3>
|
||||||
|
<div className="flex mt-2 space-x-2">
|
||||||
|
<a href="#" className="text-gray-400 hover:text-blue-500 transition-colors">
|
||||||
|
<Twitter size={18} />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-pink-500 transition-colors">
|
||||||
|
<Instagram size={18} />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-gray-700 transition-colors">
|
||||||
|
<Globe size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">{author.bio}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{author.articlesCount} статей
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
to={`/search?author=${author.id}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Статьи автора →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
146
src/components/CustomImageExtension.tsx
Normal file
146
src/components/CustomImageExtension.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
import {Editor, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
|
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
||||||
|
|
||||||
|
|
||||||
|
interface ImageNodeViewProps {
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
updateAttributes: (attrs: Record<string, number>) => void;
|
||||||
|
editor: Editor;
|
||||||
|
getPos: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React component for the image node view
|
||||||
|
const ImageNodeView: React.FC<ImageNodeViewProps> = ({ node, updateAttributes, editor, getPos }) => {
|
||||||
|
const [isSelected, setIsSelected] = useState(false);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
// Update selection state when the editor selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionUpdate = ({ editor }: { editor: Editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const nodePos = getPos();
|
||||||
|
|
||||||
|
// Check if this node is selected
|
||||||
|
const isNodeSelected = selection.$anchor.pos >= nodePos &&
|
||||||
|
selection.$anchor.pos <= nodePos + node.nodeSize;
|
||||||
|
|
||||||
|
setIsSelected(isNodeSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.on('selectionUpdate', handleSelectionUpdate);
|
||||||
|
return () => {
|
||||||
|
editor.off('selectionUpdate', handleSelectionUpdate);
|
||||||
|
};
|
||||||
|
}, [editor, getPos, node.nodeSize]);
|
||||||
|
|
||||||
|
// Handle zoom in
|
||||||
|
const handleZoomIn = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setScale(prev => Math.min(prev + 0.1, 2));
|
||||||
|
updateAttributes({ scale: Math.min(scale + 0.1, 2) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle zoom out
|
||||||
|
const handleZoomOut = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setScale(prev => Math.max(prev - 0.1, 0.5));
|
||||||
|
updateAttributes({ scale: Math.max(scale - 0.1, 0.5) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track current scale from attributes
|
||||||
|
useEffect(() => {
|
||||||
|
if (node.attrs.scale) {
|
||||||
|
setScale(node.attrs.scale);
|
||||||
|
}
|
||||||
|
}, [node.attrs.scale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-center my-4">
|
||||||
|
{/* The actual image */}
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
alt={node.attrs.alt || ''}
|
||||||
|
className={`max-w-full transition-transform ${isSelected ? 'ring-2 ring-blue-500' : ''}`}
|
||||||
|
style={{
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
}}
|
||||||
|
data-drag-handle=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Zoom controls - only visible when selected */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
|
title="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
|
title="Zoom out"
|
||||||
|
>
|
||||||
|
<ZoomOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom Image extension for TipTap
|
||||||
|
export const CustomImage = Node.create({
|
||||||
|
name: 'customImage',
|
||||||
|
group: 'block',
|
||||||
|
inline: false,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'img[src]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const { scale, ...rest } = HTMLAttributes;
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
{ class: 'flex justify-center my-4' },
|
||||||
|
['img', mergeAttributes(rest, { style: `transform: scale(${scale})` })],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ImageNodeView);
|
||||||
|
},
|
||||||
|
});
|
@ -6,14 +6,16 @@ import {Article, CategoryTitles, CityTitles} from '../types';
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE = 6;
|
||||||
|
|
||||||
export function FeaturedSection() {
|
export function FeaturedSection() {
|
||||||
// const location = useLocation();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
const city = searchParams.get('city');
|
const city = searchParams.get('city');
|
||||||
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalArticles, setTotalArticles] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Загрузка статей
|
// Загрузка статей
|
||||||
@ -27,9 +29,10 @@ export function FeaturedSection() {
|
|||||||
cityId: city || undefined,
|
cityId: city || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('Загруженные данные:', response.data);
|
//console.log('Загруженные данные:', response.data);
|
||||||
setArticles(response.data.articles);
|
setArticles(response.data.articles);
|
||||||
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
|
setTotalPages(response.data.totalPages);
|
||||||
|
setTotalArticles(response.data.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Не удалось загрузить статьи');
|
setError('Не удалось загрузить статьи');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -67,8 +70,10 @@ export function FeaturedSection() {
|
|||||||
{city ? `${CityTitles[Number(city)]} ` : ''}
|
{city ? `${CityTitles[Number(city)]} ` : ''}
|
||||||
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600">
|
<p className="font-bold text-gray-600">
|
||||||
Показано {articles.length} статей
|
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
|
||||||
|
-
|
||||||
|
{Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -20,11 +20,14 @@ import {
|
|||||||
ImagePlus
|
ImagePlus
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { CustomImage } from "./CustomImageExtension";
|
||||||
|
import TipTapImageUploadButton from "./TipTapImageUploadButton";
|
||||||
|
|
||||||
|
|
||||||
interface TipTapEditorProps {
|
interface TipTapEditorProps {
|
||||||
initialContent: string;
|
initialContent: string;
|
||||||
onContentChange: (content: string) => void;
|
onContentChange: (content: string) => void;
|
||||||
|
atricleId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -56,9 +59,10 @@ const ResizableImage = Image.extend({
|
|||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorProps) {
|
export function TipTapEditor({ initialContent, onContentChange, atricleId }: TipTapEditorProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/*
|
||||||
const ResizableImage = Image.extend({
|
const ResizableImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@ -85,6 +89,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -99,7 +104,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
|||||||
TextAlign.configure({
|
TextAlign.configure({
|
||||||
types: ['heading', 'paragraph'],
|
types: ['heading', 'paragraph'],
|
||||||
}),
|
}),
|
||||||
ResizableImage,
|
CustomImage,
|
||||||
Highlight,
|
Highlight,
|
||||||
],
|
],
|
||||||
content: initialContent || '',
|
content: initialContent || '',
|
||||||
@ -438,10 +443,11 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
|||||||
editor?.chain().focus().setImage({ src: url }).run();
|
editor?.chain().focus().setImage({ src: url }).run();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Вставить изображение"
|
title="Вставить изображение (старое)"
|
||||||
>
|
>
|
||||||
<ImagePlus size={18} />
|
<ImagePlus size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<TipTapImageUploadButton editor={editor} articleId={atricleId} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
|
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
|
||||||
|
93
src/components/TipTapImageUploadButton.tsx
Normal file
93
src/components/TipTapImageUploadButton.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Image as ImageIcon } from 'lucide-react';
|
||||||
|
import {Editor} from "@tiptap/react";
|
||||||
|
import {imageResolutions} from "../config/imageResolutions.ts";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
|
||||||
|
interface TipTapImageUploadButtonProps {
|
||||||
|
editor: Editor; // Replace with your TipTap editor type
|
||||||
|
articleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ editor, articleId }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка выбранного изображения
|
||||||
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверка разрешения изображения
|
||||||
|
const resolution = imageResolutions.find(r => r.id === 'large');
|
||||||
|
if (!resolution)
|
||||||
|
throw new Error('Invalid resolution');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('resolutionId', resolution.id);
|
||||||
|
formData.append('folder', 'articles/' + articleId);
|
||||||
|
|
||||||
|
// Отправка запроса на сервер
|
||||||
|
const response = await axios.post('/api/images/upload-url', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.fileUrl) {
|
||||||
|
editor.chain().focus().setImage({src: response.data.fileUrl}).run();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const src = e.target?.result as string;
|
||||||
|
// Insert the image with the TipTap editor
|
||||||
|
editor.chain().focus().setImage({src}).run();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to trigger file input click
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
|
title="Вставить изображение"
|
||||||
|
>
|
||||||
|
<ImageIcon size={18} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TipTapImageUploadButton;
|
@ -432,7 +432,7 @@ export function AdminPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
<span className="italic font-bold">Статья</span>
|
<span className="italic font-bold">Статья</span>
|
||||||
</label>
|
</label>
|
||||||
<TipTapEditor initialContent={content} onContentChange={setContent} />
|
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId}/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { FeaturedSection } from '../components/FeaturedSection';
|
import { FeaturedSection } from '../components/FeaturedSection';
|
||||||
|
import { AuthorsSection } from '../components/AuthorsSection';
|
||||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
@ -59,12 +61,18 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative bg-white/95">
|
||||||
<div id="featured">
|
<div id="featured">
|
||||||
<FeaturedSection />
|
<FeaturedSection />
|
||||||
</div>
|
</div>
|
||||||
|
<AuthorsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -11,21 +11,24 @@ const ARTICLES_PER_PAGE = 9;
|
|||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
|
const authorId = searchParams.get('author');
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchResults = async () => {
|
const fetchResults = async () => {
|
||||||
if (!query) return;
|
if (!query && !authorId) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/articles/search', {
|
const response = await api.get('/articles/search', {
|
||||||
params: {
|
params: {
|
||||||
q: query,
|
q: query,
|
||||||
|
author: authorId,
|
||||||
page,
|
page,
|
||||||
limit: ARTICLES_PER_PAGE
|
limit: ARTICLES_PER_PAGE
|
||||||
}
|
}
|
||||||
@ -53,7 +56,7 @@ export function SearchPage() {
|
|||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
{query ? `Результаты поиска "${query}"` : 'Search Articles'}
|
{query ? `Результаты поиска "${query}"` : 'Статьи автора'}
|
||||||
</h1>
|
</h1>
|
||||||
{articles.length > 0 && (
|
{articles.length > 0 && (
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
|
@ -7,7 +7,7 @@ import { ImagePlus, X, UserPlus, Pencil } from 'lucide-react';
|
|||||||
import { imageResolutions } from '../config/imageResolutions';
|
import { imageResolutions } from '../config/imageResolutions';
|
||||||
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
|
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useAuthStore} from "../stores/authStore.ts";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
|
||||||
const initialFormData: UserFormData = {
|
const initialFormData: UserFormData = {
|
||||||
@ -15,6 +15,7 @@ const initialFormData: UserFormData = {
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
|
bio: '',
|
||||||
avatarUrl: ''
|
avatarUrl: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -152,6 +153,7 @@ export function UserManagementPage() {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
password: '',
|
password: '',
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
bio: user.bio,
|
||||||
avatarUrl: user.avatarUrl
|
avatarUrl: user.avatarUrl
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
@ -328,7 +330,7 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={''}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||||
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
|
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||||
@ -336,6 +338,18 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Биографические данные
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -13,13 +13,24 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
bio: string;
|
||||||
permissions: UserPermissions;
|
permissions: UserPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Author {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
bio: string;
|
||||||
|
articlesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserFormData {
|
export interface UserFormData {
|
||||||
id: string
|
id: string
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
bio: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user