Merge pull request #10 from SlimusMinus/refactor

global refactoring
This commit is contained in:
SlimusMinus
2026-06-02 01:11:37 +03:00
committed by GitHub
26 changed files with 738 additions and 559 deletions

View File

@@ -1,6 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import RegisterPage from './pages/AuthPage'; import RegisterPage from './components/Authentication';
import FeedPage from './pages/FeedPage'; // ← добавь импорт import MainFeed from './components/MainFeed'; // ← добавь импорт
function App() { function App() {
return ( return (
@@ -9,7 +9,7 @@ function App() {
<Route path="/" element={<Navigate to="/feed" replace />} /> <Route path="/" element={<Navigate to="/feed" replace />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/feed" element={<FeedPage />} /> <Route path="/feed" element={<MainFeed />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -1,12 +1,11 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import MagnifierLogo from "../components/MagnifierLogo"; import MagnifierLogo from "./MagnifierLogo";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useRegister } from "../hooks/useRegister"; 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/global.css";
import "../styles/auth.css"; import "../styles/auth.css";
import likeSmile from '../assets/success.png'; import likeSmile from '../assets/success.png';
import { useEffect } from "react";
import { useCheckLogin } from "../hooks/useRegister"; import { useCheckLogin } from "../hooks/useRegister";
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -337,7 +336,7 @@ function RegisterForm({onSwitchToLogin}) {
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// AUTH PAGE — единственная страница, переключает login ↔ register // AUTH PAGE — единственная страница, переключает login ↔ register
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export default function AuthPage() { export default function Authentication() {
const [mode, setMode] = useState("login"); // "login" | "register" const [mode, setMode] = useState("login"); // "login" | "register"
const [animating, setAnimating] = useState(false); const [animating, setAnimating] = useState(false);

View File

@@ -1,130 +1,78 @@
import { useCallback, useState, useRef } from "react"; import { useCallback, useState } from "react";
import PinIcon from "./icons/PinIcon"; import PinIcon from "./icons/PinIcon";
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants"; import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
import { useCreatePost } from "../hooks/useCreatePost"; import { usePostMutation } from "../hooks/usePostMutation";
import { useUpdatePost } from "../hooks/useUpdatePost"; import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
import { API_URL } from "../config/constants"; import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone";
import { API_URL } from "../config/authConstants";
const MAX_SIZE_MB = 5; 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) => { * CreateUpdatePost — модальная форма создания и редактирования объявления.
const img = new Image(); *
const url = URL.createObjectURL(file); * @param {function} onClose — закрыть модалку без сохранения
img.onload = () => { * @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
URL.revokeObjectURL(url); * @param {object} [initialData] — если передан, форма работает в режиме редактирования
const scale = Math.min(1, maxW / img.width); */
const canvas = document.createElement("canvas"); export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
canvas.width = Math.round(img.width * scale); // Режим: редактирование (true) или создание нового (false)
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 isEdit = !!initialData;
const [form, setForm] = useState(isEdit ? { // Поля формы; при редактировании заполняем из initialData
const [form, setForm] = useState(
isEdit
? {
type: initialData.type || "loss", type: initialData.type || "loss",
title: initialData.title || "", title: initialData.title || "",
description: initialData.description || "", description: initialData.description || "",
category: initialData.category || "", category: initialData.category || "",
date: initialData.date || "", date: initialData.date || "",
phone: initialData.phone || "", phone: initialData.phone || "",
} : EMPTY_FORM); }
: EMPTY_FORM
);
// Ошибки валидации — ключ совпадает с именем поля
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [imageFile, setImageFile] = useState(null);
// при редактировании показываем существующую картинку // URL существующей картинки с сервера (только при редактировании)
const existingImage = isEdit && initialData.images?.[0] const existingImageUrl = isEdit && initialData.images?.[0]
? `${API_URL}${initialData.images[0]}` ? `${API_URL}${initialData.images[0]}`
: null; : 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 {
const loading = creating || updating || compressing; imageFile,
const apiError = createError || updateError; displayImage,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
} = useImageUpload(existingImageUrl);
const set = useCallback((k, v) => { // Единый хук для создания и обновления
setForm(p => ({ ...p, [k]: v })); const { save, loading: saving, error: apiError } = usePostMutation();
setErrors(p => ({ ...p, [k]: "" }));
// Единый флаг загрузки — блокирует кнопку отправки
const loading = saving || compressing;
/** Обновляет одно поле формы и сбрасывает его ошибку валидации. */
const set = useCallback((key, value) => {
setForm(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: "" }));
}, []); }, []);
const handleFileChange = async (e) => { /** Собирает FormData и отправляет запрос на создание или обновление. */
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 () => { const handleSubmit = async () => {
if (!validate()) return; const validationErrors = validatePostForm(form);
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
const data = new FormData(); const data = new FormData();
data.append("type", form.type); data.append("type", form.type);
@@ -135,10 +83,8 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
if (form.date) data.append("date", form.date); if (form.date) data.append("date", form.date);
if (imageFile) data.append("image", imageFile); if (imageFile) data.append("image", imageFile);
const result = isEdit // save(id, data): id = null → POST (создание), id = string → PUT (обновление)
? await updateAd(initialData.id, data) const result = await save(isEdit ? initialData.id : null, data);
: await createAd(data);
if (result) { if (result) {
onSuccess(result); onSuccess(result);
onClose(); onClose();
@@ -146,31 +92,40 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
}; };
return ( return (
// Клик по фону (не по самой модалке) закрывает её
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}> <div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal"> <div className="f-modal">
{/* Шапка модалки */}
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title"> <span className="f-modal-title">
{isEdit ? "Редактировать объявление" : "Новое объявление"} {isEdit ? "Редактировать объявление" : "Новое объявление"}
</span> </span>
<button className="f-close-btn" onClick={onClose}><CloseIcon /></button> <button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div> </div>
{/* тип */} {/* Тип: Потеря / Находка */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Тип объявления</label> <label className="f-m-label">Тип объявления</label>
<div className="f-type-toggle"> <div className="f-type-toggle">
<button <button
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`} className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")} onClick={() => set("type", "loss")}
>Потеря</button> >
Потеря
</button>
<button <button
className={`f-type-opt${form.type === "found" ? " found" : ""}`} className={`f-type-opt${form.type === "found" ? " found" : ""}`}
onClick={() => set("type", "found")} onClick={() => set("type", "found")}
>Находка</button> >
Находка
</button>
</div> </div>
</div> </div>
{/* название */} {/* Название */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Название</label> <label className="f-m-label">Название</label>
<input <input
@@ -183,7 +138,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{errors.title && <p className="f-err-msg">{errors.title}</p>} {errors.title && <p className="f-err-msg">{errors.title}</p>}
</div> </div>
{/* описание */} {/* Описание */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Описание</label> <label className="f-m-label">Описание</label>
<textarea <textarea
@@ -196,7 +151,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{errors.description && <p className="f-err-msg">{errors.description}</p>} {errors.description && <p className="f-err-msg">{errors.description}</p>}
</div> </div>
{/* фото */} {/* Фотография: превью + удаление или загрузчик */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Фотография</label> <label className="f-m-label">Фотография</label>
{displayImage ? ( {displayImage ? (
@@ -217,6 +172,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={ACCEPT} accept={ACCEPT}
// На мобильных открываем камеру напрямую
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent) ? "environment" : undefined} capture={/Mobi|Android|iPhone/i.test(navigator.userAgent) ? "environment" : undefined}
onChange={handleFileChange} onChange={handleFileChange}
className="f-img-input" 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> <p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
</div> </div>
{/* категория */} {/* Категория */}
<div className="f-m-row"> <div className="f-m-row">
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Категория</label> <label className="f-m-label">Категория</label>
@@ -245,7 +201,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
</div> </div>
</div> </div>
{/* карта */} {/* Карта — заглушка, будет в следующей версии */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Место на карте</label> <label className="f-m-label">Место на карте</label>
<div className="f-map-pick"> <div className="f-map-pick">
@@ -255,7 +211,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
<p className="f-hint">Карта будет добавлена в следующей версии</p> <p className="f-hint">Карта будет добавлена в следующей версии</p>
</div> </div>
{/* телефон */} {/* Телефон с автоматической маской */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Номер телефона</label> <label className="f-m-label">Номер телефона</label>
<input <input
@@ -268,6 +224,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{errors.phone && <p className="f-err-msg">{errors.phone}</p>} {errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div> </div>
{/* Глобальная ошибка API (не связана с конкретным полем) */}
{apiError && <p className="f-err-msg">{apiError}</p>} {apiError && <p className="f-err-msg">{apiError}</p>}
<button className="f-m-submit" onClick={handleSubmit} disabled={loading}> <button className="f-m-submit" onClick={handleSubmit} disabled={loading}>
@@ -276,6 +233,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{isEdit ? "Сохранить изменения" : "Разместить объявление"} {isEdit ? "Сохранить изменения" : "Разместить объявление"}
</div> </div>
</button> </button>
</div> </div>
</div> </div>
); );

View File

@@ -1,22 +1,35 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import "../styles/feed.css"; import "../styles/index.css";
import { API_URL } from "../config/constants"; import { API_URL } from "../config/authConstants";
import Sidebar from "../components/Sidebar"; import Sidebar from "./Sidebar";
import RightSidebar from "../components/RightSidebar"; import RightSidebar from "./RightSidebar";
import AdCard from "../components/AdCard"; import PostOnFeed from "./PostOnFeed";
import PostModal from "../components/PostModal"; import CreateUpdatePost from "./CreateUpdatePost";
import MenuIcon from "../components/icons/MenuIcon"; import MenuIcon from "./icons/MenuIcon";
export default function FeedPage() { /**
* MainFeed — корневой компонент приложения.
* Управляет состоянием ленты, модалок и сайдбара;
* остальные компоненты получают данные через пропсы.
*/
export default function MainFeed() {
// Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре
const [activeNav, setActiveNav] = useState("Главная"); const [activeNav, setActiveNav] = useState("Главная");
// Открыта ли модалка создания нового объявления
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
// Объявление, которое сейчас редактируется (null — модалка закрыта)
const [editAd, setEditAd] = useState(null); const [editAd, setEditAd] = useState(null);
// Массив объявлений из API
const [ads, setAds] = useState([]); const [ads, setAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set()); const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
/** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async () => { const loadFeed = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -24,6 +37,7 @@ export default function FeedPage() {
const res = await fetch(`${API_URL}/api/v1/posts`); const res = await fetch(`${API_URL}/api/v1/posts`);
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Ошибка ${res.status}`);
const data = await res.json(); const data = await res.json();
// API может вернуть массив напрямую или объект { items: [] }
setAds(Array.isArray(data) ? data : (data.items ?? [])); setAds(Array.isArray(data) ? data : (data.items ?? []));
} catch (err) { } catch (err) {
setError(err.message || "Не удалось загрузить объявления"); setError(err.message || "Не удалось загрузить объявления");
@@ -32,8 +46,13 @@ export default function FeedPage() {
} }
}, []); }, []);
// Первичная загрузка при монтировании компонента
useEffect(() => { loadFeed(); }, [loadFeed]); useEffect(() => { loadFeed(); }, [loadFeed]);
/**
* Переключает лайк на объявлении.
* Состояние хранится только на клиенте — без запроса к API.
*/
const toggleLike = useCallback((id) => { const toggleLike = useCallback((id) => {
setLiked(prev => { setLiked(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -42,10 +61,18 @@ export default function FeedPage() {
}); });
}, []); }, []);
// перезагружаем ленту чтобы получить images из БД // После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]); const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]); const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
/**
* Рендерит содержимое ленты в зависимости от текущего состояния:
* — спиннер во время загрузки
* — сообщение об ошибке
* — заглушка при пустой ленте
* — список карточек объявлений
*/
const renderContent = () => { const renderContent = () => {
if (loading) { if (loading) {
return ( return (
@@ -78,18 +105,19 @@ export default function FeedPage() {
); );
} }
return ads.map(ad => ( return ads.map(ad => (
<AdCard <PostOnFeed
key={ad.id} key={ad.id}
ad={ad} ad={ad}
liked={liked.has(ad.id)} liked={liked.has(ad.id)} // передаём локальное состояние лайка
onLike={toggleLike} onLike={toggleLike} // колбэк для переключения лайка
onEdit={setEditAd} onEdit={setEditAd} // колбэк открывает модалку редактирования
/> />
)); ));
}; };
return ( return (
<div className="f-app"> <div className="f-app">
{/* Левый сайдбар с навигацией */}
<Sidebar <Sidebar
open={sidebarOpen} open={sidebarOpen}
activeNav={activeNav} activeNav={activeNav}
@@ -98,6 +126,7 @@ export default function FeedPage() {
/> />
<div className="f-main"> <div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar"> <div className="f-topbar">
<button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}> <button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon /> <MenuIcon />
@@ -108,26 +137,31 @@ export default function FeedPage() {
Разместить объявление Разместить объявление
</button> </button>
</div> </div>
{/* Пустой спейсер симметрично уравновешивает бургер слева */}
<div className="f-topbar-spacer" /> <div className="f-topbar-spacer" />
</div> </div>
{/* Основная область: лента + правый сайдбар */}
<div className="f-wrap"> <div className="f-wrap">
<div className="f-feed"> <div className="f-feed">
{renderContent()} {renderContent()}
</div> </div>
{/* Правый сайдбар: промо-блоки / фильтры */}
<RightSidebar /> <RightSidebar />
</div> </div>
</div> </div>
{/* Модалка создания нового объявления */}
{modalOpen && ( {modalOpen && (
<PostModal <CreateUpdatePost
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onSuccess={handleNewAd} onSuccess={handleNewAd}
/> />
)} )}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && ( {editAd && (
<PostModal <CreateUpdatePost
initialData={editAd} initialData={editAd}
onClose={() => setEditAd(null)} onClose={() => setEditAd(null)}
onSuccess={handleEditSave} onSuccess={handleEditSave}

View File

@@ -3,9 +3,9 @@ import CalendarIcon from "./icons/CalendarIcon";
import PhoneIcon from "./icons/PhoneIcon"; import PhoneIcon from "./icons/PhoneIcon";
import MapIcon from "./icons/MapIcon"; import MapIcon from "./icons/MapIcon";
import HeartIcon from "./icons/HeartIcon"; 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; const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null;
return ( return (

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/constants"; import { API_URL } from "../config/authConstants";
export function useAuth() { export function useAuth() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { API_URL } from "../config/constants"; import { API_URL } from "../config/authConstants";
export function useCreatePost() { export function useCreatePost() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@@ -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 на <input type="file"> — нужен для сброса значения после 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,
};
}

View File

@@ -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<object|null>,
* loading: boolean,
* error: string,
* }}
*/
export function usePostMutation() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/**
* @param {string|null} id — id объявления при обновлении, null при создании
* @param {FormData} data — данные формы
* @returns {object|null} — ответ сервера или null при ошибке
*/
const save = async (id, data) => {
setLoading(true);
setError("");
try {
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`;
const method = id ? "PUT" : "POST";
const res = await fetch(url, { method, body: data });
if (!res.ok) throw new Error(`Ошибка сервера: ${res.status}`);
return await res.json();
} catch (e) {
setError(e.message || "Неизвестная ошибка");
return null;
} finally {
setLoading(false);
}
};
return { save, loading, error };
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import {API_URL} from "../config/constants"; import {API_URL} from "../config/authConstants";
export function useCheckLogin(login) { export function useCheckLogin(login) {

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { API_URL } from "../config/constants"; import { API_URL } from "../config/authConstants";
export function useUpdatePost() { export function useUpdatePost() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -14,7 +14,6 @@ export function useUpdatePost() {
const res = await fetch(`${API_URL}/api/v1/posts/${id}`, { const res = await fetch(`${API_URL}/api/v1/posts/${id}`, {
method: "PUT", method: "PUT",
headers: { headers: {
// Content-Type НЕ указываем — браузер сам выставит multipart/form-data
"Authorization": `Bearer ${token}`, "Authorization": `Bearer ${token}`,
}, },
body: formData, // FormData body: formData, // FormData

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

18
src/styles/base.css Normal file
View File

@@ -0,0 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--teal: #2de0c8;
--teal2: #1fc9b0;
--border: rgba(255,255,255,0.09);
--muted: rgba(255,255,255,0.38);
--muted2: rgba(255,255,255,0.15);
--font: 'Nunito', sans-serif;
--err: #ff6b6b;
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes mfadeIn { from{opacity:0} to{opacity:1} }
@keyframes mslideUp { from{opacity:0;transform:translateY(30px) scale(.96)} to{opacity:1;transform:none} }

93
src/styles/card.css Normal file
View File

@@ -0,0 +1,93 @@
/* ── Ad Card ── */
.f-card {
flex-shrink: 0;
background: linear-gradient(155deg, #1d2436, #131720);
border: 1px solid var(--border); border-radius: 20px; overflow: hidden;
transition: border-color .2s, transform .2s; cursor: pointer;
}
.f-card:hover { border-color: rgba(45,224,200,.3); transform: translateY(-2px); }
.f-card-inner {
display: flex; flex-direction: row; min-height: 160px;
}
/* Image Panel */
.f-card-img {
width: 180px; min-width: 180px;
background: linear-gradient(135deg, #1a3a4a 0%, #0f2235 100%);
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden; flex-shrink: 0;
}
.f-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.f-card-emoji { font-size: 3rem; opacity: .22; }
/* Badges */
.f-card-badges {
display: flex; align-items: center; justify-content: space-between;
gap: 6px; margin-bottom: 8px;
}
.f-cat-badge {
background: rgba(13,16,24,.8); backdrop-filter: blur(8px);
border: 1px solid var(--border); border-radius: 20px;
padding: 3px 10px; font-size: .72rem; font-weight: 700; color: var(--teal);
}
.f-type-badge {
border-radius: 20px; padding: 3px 10px;
font-size: .72rem; font-weight: 900;
}
.f-type-loss { background: rgba(255,107,107,.2); color: #ff6b6b; border: 1px solid rgba(255,107,107,.3); }
.f-type-found { background: rgba(45,224,200,.2); color: #2de0c8; border: 1px solid rgba(45,224,200,.3); }
/* Body */
.f-card-body {
flex: 1; padding: 14px 16px;
display: flex; flex-direction: column; min-width: 0;
}
.f-card-title { color: #fff; font-weight: 900; font-size: 1rem; margin-bottom: 5px; line-height: 1.3; }
.f-card-desc {
color: var(--muted); font-size: .82rem; line-height: 1.5; margin-bottom: 10px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* Meta pills */
.f-card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.f-meta-pill {
display: flex; align-items: center; gap: 4px;
background: rgba(255,255,255,.05); border: 1px solid var(--border);
border-radius: 20px; padding: 3px 9px; font-size: .73rem; color: var(--muted);
}
/* Map stub */
.f-card-map {
width: 100%; height: 76px;
background: #080f1c; margin-bottom: 14px; border-radius: 12px;
border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: .78rem;
cursor: pointer; transition: border-color .2s;
font-family: var(--font); font-weight: 600;
}
.f-card-map:hover { border-color: rgba(45,224,200,.3); color: var(--teal); }
/* Footer */
.f-card-foot {
display: flex; align-items: center; justify-content: space-between;
padding-top: 10px; border-top: 1px solid var(--border); margin-top: auto;
}
.f-card-foot-left { display: flex; align-items: center; }
.f-card-foot-right { display: flex; align-items: center; gap: 6px; }
.f-card-phone {
color: var(--teal); font-size: .82rem; font-weight: 700;
text-decoration: none; display: flex; align-items: center; gap: 5px;
}
.f-card-date { color: var(--muted2); font-size: .75rem; }
.f-like-btn {
display: flex; align-items: center; gap: 5px;
background: none; border: 1px solid var(--border);
border-radius: 20px; padding: 5px 11px; cursor: pointer;
font-family: var(--font); font-size: .8rem; font-weight: 700;
color: var(--muted); transition: all .2s; line-height: 1;
}
.f-like-btn:hover, .f-like-btn.liked {
border-color: rgba(255,107,107,.5); color: #ff6b6b; background: rgba(255,107,107,.08);
}

View File

@@ -1,390 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--teal: #2de0c8;
--teal2: #1fc9b0;
--border: rgba(255,255,255,0.09);
--muted: rgba(255,255,255,0.38);
--muted2: rgba(255,255,255,0.15);
--font: 'Nunito', sans-serif;
--err: #ff6b6b;
}
.f-app {
display: flex; height: 100vh;
background: linear-gradient(135deg, #1a2340 0%, #0d1018 100%);
overflow: hidden; font-family: var(--font); position: relative;
}
/* ── Overlay ── */
.f-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 40; opacity: 0; pointer-events: none; transition: opacity .32s;
}
.f-overlay.show { opacity: 1; pointer-events: all; }
/* ── Left Sidebar ── */
.f-sidebar {
width: 260px; min-width: 260px;
background: linear-gradient(180deg, #1a2540 0%, #111827 100%);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
transform: translateX(-100%);
transition: transform .32s cubic-bezier(.4,0,.2,1);
position: fixed; left: 0; top: 0; height: 100%; z-index: 50;
}
.f-sidebar.open { transform: translateX(0); }
.f-sb-head {
padding: 22px 20px 16px;
display: flex; align-items: center; gap: 11px;
border-bottom: 1px solid var(--border);
}
.f-sb-logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--teal2), var(--teal));
border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.f-sb-name { color: #fff; font-weight: 900; font-size: 1.1rem; letter-spacing: -.3px; }
.f-sb-nav { padding: 12px 0; flex: 1; }
.f-sb-bottom { padding: 12px 0; border-top: 1px solid var(--border); }
.f-sb-item {
display: flex; align-items: center; gap: 13px;
padding: 11px 20px; cursor: pointer;
transition: background .15s, color .15s;
color: var(--muted); font-size: .9rem; font-weight: 700;
border-left: 3px solid transparent;
}
.f-sb-item:hover { background: rgba(255,255,255,.05); color: #fff; }
.f-sb-item.active { background: rgba(45,224,200,.1); color: var(--teal); border-left-color: var(--teal); }
/* ── Main ── */
.f-main {
flex: 1; display: flex; flex-direction: column;
min-width: 0; height: 100vh; overflow: hidden;
}
/* ── Topbar ── */
.f-topbar {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: rgba(13,16,24,.7); backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 10;
}
.f-menu-btn {
background: none; border: none; cursor: pointer;
padding: 6px; border-radius: 8px; transition: background .15s;
flex-shrink: 0; line-height: 0; width: 32px;
}
.f-menu-btn:hover { background: rgba(255,255,255,.08); }
.f-topbar-center { flex: 1; display: flex; justify-content: center; }
.f-topbar-spacer { width: 32px; flex-shrink: 0; }
.f-post-btn {
display: flex; align-items: center; gap: 10px;
padding: 12px 28px; border-radius: 16px;
font-family: var(--font); font-weight: 900; font-size: 0.95rem; letter-spacing: 0.3px;
color: #eafffb;
background: rgba(45,224,200,0.12); backdrop-filter: blur(14px);
border: 1px solid rgba(45,224,200,0.25);
box-shadow: 0 8px 24px rgba(0,0,0,0.35), inset 0 0 12px rgba(45,224,200,0.15), 0 0 18px rgba(45,224,200,0.15);
cursor: pointer; transition: all 0.25s ease;
position: relative; overflow: hidden;
}
.f-post-btn::before {
content: ""; position: absolute; inset: 0;
background: linear-gradient(120deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05) 40%, transparent 60%);
opacity: 0.4; pointer-events: none;
}
.f-post-btn:hover {
transform: translateY(-2px) scale(1.02);
border-color: rgba(45,224,200,0.5);
box-shadow: 0 12px 32px rgba(0,0,0,0.45), inset 0 0 18px rgba(45,224,200,0.25), 0 0 26px rgba(45,224,200,0.35);
}
.f-post-btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.4), inset 0 0 10px rgba(45,224,200,0.2);
}
.f-post-btn span {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(45,224,200,0.2); box-shadow: 0 0 10px rgba(45,224,200,0.4);
font-size: 1.2rem; transform: translateY(-2px);
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Feed wrap ── */
.f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
.f-feed {
flex: 1; overflow-y: scroll; min-height: 0;
padding: 16px; display: flex; flex-direction: column; gap: 14px;
}
.f-feed::-webkit-scrollbar { width: 4px; }
.f-feed::-webkit-scrollbar-track { background: transparent; }
.f-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
/* ── States ── */
.f-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 12px; color: var(--muted); text-align: center; padding: 40px;
}
.f-center-icon { font-size: 2.8rem; opacity: .3; }
.f-center-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,.5); }
.f-center-sub { font-size: .82rem; line-height: 1.6; }
.f-spinner-wrap { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: .88rem; }
.f-spinner {
width: 20px; height: 20px;
border: 2px solid rgba(45,224,200,.2); border-top-color: var(--teal);
border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0;
}
/* ── Ad Card ── */
.f-card {
flex-shrink: 0;
background: linear-gradient(155deg, #1d2436, #131720);
border: 1px solid var(--border); border-radius: 20px; overflow: hidden;
transition: border-color .2s, transform .2s; cursor: pointer;
}
.f-card:hover { border-color: rgba(45,224,200,.3); transform: translateY(-2px); }
.f-card-inner {
display: flex; flex-direction: row; min-height: 160px;
}
.f-card-img {
width: 180px; min-width: 180px;
background: linear-gradient(135deg, #1a3a4a 0%, #0f2235 100%);
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden; flex-shrink: 0;
}
.f-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.f-card-emoji { font-size: 3rem; opacity: .22; }
.f-cat-badge {
background: rgba(13,16,24,.8);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 3px 10px;
font-size: .72rem;
font-weight: 700;
color: var(--teal);
}
.f-card-badges {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 8px;
}
.f-type-badge {
border-radius: 20px;
padding: 3px 10px;
font-size: .72rem;
font-weight: 900;
}
.f-type-loss { background: rgba(255,107,107,.2); color: #ff6b6b; border: 1px solid rgba(255,107,107,.3); }
.f-type-found { background: rgba(45,224,200,.2); color: #2de0c8; border: 1px solid rgba(45,224,200,.3); }
.f-card-body {
flex: 1; padding: 14px 16px;
display: flex; flex-direction: column; min-width: 0;
}
.f-card-title { color: #fff; font-weight: 900; font-size: 1rem; margin-bottom: 5px; line-height: 1.3; }
.f-card-desc {
color: var(--muted); font-size: .82rem; line-height: 1.5; margin-bottom: 10px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.f-card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.f-meta-pill {
display: flex; align-items: center; gap: 4px;
background: rgba(255,255,255,.05); border: 1px solid var(--border);
border-radius: 20px; padding: 3px 9px; font-size: .73rem; color: var(--muted);
}
.f-card-map {
width: 100%; height: 76px;
background: #080f1c; margin-bottom: 14px; border-radius: 12px;
border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: .78rem;
cursor: pointer; transition: border-color .2s;
font-family: var(--font); font-weight: 600;
}
.f-card-map:hover { border-color: rgba(45,224,200,.3); color: var(--teal); }
.f-card-foot {
display: flex; align-items: center; justify-content: space-between;
padding-top: 10px; border-top: 1px solid var(--border);
margin-top: auto;
}
.f-card-phone {
color: var(--teal); font-size: .82rem; font-weight: 700;
text-decoration: none; display: flex; align-items: center; gap: 5px;
}
.f-card-date { color: var(--muted2); font-size: .75rem; }
.f-like-btn {
display: flex; align-items: center; gap: 5px;
background: none; border: 1px solid var(--border);
border-radius: 20px; padding: 5px 11px; cursor: pointer;
font-family: var(--font); font-size: .8rem; font-weight: 700;
color: var(--muted); transition: all .2s; line-height: 1;
}
.f-like-btn:hover, .f-like-btn.liked {
border-color: rgba(255,107,107,.5); color: #ff6b6b; background: rgba(255,107,107,.08);
}
/* ── Right Sidebar ── */
.f-right {
width: 220px; min-width: 220px; padding: 16px 12px;
border-left: 1px solid var(--border);
display: flex; flex-direction: column; gap: 14px; overflow-y: auto;
}
.f-right::-webkit-scrollbar { width: 3px; }
.f-right::-webkit-scrollbar-thumb { background: rgba(255,255,255,.08); border-radius: 2px; }
.f-right-label {
font-size: .65rem; font-weight: 900;
text-transform: uppercase; letter-spacing: .8px; color: var(--muted2);
}
.f-right-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 12px; text-align: center;
border: 1.5px dashed rgba(255,255,255,.08);
border-radius: 16px; padding: 20px; min-height: 200px;
}
.f-right-empty-icon { font-size: 2rem; opacity: .2; }
.f-right-empty-text { color: var(--muted); font-size: .78rem; line-height: 1.5; }
.f-add-promo-btn {
background: rgba(45,224,200,.08); border: 1px solid rgba(45,224,200,.25);
border-radius: 10px; padding: 9px 14px;
color: var(--teal); font-family: var(--font);
font-size: .8rem; font-weight: 800; cursor: pointer;
transition: background .2s, transform .15s;
}
.f-add-promo-btn:hover { background: rgba(45,224,200,.15); transform: translateY(-1px); }
/* ── Modal ── */
.f-modal-bg {
position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 100;
display: flex; align-items: center; justify-content: center;
animation: mfadeIn .2s ease;
}
@keyframes mfadeIn { from{opacity:0} to{opacity:1} }
.f-modal {
background: linear-gradient(155deg, #1d2436, #131720);
border: 1px solid rgba(255,255,255,.12); border-radius: 24px;
width: 440px; max-height: 90vh; overflow-y: auto; padding: 26px 28px 24px;
animation: mslideUp .3s cubic-bezier(.34,1.56,.64,1);
}
@keyframes mslideUp { from{opacity:0;transform:translateY(30px) scale(.96)} to{opacity:1;transform:none} }
.f-modal::-webkit-scrollbar { width: 4px; }
.f-modal::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
.f-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.f-modal-title { color: #fff; font-weight: 900; font-size: 1.1rem; }
.f-close-btn {
background: rgba(255,255,255,.07); border: 1px solid var(--border);
border-radius: 8px; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--muted); transition: background .15s;
}
.f-close-btn:hover { background: rgba(255,255,255,.13); color: #fff; }
.f-m-group { margin-bottom: 14px; }
.f-m-row { display: flex; gap: 10px; }
.f-m-row .f-m-group { flex: 1; }
.f-m-label {
display: block; color: rgba(255,255,255,.45);
font-size: .72rem; font-weight: 700; letter-spacing: .6px;
margin-bottom: 6px; text-transform: uppercase; font-family: var(--font);
}
.f-m-field {
width: 100%; background: rgba(255,255,255,.055);
border: 1.5px solid rgba(255,255,255,.09); border-radius: 12px;
padding: 11px 14px; color: #fff; font-family: var(--font);
font-size: .9rem; outline: none; resize: none;
transition: border-color .2s, box-shadow .2s;
}
.f-m-field:focus { border-color: #2de0c8; background: rgba(45,224,200,.07); box-shadow: 0 0 0 3px rgba(45,224,200,.12); }
.f-m-field::placeholder { color: rgba(255,255,255,.32); }
.f-m-field option { background: #1d2436; color: #fff; }
.f-m-field.err { border-color: rgba(255,107,107,.55); }
.f-type-toggle { display: flex; gap: 8px; }
.f-type-opt {
flex: 1; padding: 10px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,.09); background: rgba(255,255,255,.04);
color: var(--muted); font-family: var(--font); font-size: .85rem;
font-weight: 800; cursor: pointer; text-align: center; transition: all .2s;
}
.f-type-opt.loss { border-color: rgba(255,107,107,.5); background: rgba(255,107,107,.12); color: #ff6b6b; }
.f-type-opt.found { border-color: rgba(45,224,200,.5); background: rgba(45,224,200,.1); color: #2de0c8; }
.f-map-pick {
height: 80px; background: linear-gradient(135deg, #0f2235, #0a1a2e);
border-radius: 12px; border: 1.5px dashed rgba(255,255,255,.15);
display: flex; align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: .83rem; cursor: pointer;
transition: border-color .2s; font-family: var(--font); font-weight: 600;
}
.f-map-pick:hover { border-color: rgba(45,224,200,.4); color: var(--teal); }
.f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }
.f-err-msg { font-size: .78rem; color: var(--err); margin-top: 5px; }
.f-m-submit {
width: 100%;
background: linear-gradient(90deg, #1fc9b0, #2de0c8, #1fc9b0);
background-size: 200% 100%; border: none; border-radius: 13px;
padding: 14px; color: #082320; font-family: var(--font);
font-size: 1rem; font-weight: 900; cursor: pointer; margin-top: 6px;
animation: shimmer 3s linear infinite; transition: opacity .15s, transform .15s;
}
.f-m-submit:hover:not(:disabled) { opacity: .88; transform: translateY(-1px); }
.f-m-submit:active:not(:disabled) { transform: translateY(0); }
.f-m-submit:disabled { opacity: .5; cursor: not-allowed; animation: none; background: #444; color: #aaa; }
.f-m-submit-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
.f-m-spinner {
width: 16px; height: 16px;
border: 2px solid rgba(8,35,32,.3); border-top-color: #082320;
border-radius: 50%; animation: spin .7s linear infinite;
}
/* ── Загрузка фото ── */
.f-img-upload-label {
display: flex; align-items: center; justify-content: center; gap: 8px;
border: 2px dashed rgba(255,255,255,.15); border-radius: 12px; padding: 20px;
cursor: pointer; color: var(--muted); font-size: .85rem; font-family: var(--font);
transition: border-color .2s, background .2s;
}
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(45,224,200,.05); color: var(--teal); }
.f-img-input { display: none; }
/* ── Превью фото ── */
.f-img-preview-wrap { position: relative; display: inline-block; width: 100%; }
.f-img-preview { width: 100%; max-height: 220px; object-fit: cover; border-radius: 12px; display: block; }
.f-img-remove {
position: absolute; top: 8px; right: 8px;
background: rgba(0,0,0,0.55); border: none; border-radius: 50%;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #fff; padding: 0; transition: background .2s;
}
.f-img-remove:hover { background: rgba(0,0,0,0.8); }
.f-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.f-card-foot-left { display: flex; align-items: center; }
.f-card-foot-right { display: flex; align-items: center; gap: 6px; }

7
src/styles/index.css Normal file
View File

@@ -0,0 +1,7 @@
/* Entry point — import order matters */
@import './base.css'; /* variables, reset, keyframes */
@import './layout.css'; /* app shell, topbar, feed */
@import './sidebar.css'; /* left nav + right panel */
@import './card.css'; /* ad card component */
@import './modal.css'; /* modal, form, image upload */
@import './ui.css'; /* buttons, states, spinner */

47
src/styles/layout.css Normal file
View File

@@ -0,0 +1,47 @@
/* ── App Shell ── */
.f-app {
display: flex; height: 100vh;
background: linear-gradient(135deg, #1a2340 0%, #0d1018 100%);
overflow: hidden; font-family: var(--font); position: relative;
}
/* ── Overlay (mobile sidebar backdrop) ── */
.f-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 40; opacity: 0; pointer-events: none; transition: opacity .32s;
}
.f-overlay.show { opacity: 1; pointer-events: all; }
/* ── Main Column ── */
.f-main {
flex: 1; display: flex; flex-direction: column;
min-width: 0; height: 100vh; overflow: hidden;
}
/* ── Topbar ── */
.f-topbar {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: rgba(13,16,24,.7); backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 10;
}
.f-menu-btn {
background: none; border: none; cursor: pointer;
padding: 6px; border-radius: 8px; transition: background .15s;
flex-shrink: 0; line-height: 0; width: 32px;
}
.f-menu-btn:hover { background: rgba(255,255,255,.08); }
.f-topbar-center { flex: 1; display: flex; justify-content: center; }
.f-topbar-spacer { width: 32px; flex-shrink: 0; }
/* ── Feed Wrap + Scroll ── */
.f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
.f-feed {
flex: 1; overflow-y: scroll; min-height: 0;
padding: 16px; display: flex; flex-direction: column; gap: 14px;
}
.f-feed::-webkit-scrollbar { width: 4px; }
.f-feed::-webkit-scrollbar-track { background: transparent; }
.f-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }

112
src/styles/modal.css Normal file
View File

@@ -0,0 +1,112 @@
/* ── Modal Backdrop ── */
.f-modal-bg {
position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 100;
display: flex; align-items: center; justify-content: center;
animation: mfadeIn .2s ease;
}
.f-modal {
background: linear-gradient(155deg, #1d2436, #131720);
border: 1px solid rgba(255,255,255,.12); border-radius: 24px;
width: 440px; max-height: 90vh; overflow-y: auto; padding: 26px 28px 24px;
animation: mslideUp .3s cubic-bezier(.34,1.56,.64,1);
}
.f-modal::-webkit-scrollbar { width: 4px; }
.f-modal::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
/* Header */
.f-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.f-modal-title { color: #fff; font-weight: 900; font-size: 1.1rem; }
.f-close-btn {
background: rgba(255,255,255,.07); border: 1px solid var(--border);
border-radius: 8px; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--muted); transition: background .15s;
}
.f-close-btn:hover { background: rgba(255,255,255,.13); color: #fff; }
/* Form Fields */
.f-m-group { margin-bottom: 14px; }
.f-m-row { display: flex; gap: 10px; }
.f-m-row .f-m-group { flex: 1; }
.f-m-label {
display: block; color: rgba(255,255,255,.45);
font-size: .72rem; font-weight: 700; letter-spacing: .6px;
margin-bottom: 6px; text-transform: uppercase; font-family: var(--font);
}
.f-m-field {
width: 100%; background: rgba(255,255,255,.055);
border: 1.5px solid rgba(255,255,255,.09); border-radius: 12px;
padding: 11px 14px; color: #fff; font-family: var(--font);
font-size: .9rem; outline: none; resize: none;
transition: border-color .2s, box-shadow .2s;
}
.f-m-field:focus { border-color: #2de0c8; background: rgba(45,224,200,.07); box-shadow: 0 0 0 3px rgba(45,224,200,.12); }
.f-m-field::placeholder { color: rgba(255,255,255,.32); }
.f-m-field option { background: #1d2436; color: #fff; }
.f-m-field.err { border-color: rgba(255,107,107,.55); }
/* Type Toggle */
.f-type-toggle { display: flex; gap: 8px; }
.f-type-opt {
flex: 1; padding: 10px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,.09); background: rgba(255,255,255,.04);
color: var(--muted); font-family: var(--font); font-size: .85rem;
font-weight: 800; cursor: pointer; text-align: center; transition: all .2s;
}
.f-type-opt.loss { border-color: rgba(255,107,107,.5); background: rgba(255,107,107,.12); color: #ff6b6b; }
.f-type-opt.found { border-color: rgba(45,224,200,.5); background: rgba(45,224,200,.1); color: #2de0c8; }
/* Map picker */
.f-map-pick {
height: 80px; background: linear-gradient(135deg, #0f2235, #0a1a2e);
border-radius: 12px; border: 1.5px dashed rgba(255,255,255,.15);
display: flex; align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: .83rem; cursor: pointer;
transition: border-color .2s; font-family: var(--font); font-weight: 600;
}
.f-map-pick:hover { border-color: rgba(45,224,200,.4); color: var(--teal); }
/* Image Upload */
.f-img-upload-label {
display: flex; align-items: center; justify-content: center; gap: 8px;
border: 2px dashed rgba(255,255,255,.15); border-radius: 12px; padding: 20px;
cursor: pointer; color: var(--muted); font-size: .85rem; font-family: var(--font);
transition: border-color .2s, background .2s;
}
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(45,224,200,.05); color: var(--teal); }
.f-img-input { display: none; }
/* Image Preview */
.f-img-preview-wrap { position: relative; display: inline-block; width: 100%; }
.f-img-preview { width: 100%; max-height: 220px; object-fit: cover; border-radius: 12px; display: block; }
.f-img-remove {
position: absolute; top: 8px; right: 8px;
background: rgba(0,0,0,0.55); border: none; border-radius: 50%;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #fff; padding: 0; transition: background .2s;
}
.f-img-remove:hover { background: rgba(0,0,0,0.8); }
/* Hints & Errors */
.f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }
.f-err-msg { font-size: .78rem; color: var(--err); margin-top: 5px; }
/* Submit Button */
.f-m-submit {
width: 100%;
background: linear-gradient(90deg, #1fc9b0, #2de0c8, #1fc9b0);
background-size: 200% 100%; border: none; border-radius: 13px;
padding: 14px; color: #082320; font-family: var(--font);
font-size: 1rem; font-weight: 900; cursor: pointer; margin-top: 6px;
animation: shimmer 3s linear infinite; transition: opacity .15s, transform .15s;
}
.f-m-submit:hover:not(:disabled) { opacity: .88; transform: translateY(-1px); }
.f-m-submit:active:not(:disabled) { transform: translateY(0); }
.f-m-submit:disabled { opacity: .5; cursor: not-allowed; animation: none; background: #444; color: #aaa; }
.f-m-submit-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
.f-m-spinner {
width: 16px; height: 16px;
border: 2px solid rgba(8,35,32,.3); border-top-color: #082320;
border-radius: 50%; animation: spin .7s linear infinite;
}

65
src/styles/sidebar.css Normal file
View File

@@ -0,0 +1,65 @@
/* ── Left Sidebar ── */
.f-sidebar {
width: 260px; min-width: 260px;
background: linear-gradient(180deg, #1a2540 0%, #111827 100%);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
transform: translateX(-100%);
transition: transform .32s cubic-bezier(.4,0,.2,1);
position: fixed; left: 0; top: 0; height: 100%; z-index: 50;
}
.f-sidebar.open { transform: translateX(0); }
.f-sb-head {
padding: 22px 20px 16px;
display: flex; align-items: center; gap: 11px;
border-bottom: 1px solid var(--border);
}
.f-sb-logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--teal2), var(--teal));
border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.f-sb-name { color: #fff; font-weight: 900; font-size: 1.1rem; letter-spacing: -.3px; }
.f-sb-nav { padding: 12px 0; flex: 1; }
.f-sb-bottom { padding: 12px 0; border-top: 1px solid var(--border); }
.f-sb-item {
display: flex; align-items: center; gap: 13px;
padding: 11px 20px; cursor: pointer;
transition: background .15s, color .15s;
color: var(--muted); font-size: .9rem; font-weight: 700;
border-left: 3px solid transparent;
}
.f-sb-item:hover { background: rgba(255,255,255,.05); color: #fff; }
.f-sb-item.active { background: rgba(45,224,200,.1); color: var(--teal); border-left-color: var(--teal); }
/* ── Right Sidebar ── */
.f-right {
width: 220px; min-width: 220px; padding: 16px 12px;
border-left: 1px solid var(--border);
display: flex; flex-direction: column; gap: 14px; overflow-y: auto;
}
.f-right::-webkit-scrollbar { width: 3px; }
.f-right::-webkit-scrollbar-thumb { background: rgba(255,255,255,.08); border-radius: 2px; }
.f-right-label {
font-size: .65rem; font-weight: 900;
text-transform: uppercase; letter-spacing: .8px; color: var(--muted2);
}
.f-right-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 12px; text-align: center;
border: 1.5px dashed rgba(255,255,255,.08);
border-radius: 16px; padding: 20px; min-height: 200px;
}
.f-right-empty-icon { font-size: 2rem; opacity: .2; }
.f-right-empty-text { color: var(--muted); font-size: .78rem; line-height: 1.5; }
.f-add-promo-btn {
background: rgba(45,224,200,.08); border: 1px solid rgba(45,224,200,.25);
border-radius: 10px; padding: 9px 14px;
color: var(--teal); font-family: var(--font);
font-size: .8rem; font-weight: 800; cursor: pointer;
transition: background .2s, transform .15s;
}
.f-add-promo-btn:hover { background: rgba(45,224,200,.15); transform: translateY(-1px); }

50
src/styles/ui.css Normal file
View File

@@ -0,0 +1,50 @@
/* ── Post Button (topbar CTA) ── */
.f-post-btn {
display: flex; align-items: center; gap: 10px;
padding: 12px 28px; border-radius: 16px;
font-family: var(--font); font-weight: 900; font-size: 0.95rem; letter-spacing: 0.3px;
color: #eafffb;
background: rgba(45,224,200,0.12); backdrop-filter: blur(14px);
border: 1px solid rgba(45,224,200,0.25);
box-shadow: 0 8px 24px rgba(0,0,0,0.35), inset 0 0 12px rgba(45,224,200,0.15), 0 0 18px rgba(45,224,200,0.15);
cursor: pointer; transition: all 0.25s ease;
position: relative; overflow: hidden;
}
.f-post-btn::before {
content: ""; position: absolute; inset: 0;
background: linear-gradient(120deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05) 40%, transparent 60%);
opacity: 0.4; pointer-events: none;
}
.f-post-btn:hover {
transform: translateY(-2px) scale(1.02);
border-color: rgba(45,224,200,0.5);
box-shadow: 0 12px 32px rgba(0,0,0,0.45), inset 0 0 18px rgba(45,224,200,0.25), 0 0 26px rgba(45,224,200,0.35);
}
.f-post-btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.4), inset 0 0 10px rgba(45,224,200,0.2);
}
.f-post-btn span {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(45,224,200,0.2); box-shadow: 0 0 10px rgba(45,224,200,0.4);
font-size: 1.2rem; transform: translateY(-2px);
}
/* ── Feed Empty / Error States ── */
.f-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 12px; color: var(--muted); text-align: center; padding: 40px;
}
.f-center-icon { font-size: 2.8rem; opacity: .3; }
.f-center-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,.5); }
.f-center-sub { font-size: .82rem; line-height: 1.6; }
/* ── Loading Spinner ── */
.f-spinner-wrap { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: .88rem; }
.f-spinner {
width: 20px; height: 20px;
border: 2px solid rgba(45,224,200,.2); border-top-color: var(--teal);
border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0;
}

View File

@@ -0,0 +1,38 @@
/**
* Сжимает изображение до maxW пикселей по ширине и конвертирует в JPEG.
* Используется перед отправкой, чтобы не гнать тяжёлые файлы на сервер.
* Маленькие картинки (< maxW) не увеличиваются.
*
* @param {File} file — оригинальный файл
* @param {number} maxW — максимальная ширина в пикселях (default 1200)
* @param {number} quality — качество JPEG 0..1 (default 0.82)
* @returns {Promise<File>}
*/
export 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("canvas.toBlob вернул null")),
"image/jpeg",
quality
);
};
img.onerror = reject;
img.src = url;
});
}

17
src/utils/formatPhone.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Форматирует строку в маску +7 (___) ___-__-__.
* Принимает любой ввод — чистит всё кроме цифр.
*
* @param {string} val — сырое значение из input
* @returns {string} — отформатированная строка
*/
export function 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;
}

View File

@@ -0,0 +1,14 @@
/**
* Валидирует поля формы объявления на стороне клиента.
*
* @param {object} form — текущие значения формы
* @returns {object} — объект ошибок; пустой = форма валидна
*/
export function validatePostForm(form) {
const errors = {};
if (!form.title.trim()) errors.title = "Введите название";
if (!form.description.trim()) errors.description = "Введите описание";
if (!form.category) errors.category = "Выберите категорию";
if (!form.phone.trim()) errors.phone = "Введите номер телефона";
return errors;
}