Files
nahodka-front/src/components/PostModal.jsx
2026-06-01 01:55:26 +03:00

282 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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