Merge pull request #9 from SlimusMinus/add_post-image

added image for posts
This commit is contained in:
SlimusMinus
2026-06-01 01:56:01 +03:00
committed by GitHub
6 changed files with 351 additions and 224 deletions

View File

@@ -3,60 +3,80 @@ import CalendarIcon from "./icons/CalendarIcon";
import PhoneIcon from "./icons/PhoneIcon";
import MapIcon from "./icons/MapIcon";
import HeartIcon from "./icons/HeartIcon";
import { API_URL } from "../config/constants";
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 (
<div className="f-card">
<div className="f-card-inner">
{/* Левая колонка — картинка */}
<div className="f-card-img">
{ad.image
? <img src={ad.image} alt={ad.title} />
{imageUrl
? <img src={imageUrl} alt={ad.title} />
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
}
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
<div className={`f-type-badge ${ad.type === "LOSS" ? "f-type-loss" : "f-type-found"}`}>
{ad.type === "LOSS" ? "Потеря" : "Находка"}
</div>
</div>
{/* Правая колонка — контент */}
<div className="f-card-body">
<div className="f-card-badges">
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss"
: "f-type-found"}`}>
{ad.type === "loss" ? "Потеря" : "Находка"}
</div>
</div>
<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>}
{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 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}
<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>
: <span />
}
<span className="f-card-date">{ad.date || ""}</span>
<div style={{ display: "flex", gap: 6 }}>
)}
</div>
<div className="f-card-foot-right">
{onEdit && (
<button
className="f-like-btn"
onClick={e => { e.stopPropagation(); onEdit(ad); }}
>
✏️
</button>
onClick={e => {
e.stopPropagation();
onEdit(ad);
}}
>✏️</button>
)}
<button
className={`f-like-btn${liked ? " liked" : ""}`}
onClick={e => { e.stopPropagation(); onLike(ad.id); }}
onClick={e => {
e.stopPropagation();
onLike(ad.id);
}}
>
<HeartIcon filled={liked} />
<HeartIcon filled={liked}/>
{liked ? (ad.likes || 0) + 1 : (ad.likes || 0)}
</button>
</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 CloseIcon from "./icons/CloseIcon";
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
import { useCreatePost } from "../hooks/useCreatePost";
import { useUpdatePost } from "../hooks/useUpdatePost";
import { API_URL } from "../config/constants";
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 }) {
const isEdit = !!initialData;
@@ -18,9 +55,20 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
} : EMPTY_FORM);
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 { updateAd, loading: updating, error: updateError } = useUpdatePost();
const loading = creating || updating;
const loading = creating || updating || compressing;
const apiError = createError || updateError;
const set = useCallback((k, v) => {
@@ -28,6 +76,43 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
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 e = {};
if (!form.title.trim()) e.title = "Введите название";
@@ -40,18 +125,19 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
type: form.type,
title: form.title.trim(),
description: form.description.trim(),
category: form.category,
date: form.date || null,
phone: form.phone.trim(),
};
const data = new FormData();
data.append("type", form.type);
data.append("title", form.title.trim());
data.append("description", form.description.trim());
data.append("category", form.category);
data.append("phone", form.phone.trim());
if (form.date) data.append("date", form.date);
if (imageFile) data.append("image", imageFile);
const result = isEdit
? await updateAd(initialData.id, payload)
: await createAd(payload);
? await updateAd(initialData.id, data)
: await createAd(data);
if (result) {
onSuccess(result);
@@ -69,24 +155,22 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
<button className="f-close-btn" onClick={onClose}><CloseIcon /></button>
</div>
{/* тип */}
<div className="f-m-group">
<label className="f-m-label">Тип объявления</label>
<div className="f-type-toggle">
<button
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")}
>
Потеря
</button>
>Потеря</button>
<button
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
onClick={() => set("type", "found")}
>
Находка
</button>
>Находка</button>
</div>
</div>
{/* название */}
<div className="f-m-group">
<label className="f-m-label">Название</label>
<input
@@ -99,6 +183,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{errors.title && <p className="f-err-msg">{errors.title}</p>}
</div>
{/* описание */}
<div className="f-m-group">
<label className="f-m-label">Описание</label>
<textarea
@@ -111,6 +196,40 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
{errors.description && <p className="f-err-msg">{errors.description}</p>}
</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-group">
<label className="f-m-label">Категория</label>
@@ -126,6 +245,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
</div>
</div>
{/* карта */}
<div className="f-m-group">
<label className="f-m-label">Место на карте</label>
<div className="f-map-pick">
@@ -135,6 +255,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
<p className="f-hint">Карта будет добавлена в следующей версии</p>
</div>
{/* телефон */}
<div className="f-m-group">
<label className="f-m-label">Номер телефона</label>
<input
@@ -142,7 +263,7 @@ export default function PostModal({ onClose, onSuccess, initialData }) {
type="tel"
placeholder="+7 (___) ___-__-__"
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>}
</div>

View File

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

View File

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

View File

@@ -17,8 +17,7 @@ export default function FeedPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const load = async () => {
const loadFeed = useCallback(async () => {
setLoading(true);
setError("");
try {
@@ -31,10 +30,10 @@ export default function FeedPage() {
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => { loadFeed(); }, [loadFeed]);
const toggleLike = useCallback((id) => {
setLiked(prev => {
const next = new Set(prev);
@@ -43,13 +42,9 @@ export default function FeedPage() {
});
}, []);
const handleNewAd = useCallback((ad) => {
setAds(prev => [ad, ...prev]);
}, []);
const handleEditSave = useCallback((updated) => {
setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
}, []);
// перезагружаем ленту чтобы получить images из БД
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
const renderContent = () => {
if (loading) {

View File

@@ -18,14 +18,14 @@
overflow: hidden; font-family: var(--font); position: relative;
}
/* Overlay */
/* ── 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 */
/* ── Left Sidebar ── */
.f-sidebar {
width: 260px; min-width: 260px;
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.active { background: rgba(45,224,200,.1); color: var(--teal); border-left-color: var(--teal); }
/* Main */
/* ── Main ── */
.f-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: hidden;
flex: 1; display: flex; flex-direction: column;
min-width: 0; height: 100vh; overflow: hidden;
}
/* Topbar */
/* ── Topbar ── */
.f-topbar {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
@@ -88,118 +85,52 @@
.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;
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);
-webkit-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;
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;
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;
}
/* glow при наведении */
.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);
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);
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;
}
.f-post-btn span {
transform: translateY(-2px);
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 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 {
flex: 1;
overflow-y: scroll;
min-height: 0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
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 */
/* ── States ── */
.f-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
@@ -214,9 +145,8 @@
border: 2px solid rgba(45,224,200,.2); border-top-color: var(--teal);
border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Ad Card */
/* ── Ad Card ── */
.f-card {
flex-shrink: 0;
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-inner {
display: flex; flex-direction: row; min-height: 160px;
}
.f-card-img {
width: 100%; height: 160px;
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;
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-cat-badge {
position: absolute; top: 10px; left: 10px;
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);
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 {
position: absolute; top: 10px; right: 10px;
border-radius: 20px; padding: 3px 10px; font-size: .72rem; font-weight: 900;
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 { 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-desc {
color: var(--muted); font-size: .82rem; line-height: 1.5; margin-bottom: 10px;
@@ -271,6 +222,7 @@
.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;
@@ -288,7 +240,7 @@
border-color: rgba(255,107,107,.5); color: #ff6b6b; background: rgba(255,107,107,.08);
}
/* Right Sidebar */
/* ── Right Sidebar ── */
.f-right {
width: 220px; min-width: 220px; padding: 16px 12px;
border-left: 1px solid var(--border);
@@ -318,7 +270,7 @@
}
.f-add-promo-btn:hover { background: rgba(45,224,200,.15); transform: translateY(-1px); }
/* Modal */
/* ── 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;
@@ -403,3 +355,36 @@
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; }