added image for posts

This commit is contained in:
SlimusMinus
2026-06-01 01:55:26 +03:00
parent 825f86fb16
commit a2dd7363b5
6 changed files with 351 additions and 224 deletions

View File

@@ -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>
);

View File

@@ -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>