Добавлен раздел Наши авторы, поиск по статьям авторов. Добавлено описание 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);
|
||||
},
|
||||
});
|
@ -2,18 +2,20 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ArticleCard } from './ArticleCard';
|
||||
import { Pagination } from './Pagination';
|
||||
import {Article, CategoryTitles, CityTitles} from '../types';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import axios from "axios";
|
||||
|
||||
|
||||
const ARTICLES_PER_PAGE = 6;
|
||||
|
||||
export function FeaturedSection() {
|
||||
// const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const category = searchParams.get('category');
|
||||
const city = searchParams.get('city');
|
||||
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalArticles, setTotalArticles] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка статей
|
||||
@ -27,9 +29,10 @@ export function FeaturedSection() {
|
||||
cityId: city || undefined,
|
||||
},
|
||||
});
|
||||
console.log('Загруженные данные:', response.data);
|
||||
//console.log('Загруженные данные:', response.data);
|
||||
setArticles(response.data.articles);
|
||||
setTotalPages(response.data.totalPages); // Берём totalPages из ответа
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalArticles(response.data.total);
|
||||
} catch (error) {
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error(error);
|
||||
@ -67,8 +70,10 @@ export function FeaturedSection() {
|
||||
{city ? `${CityTitles[Number(city)]} ` : ''}
|
||||
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Показано {articles.length} статей
|
||||
<p className="font-bold text-gray-600">
|
||||
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
|
||||
-
|
||||
{Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
@ -20,11 +20,14 @@ import {
|
||||
ImagePlus
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { CustomImage } from "./CustomImageExtension";
|
||||
import TipTapImageUploadButton from "./TipTapImageUploadButton";
|
||||
|
||||
|
||||
interface TipTapEditorProps {
|
||||
initialContent: string;
|
||||
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 ResizableImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@ -85,6 +89,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
||||
};
|
||||
},
|
||||
});
|
||||
*/
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@ -99,7 +104,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
ResizableImage,
|
||||
CustomImage,
|
||||
Highlight,
|
||||
],
|
||||
content: initialContent || '',
|
||||
@ -438,10 +443,11 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
||||
editor?.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}}
|
||||
title="Вставить изображение"
|
||||
title="Вставить изображение (старое)"
|
||||
>
|
||||
<ImagePlus size={18} />
|
||||
</button>
|
||||
<TipTapImageUploadButton editor={editor} articleId={atricleId} />
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<span className="italic font-bold">Статья</span>
|
||||
</label>
|
||||
<TipTapEditor initialContent={content} onContentChange={setContent} />
|
||||
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId}/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { FeaturedSection } from '../components/FeaturedSection';
|
||||
import { AuthorsSection } from '../components/AuthorsSection';
|
||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||
|
||||
@ -30,6 +31,7 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}>
|
||||
<Header />
|
||||
<main>
|
||||
<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 id="featured">
|
||||
<FeaturedSection />
|
||||
|
||||
<div className="relative bg-white/95">
|
||||
<div id="featured">
|
||||
<FeaturedSection />
|
||||
</div>
|
||||
<AuthorsSection />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -11,21 +11,24 @@ const ARTICLES_PER_PAGE = 9;
|
||||
export function SearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const authorId = searchParams.get('author');
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResults = async () => {
|
||||
if (!query) return;
|
||||
if (!query && !authorId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/articles/search', {
|
||||
params: {
|
||||
q: query,
|
||||
author: authorId,
|
||||
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">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{query ? `Результаты поиска "${query}"` : 'Search Articles'}
|
||||
{query ? `Результаты поиска "${query}"` : 'Статьи автора'}
|
||||
</h1>
|
||||
{articles.length > 0 && (
|
||||
<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 { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
|
||||
import axios from "axios";
|
||||
import {useAuthStore} from "../stores/authStore.ts";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
|
||||
|
||||
const initialFormData: UserFormData = {
|
||||
@ -15,6 +15,7 @@ const initialFormData: UserFormData = {
|
||||
email: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
avatarUrl: ''
|
||||
};
|
||||
|
||||
@ -152,6 +153,7 @@ export function UserManagementPage() {
|
||||
email: user.email,
|
||||
password: '',
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl
|
||||
});
|
||||
setShowEditModal(true);
|
||||
@ -328,7 +330,7 @@ export function UserManagementPage() {
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
value={''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
|
||||
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>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -13,13 +13,24 @@ export interface User {
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bio: string;
|
||||
permissions: UserPermissions;
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bio: string;
|
||||
articlesCount: number;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
id: string
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user