added image for posts
This commit is contained in:
@@ -3,59 +3,79 @@ import CalendarIcon from "./icons/CalendarIcon";
|
||||
import PhoneIcon from "./icons/PhoneIcon";
|
||||
import MapIcon from "./icons/MapIcon";
|
||||
import HeartIcon from "./icons/HeartIcon";
|
||||
import { API_URL } from "../config/constants";
|
||||
|
||||
export default function AdCard({ ad, liked, onLike, onEdit }) {
|
||||
console.log("ad object:", ad);
|
||||
const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null;
|
||||
|
||||
return (
|
||||
<div className="f-card">
|
||||
<div className="f-card-img">
|
||||
{ad.image
|
||||
? <img src={ad.image} alt={ad.title} />
|
||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||
}
|
||||
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
||||
<div className={`f-type-badge ${ad.type === "LOSS" ? "f-type-loss" : "f-type-found"}`}>
|
||||
{ad.type === "LOSS" ? "Потеря" : "Находка"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-card-body">
|
||||
<div className="f-card-title">{ad.title}</div>
|
||||
<div className="f-card-desc">{ad.description}</div>
|
||||
<div className="f-card-meta">
|
||||
{ad.location && <div className="f-meta-pill"><PinIcon /> {ad.location}</div>}
|
||||
{ad.date && <div className="f-meta-pill"><CalendarIcon /> {ad.date}</div>}
|
||||
</div>
|
||||
{ad.location && (
|
||||
<button className="f-card-map" onClick={e => e.stopPropagation()}>
|
||||
<MapIcon /> Показать на карте — {ad.location}
|
||||
</button>
|
||||
)}
|
||||
<div className="f-card-foot">
|
||||
{ad.phone
|
||||
? <a className="f-card-phone" href={`tel:${ad.phone}`} onClick={e => e.stopPropagation()}>
|
||||
<PhoneIcon /> {ad.phone}
|
||||
</a>
|
||||
: <span />
|
||||
<div className="f-card-inner">
|
||||
|
||||
{/* Левая колонка — картинка */}
|
||||
<div className="f-card-img">
|
||||
{imageUrl
|
||||
? <img src={imageUrl} alt={ad.title} />
|
||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||
}
|
||||
<span className="f-card-date">{ad.date || ""}</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{onEdit && (
|
||||
<button
|
||||
className="f-like-btn"
|
||||
onClick={e => { e.stopPropagation(); onEdit(ad); }}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`f-like-btn${liked ? " liked" : ""}`}
|
||||
onClick={e => { e.stopPropagation(); onLike(ad.id); }}
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
{liked ? (ad.likes || 0) + 1 : (ad.likes || 0)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка — контент */}
|
||||
<div className="f-card-body">
|
||||
<div className="f-card-badges">
|
||||
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
||||
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss"
|
||||
: "f-type-found"}`}>
|
||||
{ad.type === "loss" ? "Потеря" : "Находка"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-card-title">{ad.title}</div>
|
||||
<div className="f-card-desc">{ad.description}</div>
|
||||
<div className="f-card-meta">
|
||||
{ad.location && <div className="f-meta-pill">
|
||||
<PinIcon/> {ad.location}</div>}
|
||||
{ad.date && <div className="f-meta-pill"><CalendarIcon/> {ad.date}
|
||||
</div>}
|
||||
</div>
|
||||
{ad.location && (
|
||||
<button className="f-card-map"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<MapIcon/> Показать на карте — {ad.location}
|
||||
</button>
|
||||
)}
|
||||
<div className="f-card-foot">
|
||||
<div className="f-card-foot-left">
|
||||
{ad.phone && (
|
||||
<a className="f-card-phone" href={`tel:${ad.phone}`}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<PhoneIcon/> {ad.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="f-card-foot-right">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="f-like-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEdit(ad);
|
||||
}}
|
||||
>✏️</button>
|
||||
)}
|
||||
<button
|
||||
className={`f-like-btn${liked ? " liked" : ""}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onLike(ad.id);
|
||||
}}
|
||||
>
|
||||
<HeartIcon filled={liked}/>
|
||||
{liked ? (ad.likes || 0) + 1 : (ad.likes || 0)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import PinIcon from "./icons/PinIcon";
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
|
||||
import { useCreatePost } from "../hooks/useCreatePost";
|
||||
import { useUpdatePost } from "../hooks/useUpdatePost";
|
||||
import { API_URL } from "../config/constants";
|
||||
|
||||
const MAX_SIZE_MB = 5;
|
||||
const ACCEPT = "image/jpeg,image/png,image/webp";
|
||||
|
||||
function compressImage(file, maxW = 1200, quality = 0.82) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const scale = Math.min(1, maxW / img.width);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob(
|
||||
blob => blob ? resolve(new File([blob], file.name, { type: "image/jpeg" })) : reject(new Error("Blob error")),
|
||||
"image/jpeg",
|
||||
quality
|
||||
);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const formatPhone = (val) => {
|
||||
const digits = val.replace(/\D/g, "");
|
||||
const local = digits.startsWith("7") ? digits.slice(1) : digits;
|
||||
let result = "+7";
|
||||
if (local.length > 0) result += " (" + local.slice(0, 3);
|
||||
if (local.length >= 3) result += ") " + local.slice(3, 6);
|
||||
if (local.length >= 6) result += "-" + local.slice(6, 8);
|
||||
if (local.length >= 8) result += "-" + local.slice(8, 10);
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
const isEdit = !!initialData;
|
||||
@@ -17,10 +54,21 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
phone: initialData.phone || "",
|
||||
} : EMPTY_FORM);
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
// при редактировании показываем существующую картинку
|
||||
const existingImage = isEdit && initialData.images?.[0]
|
||||
? `${API_URL}${initialData.images[0]}`
|
||||
: null;
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [imgRemoved, setImgRemoved] = useState(false); // пользователь удалил старую
|
||||
const [imgError, setImgError] = useState("");
|
||||
const [compressing, setCompressing] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const { createAd, loading: creating, error: createError } = useCreatePost();
|
||||
const { updateAd, loading: updating, error: updateError } = useUpdatePost();
|
||||
const loading = creating || updating;
|
||||
const loading = creating || updating || compressing;
|
||||
const apiError = createError || updateError;
|
||||
|
||||
const set = useCallback((k, v) => {
|
||||
@@ -28,6 +76,43 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
setErrors(p => ({ ...p, [k]: "" }));
|
||||
}, []);
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) 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;
|
||||
}
|
||||
setCompressing(true);
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
setImageFile(compressed);
|
||||
setPreview(URL.createObjectURL(compressed));
|
||||
setImgRemoved(false);
|
||||
} catch {
|
||||
setImgError("Не удалось обработать изображение");
|
||||
} finally {
|
||||
setCompressing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
setImageFile(null);
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(null);
|
||||
setImgRemoved(true);
|
||||
setImgError("");
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
// что показывать в блоке превью
|
||||
const displayImage = preview || (!imgRemoved ? existingImage : null);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!form.title.trim()) e.title = "Введите название";
|
||||
@@ -40,18 +125,19 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
const payload = {
|
||||
type: form.type,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
category: form.category,
|
||||
date: form.date || null,
|
||||
phone: form.phone.trim(),
|
||||
};
|
||||
|
||||
const data = new FormData();
|
||||
data.append("type", form.type);
|
||||
data.append("title", form.title.trim());
|
||||
data.append("description", form.description.trim());
|
||||
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 result = isEdit
|
||||
? await updateAd(initialData.id, payload)
|
||||
: await createAd(payload);
|
||||
? await updateAd(initialData.id, data)
|
||||
: await createAd(data);
|
||||
|
||||
if (result) {
|
||||
onSuccess(result);
|
||||
@@ -69,24 +155,22 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
<button className="f-close-btn" onClick={onClose}><CloseIcon /></button>
|
||||
</div>
|
||||
|
||||
{/* тип */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Тип объявления</label>
|
||||
<div className="f-type-toggle">
|
||||
<button
|
||||
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
|
||||
onClick={() => set("type", "loss")}
|
||||
>
|
||||
Потеря
|
||||
</button>
|
||||
>Потеря</button>
|
||||
<button
|
||||
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
|
||||
onClick={() => set("type", "found")}
|
||||
>
|
||||
Находка
|
||||
</button>
|
||||
>Находка</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* название */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Название</label>
|
||||
<input
|
||||
@@ -99,6 +183,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
{errors.title && <p className="f-err-msg">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* описание */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Описание</label>
|
||||
<textarea
|
||||
@@ -111,6 +196,40 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
{errors.description && <p className="f-err-msg">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* фото */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Фотография</label>
|
||||
{displayImage ? (
|
||||
<div className="f-img-preview-wrap">
|
||||
<img src={displayImage} alt="preview" className="f-img-preview" />
|
||||
<button
|
||||
className="f-img-remove"
|
||||
onClick={removeImage}
|
||||
type="button"
|
||||
aria-label="Удалить фото"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
{imgError && <p className="f-err-msg">{imgError}</p>}
|
||||
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
|
||||
</div>
|
||||
|
||||
{/* категория */}
|
||||
<div className="f-m-row">
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Категория</label>
|
||||
@@ -126,6 +245,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* карта */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Место на карте</label>
|
||||
<div className="f-map-pick">
|
||||
@@ -135,6 +255,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
<p className="f-hint">Карта будет добавлена в следующей версии</p>
|
||||
</div>
|
||||
|
||||
{/* телефон */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Номер телефона</label>
|
||||
<input
|
||||
@@ -142,7 +263,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
type="tel"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
value={form.phone}
|
||||
onChange={e => set("phone", e.target.value)}
|
||||
onChange={e => set("phone", formatPhone(e.target.value))}
|
||||
/>
|
||||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user