Добавлен раздел Наши авторы, поиск по статьям авторов. Добавлено описание bio для автора.

This commit is contained in:
anibilag 2025-03-06 16:21:22 +03:00
parent 00376c124f
commit 7afbcf27f8
12 changed files with 382 additions and 16 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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