282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
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;
|
||
|
||
const [form, setForm] = useState(isEdit ? {
|
||
type: initialData.type || "loss",
|
||
title: initialData.title || "",
|
||
description: initialData.description || "",
|
||
category: initialData.category || "",
|
||
date: initialData.date || "",
|
||
phone: initialData.phone || "",
|
||
} : EMPTY_FORM);
|
||
|
||
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 || compressing;
|
||
const apiError = createError || updateError;
|
||
|
||
const set = useCallback((k, v) => {
|
||
setForm(p => ({ ...p, [k]: v }));
|
||
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 = "Введите название";
|
||
if (!form.description.trim()) e.description = "Введите описание";
|
||
if (!form.category) e.category = "Выберите категорию";
|
||
if (!form.phone.trim()) e.phone = "Введите номер телефона";
|
||
setErrors(e);
|
||
return !Object.keys(e).length;
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!validate()) return;
|
||
|
||
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, data)
|
||
: await createAd(data);
|
||
|
||
if (result) {
|
||
onSuccess(result);
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
|
||
<div className="f-modal">
|
||
<div className="f-modal-head">
|
||
<span className="f-modal-title">
|
||
{isEdit ? "Редактировать объявление" : "Новое объявление"}
|
||
</span>
|
||
<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
|
||
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
|
||
onClick={() => set("type", "found")}
|
||
>Находка</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* название */}
|
||
<div className="f-m-group">
|
||
<label className="f-m-label">Название</label>
|
||
<input
|
||
className={`f-m-field${errors.title ? " err" : ""}`}
|
||
type="text"
|
||
placeholder="Например: Кошелёк чёрный кожаный"
|
||
value={form.title}
|
||
onChange={e => set("title", e.target.value)}
|
||
/>
|
||
{errors.title && <p className="f-err-msg">{errors.title}</p>}
|
||
</div>
|
||
|
||
{/* описание */}
|
||
<div className="f-m-group">
|
||
<label className="f-m-label">Описание</label>
|
||
<textarea
|
||
className={`f-m-field${errors.description ? " err" : ""}`}
|
||
rows={3}
|
||
placeholder="Подробно опишите предмет — цвет, размер, особые приметы..."
|
||
value={form.description}
|
||
onChange={e => set("description", e.target.value)}
|
||
/>
|
||
{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>
|
||
<select
|
||
className={`f-m-field${errors.category ? " err" : ""}`}
|
||
value={form.category}
|
||
onChange={e => set("category", e.target.value)}
|
||
>
|
||
<option value="">Выбрать...</option>
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* карта */}
|
||
<div className="f-m-group">
|
||
<label className="f-m-label">Место на карте</label>
|
||
<div className="f-map-pick">
|
||
<PinIcon size={14} />
|
||
Нажмите, чтобы отметить место
|
||
</div>
|
||
<p className="f-hint">Карта будет добавлена в следующей версии</p>
|
||
</div>
|
||
|
||
{/* телефон */}
|
||
<div className="f-m-group">
|
||
<label className="f-m-label">Номер телефона</label>
|
||
<input
|
||
className={`f-m-field${errors.phone ? " err" : ""}`}
|
||
type="tel"
|
||
placeholder="+7 (___) ___-__-__"
|
||
value={form.phone}
|
||
onChange={e => set("phone", formatPhone(e.target.value))}
|
||
/>
|
||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||
</div>
|
||
|
||
{apiError && <p className="f-err-msg">{apiError}</p>}
|
||
|
||
<button className="f-m-submit" onClick={handleSubmit} disabled={loading}>
|
||
<div className="f-m-submit-row">
|
||
{loading && <div className="f-m-spinner" />}
|
||
{isEdit ? "Сохранить изменения" : "Разместить объявление"}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |