Добавлена реакция на статьи (like/dislike) и URL вынесены в .env
This commit is contained in:
parent
7afbcf27f8
commit
7fb5daf210
4
.env
4
.env
@ -1,3 +1 @@
|
||||
DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
|
||||
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
|
||||
PORT=5000
|
||||
VITE_API_URL="http://localhost:5000"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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 && (
|
||||
|
@ -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' }
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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 { 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}
|
||||
|
@ -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}
|
||||
|
@ -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[];
|
||||
}
|
||||
};
|
@ -1,22 +1,18 @@
|
||||
import axios from '../utils/api';
|
||||
import { ImageResolution, UploadedImage } from '../types/image';
|
||||
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'
|
||||
};
|
||||
}
|
@ -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 });
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -11,7 +11,8 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
|
@ -10,7 +10,8 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user