Добавлена реакция на статьи (like/dislike) и URL вынесены в .env

This commit is contained in:
anibilag 2025-03-11 12:47:09 +03:00
parent 7afbcf27f8
commit 7fb5daf210
19 changed files with 192 additions and 360 deletions

4
.env
View File

@ -1,3 +1 @@
DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
PORT=5000
VITE_API_URL="http://localhost:5000"

View File

@ -1,220 +0,0 @@
import { useState } from 'react';
import { X, Plus, Move, Pencil, Trash2 } from 'lucide-react';
import { GalleryImage } from '../types';
interface GalleryManagerProps {
images: GalleryImage[];
onChange: (images: GalleryImage[]) => void;
}
export function GalleryManager({ images, onChange }: GalleryManagerProps) {
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
const [newImageUrl, setNewImageUrl] = useState('');
const [newImageCaption, setNewImageCaption] = useState('');
const [newImageAlt, setNewImageAlt] = useState('');
const handleAddImage = () => {
if (!newImageUrl.trim()) return;
const newImage: GalleryImage = {
id: Date.now().toString(),
url: newImageUrl,
caption: newImageCaption,
alt: newImageAlt || newImageCaption
};
onChange([...images, newImage]);
setNewImageUrl('');
setNewImageCaption('');
setNewImageAlt('');
};
const handleUpdateImage = (updatedImage: GalleryImage) => {
onChange(images.map(img => img.id === updatedImage.id ? updatedImage : img));
setEditingImage(null);
};
const handleRemoveImage = (id: string) => {
onChange(images.filter(img => img.id !== id));
};
const handleReorder = (dragIndex: number, dropIndex: number) => {
const reorderedImages = [...images];
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
reorderedImages.splice(dropIndex, 0, draggedImage);
onChange(reorderedImages);
};
return (
<div className="space-y-6">
{/* Add New Image */}
<div className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium text-gray-900">Добавить новое фото</h3>
<div className="space-y-3">
<div>
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">URL изображения</span>
</label>
<input
type="url"
id="imageUrl"
value={newImageUrl}
onChange={(e) => setNewImageUrl(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"
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="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="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
onClick={handleAddImage}
disabled={!newImageUrl.trim()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} className="mr-2" />
Добавить фото
</button>
</div>
</div>
{/* Gallery Preview */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Состав галереи</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((image, index) => (
<div
key={image.id}
className="relative group border rounded-lg overflow-hidden"
draggable
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
handleReorder(dragIndex, index);
}}
>
<img
src={image.url}
alt={image.alt}
className="w-full h-48 object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity duration-200">
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="flex space-x-2">
<button
onClick={() => setEditingImage(image)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleRemoveImage(image.id)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
>
<Trash2 size={16} />
</button>
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
<Move size={16} />
</button>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
{image.caption}
</div>
</div>
))}
</div>
</div>
{/* Edit Image Modal */}
{editingImage && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<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">Edit Image</h3>
<button
onClick={() => setEditingImage(null)}
className="text-gray-400 hover:text-gray-500"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Image URL
</label>
<input
type="url"
value={editingImage.url}
onChange={(e) => setEditingImage({ ...editingImage, url: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Caption
</label>
<input
type="text"
value={editingImage.caption}
onChange={(e) => setEditingImage({ ...editingImage, caption: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Alt Text
</label>
<input
type="text"
value={editingImage.alt}
onChange={(e) => setEditingImage({ ...editingImage, alt: e.target.value })}
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">
<button
onClick={() => setEditingImage(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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
onClick={() => handleUpdateImage(editingImage)}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Save Changes
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -16,7 +16,7 @@ export function EditImageModal({ image, onClose, onSave }: EditImageModalProps)
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<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">Edit Image</h3>
<h3 className="text-lg font-medium text-gray-900">Редактирование изображения</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500"

View File

@ -25,39 +25,39 @@ export function ImageForm({
<div className="space-y-3">
<div>
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
Image URL
<span className="italic font-bold">URL изображения</span>
</label>
<input
type="url"
id="imageUrl"
value={url}
onChange={(e) => onUrlChange(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">
Caption
<span className="italic font-bold">Заголовок</span>
</label>
<input
type="text"
id="imageCaption"
value={caption}
onChange={(e) => onCaptionChange(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">
Alt Text
<span className="italic font-bold">Текст при наведении</span>
</label>
<input
type="text"
id="imageAlt"
value={alt}
onChange={(e) => onAltChange(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

@ -6,16 +6,19 @@ import { EditImageModal } from './EditImageModal';
interface GalleryManagerProps {
images: GalleryImage[];
imageUrl: string;
onChange: (images: GalleryImage[]) => void;
}
export function GalleryManager({ images, onChange }: GalleryManagerProps) {
export function GalleryManager({ images, imageUrl, onChange }: GalleryManagerProps) {
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
const [newImageUrl, setNewImageUrl] = useState('');
const [newImageCaption, setNewImageCaption] = useState('');
const [newImageAlt, setNewImageAlt] = useState('');
const handleAddImage = () => {
setNewImageUrl(imageUrl);
if (!newImageUrl.trim()) return;
const newImage: GalleryImage = {
@ -51,7 +54,7 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
<div className="space-y-6">
{/* Add New Image */}
<div className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium text-gray-900">Add New Image</h3>
<h3 className="text-lg font-medium text-gray-900">Добавить фото</h3>
<ImageForm
url={newImageUrl}
caption={newImageCaption}
@ -60,13 +63,13 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
onCaptionChange={setNewImageCaption}
onAltChange={setNewImageAlt}
onSubmit={handleAddImage}
submitLabel="Add Image"
submitLabel="Добавить в галерею"
/>
</div>
{/* Gallery Preview */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Gallery Images</h3>
<h3 className="text-lg font-medium text-gray-900 mb-4">Изображения галереи</h3>
<GalleryGrid
images={images}
onEdit={setEditingImage}

View File

@ -61,14 +61,14 @@ export function CoverImageUpload({ coverImage, articleId, onImageUpload, onError
return (
<div>
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700">
Изображение
<span className="italic font-bold">Обложка</span>
</label>
<div className="mt-1 space-y-4">
{coverImage && (
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
<img
src={coverImage}
alt="Cover preview"
alt="Превью обложки"
className="w-full h-full object-cover"
/>
{allowUpload && (

View File

@ -3,14 +3,15 @@ import { ImageDropzone } from './ImageDropzone';
import { ResolutionSelect } from './ResolutionSelect';
import { UploadProgress } from './UploadProgress';
import { imageResolutions } from '../../config/imageResolutions';
import { uploadImage } from '../../services/imageService';
import { uploadImageToS3 } from '../../services/imageService';
import { ImageUploadProgress } from '../../types/image';
interface ImageUploaderProps {
onUploadComplete: (imageUrl: string) => void;
articleId: string;
}
export function ImageUploader({ onUploadComplete }: ImageUploaderProps) {
export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProps) {
const [selectedResolution, setSelectedResolution] = useState('medium');
const [uploadProgress, setUploadProgress] = useState<Record<string, ImageUploadProgress>>({});
@ -25,7 +26,7 @@ export function ImageUploader({ onUploadComplete }: ImageUploaderProps) {
const resolution = imageResolutions.find(r => r.id === selectedResolution);
if (!resolution) throw new Error('Invalid resolution selected');
const uploadedImage = await uploadImage(file, resolution, (progress) => {
const uploadedImage = await uploadImageToS3(file, resolution, articleId, (progress) => {
setUploadProgress(prev => ({
...prev,
[file.name]: { progress, status: 'uploading' }

View File

@ -85,7 +85,7 @@ export function PhotoGallery({ images }: PhotoGalleryProps) {
<div className="text-center mt-4">
<p className="text-white text-lg">{images[currentIndex].caption}</p>
<p className="text-gray-400 text-sm mt-2">
Image {currentIndex + 1} of {images.length}
Фото {currentIndex + 1} из {images.length}
</p>
</div>
</div>

View File

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { GalleryImage } from '../types';
import { galleryService } from '../services/galleryService';
import { uploadImage } from '../services/imageService';
import { ImageResolution } from '../types/image';
export function useGallery(articleId: string) {
const [images, setImages] = useState<GalleryImage[]>([]);
@ -10,6 +9,7 @@ export function useGallery(articleId: string) {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!articleId) return; // Если articleId пустой, не загружаем галерею
loadGallery();
}, [articleId]);
@ -19,30 +19,29 @@ export function useGallery(articleId: string) {
const galleryImages = await galleryService.getArticleGallery(articleId);
setImages(galleryImages);
setError(null);
} catch (err) {
setError('Failed to load gallery images');
console.error('Error loading gallery:', err);
} catch (error) {
setError('Ошибка загрузки фото галереи');
console.error('Ошибка загрузки фото галереи:', error);
} finally {
setLoading(false);
}
};
const addImage = async (file: File, resolution: ImageResolution) => {
const addImage = async (imageData: {
url: string;
caption: string;
alt: string;
width: number;
height: number;
size: number;
format: string;
}) => {
try {
const uploadedImage = await uploadImage(file, resolution);
const galleryImage = await galleryService.createImage(articleId, {
url: uploadedImage.url,
caption: '',
alt: file.name,
width: uploadedImage.width,
height: uploadedImage.height,
size: uploadedImage.size,
format: uploadedImage.format
});
const galleryImage = await galleryService.createImage(articleId, imageData);
setImages([...images, galleryImage]);
return galleryImage;
} catch (err) {
console.error('Error adding image:', err);
console.error('Ошибка добавления изображения:', err);
throw err;
}
};

View File

@ -5,8 +5,9 @@ import { Header } from '../components/Header';
import { GalleryManager } from '../components/GalleryManager';
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
import { useGallery } from '../hooks/useGallery';
import MinutesWord from '../components/MinutesWord';
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author } from '../types';
import { CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
@ -16,6 +17,7 @@ const allCityIds: number[] = CityIds;
// Обложка по умоланию для новых статей
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
export function AdminPage() {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
@ -27,7 +29,6 @@ export function AdminPage() {
const [cityId, setCityId] = useState(1);
const [coverImage, setCoverImage] = useState(DEFAULT_COVER_IMAGE);
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);
@ -42,6 +43,19 @@ export function AdminPage() {
const [showDraftOnly, setShowDraftOnly] = useState(false);
const [authors, setAuthors] = useState<Author[]>([]);
const [authorId, setAuthorId] = useState<string>('');
const [newImageUrl, setNewImageUrl] = useState('');
// Инициализация хука useGallery с текущим значением articleId (editingId)
const {
images: galleryImages,
loading: galleryLoading,
error: galleryError,
// addImage: addGalleryImage,
// updateImage: updateGalleryImage,
// deleteImage: deleteGalleryImage,
reorderImages: reorderGalleryImages,
// refresh: refreshGallery
} = useGallery(editingId || '');
// Загрузка списка авторов
useEffect(() => {
@ -134,7 +148,6 @@ export function AdminPage() {
setCoverImage(article.coverImage);
setReadTime(article.readTime);
setAuthorId(article.author.id);
setGallery(article.gallery || []);
setContent(article.content);
setEditingId(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
@ -190,7 +203,6 @@ export function AdminPage() {
// Создание новой статьи, сохранение существующей
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
//const selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
let selectedAuthor;
@ -214,7 +226,7 @@ export function AdminPage() {
cityId,
coverImage,
readTime,
gallery,
gallery: galleryImages,
content: content || '',
importId: 0,
isActive: false,
@ -260,21 +272,18 @@ export function AdminPage() {
setArticleId('');
setTitle('');
setExcerpt('');
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
/*
setCategoryId(1);
setCityId(1);
*/
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]);
setContent(''); // Очищаем содержимое редактора
setContent('');
setEditingId(null);
};
@ -282,15 +291,33 @@ export function AdminPage() {
// Проверка прав пользователя
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
const handleGalleryImageUpload = (imageUrl: string) => {
const newImage: GalleryImage = {
id: Date.now().toString(),
/*
const handleGalleryImageUpload = async (imageUrl: string) => {
try {
await addGalleryImage({
url: imageUrl,
caption: '',
alt: ''
};
setGallery(prev => [...prev, newImage]);
alt: '',
width: 0,
height: 0,
size: 0,
format: 'webp'
});
setShowGalleryUploader(false);
} catch {
setError('Failed to add gallery image');
}
};
*/
// Show loading state while gallery is loading
if (editingId && galleryLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
@ -312,9 +339,9 @@ export function AdminPage() {
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
</h1>
{error && isAdmin && (
{(error || galleryError) && isAdmin && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
{error}
{error || galleryError}
</div>
)}
@ -451,8 +478,12 @@ export function AdminPage() {
)}
</div>
<GalleryManager
images={gallery}
onChange={setGallery}
images={galleryImages}
imageUrl={newImageUrl}
onChange={(images) => {
// Обработка реорганизации галереи через хук
reorderGalleryImages(images.map(img => img.id));
}}
/>
</div>
@ -470,12 +501,9 @@ export function AdminPage() {
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
//setCategoryId(1);
//setCityId(1);
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]);
setContent('');
}}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@ -510,9 +538,11 @@ export function AdminPage() {
</button>
</div>
<ImageUploader onUploadComplete={(imageUrl) => {
handleGalleryImageUpload(imageUrl);
setNewImageUrl(imageUrl);
setShowGalleryUploader(false);
}} />
}}
articleId={articleId}
/>
</div>
</div>
)}

View File

@ -4,22 +4,16 @@ 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, CategoryTitles } from '../types';
import { ArticleContent } from '../components/ArticleContent';
import MinutesWord from '../components/MinutesWord';
import axios from "axios";
import api from "../utils/api";
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)
);
*/
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchArticle = async () => {
@ -27,7 +21,7 @@ export function ArticlePage() {
const response = await axios.get(`/api/articles/${id}`);
setArticleData(response.data);
} catch (error) {
//setError('Не удалось загрузить статью');
setError('Не удалось загрузить статью');
console.error(error);
}
};
@ -43,7 +37,7 @@ export function ArticlePage() {
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">Article not found</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Статья не найдена</h2>
<Link to="/" className="text-blue-600 hover:text-blue-800">
Назад на главную
</Link>
@ -52,7 +46,15 @@ export function ArticlePage() {
);
}
const handleReaction = (reaction: 'like' | 'dislike') => {
const handleReaction = async (reaction: 'like' | 'dislike') => {
if (!articleData || !id) return;
try {
const { data: updatedArticle } = await api.put(`/articles/react/${id}`, {
reaction: articleData.userReaction === reaction ? null : reaction,
likes: articleData.likes,
dislikes: articleData.dislikes,
});
setArticleData(prev => {
if (!prev) return prev;
@ -62,8 +64,8 @@ export function ArticlePage() {
if (prev.userReaction === 'dislike') newArticle.dislikes--;
if (prev.userReaction !== reaction) {
if (reaction === 'like') newArticle.likes++;
if (reaction === 'dislike') newArticle.dislikes++;
if (reaction === 'like') newArticle.likes = updatedArticle.likes;
if (reaction === 'dislike') newArticle.dislikes = updatedArticle.dislikes;
newArticle.userReaction = reaction;
} else {
newArticle.userReaction = null;
@ -71,6 +73,10 @@ export function ArticlePage() {
return newArticle;
});
} catch (error) {
setError('Ошибка оценки статьи. Попробуйте еще раз.');
console.error('Ошибка оценки статьи:', error);
}
};
return (
@ -149,6 +155,11 @@ export function ArticlePage() {
{/* Article Footer */}
<div className="border-t pt-8">
{error && (
<div className="mb-4 text-sm text-red-600">
{error}
</div>
)}
<ReactionButtons
likes={articleData.likes}
dislikes={articleData.dislikes}

View File

@ -8,6 +8,7 @@ import api from '../utils/api';
const ARTICLES_PER_PAGE = 9;
export function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
@ -18,7 +19,6 @@ export function SearchPage() {
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchResults = async () => {
if (!query && !authorId) return;
@ -36,14 +36,16 @@ export function SearchPage() {
setArticles(response.data.articles);
setTotalPages(response.data.totalPages);
} catch (error) {
console.error('Search failed:', error);
console.error('Ошибка поиска:', error);
} finally {
setLoading(false);
}
};
window.scrollTo({ top: 0, behavior: 'smooth' });
fetchResults();
}, [query, page]);
}, [query, authorId, page]);
const handlePageChange = (newPage: number) => {
setSearchParams({ q: query, page: newPage.toString() });
@ -84,13 +86,13 @@ export function SearchPage() {
/>
)}
</>
) : query ? (
) : (query || authorId) ? (
<div className="text-center py-12">
<h2 className="text-xl font-medium text-gray-900 mb-2">
Не найдено ни одной статьи
</h2>
<p className="text-gray-500">
Попытайтесь изменить сроку поиска или browse our categories instead
{authorId ? "Этот автор не опубликовал пока ни одной статьи" : "Ничего не найдено. Попытайтесь изменить сроку поиска"}
</p>
</div>
) : null}

View File

@ -11,12 +11,12 @@ export const galleryService = {
size: number;
format: string
}) => {
const { data } = await axios.post(`/api/gallery/article/${articleId}`, imageData);
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
return data;
},
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
const { data } = await axios.put(`/api/gallery/${id}`, updates);
const { data } = await axios.put(`/gallery/${id}`, updates);
return data;
},
@ -25,11 +25,11 @@ export const galleryService = {
},
reorderImages: async (articleId: string, imageIds: string[]) => {
await axios.post(`/api/gallery/article/${articleId}/reorder`, { imageIds });
await axios.post(`/gallery/article/${articleId}/reorder`, { imageIds });
},
getArticleGallery: async (articleId: string) => {
const { data } = await axios.get(`/api/gallery/article/${articleId}`);
const { data } = await axios.get(`/gallery/article/${articleId}`);
return data as GalleryImage[];
}
};

View File

@ -1,22 +1,18 @@
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
});
export async function uploadImageToS3(file: File, resolution: ImageResolution, articleId: string, onProgress?: (progress: number) => void): Promise<UploadedImage> {
// Upload to S3
await axios.put(uploadUrl, file, {
const formData = new FormData();
formData.append('file', file);
formData.append('resolutionId', resolution.id);
formData.append('folder', 'articles/' + articleId + '/gallery');
// Загрузка файла изображения в хранилище S3
const response = await axios.post('/images/upload-url', formData, {
headers: {
'Content-Type': file.type
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
@ -26,7 +22,12 @@ export async function uploadImage(
}
});
// Get the processed image details
const { data: image } = await axios.get(`/images/${imageId}`);
return image;
return {
id: '',
url: response.data?.fileUrl,
width: resolution.width,
height: resolution.height,
size: 102400,
format: 'webp'
};
}

View File

@ -27,15 +27,13 @@ export const useAuthStore = create<AuthState>((set) => ({
return;
}
const response = await axios.post('http://localhost:5000/api/auth/login', {
const response = await axios.post('/api/auth/login', {
email,
password
});
const { user, token } = response.data;
localStorage.setItem('token', token);
set({ user });
} catch (error) {
throw error;
} finally {
set({ loading: false });
}

View File

@ -1,7 +1,10 @@
import axios from 'axios';
// Берем baseURL из .env
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
const api = axios.create({
baseURL: 'http://localhost:5000/api'
baseURL: `${API_URL}/api`
});
api.interceptors.request.use((config) => {

View File

@ -11,7 +11,8 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
"noEmit": false,
"jsx": "react-jsx",
/* Linting */

View File

@ -10,7 +10,8 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
"noEmit": false,
/* Linting */
"strict": true,

View File

@ -1,7 +1,10 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [react()],
optimizeDeps: {
exclude: ['@prisma/client', 'lucide-react'],
@ -10,9 +13,10 @@ export default defineConfig({
host: true,
proxy: {
'/api': {
target: 'http://localhost:5000',
target: env.VITE_API_URL || 'http://localhost:5000',
changeOrigin: true,
},
},
},
};
});