From fbdcada3d26daca0289d61c7279cef7695a0e011 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Tue, 2 Jun 2026 01:10:56 +0300 Subject: [PATCH] global refactoring --- src/App.js | 6 +- src/App.test.js | 8 - .../Authentication.jsx} | 7 +- .../{PostModal.jsx => CreateUpdatePost.jsx} | 210 ++++------ .../FeedPage.jsx => components/MainFeed.jsx} | 64 ++- src/components/{AdCard.jsx => PostOnFeed.jsx} | 4 +- src/config/{constants.js => authConstants.js} | 0 src/hooks/useAuth.js | 2 +- src/hooks/useCreatePost.js | 2 +- src/hooks/useImageUpload.js | 87 ++++ src/hooks/usePostMutation.js | 45 ++ src/hooks/useRegister.js | 2 +- src/hooks/useUpdatePost.js | 3 +- src/logo.svg | 1 - src/setupTests.js | 5 - src/styles/base.css | 18 + src/styles/card.css | 93 +++++ src/styles/feed.css | 390 ------------------ src/styles/index.css | 7 + src/styles/layout.css | 47 +++ src/styles/modal.css | 112 +++++ src/styles/sidebar.css | 65 +++ src/styles/ui.css | 50 +++ src/utils/compressImage.js | 38 ++ src/utils/formatPhone.js | 17 + src/utils/validatePostForm.js | 14 + 26 files changed, 738 insertions(+), 559 deletions(-) delete mode 100644 src/App.test.js rename src/{pages/AuthPage.jsx => components/Authentication.jsx} (98%) rename src/components/{PostModal.jsx => CreateUpdatePost.jsx} (55%) rename src/{pages/FeedPage.jsx => components/MainFeed.jsx} (53%) rename src/components/{AdCard.jsx => PostOnFeed.jsx} (96%) rename src/config/{constants.js => authConstants.js} (100%) create mode 100644 src/hooks/useImageUpload.js create mode 100644 src/hooks/usePostMutation.js delete mode 100644 src/logo.svg delete mode 100644 src/setupTests.js create mode 100644 src/styles/base.css create mode 100644 src/styles/card.css delete mode 100644 src/styles/feed.css create mode 100644 src/styles/index.css create mode 100644 src/styles/layout.css create mode 100644 src/styles/modal.css create mode 100644 src/styles/sidebar.css create mode 100644 src/styles/ui.css create mode 100644 src/utils/compressImage.js create mode 100644 src/utils/formatPhone.js create mode 100644 src/utils/validatePostForm.js diff --git a/src/App.js b/src/App.js index 5c0a08f..de681bb 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,6 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import RegisterPage from './pages/AuthPage'; -import FeedPage from './pages/FeedPage'; // ← добавь импорт +import RegisterPage from './components/Authentication'; +import MainFeed from './components/MainFeed'; // ← добавь импорт function App() { return ( @@ -9,7 +9,7 @@ function App() { } /> } /> - } /> + } /> ); diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/pages/AuthPage.jsx b/src/components/Authentication.jsx similarity index 98% rename from src/pages/AuthPage.jsx rename to src/components/Authentication.jsx index 6374036..739041b 100644 --- a/src/pages/AuthPage.jsx +++ b/src/components/Authentication.jsx @@ -1,12 +1,11 @@ import { useState, useCallback } from "react"; -import MagnifierLogo from "../components/MagnifierLogo"; +import MagnifierLogo from "./MagnifierLogo"; import { useAuth } from "../hooks/useAuth"; import { useRegister } from "../hooks/useRegister"; -import {API_URL, FLOAT_ICONS} from "../config/constants"; +import {FLOAT_ICONS} from "../config/authConstants"; import "../styles/global.css"; import "../styles/auth.css"; import likeSmile from '../assets/success.png'; -import { useEffect } from "react"; import { useCheckLogin } from "../hooks/useRegister"; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -337,7 +336,7 @@ function RegisterForm({onSwitchToLogin}) { // ═══════════════════════════════════════════════════════════════════════════════ // AUTH PAGE — единственная страница, переключает login ↔ register // ═══════════════════════════════════════════════════════════════════════════════ -export default function AuthPage() { +export default function Authentication() { const [mode, setMode] = useState("login"); // "login" | "register" const [animating, setAnimating] = useState(false); diff --git a/src/components/PostModal.jsx b/src/components/CreateUpdatePost.jsx similarity index 55% rename from src/components/PostModal.jsx rename to src/components/CreateUpdatePost.jsx index 7be2703..4750639 100644 --- a/src/components/PostModal.jsx +++ b/src/components/CreateUpdatePost.jsx @@ -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 ( + // Клик по фону (не по самой модалке) закрывает её
e.target === e.currentTarget && onClose()}>
+ + {/* Шапка модалки */}
{isEdit ? "Редактировать объявление" : "Новое объявление"} - +
- {/* тип */} + {/* Тип: Потеря / Находка */}
+ > + Потеря + + > + Находка +
- {/* название */} + {/* Название */}
{errors.title}

}
- {/* описание */} + {/* Описание */}