Merge pull request #8 from SlimusMinus/add-edit-card
added edit card and get number
This commit is contained in:
@@ -4,7 +4,8 @@ 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";
|
||||||
|
|
||||||
export default function AdCard({ ad, liked, onLike }) {
|
export default function AdCard({ ad, liked, onLike, onEdit }) {
|
||||||
|
console.log("ad object:", ad);
|
||||||
return (
|
return (
|
||||||
<div className="f-card">
|
<div className="f-card">
|
||||||
<div className="f-card-img">
|
<div className="f-card-img">
|
||||||
@@ -13,8 +14,8 @@ export default function AdCard({ ad, liked, onLike }) {
|
|||||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||||
}
|
}
|
||||||
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
||||||
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss" : "f-type-found"}`}>
|
<div className={`f-type-badge ${ad.type === "LOSS" ? "f-type-loss" : "f-type-found"}`}>
|
||||||
{ad.type === "loss" ? "Потеря" : "Находка"}
|
{ad.type === "LOSS" ? "Потеря" : "Находка"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="f-card-body">
|
<div className="f-card-body">
|
||||||
@@ -37,6 +38,15 @@ export default function AdCard({ ad, liked, onLike }) {
|
|||||||
: <span />
|
: <span />
|
||||||
}
|
}
|
||||||
<span className="f-card-date">{ad.date || ""}</span>
|
<span className="f-card-date">{ad.date || ""}</span>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
className="f-like-btn"
|
||||||
|
onClick={e => { e.stopPropagation(); onEdit(ad); }}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`f-like-btn${liked ? " liked" : ""}`}
|
className={`f-like-btn${liked ? " liked" : ""}`}
|
||||||
onClick={e => { e.stopPropagation(); onLike(ad.id); }}
|
onClick={e => { e.stopPropagation(); onLike(ad.id); }}
|
||||||
@@ -47,5 +57,6 @@ export default function AdCard({ ad, liked, onLike }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,25 @@ 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";
|
||||||
|
|
||||||
|
export default function PostModal({ onClose, onSuccess, initialData }) {
|
||||||
|
const isEdit = !!initialData;
|
||||||
|
|
||||||
|
const [form, setForm] = useState(isEdit ? {
|
||||||
|
type: initialData.type || "loss",
|
||||||
|
title: initialData.title || "",
|
||||||
|
description: initialData.description || "",
|
||||||
|
category: initialData.category || "",
|
||||||
|
date: initialData.date || "",
|
||||||
|
phone: initialData.phone || "",
|
||||||
|
} : EMPTY_FORM);
|
||||||
|
|
||||||
export default function PostModal({ onClose, onSuccess }) {
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const { createAd, loading, error: apiError } = useCreatePost();
|
const { createAd, loading: creating, error: createError } = useCreatePost();
|
||||||
|
const { updateAd, loading: updating, error: updateError } = useUpdatePost();
|
||||||
|
const loading = creating || updating;
|
||||||
|
const apiError = createError || updateError;
|
||||||
|
|
||||||
const set = useCallback((k, v) => {
|
const set = useCallback((k, v) => {
|
||||||
setForm(p => ({ ...p, [k]: v }));
|
setForm(p => ({ ...p, [k]: v }));
|
||||||
@@ -26,9 +40,8 @@ export default function PostModal({ onClose, onSuccess }) {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
type: form.type, // "loss" | "found"
|
type: form.type,
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
category: form.category,
|
category: form.category,
|
||||||
@@ -36,9 +49,12 @@ export default function PostModal({ onClose, onSuccess }) {
|
|||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const newAd = await createAd(payload);
|
const result = isEdit
|
||||||
if (newAd) {
|
? await updateAd(initialData.id, payload)
|
||||||
onSuccess(newAd);
|
: await createAd(payload);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
onSuccess(result);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -47,17 +63,25 @@ export default function PostModal({ onClose, onSuccess }) {
|
|||||||
<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>
|
<span className="f-modal-title">
|
||||||
|
{isEdit ? "Редактировать объявление" : "Новое объявление"}
|
||||||
|
</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 className={`f-type-opt${form.type === "loss" ? " loss" : ""}`} onClick={() => set("type", "loss")}>
|
<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
|
||||||
|
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
|
||||||
|
onClick={() => set("type", "found")}
|
||||||
|
>
|
||||||
Находка
|
Находка
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +124,6 @@ export default function PostModal({ onClose, onSuccess }) {
|
|||||||
</select>
|
</select>
|
||||||
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
@@ -124,16 +147,12 @@ export default function PostModal({ onClose, onSuccess }) {
|
|||||||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && <p className="f-err-msg">{apiError}</p>}
|
||||||
<p className="f-err-msg" style={{ textAlign: "center", marginBottom: 10 }}>
|
|
||||||
{apiError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="f-m-submit" onClick={handleSubmit} disabled={loading}>
|
<button className="f-m-submit" onClick={handleSubmit} disabled={loading}>
|
||||||
<div className="f-m-submit-row">
|
<div className="f-m-submit-row">
|
||||||
{loading && <div className="f-m-spinner" />}
|
{loading && <div className="f-m-spinner" />}
|
||||||
{loading ? "Размещаем..." : "Разместить объявление"}
|
{isEdit ? "Сохранить изменения" : "Разместить объявление"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/hooks/useUpdatePost.js
Normal file
28
src/hooks/useUpdatePost.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { API_URL } from "../config/constants";
|
||||||
|
|
||||||
|
export function useUpdatePost() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const updateAd = async (id, payload) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/posts/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Не удалось сохранить");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { updateAd, loading, error };
|
||||||
|
}
|
||||||
@@ -11,3 +11,10 @@ code {
|
|||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import Sidebar from "../components/Sidebar";
|
|||||||
import RightSidebar from "../components/RightSidebar";
|
import RightSidebar from "../components/RightSidebar";
|
||||||
import AdCard from "../components/AdCard";
|
import AdCard from "../components/AdCard";
|
||||||
import PostModal from "../components/PostModal";
|
import PostModal from "../components/PostModal";
|
||||||
import MenuIcon from "../components/icons/MenuIcon"
|
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 [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);
|
||||||
@@ -22,11 +23,8 @@ export default function FeedPage() {
|
|||||||
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) {
|
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||||||
throw new Error(`Ошибка ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// поддерживаем { 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 || "Не удалось загрузить объявления");
|
||||||
@@ -45,11 +43,14 @@ export default function FeedPage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Новое объявление — добавляем в начало ленты
|
|
||||||
const handleNewAd = useCallback((ad) => {
|
const handleNewAd = useCallback((ad) => {
|
||||||
setAds(prev => [ad, ...prev]);
|
setAds(prev => [ad, ...prev]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleEditSave = useCallback((updated) => {
|
||||||
|
setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -82,15 +83,18 @@ export default function FeedPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ads.map(ad => (
|
return ads.map(ad => (
|
||||||
<AdCard key={ad.id} ad={ad} liked={liked.has(ad.id)}
|
<AdCard
|
||||||
onLike={toggleLike}/>
|
key={ad.id}
|
||||||
|
ad={ad}
|
||||||
|
liked={liked.has(ad.id)}
|
||||||
|
onLike={toggleLike}
|
||||||
|
onEdit={setEditAd}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="f-app">
|
<div className="f-app">
|
||||||
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
activeNav={activeNav}
|
activeNav={activeNav}
|
||||||
@@ -100,18 +104,12 @@ 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"
|
<button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}>
|
||||||
onClick={() => setSidebarOpen(v => !v)}>
|
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</button>
|
</button>
|
||||||
<div className="f-topbar-center">
|
<div className="f-topbar-center">
|
||||||
<button className="f-post-btn"
|
<button className="f-post-btn" onClick={() => setModalOpen(true)}>
|
||||||
onClick={() => setModalOpen(true)}>
|
<span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span>
|
||||||
<span style={{
|
|
||||||
fontSize: "1.15rem",
|
|
||||||
fontWeight: 900,
|
|
||||||
lineHeight: 1
|
|
||||||
}}>+</span>
|
|
||||||
Разместить объявление
|
Разместить объявление
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +131,13 @@ export default function FeedPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editAd && (
|
||||||
|
<PostModal
|
||||||
|
initialData={editAd}
|
||||||
|
onClose={() => setEditAd(null)}
|
||||||
|
onSuccess={handleEditSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,8 +62,14 @@
|
|||||||
.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 { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.f-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
@@ -169,15 +175,25 @@
|
|||||||
transform: translateY(-2px);
|
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} }
|
||||||
|
|
||||||
/* Feed wrap */
|
/* Feed wrap */
|
||||||
.f-wrap { display: flex; flex: 1; overflow: hidden; }
|
.f-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
/* Feed */
|
/* Feed */
|
||||||
.f-feed {
|
.f-feed {
|
||||||
flex: 1; overflow-y: auto;
|
flex: 1;
|
||||||
padding: 16px; display: flex; flex-direction: column; gap: 14px;
|
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 { width: 4px; }
|
||||||
.f-feed::-webkit-scrollbar-track { background: transparent; }
|
.f-feed::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -202,6 +218,7 @@
|
|||||||
|
|
||||||
/* Ad Card */
|
/* Ad Card */
|
||||||
.f-card {
|
.f-card {
|
||||||
|
flex-shrink: 0;
|
||||||
background: linear-gradient(155deg, #1d2436, #131720);
|
background: linear-gradient(155deg, #1d2436, #131720);
|
||||||
border: 1px solid var(--border); border-radius: 20px; overflow: hidden;
|
border: 1px solid var(--border); border-radius: 20px; overflow: hidden;
|
||||||
transition: border-color .2s, transform .2s; cursor: pointer;
|
transition: border-color .2s, transform .2s; cursor: pointer;
|
||||||
|
|||||||
Reference in New Issue
Block a user