added image for posts

This commit is contained in:
SlimusMinus
2026-06-01 01:55:26 +03:00
parent 825f86fb16
commit a2dd7363b5
6 changed files with 351 additions and 224 deletions

View File

@@ -3,59 +3,79 @@ 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";
export default function AdCard({ ad, liked, onLike, onEdit }) { export default function AdCard({ ad, liked, onLike, onEdit }) {
console.log("ad object:", ad); const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null;
return ( return (
<div className="f-card"> <div className="f-card">
<div className="f-card-img"> <div className="f-card-inner">
{ad.image
? <img src={ad.image} alt={ad.title} /> {/* Левая колонка — картинка */}
: <span className="f-card-emoji">{ad.emoji || "📋"}</span> <div className="f-card-img">
} {imageUrl
{ad.category && <div className="f-cat-badge">{ad.category}</div>} ? <img src={imageUrl} alt={ad.title} />
<div className={`f-type-badge ${ad.type === "LOSS" ? "f-type-loss" : "f-type-found"}`}> : <span className="f-card-emoji">{ad.emoji || "📋"}</span>
{ad.type === "LOSS" ? "Потеря" : "Находка"}
</div>
</div>
<div className="f-card-body">
<div className="f-card-title">{ad.title}</div>
<div className="f-card-desc">{ad.description}</div>
<div className="f-card-meta">
{ad.location && <div className="f-meta-pill"><PinIcon /> {ad.location}</div>}
{ad.date && <div className="f-meta-pill"><CalendarIcon /> {ad.date}</div>}
</div>
{ad.location && (
<button className="f-card-map" onClick={e => e.stopPropagation()}>
<MapIcon /> Показать на карте — {ad.location}
</button>
)}
<div className="f-card-foot">
{ad.phone
? <a className="f-card-phone" href={`tel:${ad.phone}`} onClick={e => e.stopPropagation()}>
<PhoneIcon /> {ad.phone}
</a>
: <span />
} }
<span className="f-card-date">{ad.date || ""}</span> </div>
<div style={{ display: "flex", gap: 6 }}>
{onEdit && ( {/* Правая колонка — контент */}
<button <div className="f-card-body">
className="f-like-btn" <div className="f-card-badges">
onClick={e => { e.stopPropagation(); onEdit(ad); }} {ad.category && <div className="f-cat-badge">{ad.category}</div>}
> <div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss"
✏️ : "f-type-found"}`}>
</button> {ad.type === "loss" ? "Потеря" : "Находка"}
)} </div>
<button </div>
className={`f-like-btn${liked ? " liked" : ""}`} <div className="f-card-title">{ad.title}</div>
onClick={e => { e.stopPropagation(); onLike(ad.id); }} <div className="f-card-desc">{ad.description}</div>
> <div className="f-card-meta">
<HeartIcon filled={liked} /> {ad.location && <div className="f-meta-pill">
{liked ? (ad.likes || 0) + 1 : (ad.likes || 0)} <PinIcon/> {ad.location}</div>}
</button> {ad.date && <div className="f-meta-pill"><CalendarIcon/> {ad.date}
</div>}
</div>
{ad.location && (
<button className="f-card-map"
onClick={e => e.stopPropagation()}>
<MapIcon/> Показать на карте — {ad.location}
</button>
)}
<div className="f-card-foot">
<div className="f-card-foot-left">
{ad.phone && (
<a className="f-card-phone" href={`tel:${ad.phone}`}
onClick={e => e.stopPropagation()}>
<PhoneIcon/> {ad.phone}
</a>
)}
</div>
<div className="f-card-foot-right">
{onEdit && (
<button
className="f-like-btn"
onClick={e => {
e.stopPropagation();
onEdit(ad);
}}
>✏️</button>
)}
<button
className={`f-like-btn${liked ? " liked" : ""}`}
onClick={e => {
e.stopPropagation();
onLike(ad.id);
}}
>
<HeartIcon filled={liked}/>
{liked ? (ad.likes || 0) + 1 : (ad.likes || 0)}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@@ -1,9 +1,46 @@
import { useCallback, useState } from "react"; import { useCallback, useState, useRef } 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 { useCreatePost } from "../hooks/useCreatePost";
import { useUpdatePost } from "../hooks/useUpdatePost"; import { useUpdatePost } from "../hooks/useUpdatePost";
import { API_URL } from "../config/constants";
const MAX_SIZE_MB = 5;
const ACCEPT = "image/jpeg,image/png,image/webp";
function compressImage(file, maxW = 1200, quality = 0.82) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxW / img.width);
const canvas = document.createElement("canvas");
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
blob => blob ? resolve(new File([blob], file.name, { type: "image/jpeg" })) : reject(new Error("Blob error")),
"image/jpeg",
quality
);
};
img.onerror = reject;
img.src = url;
});
}
const formatPhone = (val) => {
const digits = val.replace(/\D/g, "");
const local = digits.startsWith("7") ? digits.slice(1) : digits;
let result = "+7";
if (local.length > 0) result += " (" + local.slice(0, 3);
if (local.length >= 3) result += ") " + local.slice(3, 6);
if (local.length >= 6) result += "-" + local.slice(6, 8);
if (local.length >= 8) result += "-" + local.slice(8, 10);
return result;
};
export default function PostModal({ onClose, onSuccess, initialData }) { export default function PostModal({ onClose, onSuccess, initialData }) {
const isEdit = !!initialData; const isEdit = !!initialData;
@@ -17,10 +54,21 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
phone: initialData.phone || "", phone: initialData.phone || "",
} : EMPTY_FORM); } : EMPTY_FORM);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [imageFile, setImageFile] = useState(null);
// при редактировании показываем существующую картинку
const existingImage = isEdit && initialData.images?.[0]
? `${API_URL}${initialData.images[0]}`
: null;
const [preview, setPreview] = useState(null);
const [imgRemoved, setImgRemoved] = useState(false); // пользователь удалил старую
const [imgError, setImgError] = useState("");
const [compressing, setCompressing] = useState(false);
const fileInputRef = useRef(null);
const { createAd, loading: creating, error: createError } = useCreatePost(); const { createAd, loading: creating, error: createError } = useCreatePost();
const { updateAd, loading: updating, error: updateError } = useUpdatePost(); const { updateAd, loading: updating, error: updateError } = useUpdatePost();
const loading = creating || updating; const loading = creating || updating || compressing;
const apiError = createError || updateError; const apiError = createError || updateError;
const set = useCallback((k, v) => { const set = useCallback((k, v) => {
@@ -28,6 +76,43 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
setErrors(p => ({ ...p, [k]: "" })); setErrors(p => ({ ...p, [k]: "" }));
}, []); }, []);
const handleFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImgError("");
if (!file.type.startsWith("image/")) {
setImgError("Только изображения (JPEG, PNG, WebP)");
return;
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`);
return;
}
setCompressing(true);
try {
const compressed = await compressImage(file);
setImageFile(compressed);
setPreview(URL.createObjectURL(compressed));
setImgRemoved(false);
} catch {
setImgError("Не удалось обработать изображение");
} finally {
setCompressing(false);
}
};
const removeImage = () => {
setImageFile(null);
if (preview) URL.revokeObjectURL(preview);
setPreview(null);
setImgRemoved(true);
setImgError("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
// что показывать в блоке превью
const displayImage = preview || (!imgRemoved ? existingImage : null);
const validate = () => { const validate = () => {
const e = {}; const e = {};
if (!form.title.trim()) e.title = "Введите название"; if (!form.title.trim()) e.title = "Введите название";
@@ -40,18 +125,19 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validate()) return; if (!validate()) return;
const payload = {
type: form.type, const data = new FormData();
title: form.title.trim(), data.append("type", form.type);
description: form.description.trim(), data.append("title", form.title.trim());
category: form.category, data.append("description", form.description.trim());
date: form.date || null, data.append("category", form.category);
phone: form.phone.trim(), data.append("phone", form.phone.trim());
}; if (form.date) data.append("date", form.date);
if (imageFile) data.append("image", imageFile);
const result = isEdit const result = isEdit
? await updateAd(initialData.id, payload) ? await updateAd(initialData.id, data)
: await createAd(payload); : await createAd(data);
if (result) { if (result) {
onSuccess(result); onSuccess(result);
@@ -69,24 +155,22 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
<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
@@ -99,6 +183,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
@@ -111,6 +196,40 @@ 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">
<label className="f-m-label">Фотография</label>
{displayImage ? (
<div className="f-img-preview-wrap">
<img src={displayImage} alt="preview" className="f-img-preview" />
<button
className="f-img-remove"
onClick={removeImage}
type="button"
aria-label="Удалить фото"
>
<CloseIcon />
</button>
</div>
) : (
<label className="f-img-upload-label" aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent) ? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>}
</label>
)}
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
</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>
@@ -126,6 +245,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">
@@ -135,6 +255,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
@@ -142,7 +263,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
type="tel" type="tel"
placeholder="+7 (___) ___-__-__" placeholder="+7 (___) ___-__-__"
value={form.phone} value={form.phone}
onChange={e => set("phone", e.target.value)} onChange={e => set("phone", formatPhone(e.target.value))}
/> />
{errors.phone && <p className="f-err-msg">{errors.phone}</p>} {errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div> </div>

View File

@@ -6,7 +6,7 @@ export function useCreatePost() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const createAd = async (adData) => { const createAd = async (formData) => { // ← принимаем FormData
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -16,20 +16,20 @@ export function useCreatePost() {
const res = await fetch(`${API_URL}/api/v1/posts`, { const res = await fetch(`${API_URL}/api/v1/posts`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", // Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
"Authorization": `Bearer ${token}`, "Authorization": `Bearer ${token}`,
}, },
body: JSON.stringify(adData), body: formData, // ← передаём FormData
}); });
const data = await res.json(); const data = await res.json(); // ← объявляем ПОСЛЕ await res
if (!res.ok) { if (!res.ok) {
throw new Error(data.message || "Ошибка при создании объявления"); throw new Error(data.message || "Ошибка при создании объявления");
} }
setSuccess(true); setSuccess(true);
return data; // возвращает созданное объявление return data;
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
return null; return null;

View File

@@ -5,15 +5,21 @@ export function useUpdatePost() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const updateAd = async (id, payload) => { const updateAd = async (id, formData) => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const token = localStorage.getItem("token");
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: { "Content-Type": "application/json" }, headers: {
body: JSON.stringify(payload), // Content-Type НЕ указываем — браузер сам выставит multipart/form-data
"Authorization": `Bearer ${token}`,
},
body: formData, // FormData
}); });
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Ошибка ${res.status}`);
return await res.json(); return await res.json();
} catch (err) { } catch (err) {

View File

@@ -9,32 +9,31 @@ import MenuIcon from "../components/icons/MenuIcon";
export default function FeedPage() { export default function FeedPage() {
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);
const [editAd, setEditAd] = useState(null); const [editAd, setEditAd] = useState(null);
const [ads, setAds] = useState([]); const [ads, setAds] = useState([]);
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("");
useEffect(() => { const loadFeed = useCallback(async () => {
const load = async () => { setLoading(true);
setLoading(true); setError("");
setError(""); try {
try { 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(); setAds(Array.isArray(data) ? data : (data.items ?? []));
setAds(Array.isArray(data) ? data : (data.items ?? [])); } catch (err) {
} catch (err) { setError(err.message || "Не удалось загрузить объявления");
setError(err.message || "Не удалось загрузить объявления"); } finally {
} finally { setLoading(false);
setLoading(false); }
}
};
load();
}, []); }, []);
useEffect(() => { loadFeed(); }, [loadFeed]);
const toggleLike = useCallback((id) => { const toggleLike = useCallback((id) => {
setLiked(prev => { setLiked(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -43,13 +42,9 @@ export default function FeedPage() {
}); });
}, []); }, []);
const handleNewAd = useCallback((ad) => { // перезагружаем ленту чтобы получить images из БД
setAds(prev => [ad, ...prev]); const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
}, []); const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
const handleEditSave = useCallback((updated) => {
setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
}, []);
const renderContent = () => { const renderContent = () => {
if (loading) { if (loading) {

View File

@@ -18,14 +18,14 @@
overflow: hidden; font-family: var(--font); position: relative; overflow: hidden; font-family: var(--font); position: relative;
} }
/* Overlay */ /* ── Overlay ── */
.f-overlay { .f-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 40; opacity: 0; pointer-events: none; transition: opacity .32s; z-index: 40; opacity: 0; pointer-events: none; transition: opacity .32s;
} }
.f-overlay.show { opacity: 1; pointer-events: all; } .f-overlay.show { opacity: 1; pointer-events: all; }
/* Left Sidebar */ /* ── Left Sidebar ── */
.f-sidebar { .f-sidebar {
width: 260px; min-width: 260px; width: 260px; min-width: 260px;
background: linear-gradient(180deg, #1a2540 0%, #111827 100%); background: linear-gradient(180deg, #1a2540 0%, #111827 100%);
@@ -61,16 +61,13 @@
.f-sb-item:hover { background: rgba(255,255,255,.05); color: #fff; } .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); } .f-sb-item.active { background: rgba(45,224,200,.1); color: var(--teal); border-left-color: var(--teal); }
/* Main */ /* ── Main ── */
.f-main { .f-main {
flex: 1; flex: 1; display: flex; flex-direction: column;
display: flex; min-width: 0; height: 100vh; overflow: hidden;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: hidden;
} }
/* Topbar */
/* ── Topbar ── */
.f-topbar { .f-topbar {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 14px 16px; padding: 14px 16px;
@@ -88,118 +85,52 @@
.f-topbar-spacer { width: 32px; flex-shrink: 0; } .f-topbar-spacer { width: 32px; flex-shrink: 0; }
.f-post-btn { .f-post-btn {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center; padding: 12px 28px; border-radius: 16px;
gap: 10px; font-family: var(--font); font-weight: 900; font-size: 0.95rem; letter-spacing: 0.3px;
padding: 12px 28px;
border-radius: 16px;
font-family: var(--font);
font-weight: 900;
font-size: 0.95rem;
letter-spacing: 0.3px;
color: #eafffb; color: #eafffb;
background: rgba(45,224,200,0.12); backdrop-filter: blur(14px);
background: rgba(45, 224, 200, 0.12); border: 1px solid rgba(45,224,200,0.25);
backdrop-filter: blur(14px); 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);
-webkit-backdrop-filter: blur(14px); cursor: pointer; transition: all 0.25s ease;
position: relative; overflow: hidden;
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 { .f-post-btn::before {
content: ""; content: ""; position: absolute; inset: 0;
position: absolute; background: linear-gradient(120deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05) 40%, transparent 60%);
inset: 0; opacity: 0.4; pointer-events: none;
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;
} }
/* glow при наведении */
.f-post-btn:hover { .f-post-btn:hover {
transform: translateY(-2px) scale(1.02); transform: translateY(-2px) scale(1.02);
border-color: rgba(45,224,200,0.5);
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);
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 { .f-post-btn:active {
transform: translateY(0) scale(0.98); transform: translateY(0) scale(0.98);
box-shadow: box-shadow: 0 4px 12px rgba(0,0,0,0.4), inset 0 0 10px rgba(45,224,200,0.2);
0 4px 12px rgba(0, 0, 0, 0.4),
inset 0 0 10px rgba(45, 224, 200, 0.2);
} }
/* плюсик */
.f-post-btn span { .f-post-btn span {
display: flex; display: flex; align-items: center; justify-content: center;
align-items: center; width: 22px; height: 22px; border-radius: 50%;
justify-content: center; 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);
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;
}
.f-post-btn span {
transform: translateY(-2px);
} }
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
@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; }
/* Feed wrap */
.f-wrap {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Feed */
.f-feed { .f-feed {
flex: 1; flex: 1; overflow-y: scroll; min-height: 0;
overflow-y: scroll; padding: 16px; display: flex; flex-direction: column; gap: 14px;
min-height: 0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
} }
.f-feed::-webkit-scrollbar { width: 4px; } .f-feed::-webkit-scrollbar { width: 4px; }
.f-feed::-webkit-scrollbar-track { background: transparent; } .f-feed::-webkit-scrollbar-track { background: transparent; }
.f-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; } .f-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
/* States */ /* ── States ── */
.f-center { .f-center {
flex: 1; display: flex; flex-direction: column; flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; align-items: center; justify-content: center;
@@ -214,9 +145,8 @@
border: 2px solid rgba(45,224,200,.2); border-top-color: var(--teal); border: 2px solid rgba(45,224,200,.2); border-top-color: var(--teal);
border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0; border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0;
} }
@keyframes spin { to { transform: rotate(360deg); } }
/* Ad Card */ /* ── Ad Card ── */
.f-card { .f-card {
flex-shrink: 0; flex-shrink: 0;
background: linear-gradient(155deg, #1d2436, #131720); background: linear-gradient(155deg, #1d2436, #131720);
@@ -225,28 +155,49 @@
} }
.f-card:hover { border-color: rgba(45,224,200,.3); transform: translateY(-2px); } .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 { .f-card-img {
width: 100%; height: 160px; width: 180px; min-width: 180px;
background: linear-gradient(135deg, #1a3a4a 0%, #0f2235 100%); background: linear-gradient(135deg, #1a3a4a 0%, #0f2235 100%);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden; position: relative; overflow: hidden; flex-shrink: 0;
} }
.f-card-img img { width: 100%; height: 100%; object-fit: cover; } .f-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.f-card-emoji { font-size: 3rem; opacity: .22; } .f-card-emoji { font-size: 3rem; opacity: .22; }
.f-cat-badge { .f-cat-badge {
position: absolute; top: 10px; left: 10px; background: rgba(13,16,24,.8);
background: rgba(13,16,24,.8); backdrop-filter: blur(8px); backdrop-filter: blur(8px);
border: 1px solid var(--border); border-radius: 20px; border: 1px solid var(--border);
padding: 3px 10px; font-size: .72rem; font-weight: 700; color: var(--teal); 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 { .f-type-badge {
position: absolute; top: 10px; right: 10px; border-radius: 20px;
border-radius: 20px; padding: 3px 10px; font-size: .72rem; font-weight: 900; 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-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-type-found { background: rgba(45,224,200,.2); color: #2de0c8; border: 1px solid rgba(45,224,200,.3); }
.f-card-body { padding: 14px 16px; } .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-title { color: #fff; font-weight: 900; font-size: 1rem; margin-bottom: 5px; line-height: 1.3; }
.f-card-desc { .f-card-desc {
color: var(--muted); font-size: .82rem; line-height: 1.5; margin-bottom: 10px; color: var(--muted); font-size: .82rem; line-height: 1.5; margin-bottom: 10px;
@@ -271,6 +222,7 @@
.f-card-foot { .f-card-foot {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; border-top: 1px solid var(--border);
margin-top: auto;
} }
.f-card-phone { .f-card-phone {
color: var(--teal); font-size: .82rem; font-weight: 700; color: var(--teal); font-size: .82rem; font-weight: 700;
@@ -288,7 +240,7 @@
border-color: rgba(255,107,107,.5); color: #ff6b6b; background: rgba(255,107,107,.08); border-color: rgba(255,107,107,.5); color: #ff6b6b; background: rgba(255,107,107,.08);
} }
/* Right Sidebar */ /* ── Right Sidebar ── */
.f-right { .f-right {
width: 220px; min-width: 220px; padding: 16px 12px; width: 220px; min-width: 220px; padding: 16px 12px;
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
@@ -318,7 +270,7 @@
} }
.f-add-promo-btn:hover { background: rgba(45,224,200,.15); transform: translateY(-1px); } .f-add-promo-btn:hover { background: rgba(45,224,200,.15); transform: translateY(-1px); }
/* Modal */ /* ── Modal ── */
.f-modal-bg { .f-modal-bg {
position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 100; position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 100;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -372,8 +324,8 @@
color: var(--muted); font-family: var(--font); font-size: .85rem; color: var(--muted); font-family: var(--font); font-size: .85rem;
font-weight: 800; cursor: pointer; text-align: center; transition: all .2s; 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.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-type-opt.found { border-color: rgba(45,224,200,.5); background: rgba(45,224,200,.1); color: #2de0c8; }
.f-map-pick { .f-map-pick {
height: 80px; background: linear-gradient(135deg, #0f2235, #0a1a2e); height: 80px; background: linear-gradient(135deg, #0f2235, #0a1a2e);
@@ -403,3 +355,36 @@
border: 2px solid rgba(8,35,32,.3); border-top-color: #082320; border: 2px solid rgba(8,35,32,.3); border-top-color: #082320;
border-radius: 50%; animation: spin .7s linear infinite; 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; }