Merge pull request #26 from SlimusMinus/fix_images

added many images in post
This commit is contained in:
SlimusMinus
2026-07-01 00:31:15 +03:00
committed by GitHub
5 changed files with 256 additions and 112 deletions

View File

@@ -1,5 +1,4 @@
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useState} from "react";
import PinIcon from "./icons/PinIcon";
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants"; import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
import { usePostMutation } from "../hooks/usePostMutation"; import { usePostMutation } from "../hooks/usePostMutation";
@@ -7,7 +6,6 @@ import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm"; import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone"; import { formatPhone } from "../utils/formatPhone";
import { useYandexMap } from "../hooks/useYandexMap"; import { useYandexMap } from "../hooks/useYandexMap";
import { API_URL } from "../config/authConstants";
const MAX_SIZE_MB = 5; const MAX_SIZE_MB = 5;
@@ -40,21 +38,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
// Ошибки валидации — ключ совпадает с именем поля // Ошибки валидации — ключ совпадает с именем поля
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
// URL существующей картинки с сервера (только при редактировании)
const existingImageUrl = isEdit && initialData.images?.[0]
? `${API_URL}${initialData.images[0]}`
: null;
// Вся логика работы с фото вынесена в хук
const { const {
imageFile, images: uploadedImages,
displayImage,
imgError, imgError,
compressing, compressing,
fileInputRef, fileInputRef,
handleFileChange, handleFileChange,
removeImage, removeImage,
} = useImageUpload(existingImageUrl); getFilesForUpload,
getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []);
// Единый хук для создания и обновления // Единый хук для создания и обновления
const { save, loading: saving, error: apiError } = usePostMutation(); 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("category", form.category);
data.append("phone", form.phone.trim()); data.append("phone", form.phone.trim());
if (form.date) data.append("date", form.date); 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) { if (coords) {
data.append("latitude", coords.latitude); data.append("latitude", coords.latitude);
@@ -189,41 +190,50 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
className="f-err-msg">{errors.description}</p>} className="f-err-msg">{errors.description}</p>}
</div> </div>
{/* Фотография: превью + удаление или загрузчик */} {/* Фотографии: сетка превью + кнопка добавления */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Фотография</label> <label className="f-m-label">Фотографии</label>
{displayImage ? (
<div className="f-img-preview-wrap"> {uploadedImages.length > 0 && (
<img src={displayImage} alt="preview" <div className="f-img-grid">
className="f-img-preview"/> {uploadedImages.map(img => (
<button <div key={img.id} className="f-img-grid-item">
className="f-img-remove" <img src={img.preview} alt="" className="f-img-grid-preview"/>
onClick={removeImage} <button
type="button" className="f-img-remove"
aria-label="Удалить фото" onClick={() => removeImage(img.id)}
> type="button"
<CloseIcon/> aria-label="Удалить фото"
</button> >
<CloseIcon/>
</button>
</div>
))}
</div> </div>
) : (
<label className="f-img-upload-label"
aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
// На мобильных открываем камеру напрямую
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>}
</label>
)} )}
<label className="f-img-upload-label"
aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? (
<span>Обработка...</span>
) : (
<span>{uploadedImages.length > 0 ? "➕ Добавить ещё" : "📷 Добавить фото"}</span>
)}
</label>
{imgError && <p className="f-err-msg">{imgError}</p>} {imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p> <p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ · можно несколько</p>
</div> </div>
{/* Категория */} {/* Категория */}

View File

@@ -6,6 +6,7 @@ import Sidebar from "./Sidebar";
import RightSidebar from "./RightSidebar"; import RightSidebar from "./RightSidebar";
import PostOnFeed from "./PostOnFeed"; import PostOnFeed from "./PostOnFeed";
import CreateUpdatePost from "./CreateUpdatePost"; import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "./ImageViewer";
import MenuIcon from "./icons/MenuIcon"; import MenuIcon from "./icons/MenuIcon";
import ProfileAvatar from "./profile/ProfileAvatar"; import ProfileAvatar from "./profile/ProfileAvatar";
import { ProfileModal } from "./profile"; import { ProfileModal } from "./profile";
@@ -44,6 +45,8 @@ export default function MainFeed() {
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
// Открыто ли модальное окно правил площадки // Открыто ли модальное окно правил площадки
const [rulesModalOpen, setRulesModalOpen] = useState(false); const [rulesModalOpen, setRulesModalOpen] = useState(false);
// Просмотр изображений: null — закрыто, { images, index } — открыто
const [viewerData, setViewerData] = useState(null);
// Массив объявлений из API (основная лента) // Массив объявлений из API (основная лента)
const [ads, setAds] = useState([]); const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId) // Объявления текущего пользователя (загружаются отдельно по userId)
@@ -193,6 +196,12 @@ export default function MainFeed() {
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleEditSave = 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} onDelete={activeNav === "Мои объявления" ? handleDelete : undefined}
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined} onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
isOwner={activeNav === "Мои объявления"} isOwner={activeNav === "Мои объявления"}
onImageClick={handleImageClick}
/> />
)); ));
}; };
@@ -411,6 +421,15 @@ export default function MainFeed() {
{rulesModalOpen && ( {rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} /> <RulesModal onClose={() => setRulesModalOpen(false)} />
)} )}
{/* Просмотр изображений */}
{viewerData && (
<ImageViewer
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -9,7 +9,7 @@ import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext"; import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants"; 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 navigate = useNavigate();
const { createOrGetChat } = useChats(); const { createOrGetChat } = useChats();
const { isAuthenticated } = useAuthContext(); const { isAuthenticated } = useAuthContext();
@@ -55,7 +55,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
return ( return (
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}> <div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
<div className="f-card-inner"> <div className="f-card-inner">
<div className="f-card-img"> <div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0); }}>
{imageUrl {imageUrl
? <img src={imageUrl} alt={ad.title} /> ? <img src={imageUrl} alt={ad.title} />
: <span className="f-card-emoji">{ad.emoji || "📋"}</span> : <span className="f-card-emoji">{ad.emoji || "📋"}</span>

View File

@@ -1,87 +1,106 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { compressImage } from "../utils/compressImage"; import { compressImage } from "../utils/compressImage";
import { API_URL } from "../config/authConstants";
const MAX_SIZE_MB = 5; const MAX_SIZE_MB = 5;
export const ACCEPT = "image/jpeg,image/png,image/webp"; export const ACCEPT = "image/jpeg,image/png,image/webp";
/** let idCounter = 0;
* Управляет выбором, сжатием и удалением фотографии в форме объявления. const nextId = () => `img-${++idCounter}-${Date.now()}`;
*
* @param {string|null} existingUrl — URL существующей картинки с сервера export function useImageUpload(existingUrls = []) {
* (передаётся при редактировании) const [images, setImages] = useState([]);
* @returns {{ const [imgError, setImgError] = useState("");
* 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 пока идёт сжатие — нужен чтобы заблокировать сабмит
const [compressing, setCompressing] = useState(false); const [compressing, setCompressing] = useState(false);
// ref на <input type="file"> — нужен для сброса значения после removeImage
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Что показывать в блоке превью: const existingUrlList = Array.isArray(existingUrls) ? existingUrls : [];
// новый preview имеет приоритет, иначе existingUrl (если не удалён)
const displayImage = preview || (!removed ? existingUrl : null);
/** Проверяет файл, сжимает его и создаёт превью. */ useEffect(() => {
const handleFileChange = async (e) => { if (existingUrlList.length > 0) {
const file = e.target.files?.[0]; setImages(existingUrlList.map((url, i) => ({
if (!file) return; 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(""); setImgError("");
if (!file.type.startsWith("image/")) { for (const file of files) {
setImgError("Только изображения (JPEG, PNG, WebP)"); if (!file.type.startsWith("image/")) {
return; setImgError("Только изображения (JPEG, PNG, WebP)");
} return;
if (file.size > MAX_SIZE_MB * 1024 * 1024) { }
setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`); if (file.size > MAX_SIZE_MB * 1024 * 1024) {
return; setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`);
return;
}
} }
setCompressing(true); setCompressing(true);
try { try {
const compressed = await compressImage(file); const compressedFiles = await Promise.all(files.map(f => compressImage(f)));
setImageFile(compressed); const newImages = compressedFiles.map(file => ({
setPreview(URL.createObjectURL(compressed)); id: nextId(),
setRemoved(false); file,
preview: URL.createObjectURL(file),
isExisting: false,
serverUrl: null,
}));
setImages(prev => [...prev, ...newImages]);
} catch { } catch {
setImgError("Не удалось обработать изображение"); setImgError("Не удалось обработать изображение");
} finally { } finally {
setCompressing(false); setCompressing(false);
if (fileInputRef.current) fileInputRef.current.value = "";
} }
}; }, []);
/** Удаляет фото, освобождает ObjectURL и сбрасывает input. */ const removeImage = useCallback((id) => {
const removeImage = () => { setImages(prev => {
setImageFile(null); const img = prev.find(i => i.id === id);
if (preview) URL.revokeObjectURL(preview); // предотвращаем утечку памяти if (img && !img.isExisting && img.preview) {
setPreview(null); URL.revokeObjectURL(img.preview);
setRemoved(true); }
setImgError(""); return prev.filter(i => i.id !== id);
// Сброс нужен, иначе повторный выбор того же файла не вызовет onChange });
if (fileInputRef.current) fileInputRef.current.value = ""; }, []);
};
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 { return {
imageFile, images,
displayImage,
imgError, imgError,
compressing, compressing,
fileInputRef, fileInputRef,
handleFileChange, handleFileChange,
removeImage, removeImage,
getFilesForUpload,
getExistingUrlsToKeep,
}; };
} }

View File

@@ -76,17 +76,113 @@
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); } .f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); }
.f-img-input { display: none; } .f-img-input { display: none; }
/* Image Preview */ /* Multi-image Grid */
.f-img-preview-wrap { position: relative; display: block; width: 100%; } .f-img-grid {
.f-img-preview { width: 100%; height: 200px; object-fit: cover; object-position: center; border-radius: 12px; display: block; } display: grid;
.f-img-remove { grid-template-columns: repeat(3, 1fr);
position: absolute; top: 8px; right: 8px; gap: 8px;
background: rgba(0,0,0,0.55); border: none; border-radius: 50%; margin-bottom: 10px;
width: 28px; height: 28px; }
display: flex; align-items: center; justify-content: center; .f-img-grid-item {
cursor: pointer; color: #fff; padding: 0; transition: background .2s; 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 */ /* Hints & Errors */
.f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; } .f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }