Добавлена реакция на статьи (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"
|
VITE_API_URL="http://localhost:5000"
|
||||||
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
|
|
||||||
PORT=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="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="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-500"
|
className="text-gray-400 hover:text-gray-500"
|
||||||
|
@ -25,39 +25,39 @@ export function ImageForm({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
|
||||||
Image URL
|
<span className="italic font-bold">URL изображения</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
id="imageUrl"
|
id="imageUrl"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => onUrlChange(e.target.value)}
|
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"
|
placeholder="https://example.com/image.jpg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
|
||||||
Caption
|
<span className="italic font-bold">Заголовок</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="imageCaption"
|
id="imageCaption"
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => onCaptionChange(e.target.value)}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
|
||||||
Alt Text
|
<span className="italic font-bold">Текст при наведении</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="imageAlt"
|
id="imageAlt"
|
||||||
value={alt}
|
value={alt}
|
||||||
onChange={(e) => onAltChange(e.target.value)}
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
@ -6,16 +6,19 @@ import { EditImageModal } from './EditImageModal';
|
|||||||
|
|
||||||
interface GalleryManagerProps {
|
interface GalleryManagerProps {
|
||||||
images: GalleryImage[];
|
images: GalleryImage[];
|
||||||
|
imageUrl: string;
|
||||||
onChange: (images: GalleryImage[]) => void;
|
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 [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
|
||||||
const [newImageUrl, setNewImageUrl] = useState('');
|
const [newImageUrl, setNewImageUrl] = useState('');
|
||||||
const [newImageCaption, setNewImageCaption] = useState('');
|
const [newImageCaption, setNewImageCaption] = useState('');
|
||||||
const [newImageAlt, setNewImageAlt] = useState('');
|
const [newImageAlt, setNewImageAlt] = useState('');
|
||||||
|
|
||||||
const handleAddImage = () => {
|
const handleAddImage = () => {
|
||||||
|
setNewImageUrl(imageUrl);
|
||||||
|
|
||||||
if (!newImageUrl.trim()) return;
|
if (!newImageUrl.trim()) return;
|
||||||
|
|
||||||
const newImage: GalleryImage = {
|
const newImage: GalleryImage = {
|
||||||
@ -51,7 +54,7 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Add New Image */}
|
{/* Add New Image */}
|
||||||
<div className="border rounded-lg p-4 space-y-4">
|
<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
|
<ImageForm
|
||||||
url={newImageUrl}
|
url={newImageUrl}
|
||||||
caption={newImageCaption}
|
caption={newImageCaption}
|
||||||
@ -60,13 +63,13 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
|
|||||||
onCaptionChange={setNewImageCaption}
|
onCaptionChange={setNewImageCaption}
|
||||||
onAltChange={setNewImageAlt}
|
onAltChange={setNewImageAlt}
|
||||||
onSubmit={handleAddImage}
|
onSubmit={handleAddImage}
|
||||||
submitLabel="Add Image"
|
submitLabel="Добавить в галерею"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gallery Preview */}
|
{/* Gallery Preview */}
|
||||||
<div className="border rounded-lg p-4">
|
<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
|
<GalleryGrid
|
||||||
images={images}
|
images={images}
|
||||||
onEdit={setEditingImage}
|
onEdit={setEditingImage}
|
||||||
|
@ -61,14 +61,14 @@ export function CoverImageUpload({ coverImage, articleId, onImageUpload, onError
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700">
|
||||||
Изображение
|
<span className="italic font-bold">Обложка</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 space-y-4">
|
<div className="mt-1 space-y-4">
|
||||||
{coverImage && (
|
{coverImage && (
|
||||||
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
|
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
|
||||||
<img
|
<img
|
||||||
src={coverImage}
|
src={coverImage}
|
||||||
alt="Cover preview"
|
alt="Превью обложки"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
{allowUpload && (
|
{allowUpload && (
|
||||||
|
@ -3,14 +3,15 @@ import { ImageDropzone } from './ImageDropzone';
|
|||||||
import { ResolutionSelect } from './ResolutionSelect';
|
import { ResolutionSelect } from './ResolutionSelect';
|
||||||
import { UploadProgress } from './UploadProgress';
|
import { UploadProgress } from './UploadProgress';
|
||||||
import { imageResolutions } from '../../config/imageResolutions';
|
import { imageResolutions } from '../../config/imageResolutions';
|
||||||
import { uploadImage } from '../../services/imageService';
|
import { uploadImageToS3 } from '../../services/imageService';
|
||||||
import { ImageUploadProgress } from '../../types/image';
|
import { ImageUploadProgress } from '../../types/image';
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
onUploadComplete: (imageUrl: string) => void;
|
onUploadComplete: (imageUrl: string) => void;
|
||||||
|
articleId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageUploader({ onUploadComplete }: ImageUploaderProps) {
|
export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProps) {
|
||||||
const [selectedResolution, setSelectedResolution] = useState('medium');
|
const [selectedResolution, setSelectedResolution] = useState('medium');
|
||||||
const [uploadProgress, setUploadProgress] = useState<Record<string, ImageUploadProgress>>({});
|
const [uploadProgress, setUploadProgress] = useState<Record<string, ImageUploadProgress>>({});
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ export function ImageUploader({ onUploadComplete }: ImageUploaderProps) {
|
|||||||
const resolution = imageResolutions.find(r => r.id === selectedResolution);
|
const resolution = imageResolutions.find(r => r.id === selectedResolution);
|
||||||
if (!resolution) throw new Error('Invalid resolution selected');
|
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 => ({
|
setUploadProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[file.name]: { progress, status: 'uploading' }
|
[file.name]: { progress, status: 'uploading' }
|
||||||
|
@ -85,7 +85,7 @@ export function PhotoGallery({ images }: PhotoGalleryProps) {
|
|||||||
<div className="text-center mt-4">
|
<div className="text-center mt-4">
|
||||||
<p className="text-white text-lg">{images[currentIndex].caption}</p>
|
<p className="text-white text-lg">{images[currentIndex].caption}</p>
|
||||||
<p className="text-gray-400 text-sm mt-2">
|
<p className="text-gray-400 text-sm mt-2">
|
||||||
Image {currentIndex + 1} of {images.length}
|
Фото {currentIndex + 1} из {images.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { GalleryImage } from '../types';
|
import { GalleryImage } from '../types';
|
||||||
import { galleryService } from '../services/galleryService';
|
import { galleryService } from '../services/galleryService';
|
||||||
import { uploadImage } from '../services/imageService';
|
|
||||||
import { ImageResolution } from '../types/image';
|
|
||||||
|
|
||||||
export function useGallery(articleId: string) {
|
export function useGallery(articleId: string) {
|
||||||
const [images, setImages] = useState<GalleryImage[]>([]);
|
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||||
@ -10,6 +9,7 @@ export function useGallery(articleId: string) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!articleId) return; // Если articleId пустой, не загружаем галерею
|
||||||
loadGallery();
|
loadGallery();
|
||||||
}, [articleId]);
|
}, [articleId]);
|
||||||
|
|
||||||
@ -19,30 +19,29 @@ export function useGallery(articleId: string) {
|
|||||||
const galleryImages = await galleryService.getArticleGallery(articleId);
|
const galleryImages = await galleryService.getArticleGallery(articleId);
|
||||||
setImages(galleryImages);
|
setImages(galleryImages);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
setError('Failed to load gallery images');
|
setError('Ошибка загрузки фото галереи');
|
||||||
console.error('Error loading gallery:', err);
|
console.error('Ошибка загрузки фото галереи:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 {
|
try {
|
||||||
const uploadedImage = await uploadImage(file, resolution);
|
const galleryImage = await galleryService.createImage(articleId, imageData);
|
||||||
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
|
|
||||||
});
|
|
||||||
setImages([...images, galleryImage]);
|
setImages([...images, galleryImage]);
|
||||||
return galleryImage;
|
return galleryImage;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding image:', err);
|
console.error('Ошибка добавления изображения:', err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -5,8 +5,9 @@ import { Header } from '../components/Header';
|
|||||||
import { GalleryManager } from '../components/GalleryManager';
|
import { GalleryManager } from '../components/GalleryManager';
|
||||||
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
|
||||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
||||||
|
import { useGallery } from '../hooks/useGallery';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
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 { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ const allCityIds: number[] = CityIds;
|
|||||||
// Обложка по умоланию для новых статей
|
// Обложка по умоланию для новых статей
|
||||||
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
||||||
|
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const isAdmin = user?.permissions.isAdmin || false;
|
const isAdmin = user?.permissions.isAdmin || false;
|
||||||
@ -27,7 +29,6 @@ export function AdminPage() {
|
|||||||
const [cityId, setCityId] = useState(1);
|
const [cityId, setCityId] = useState(1);
|
||||||
const [coverImage, setCoverImage] = useState(DEFAULT_COVER_IMAGE);
|
const [coverImage, setCoverImage] = useState(DEFAULT_COVER_IMAGE);
|
||||||
const [readTime, setReadTime] = useState(5);
|
const [readTime, setReadTime] = useState(5);
|
||||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
|
||||||
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
|
const [showGalleryUploader, setShowGalleryUploader] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||||
@ -42,6 +43,19 @@ export function AdminPage() {
|
|||||||
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
||||||
const [authors, setAuthors] = useState<Author[]>([]);
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
const [authorId, setAuthorId] = useState<string>('');
|
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(() => {
|
useEffect(() => {
|
||||||
@ -134,7 +148,6 @@ export function AdminPage() {
|
|||||||
setCoverImage(article.coverImage);
|
setCoverImage(article.coverImage);
|
||||||
setReadTime(article.readTime);
|
setReadTime(article.readTime);
|
||||||
setAuthorId(article.author.id);
|
setAuthorId(article.author.id);
|
||||||
setGallery(article.gallery || []);
|
|
||||||
setContent(article.content);
|
setContent(article.content);
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
@ -190,7 +203,6 @@ export function AdminPage() {
|
|||||||
// Создание новой статьи, сохранение существующей
|
// Создание новой статьи, сохранение существующей
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
//const selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
|
|
||||||
|
|
||||||
let selectedAuthor;
|
let selectedAuthor;
|
||||||
|
|
||||||
@ -214,7 +226,7 @@ export function AdminPage() {
|
|||||||
cityId,
|
cityId,
|
||||||
coverImage,
|
coverImage,
|
||||||
readTime,
|
readTime,
|
||||||
gallery,
|
gallery: galleryImages,
|
||||||
content: content || '',
|
content: content || '',
|
||||||
importId: 0,
|
importId: 0,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
@ -260,21 +272,18 @@ export function AdminPage() {
|
|||||||
setArticleId('');
|
setArticleId('');
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setExcerpt('');
|
setExcerpt('');
|
||||||
|
|
||||||
if (availableCategoryIds.length > 0) {
|
if (availableCategoryIds.length > 0) {
|
||||||
setCategoryId(availableCategoryIds[0]);
|
setCategoryId(availableCategoryIds[0]);
|
||||||
}
|
}
|
||||||
if (availableCityIds.length > 0) {
|
if (availableCityIds.length > 0) {
|
||||||
setCityId(availableCityIds[0]);
|
setCityId(availableCityIds[0]);
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
setCategoryId(1);
|
|
||||||
setCityId(1);
|
|
||||||
*/
|
|
||||||
setCoverImage(DEFAULT_COVER_IMAGE);
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
setAuthorId(authors[0].id || '');
|
setAuthorId(authors[0].id || '');
|
||||||
setGallery([]);
|
setContent('');
|
||||||
setContent(''); // Очищаем содержимое редактора
|
|
||||||
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
};
|
};
|
||||||
@ -282,15 +291,33 @@ export function AdminPage() {
|
|||||||
// Проверка прав пользователя
|
// Проверка прав пользователя
|
||||||
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
||||||
|
|
||||||
const handleGalleryImageUpload = (imageUrl: string) => {
|
/*
|
||||||
const newImage: GalleryImage = {
|
const handleGalleryImageUpload = async (imageUrl: string) => {
|
||||||
id: Date.now().toString(),
|
try {
|
||||||
url: imageUrl,
|
await addGalleryImage({
|
||||||
caption: '',
|
url: imageUrl,
|
||||||
alt: ''
|
caption: '',
|
||||||
};
|
alt: '',
|
||||||
setGallery(prev => [...prev, newImage]);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
@ -312,9 +339,9 @@ export function AdminPage() {
|
|||||||
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
|
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{error && isAdmin && (
|
{(error || galleryError) && isAdmin && (
|
||||||
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
|
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
|
||||||
{error}
|
{error || galleryError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -451,8 +478,12 @@ export function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<GalleryManager
|
<GalleryManager
|
||||||
images={gallery}
|
images={galleryImages}
|
||||||
onChange={setGallery}
|
imageUrl={newImageUrl}
|
||||||
|
onChange={(images) => {
|
||||||
|
// Обработка реорганизации галереи через хук
|
||||||
|
reorderGalleryImages(images.map(img => img.id));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -470,12 +501,9 @@ export function AdminPage() {
|
|||||||
if (availableCityIds.length > 0) {
|
if (availableCityIds.length > 0) {
|
||||||
setCityId(availableCityIds[0]);
|
setCityId(availableCityIds[0]);
|
||||||
}
|
}
|
||||||
//setCategoryId(1);
|
|
||||||
//setCityId(1);
|
|
||||||
setCoverImage(DEFAULT_COVER_IMAGE);
|
setCoverImage(DEFAULT_COVER_IMAGE);
|
||||||
setReadTime(5);
|
setReadTime(5);
|
||||||
setAuthorId(authors[0].id || '');
|
setAuthorId(authors[0].id || '');
|
||||||
setGallery([]);
|
|
||||||
setContent('');
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ImageUploader onUploadComplete={(imageUrl) => {
|
<ImageUploader onUploadComplete={(imageUrl) => {
|
||||||
handleGalleryImageUpload(imageUrl);
|
setNewImageUrl(imageUrl);
|
||||||
setShowGalleryUploader(false);
|
setShowGalleryUploader(false);
|
||||||
}} />
|
}}
|
||||||
|
articleId={articleId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -4,22 +4,16 @@ import { ArrowLeft, Clock, Share2, Bookmark } from 'lucide-react';
|
|||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { ReactionButtons } from '../components/ReactionButtons';
|
import { ReactionButtons } from '../components/ReactionButtons';
|
||||||
import { PhotoGallery } from '../components/PhotoGallery';
|
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 { ArticleContent } from '../components/ArticleContent';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
import MinutesWord from '../components/MinutesWord';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import api from "../utils/api";
|
||||||
|
|
||||||
export function ArticlePage() {
|
export function ArticlePage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [articleData, setArticleData] = useState<Article | null>(null);
|
const [articleData, setArticleData] = useState<Article | null>(null);
|
||||||
// const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
/*
|
|
||||||
const [articleData, setArticleData] = useState<Article | undefined>(
|
|
||||||
articles.find(a => a.id === id)
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchArticle = async () => {
|
const fetchArticle = async () => {
|
||||||
@ -27,7 +21,7 @@ export function ArticlePage() {
|
|||||||
const response = await axios.get(`/api/articles/${id}`);
|
const response = await axios.get(`/api/articles/${id}`);
|
||||||
setArticleData(response.data);
|
setArticleData(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
//setError('Не удалось загрузить статью');
|
setError('Не удалось загрузить статью');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -43,7 +37,7 @@ export function ArticlePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-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 to="/" className="text-blue-600 hover:text-blue-800">
|
||||||
Назад на главную
|
Назад на главную
|
||||||
</Link>
|
</Link>
|
||||||
@ -52,25 +46,37 @@ export function ArticlePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReaction = (reaction: 'like' | 'dislike') => {
|
const handleReaction = async (reaction: 'like' | 'dislike') => {
|
||||||
setArticleData(prev => {
|
if (!articleData || !id) return;
|
||||||
if (!prev) return prev;
|
|
||||||
|
|
||||||
const newArticle = { ...prev };
|
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;
|
||||||
|
|
||||||
if (prev.userReaction === 'like') newArticle.likes--;
|
const newArticle = { ...prev };
|
||||||
if (prev.userReaction === 'dislike') newArticle.dislikes--;
|
|
||||||
|
|
||||||
if (prev.userReaction !== reaction) {
|
if (prev.userReaction === 'like') newArticle.likes--;
|
||||||
if (reaction === 'like') newArticle.likes++;
|
if (prev.userReaction === 'dislike') newArticle.dislikes--;
|
||||||
if (reaction === 'dislike') newArticle.dislikes++;
|
|
||||||
newArticle.userReaction = reaction;
|
|
||||||
} else {
|
|
||||||
newArticle.userReaction = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newArticle;
|
if (prev.userReaction !== reaction) {
|
||||||
});
|
if (reaction === 'like') newArticle.likes = updatedArticle.likes;
|
||||||
|
if (reaction === 'dislike') newArticle.dislikes = updatedArticle.dislikes;
|
||||||
|
newArticle.userReaction = reaction;
|
||||||
|
} else {
|
||||||
|
newArticle.userReaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArticle;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setError('Ошибка оценки статьи. Попробуйте еще раз.');
|
||||||
|
console.error('Ошибка оценки статьи:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -149,6 +155,11 @@ export function ArticlePage() {
|
|||||||
|
|
||||||
{/* Article Footer */}
|
{/* Article Footer */}
|
||||||
<div className="border-t pt-8">
|
<div className="border-t pt-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ReactionButtons
|
<ReactionButtons
|
||||||
likes={articleData.likes}
|
likes={articleData.likes}
|
||||||
dislikes={articleData.dislikes}
|
dislikes={articleData.dislikes}
|
||||||
|
@ -8,6 +8,7 @@ import api from '../utils/api';
|
|||||||
|
|
||||||
const ARTICLES_PER_PAGE = 9;
|
const ARTICLES_PER_PAGE = 9;
|
||||||
|
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
@ -18,7 +19,6 @@ export function SearchPage() {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchResults = async () => {
|
const fetchResults = async () => {
|
||||||
if (!query && !authorId) return;
|
if (!query && !authorId) return;
|
||||||
@ -36,14 +36,16 @@ export function SearchPage() {
|
|||||||
setArticles(response.data.articles);
|
setArticles(response.data.articles);
|
||||||
setTotalPages(response.data.totalPages);
|
setTotalPages(response.data.totalPages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Ошибка поиска:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|
||||||
fetchResults();
|
fetchResults();
|
||||||
}, [query, page]);
|
}, [query, authorId, page]);
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setSearchParams({ q: query, page: newPage.toString() });
|
setSearchParams({ q: query, page: newPage.toString() });
|
||||||
@ -84,13 +86,13 @@ export function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : query ? (
|
) : (query || authorId) ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h2 className="text-xl font-medium text-gray-900 mb-2">
|
<h2 className="text-xl font-medium text-gray-900 mb-2">
|
||||||
Не найдено ни одной статьи
|
Не найдено ни одной статьи
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
Попытайтесь изменить сроку поиска или browse our categories instead
|
{authorId ? "Этот автор не опубликовал пока ни одной статьи" : "Ничего не найдено. Попытайтесь изменить сроку поиска"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -11,12 +11,12 @@ export const galleryService = {
|
|||||||
size: number;
|
size: number;
|
||||||
format: string
|
format: string
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await axios.post(`/api/gallery/article/${articleId}`, imageData);
|
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -25,11 +25,11 @@ export const galleryService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reorderImages: async (articleId: string, imageIds: string[]) => {
|
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) => {
|
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[];
|
return data as GalleryImage[];
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -1,22 +1,18 @@
|
|||||||
import axios from '../utils/api';
|
import axios from '../utils/api';
|
||||||
import { ImageResolution, UploadedImage } from '../types/image';
|
import {ImageResolution, UploadedImage} from '../types/image';
|
||||||
|
|
||||||
export async function uploadImage(
|
export async function uploadImageToS3(file: File, resolution: ImageResolution, articleId: string, onProgress?: (progress: number) => void): Promise<UploadedImage> {
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload to S3
|
const formData = new FormData();
|
||||||
await axios.put(uploadUrl, file, {
|
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: {
|
headers: {
|
||||||
'Content-Type': file.type
|
'Content-Type': 'multipart/form-data',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
|
||||||
},
|
},
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total) {
|
if (progressEvent.total) {
|
||||||
@ -26,7 +22,12 @@ export async function uploadImage(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the processed image details
|
return {
|
||||||
const { data: image } = await axios.get(`/images/${imageId}`);
|
id: '',
|
||||||
return image;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post('http://localhost:5000/api/auth/login', {
|
const response = await axios.post('/api/auth/login', {
|
||||||
email,
|
email,
|
||||||
password
|
password
|
||||||
});
|
});
|
||||||
const { user, token } = response.data;
|
const { user, token } = response.data;
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
set({ user });
|
set({ user });
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Берем baseURL из .env
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: 'http://localhost:5000/api'
|
baseURL: `${API_URL}/api`
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
|
@ -11,7 +11,8 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"composite": true,
|
||||||
|
"noEmit": false,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
|
@ -10,7 +10,8 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"composite": true,
|
||||||
|
"noEmit": false,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd());
|
||||||
optimizeDeps: {
|
|
||||||
exclude: ['@prisma/client', 'lucide-react'],
|
return {
|
||||||
},
|
plugins: [react()],
|
||||||
server: {
|
optimizeDeps: {
|
||||||
host: true,
|
exclude: ['@prisma/client', 'lucide-react'],
|
||||||
proxy: {
|
},
|
||||||
'/api': {
|
server: {
|
||||||
target: 'http://localhost:5000',
|
host: true,
|
||||||
changeOrigin: true,
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.VITE_API_URL || 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
Loading…
x
Reference in New Issue
Block a user