global refactoring
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -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
|
||||||
type: initialData.type || "loss",
|
const [form, setForm] = useState(
|
||||||
title: initialData.title || "",
|
isEdit
|
||||||
description: initialData.description || "",
|
? {
|
||||||
category: initialData.category || "",
|
type: initialData.type || "loss",
|
||||||
date: initialData.date || "",
|
title: initialData.title || "",
|
||||||
phone: initialData.phone || "",
|
description: initialData.description || "",
|
||||||
} : EMPTY_FORM);
|
category: initialData.category || "",
|
||||||
|
date: initialData.date || "",
|
||||||
|
phone: initialData.phone || "",
|
||||||
|
}
|
||||||
|
: EMPTY_FORM
|
||||||
|
);
|
||||||
|
|
||||||
const [errors, setErrors] = useState({});
|
// Ошибки валидации — ключ совпадает с именем поля
|
||||||
const [imageFile, setImageFile] = useState(null);
|
const [errors, setErrors] = useState({});
|
||||||
// при редактировании показываем существующую картинку
|
|
||||||
const existingImage = isEdit && initialData.images?.[0]
|
// URL существующей картинки с сервера (только при редактировании)
|
||||||
|
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);
|
||||||
@@ -132,13 +80,11 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
|
|||||||
data.append("description", form.description.trim());
|
data.append("description", form.description.trim());
|
||||||
data.append("category", form.category);
|
data.append("category", form.category);
|
||||||
data.append("phone", form.phone.trim());
|
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);
|
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>
|
||||||
);
|
);
|
||||||
@@ -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}
|
||||||
@@ -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 (
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
87
src/hooks/useImageUpload.js
Normal file
87
src/hooks/useImageUpload.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/hooks/usePostMutation.js
Normal file
45
src/hooks/usePostMutation.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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
18
src/styles/base.css
Normal 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
93
src/styles/card.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
7
src/styles/index.css
Normal 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
47
src/styles/layout.css
Normal 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
112
src/styles/modal.css
Normal 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
65
src/styles/sidebar.css
Normal 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
50
src/styles/ui.css
Normal 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;
|
||||||
|
}
|
||||||
38
src/utils/compressImage.js
Normal file
38
src/utils/compressImage.js
Normal 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
17
src/utils/formatPhone.js
Normal 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;
|
||||||
|
}
|
||||||
14
src/utils/validatePostForm.js
Normal file
14
src/utils/validatePostForm.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user