diff --git a/src/components/CreateUpdatePost.jsx b/src/components/CreateUpdatePost.jsx index 44527cf..2e8d950 100644 --- a/src/components/CreateUpdatePost.jsx +++ b/src/components/CreateUpdatePost.jsx @@ -1,5 +1,4 @@ import {useCallback, useEffect, useState} from "react"; -import PinIcon from "./icons/PinIcon"; import CloseIcon from "./icons/CloseIcon"; import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants"; import { usePostMutation } from "../hooks/usePostMutation"; @@ -7,7 +6,6 @@ import { useImageUpload, ACCEPT } from "../hooks/useImageUpload"; import { validatePostForm } from "../utils/validatePostForm"; import { formatPhone } from "../utils/formatPhone"; import { useYandexMap } from "../hooks/useYandexMap"; -import { API_URL } from "../config/authConstants"; const MAX_SIZE_MB = 5; @@ -40,21 +38,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { // Ошибки валидации — ключ совпадает с именем поля const [errors, setErrors] = useState({}); - // URL существующей картинки с сервера (только при редактировании) - const existingImageUrl = isEdit && initialData.images?.[0] - ? `${API_URL}${initialData.images[0]}` - : null; - - // Вся логика работы с фото вынесена в хук const { - imageFile, - displayImage, + images: uploadedImages, imgError, compressing, fileInputRef, handleFileChange, removeImage, - } = useImageUpload(existingImageUrl); + getFilesForUpload, + getExistingUrlsToKeep, + } = useImageUpload(isEdit ? initialData.images : []); // Единый хук для создания и обновления const { save, loading: saving, error: apiError } = usePostMutation(); @@ -106,7 +99,15 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { data.append("category", form.category); data.append("phone", form.phone.trim()); if (form.date) data.append("date", form.date); - if (imageFile) data.append("image", imageFile); + const files = getFilesForUpload(); + files.forEach(f => data.append("images", f)); + + if (isEdit) { + const existing = getExistingUrlsToKeep(); + if (existing.length > 0) { + data.append("existingImages", JSON.stringify(existing)); + } + } if (coords) { data.append("latitude", coords.latitude); @@ -189,41 +190,50 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { className="f-err-msg">{errors.description}

} - {/* Фотография: превью + удаление или загрузчик */} + {/* Фотографии: сетка превью + кнопка добавления */}
- - {displayImage ? ( -
- preview - + + + {uploadedImages.length > 0 && ( +
+ {uploadedImages.map(img => ( +
+ + +
+ ))}
- ) : ( - )} + + + {imgError &&

{imgError}

} -

JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ

+

JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ · можно несколько

{/* Категория */} diff --git a/src/components/MainFeed.jsx b/src/components/MainFeed.jsx index a6ebbd6..2e1a2e7 100644 --- a/src/components/MainFeed.jsx +++ b/src/components/MainFeed.jsx @@ -6,6 +6,7 @@ import Sidebar from "./Sidebar"; import RightSidebar from "./RightSidebar"; import PostOnFeed from "./PostOnFeed"; import CreateUpdatePost from "./CreateUpdatePost"; +import ImageViewer from "./ImageViewer"; import MenuIcon from "./icons/MenuIcon"; import ProfileAvatar from "./profile/ProfileAvatar"; import { ProfileModal } from "./profile"; @@ -44,6 +45,8 @@ export default function MainFeed() { const [authModalOpen, setAuthModalOpen] = useState(false); // Открыто ли модальное окно правил площадки const [rulesModalOpen, setRulesModalOpen] = useState(false); + // Просмотр изображений: null — закрыто, { images, index } — открыто + const [viewerData, setViewerData] = useState(null); // Массив объявлений из API (основная лента) const [ads, setAds] = useState([]); // Объявления текущего пользователя (загружаются отдельно по userId) @@ -193,6 +196,12 @@ export default function MainFeed() { const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); + const handleImageClick = useCallback((images, index) => { + if (images?.length) { + setViewerData({ images, index }); + } + }, []); + /** * Обработчик нажатия «Разместить объявление». * Если пользователь не авторизован — открывает модалку входа. @@ -301,6 +310,7 @@ export default function MainFeed() { onDelete={activeNav === "Мои объявления" ? handleDelete : undefined} onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined} isOwner={activeNav === "Мои объявления"} + onImageClick={handleImageClick} /> )); }; @@ -411,6 +421,15 @@ export default function MainFeed() { {rulesModalOpen && ( setRulesModalOpen(false)} /> )} + + {/* Просмотр изображений */} + {viewerData && ( + setViewerData(null)} + /> + )}
); } diff --git a/src/components/PostOnFeed.jsx b/src/components/PostOnFeed.jsx index 3e4c635..cc61ec0 100644 --- a/src/components/PostOnFeed.jsx +++ b/src/components/PostOnFeed.jsx @@ -9,7 +9,7 @@ import { useChats } from "../hooks/useChats"; import { useAuthContext } from "../context/AuthContext"; import { API_URL } from "../config/authConstants"; -export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner }) { +export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick }) { const navigate = useNavigate(); const { createOrGetChat } = useChats(); const { isAuthenticated } = useAuthContext(); @@ -55,7 +55,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark return (
-
+
{ e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0); }}> {imageUrl ? {ad.title} : {ad.emoji || "📋"} diff --git a/src/hooks/useImageUpload.js b/src/hooks/useImageUpload.js index 6b5cc3d..b17a9f1 100644 --- a/src/hooks/useImageUpload.js +++ b/src/hooks/useImageUpload.js @@ -1,87 +1,106 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { compressImage } from "../utils/compressImage"; +import { API_URL } from "../config/authConstants"; const MAX_SIZE_MB = 5; export const ACCEPT = "image/jpeg,image/png,image/webp"; -/** - * Управляет выбором, сжатием и удалением фотографии в форме объявления. - * - * @param {string|null} existingUrl — URL существующей картинки с сервера - * (передаётся при редактировании) - * @returns {{ - * imageFile: File|null, - * displayImage: string|null, - * imgError: string, - * compressing: boolean, - * fileInputRef: React.RefObject, - * handleFileChange: function, - * removeImage: function, - * }} - */ -export function useImageUpload(existingUrl = null) { - // Сжатый файл, готовый к отправке в FormData - const [imageFile, setImageFile] = useState(null); - // ObjectURL для предпросмотра нового файла - const [preview, setPreview] = useState(null); - // true — пользователь явно удалил фото, existingUrl больше не показываем - const [removed, setRemoved] = useState(false); - const [imgError, setImgError] = useState(""); - // true пока идёт сжатие — нужен чтобы заблокировать сабмит +let idCounter = 0; +const nextId = () => `img-${++idCounter}-${Date.now()}`; + +export function useImageUpload(existingUrls = []) { + const [images, setImages] = useState([]); + const [imgError, setImgError] = useState(""); const [compressing, setCompressing] = useState(false); - // ref на — нужен для сброса значения после removeImage const fileInputRef = useRef(null); - // Что показывать в блоке превью: - // новый preview имеет приоритет, иначе existingUrl (если не удалён) - const displayImage = preview || (!removed ? existingUrl : null); + const existingUrlList = Array.isArray(existingUrls) ? existingUrls : []; - /** Проверяет файл, сжимает его и создаёт превью. */ - const handleFileChange = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; + useEffect(() => { + if (existingUrlList.length > 0) { + setImages(existingUrlList.map((url, i) => ({ + id: `existing-${i}`, + file: null, + preview: `${API_URL}${url}`, + isExisting: true, + serverUrl: url, + }))); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleFileChange = useCallback(async (e) => { + const files = Array.from(e.target.files || []); + if (!files.length) return; setImgError(""); - if (!file.type.startsWith("image/")) { - setImgError("Только изображения (JPEG, PNG, WebP)"); - return; - } - if (file.size > MAX_SIZE_MB * 1024 * 1024) { - setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`); - return; + for (const file of files) { + if (!file.type.startsWith("image/")) { + setImgError("Только изображения (JPEG, PNG, WebP)"); + return; + } + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`); + return; + } } setCompressing(true); try { - const compressed = await compressImage(file); - setImageFile(compressed); - setPreview(URL.createObjectURL(compressed)); - setRemoved(false); + const compressedFiles = await Promise.all(files.map(f => compressImage(f))); + const newImages = compressedFiles.map(file => ({ + id: nextId(), + file, + preview: URL.createObjectURL(file), + isExisting: false, + serverUrl: null, + })); + setImages(prev => [...prev, ...newImages]); } catch { setImgError("Не удалось обработать изображение"); } finally { setCompressing(false); + if (fileInputRef.current) fileInputRef.current.value = ""; } - }; + }, []); - /** Удаляет фото, освобождает ObjectURL и сбрасывает input. */ - const removeImage = () => { - setImageFile(null); - if (preview) URL.revokeObjectURL(preview); // предотвращаем утечку памяти - setPreview(null); - setRemoved(true); - setImgError(""); - // Сброс нужен, иначе повторный выбор того же файла не вызовет onChange - if (fileInputRef.current) fileInputRef.current.value = ""; - }; + const removeImage = useCallback((id) => { + setImages(prev => { + const img = prev.find(i => i.id === id); + if (img && !img.isExisting && img.preview) { + URL.revokeObjectURL(img.preview); + } + return prev.filter(i => i.id !== id); + }); + }, []); + + const getFilesForUpload = useCallback(() => { + return images.filter(i => !i.isExisting).map(i => i.file); + }, [images]); + + const getExistingUrlsToKeep = useCallback(() => { + return images.filter(i => i.isExisting).map(i => i.serverUrl); + }, [images]); + + useEffect(() => { + return () => { + images.forEach(img => { + if (!img.isExisting && img.preview) { + URL.revokeObjectURL(img.preview); + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return { - imageFile, - displayImage, + images, imgError, compressing, fileInputRef, handleFileChange, removeImage, + getFilesForUpload, + getExistingUrlsToKeep, }; } diff --git a/src/styles/modal.css b/src/styles/modal.css index 65e8ecc..97c7b7f 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -76,17 +76,113 @@ .f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); } .f-img-input { display: none; } -/* Image Preview */ -.f-img-preview-wrap { position: relative; display: block; width: 100%; } -.f-img-preview { width: 100%; height: 200px; object-fit: cover; object-position: center; border-radius: 12px; display: block; } -.f-img-remove { - position: absolute; top: 8px; right: 8px; - background: rgba(0,0,0,0.55); border: none; border-radius: 50%; - width: 28px; height: 28px; - display: flex; align-items: center; justify-content: center; - cursor: pointer; color: #fff; padding: 0; transition: background .2s; +/* Multi-image Grid */ +.f-img-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 10px; +} +.f-img-grid-item { + position: relative; + aspect-ratio: 1; + border-radius: 10px; + overflow: hidden; +} +.f-img-grid-preview { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.f-img-grid-item .f-img-remove { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + background: rgba(0,0,0,0.55); + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #fff; + padding: 0; + transition: background .2s; +} +.f-img-grid-item .f-img-remove:hover { background: rgba(0,0,0,0.8); } +.f-img-grid-item .f-img-remove svg { + width: 12px; + height: 12px; +} + +/* ── Image Viewer Modal ── */ +.f-viewer-bg { + position: fixed; inset: 0; + background: rgba(0,0,0,.85); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + animation: mfadeIn .2s ease; +} +.f-viewer { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; +} +.f-viewer-img { + max-width: 90vw; + max-height: 80vh; + border-radius: 12px; + object-fit: contain; + cursor: pointer; + box-shadow: 0 8px 40px rgba(0,0,0,.5); +} +.f-viewer-close { + position: fixed; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255,255,255,.12); + border: 1px solid rgba(255,255,255,.2); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 210; + transition: background .2s; +} +.f-viewer-close:hover { + background: rgba(255,255,255,.25); +} +.f-viewer-dots { + display: flex; + gap: 8px; + margin-top: 16px; +} +.f-viewer-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255,255,255,.3); + border: none; + cursor: pointer; + padding: 0; + transition: background .2s; +} +.f-viewer-dot.active { + background: #fff; } -.f-img-remove:hover { background: rgba(0,0,0,0.8); } /* Hints & Errors */ .f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }