global refactoring
This commit is contained in:
379
src/components/Authentication.jsx
Normal file
379
src/components/Authentication.jsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import MagnifierLogo from "./MagnifierLogo";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useRegister } from "../hooks/useRegister";
|
||||
import {FLOAT_ICONS} from "../config/authConstants";
|
||||
import "../styles/global.css";
|
||||
import "../styles/auth.css";
|
||||
import likeSmile from '../assets/success.png';
|
||||
import { useCheckLogin } from "../hooks/useRegister";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
function getStrength(val) {
|
||||
if (!val) return { level: 0, label: "", color: "transparent", cls: "" };
|
||||
let s = 0;
|
||||
if (val.length >= 6) s++;
|
||||
if (val.length >= 10) s++;
|
||||
if (/[A-Z]/.test(val) || /[0-9]/.test(val)) s++;
|
||||
if (/[^A-Za-z0-9]/.test(val)) s++;
|
||||
const labels = ["", "Слабый", "Средний", "Хороший", "Отличный"];
|
||||
const colors = ["", "#ff6b6b", "#ffd166", "#2de0c8", "#2de0c8"];
|
||||
const cls = ["", "sw", "sm", "ss", "ss"];
|
||||
return { level: s, label: labels[s], color: colors[s], cls: cls[s] };
|
||||
}
|
||||
|
||||
// ─── Shared eye button ────────────────────────────────────────────────────────
|
||||
function EyeBtn({ show, onToggle}) {
|
||||
return (
|
||||
<button type="button" className="eye-btn" onClick={onToggle}>
|
||||
{show ? "🔒" : "👁"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step dots ────────────────────────────────────────────────────────────────
|
||||
function StepDots({ current, total }) {
|
||||
return (
|
||||
<div className="step-dots">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`dot ${i === current ? "dot-active" : i < current ? "dot-done" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// LOGIN FORM
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
function LoginForm({ onSwitchToRegister }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const { login, loading, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="form-body">
|
||||
<p className="form-subtitle">С возвращением</p>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); login(email, password); }} noValidate>
|
||||
<div className="field-wrap">
|
||||
<input
|
||||
className="field"
|
||||
type="email"
|
||||
placeholder="Введите email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="off"
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute("readOnly")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-wrap" style={{ position: "relative" }}>
|
||||
<input
|
||||
className="field"
|
||||
type={showPass ? "text" : "password"}
|
||||
placeholder="Введите пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ paddingRight: "44px" }}
|
||||
autoComplete="off"
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute("readOnly")}
|
||||
/>
|
||||
<EyeBtn show={showPass} onToggle={() => setShowPass(v => !v)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="err-msg">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? "Входим..." : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button className="ghost-btn">Забыли пароль?</button>
|
||||
|
||||
<p className="switch-row">
|
||||
Нет аккаунта?{" "}
|
||||
<button className="link-btn" onClick={onSwitchToRegister}>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── step 0+1: name + login + passwords ───────────────────────────────────────
|
||||
function RegStep01({ data, onChange, onNext }) {
|
||||
const [show1, setShow1] = useState(false);
|
||||
const [show2, setShow2] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const strength = getStrength(data.password);
|
||||
const matched = data.password && data.password === data.password2;
|
||||
|
||||
const { checking: checkingLogin, exists: loginExists } = useCheckLogin(data.login);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!data.name.trim()) e.name = "Введите имя";
|
||||
if (!data.login.trim()) e.login = "Введите логин";
|
||||
else if (data.login.length < 3) e.login = "Минимум 3 символа";
|
||||
if (data.password.length < 6) e.password = "Минимум 6 символов";
|
||||
else if (data.password !== data.password2) e.password2 = "Пароли не совпадают";
|
||||
|
||||
setErrors(e);
|
||||
return !Object.keys(e).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Имя пользователя</label>
|
||||
<input
|
||||
className={`field ${errors.name ? "field-err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Как вас зовут?"
|
||||
value={data.name}
|
||||
onChange={(e) => {
|
||||
onChange("name", e.target.value);
|
||||
setErrors(p => ({...p, name: ""}));
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.name && <p className="err-msg">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Логин</label>
|
||||
<input
|
||||
className={`field ${errors.login || loginExists ? "field-err"
|
||||
: ""}`}
|
||||
type="text"
|
||||
placeholder="Придумайте логин"
|
||||
value={data.login}
|
||||
onChange={(e) => {
|
||||
onChange("login", e.target.value);
|
||||
setErrors(p => ({...p, login: ""}));
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{checkingLogin && <p className="hint-msg">Проверяем...</p>}
|
||||
|
||||
{!checkingLogin && data.login.length >= 3 && (
|
||||
<p className="err-msg"
|
||||
style={{color: loginExists ? "#ff6b6b" : "#2de0c8"}}>
|
||||
{loginExists ? "✗ Логин занят" : "✓ Логин свободен"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errors.login && <p className="err-msg">{errors.login}</p>}
|
||||
</div>
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Пароль</label>
|
||||
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`field ${errors.password ? "field-err" : ""}`}
|
||||
type={show1 ? "text" : "password"}
|
||||
placeholder="Минимум 6 символов"
|
||||
value={data.password}
|
||||
onChange={(e) => {
|
||||
onChange("password", e.target.value);
|
||||
setErrors(p => ({...p, password: ""}));
|
||||
}}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<EyeBtn show={show1} onToggle={() => setShow1(v => !v)}/>
|
||||
</div>
|
||||
|
||||
{data.password && (
|
||||
<>
|
||||
<div className="strength-bar">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i}
|
||||
className={`seg ${i < strength.level ? strength.cls
|
||||
: ""}`}/>
|
||||
))}
|
||||
</div>
|
||||
<p className="strength-label" style={{color: strength.color}}>
|
||||
{strength.label}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errors.password && <p className="err-msg">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Повторите пароль</label>
|
||||
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`field ${data.password2 ? (matched ? "field-ok"
|
||||
: "field-err") : ""}`}
|
||||
type={show2 ? "text" : "password"}
|
||||
placeholder="Введите ещё раз"
|
||||
value={data.password2}
|
||||
onChange={(e) => {
|
||||
onChange("password2", e.target.value);
|
||||
setErrors(p => ({...p, password2: ""}));
|
||||
}}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<EyeBtn show={show2} onToggle={() => setShow2(v => !v)}/>
|
||||
</div>
|
||||
|
||||
{data.password2 && (
|
||||
<p className="err-msg"
|
||||
style={{color: matched ? "#2de0c8" : "#ff6b6b"}}>
|
||||
{matched ? "✓ Совпадают" : "✗ Не совпадают"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errors.password2 && <p className="err-msg">{errors.password2}</p>}
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={() => validate() && onNext()}>
|
||||
Далее →
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── step 2: confirm ──────────────────────────────────────────────────────────
|
||||
function RegStep2({data, onSubmit, onBack, loading, error}) {
|
||||
return (
|
||||
<>
|
||||
<div className="confirm-box">
|
||||
<div className="confirm-row">
|
||||
<span className="confirm-label">Имя</span>
|
||||
<span className="confirm-val">{data.name}</span>
|
||||
</div>
|
||||
<div className="confirm-divider"/>
|
||||
<div className="confirm-row">
|
||||
<span className="confirm-label">Логин</span>
|
||||
<span className="confirm-val accent">@{data.login}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="err-msg" style={{
|
||||
textAlign: "center",
|
||||
marginBottom: "10px"
|
||||
}}>{error}</p>}
|
||||
<button className="btn-primary" onClick={onSubmit} disabled={loading}>
|
||||
{loading ? "Создаём..." : "Создать аккаунт ✓"}
|
||||
</button>
|
||||
<button className="back-btn" onClick={onBack}>← Назад</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── success ──────────────────────────────────────────────────────────────────
|
||||
function RegSuccess({name, onLogin}) {
|
||||
return (
|
||||
<div className="success-wrap">
|
||||
<img src={likeSmile} alt="success" className="success-icon"/>
|
||||
<p
|
||||
className="success-title">Добро пожаловать, {name}!</p>
|
||||
<p className="success-sub">Аккаунт создан успешно.</p>
|
||||
<button className="btn-primary" style={{marginTop: "20px"}}
|
||||
onClick={onLogin}>
|
||||
Войти →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterForm({onSwitchToLogin}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState(
|
||||
{name: "", login: "", password: "", password2: ""});
|
||||
const {register, loading, error, success} = useRegister();
|
||||
|
||||
const setField = useCallback((key, val) => setForm(p => ({ ...p, [key]: val })), []);
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="form-body">
|
||||
<RegSuccess name={form.name} onLogin={onSwitchToLogin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-body">
|
||||
<p className="form-subtitle">Создайте аккаунт</p>
|
||||
<StepDots current={step} total={2} />
|
||||
|
||||
{step === 0 && (
|
||||
<RegStep01 data={form} onChange={setField} onNext={() => setStep(1)} />
|
||||
)}
|
||||
{step === 1 && (
|
||||
<RegStep2
|
||||
data={form}
|
||||
onSubmit={() => register({ name: form.name, login: form.login, password: form.password })}
|
||||
onBack={() => setStep(0)}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<p className="switch-row">
|
||||
Уже есть аккаунт?{" "}
|
||||
<button className="link-btn" onClick={onSwitchToLogin}>Войти</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// AUTH PAGE — единственная страница, переключает login ↔ register
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
export default function Authentication() {
|
||||
const [mode, setMode] = useState("login"); // "login" | "register"
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const switchTo = (next) => {
|
||||
if (animating) return;
|
||||
setAnimating(true);
|
||||
// Небольшая задержка — карточка уходит, потом приходит новая
|
||||
setTimeout(() => {
|
||||
setMode(next);
|
||||
setAnimating(false);
|
||||
}, 320);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="bg-glow" />
|
||||
|
||||
{FLOAT_ICONS.map(({ icon, delay, size, ...pos }, i) => (
|
||||
<img
|
||||
key={i} src={icon} alt="" className="fi"
|
||||
style={{ ...pos, animationDelay: delay, width: size || "7rem", height: size || "7rem", filter: "grayscale(100%)" }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className={`auth-card ${animating ? "card-out" : "card-in"}`}>
|
||||
<button className="dots-btn">⋮</button>
|
||||
|
||||
<div className="logo-wrap">
|
||||
<MagnifierLogo />
|
||||
</div>
|
||||
<h1 className="auth-title">Находка</h1>
|
||||
|
||||
{mode === "login"
|
||||
? <LoginForm onSwitchToRegister={() => switchTo("register")} />
|
||||
: <RegisterForm onSwitchToLogin={() => switchTo("login")} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +1,78 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { useCallback, useState } 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";
|
||||
import { usePostMutation } from "../hooks/usePostMutation";
|
||||
import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
|
||||
import { validatePostForm } from "../utils/validatePostForm";
|
||||
import { formatPhone } from "../utils/formatPhone";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
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 }) {
|
||||
/**
|
||||
* CreateUpdatePost — модальная форма создания и редактирования объявления.
|
||||
*
|
||||
* @param {function} onClose — закрыть модалку без сохранения
|
||||
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
|
||||
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
|
||||
*/
|
||||
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
// Режим: редактирование (true) или создание нового (false)
|
||||
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);
|
||||
// Поля формы; при редактировании заполняем из 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]
|
||||
// Ошибки валидации — ключ совпадает с именем поля
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// URL существующей картинки с сервера (только при редактировании)
|
||||
const existingImageUrl = 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 {
|
||||
imageFile,
|
||||
displayImage,
|
||||
imgError,
|
||||
compressing,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
removeImage,
|
||||
} = useImageUpload(existingImageUrl);
|
||||
|
||||
const set = useCallback((k, v) => {
|
||||
setForm(p => ({ ...p, [k]: v }));
|
||||
setErrors(p => ({ ...p, [k]: "" }));
|
||||
// Единый хук для создания и обновления
|
||||
const { save, loading: saving, error: apiError } = usePostMutation();
|
||||
|
||||
// Единый флаг загрузки — блокирует кнопку отправки
|
||||
const loading = saving || compressing;
|
||||
|
||||
/** Обновляет одно поле формы и сбрасывает его ошибку валидации. */
|
||||
const set = useCallback((key, value) => {
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
setErrors(prev => ({ ...prev, [key]: "" }));
|
||||
}, []);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/** Собирает FormData и отправляет запрос на создание или обновление. */
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
const validationErrors = validatePostForm(form);
|
||||
if (Object.keys(validationErrors).length) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new FormData();
|
||||
data.append("type", form.type);
|
||||
@@ -132,13 +80,11 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
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 (form.date) data.append("date", form.date);
|
||||
if (imageFile) data.append("image", imageFile);
|
||||
|
||||
const result = isEdit
|
||||
? await updateAd(initialData.id, data)
|
||||
: await createAd(data);
|
||||
|
||||
// save(id, data): id = null → POST (создание), id = string → PUT (обновление)
|
||||
const result = await save(isEdit ? initialData.id : null, data);
|
||||
if (result) {
|
||||
onSuccess(result);
|
||||
onClose();
|
||||
@@ -146,31 +92,40 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
};
|
||||
|
||||
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>
|
||||
<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
|
||||
@@ -183,7 +138,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
|
||||
@@ -196,7 +151,7 @@ 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 ? (
|
||||
@@ -217,6 +172,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
// На мобильных открываем камеру напрямую
|
||||
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent) ? "environment" : undefined}
|
||||
onChange={handleFileChange}
|
||||
className="f-img-input"
|
||||
@@ -229,7 +185,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
<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>
|
||||
@@ -245,7 +201,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">
|
||||
@@ -255,7 +211,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
|
||||
@@ -268,6 +224,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
{/* Глобальная ошибка API (не связана с конкретным полем) */}
|
||||
{apiError && <p className="f-err-msg">{apiError}</p>}
|
||||
|
||||
<button className="f-m-submit" onClick={handleSubmit} disabled={loading}>
|
||||
@@ -276,6 +233,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||
{isEdit ? "Сохранить изменения" : "Разместить объявление"}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
172
src/components/MainFeed.jsx
Normal file
172
src/components/MainFeed.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import "../styles/index.css";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import Sidebar from "./Sidebar";
|
||||
import RightSidebar from "./RightSidebar";
|
||||
import PostOnFeed from "./PostOnFeed";
|
||||
import CreateUpdatePost from "./CreateUpdatePost";
|
||||
import MenuIcon from "./icons/MenuIcon";
|
||||
|
||||
/**
|
||||
* MainFeed — корневой компонент приложения.
|
||||
* Управляет состоянием ленты, модалок и сайдбара;
|
||||
* остальные компоненты получают данные через пропсы.
|
||||
*/
|
||||
export default function MainFeed() {
|
||||
// Открыт ли левый сайдбар (мобильный слайд-аут)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
// Активный пункт навигации в сайдбаре
|
||||
const [activeNav, setActiveNav] = useState("Главная");
|
||||
// Открыта ли модалка создания нового объявления
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// Объявление, которое сейчас редактируется (null — модалка закрыта)
|
||||
const [editAd, setEditAd] = useState(null);
|
||||
// Массив объявлений из API
|
||||
const [ads, setAds] = useState([]);
|
||||
// Set id объявлений, которые пользователь лайкнул (только локально)
|
||||
const [liked, setLiked] = useState(new Set());
|
||||
// Состояния загрузки и ошибки для ленты
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
/** Загружает ленту объявлений с бэкенда. */
|
||||
const loadFeed = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/posts`);
|
||||
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||||
const data = await res.json();
|
||||
// API может вернуть массив напрямую или объект { items: [] }
|
||||
setAds(Array.isArray(data) ? data : (data.items ?? []));
|
||||
} catch (err) {
|
||||
setError(err.message || "Не удалось загрузить объявления");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Первичная загрузка при монтировании компонента
|
||||
useEffect(() => { loadFeed(); }, [loadFeed]);
|
||||
|
||||
/**
|
||||
* Переключает лайк на объявлении.
|
||||
* Состояние хранится только на клиенте — без запроса к API.
|
||||
*/
|
||||
const toggleLike = useCallback((id) => {
|
||||
setLiked(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// После создания или редактирования объявления перезагружаем ленту,
|
||||
// чтобы получить актуальные данные (включая изображения) из БД
|
||||
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
|
||||
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
|
||||
|
||||
/**
|
||||
* Рендерит содержимое ленты в зависимости от текущего состояния:
|
||||
* — спиннер во время загрузки
|
||||
* — сообщение об ошибке
|
||||
* — заглушка при пустой ленте
|
||||
* — список карточек объявлений
|
||||
*/
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-spinner-wrap">
|
||||
<div className="f-spinner" />
|
||||
Загружаем объявления...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">⚠️</div>
|
||||
<div className="f-center-title">Не удалось загрузить ленту</div>
|
||||
<div className="f-center-sub">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!ads.length) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">🔍</div>
|
||||
<div className="f-center-title">Объявлений пока нет</div>
|
||||
<div className="f-center-sub">
|
||||
Будьте первым — разместите объявление<br />о потере или находке
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return ads.map(ad => (
|
||||
<PostOnFeed
|
||||
key={ad.id}
|
||||
ad={ad}
|
||||
liked={liked.has(ad.id)} // передаём локальное состояние лайка
|
||||
onLike={toggleLike} // колбэк для переключения лайка
|
||||
onEdit={setEditAd} // колбэк открывает модалку редактирования
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="f-app">
|
||||
{/* Левый сайдбар с навигацией */}
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav={activeNav}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={setActiveNav}
|
||||
/>
|
||||
|
||||
<div className="f-main">
|
||||
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
|
||||
<div className="f-topbar">
|
||||
<button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<div className="f-topbar-center">
|
||||
<button className="f-post-btn" onClick={() => setModalOpen(true)}>
|
||||
<span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span>
|
||||
Разместить объявление
|
||||
</button>
|
||||
</div>
|
||||
{/* Пустой спейсер симметрично уравновешивает бургер слева */}
|
||||
<div className="f-topbar-spacer" />
|
||||
</div>
|
||||
|
||||
{/* Основная область: лента + правый сайдбар */}
|
||||
<div className="f-wrap">
|
||||
<div className="f-feed">
|
||||
{renderContent()}
|
||||
</div>
|
||||
{/* Правый сайдбар: промо-блоки / фильтры */}
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка создания нового объявления */}
|
||||
{modalOpen && (
|
||||
<CreateUpdatePost
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={handleNewAd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования — открывается когда editAd !== null */}
|
||||
{editAd && (
|
||||
<CreateUpdatePost
|
||||
initialData={editAd}
|
||||
onClose={() => setEditAd(null)}
|
||||
onSuccess={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,9 @@ 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";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
export default function AdCard({ ad, liked, onLike, onEdit }) {
|
||||
export default function PostOnFeed({ ad, liked, onLike, onEdit }) {
|
||||
const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null;
|
||||
|
||||
return (
|
||||
Reference in New Issue
Block a user