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}}
- {/* описание */}
+ {/* Описание */}
- {/* фото */}
+ {/* Фотография: превью + удаление или загрузчик */}
{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 }) {
JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ
- {/* категория */}
+ {/* Категория */}
@@ -245,7 +201,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
- {/* карта */}
+ {/* Карта — заглушка, будет в следующей версии */}
@@ -255,7 +211,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
Карта будет добавлена в следующей версии
- {/* телефон */}
+ {/* Телефон с автоматической маской */}
{errors.phone}}
+ {/* Глобальная ошибка API (не связана с конкретным полем) */}
{apiError &&
{apiError}
}
+
);
diff --git a/src/pages/FeedPage.jsx b/src/components/MainFeed.jsx
similarity index 53%
rename from src/pages/FeedPage.jsx
rename to src/components/MainFeed.jsx
index 94ca242..91b29ea 100644
--- a/src/pages/FeedPage.jsx
+++ b/src/components/MainFeed.jsx
@@ -1,22 +1,35 @@
import { useState, useEffect, useCallback } from "react";
-import "../styles/feed.css";
-import { API_URL } from "../config/constants";
-import Sidebar from "../components/Sidebar";
-import RightSidebar from "../components/RightSidebar";
-import AdCard from "../components/AdCard";
-import PostModal from "../components/PostModal";
-import MenuIcon from "../components/icons/MenuIcon";
+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";
-export default function FeedPage() {
+/**
+ * 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("");
@@ -24,6 +37,7 @@ export default function FeedPage() {
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 || "Не удалось загрузить объявления");
@@ -32,8 +46,13 @@ export default function FeedPage() {
}
}, []);
+ // Первичная загрузка при монтировании компонента
useEffect(() => { loadFeed(); }, [loadFeed]);
+ /**
+ * Переключает лайк на объявлении.
+ * Состояние хранится только на клиенте — без запроса к API.
+ */
const toggleLike = useCallback((id) => {
setLiked(prev => {
const next = new Set(prev);
@@ -42,10 +61,18 @@ export default function FeedPage() {
});
}, []);
- // перезагружаем ленту чтобы получить images из БД
+ // После создания или редактирования объявления перезагружаем ленту,
+ // чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
+ /**
+ * Рендерит содержимое ленты в зависимости от текущего состояния:
+ * — спиннер во время загрузки
+ * — сообщение об ошибке
+ * — заглушка при пустой ленте
+ * — список карточек объявлений
+ */
const renderContent = () => {
if (loading) {
return (
@@ -78,18 +105,19 @@ export default function FeedPage() {
);
}
return ads.map(ad => (
-
));
};
return (
+ {/* Левый сайдбар с навигацией */}
+ {/* Верхняя панель: бургер-меню + кнопка создания объявления */}
+ {/* Пустой спейсер симметрично уравновешивает бургер слева */}
+ {/* Основная область: лента + правый сайдбар */}
{renderContent()}
+ {/* Правый сайдбар: промо-блоки / фильтры */}
+ {/* Модалка создания нового объявления */}
{modalOpen && (
- setModalOpen(false)}
onSuccess={handleNewAd}
/>
)}
+ {/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && (
- setEditAd(null)}
onSuccess={handleEditSave}
diff --git a/src/components/AdCard.jsx b/src/components/PostOnFeed.jsx
similarity index 96%
rename from src/components/AdCard.jsx
rename to src/components/PostOnFeed.jsx
index 0924c91..41ef330 100644
--- a/src/components/AdCard.jsx
+++ b/src/components/PostOnFeed.jsx
@@ -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 (
diff --git a/src/config/constants.js b/src/config/authConstants.js
similarity index 100%
rename from src/config/constants.js
rename to src/config/authConstants.js
diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js
index 398d509..c217d7c 100644
--- a/src/hooks/useAuth.js
+++ b/src/hooks/useAuth.js
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { API_URL } from "../config/constants";
+import { API_URL } from "../config/authConstants";
export function useAuth() {
const navigate = useNavigate();
diff --git a/src/hooks/useCreatePost.js b/src/hooks/useCreatePost.js
index f278676..1af332f 100644
--- a/src/hooks/useCreatePost.js
+++ b/src/hooks/useCreatePost.js
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { API_URL } from "../config/constants";
+import { API_URL } from "../config/authConstants";
export function useCreatePost() {
const [loading, setLoading] = useState(false);
diff --git a/src/hooks/useImageUpload.js b/src/hooks/useImageUpload.js
new file mode 100644
index 0000000..6b5cc3d
--- /dev/null
+++ b/src/hooks/useImageUpload.js
@@ -0,0 +1,87 @@
+import { useState, useRef } from "react";
+import { compressImage } from "../utils/compressImage";
+
+const MAX_SIZE_MB = 5;
+export const ACCEPT = "image/jpeg,image/png,image/webp";
+
+/**
+ * Управляет выбором, сжатием и удалением фотографии в форме объявления.
+ *
+ * @param {string|null} existingUrl — URL существующей картинки с сервера
+ * (передаётся при редактировании)
+ * @returns {{
+ * imageFile: File|null,
+ * displayImage: string|null,
+ * imgError: string,
+ * compressing: boolean,
+ * fileInputRef: React.RefObject,
+ * handleFileChange: function,
+ * removeImage: function,
+ * }}
+ */
+export function useImageUpload(existingUrl = null) {
+ // Сжатый файл, готовый к отправке в FormData
+ const [imageFile, setImageFile] = useState(null);
+ // ObjectURL для предпросмотра нового файла
+ const [preview, setPreview] = useState(null);
+ // true — пользователь явно удалил фото, existingUrl больше не показываем
+ const [removed, setRemoved] = useState(false);
+ const [imgError, setImgError] = useState("");
+ // true пока идёт сжатие — нужен чтобы заблокировать сабмит
+ const [compressing, setCompressing] = useState(false);
+ // ref на — нужен для сброса значения после removeImage
+ const fileInputRef = useRef(null);
+
+ // Что показывать в блоке превью:
+ // новый preview имеет приоритет, иначе existingUrl (если не удалён)
+ const displayImage = preview || (!removed ? existingUrl : null);
+
+ /** Проверяет файл, сжимает его и создаёт превью. */
+ 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));
+ setRemoved(false);
+ } catch {
+ setImgError("Не удалось обработать изображение");
+ } finally {
+ setCompressing(false);
+ }
+ };
+
+ /** Удаляет фото, освобождает ObjectURL и сбрасывает input. */
+ const removeImage = () => {
+ setImageFile(null);
+ if (preview) URL.revokeObjectURL(preview); // предотвращаем утечку памяти
+ setPreview(null);
+ setRemoved(true);
+ setImgError("");
+ // Сброс нужен, иначе повторный выбор того же файла не вызовет onChange
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ return {
+ imageFile,
+ displayImage,
+ imgError,
+ compressing,
+ fileInputRef,
+ handleFileChange,
+ removeImage,
+ };
+}
diff --git a/src/hooks/usePostMutation.js b/src/hooks/usePostMutation.js
new file mode 100644
index 0000000..d8701d8
--- /dev/null
+++ b/src/hooks/usePostMutation.js
@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { API_URL } from "../config/authConstants";
+
+/**
+ * Универсальная мутация для создания и обновления объявления.
+ * Заменяет отдельные хуки useCreatePost и useUpdatePost.
+ *
+ * Логика выбора метода:
+ * id передан → PUT /api/v1/posts/:id (обновление)
+ * id не передан → POST /api/v1/posts (создание)
+ *
+ * @returns {{
+ * save: (id: string|null, data: FormData) => Promise