added all feeds for admin
This commit is contained in:
@@ -123,7 +123,7 @@ export default function Sidebar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="f-sb-item"
|
||||
className={`f-sb-item${activeNav === "admin-campaigns" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
navigate("/admin/ads");
|
||||
onClose();
|
||||
@@ -131,6 +131,16 @@ export default function Sidebar({
|
||||
>
|
||||
{NavIcons.grid()} {t('adminPanel.campaignsNav')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`f-sb-item${activeNav === "admin-posts" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
navigate("/admin/ads?tab=posts");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{NavIcons.rules()} {t('adminPanel.postsNav')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
273
src/components/admin/PostsTab.jsx
Normal file
273
src/components/admin/PostsTab.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
// src/components/admin/PostsTab.jsx
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PostOnFeed from "../../pages/PostOnFeed";
|
||||
import CreateUpdatePost from "../../pages/CreateUpdatePost";
|
||||
import ImageViewer from "../ImageViewer";
|
||||
import Toast from "../Toast";
|
||||
import {
|
||||
getAdminPosts,
|
||||
deleteAdminPost,
|
||||
updateAdminPostStatus,
|
||||
} from "../../api/adminContentApi";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const SEARCH_DEBOUNCE_MS = 400;
|
||||
|
||||
const TYPE_FILTERS = [
|
||||
{ value: "", labelKey: "adminPanel.filterTypeAll" },
|
||||
{ value: "LOSS", labelKey: "postCard.loss" },
|
||||
{ value: "FOUND", labelKey: "postCard.found" },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: "", labelKey: "adminPanel.filterStatusAll" },
|
||||
{ value: "ACTIVE", labelKey: "adminPanel.statusActive" },
|
||||
{ value: "CLOSED", labelKey: "adminPanel.statusClosed" },
|
||||
];
|
||||
|
||||
/** Подпись автора объявления: у бэкенда поле называется по-разному в разных DTO. */
|
||||
function authorLabelOf(post) {
|
||||
const name = post.ownerName || post.authorName || post.userName ||
|
||||
[post.ownerFirstName, post.ownerLastName].filter(Boolean).join(" ");
|
||||
const login = post.ownerLogin || post.authorLogin || post.userLogin;
|
||||
const parts = [name, login ? `@${login}` : null].filter(Boolean);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вкладка «Все объявления» админ-панели.
|
||||
*
|
||||
* Показывает ВСЕ объявления площадки с тем же набором действий, что и вкладка
|
||||
* «Мои объявления» у обычного пользователя (редактирование, удаление, отметка
|
||||
* «найдено»), плюс возможность вернуть закрытое объявление обратно в ленту.
|
||||
*
|
||||
* Все мутации идут через /api/v1/admin/posts — сервер проверяет роль ADMIN
|
||||
* независимо от того, что интерфейс вообще показал админские кнопки.
|
||||
*
|
||||
* Разметка рассчитана на то, что страница уже нарисовала .admp > .admp-inner,
|
||||
* поэтому собственного скролл-контейнера у вкладки нет.
|
||||
*/
|
||||
export default function PostsTab({ isAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [viewer, setViewer] = useState(null);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = useCallback((message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
}, []);
|
||||
|
||||
// Дебаунс поиска, чтобы не дёргать сервер на каждое нажатие клавиши
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
// Смена фильтра сбрасывает пагинацию, поэтому load всегда стартует с 0-й страницы.
|
||||
const load = useCallback(async ({ pageNum = 0, append = false, silent = false } = {}) => {
|
||||
if (!isAdmin) return;
|
||||
if (append) setLoadingMore(true);
|
||||
else if (!silent) setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await getAdminPosts({ page: pageNum, size: PAGE_SIZE, search, type: typeFilter, status: statusFilter });
|
||||
setPosts((prev) => (append ? [...prev, ...result.items] : result.items));
|
||||
setHasMore(!result.last);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
setError(t("adminPanel.postsLoadError"));
|
||||
if (!append) setPosts([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [isAdmin, search, typeFilter, statusFilter, t]);
|
||||
|
||||
// Первичная загрузка + перезагрузка при смене фильтров
|
||||
useEffect(() => {
|
||||
load({ pageNum: 0 });
|
||||
}, [load]);
|
||||
|
||||
const patchPost = useCallback((postId, patch) => {
|
||||
setPosts((prev) => prev.map((p) => (p.id === postId ? { ...p, ...patch } : p)));
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (postId) => {
|
||||
if (!window.confirm(t("adminPanel.postsConfirmDelete"))) return;
|
||||
setBusyId(postId);
|
||||
try {
|
||||
await deleteAdminPost(postId);
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId));
|
||||
showToast(t("adminPanel.postsDeleted"));
|
||||
} catch {
|
||||
showToast(t("adminPanel.postsDeleteFailed"), "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
|
||||
const setStatus = useCallback(async (postId, status) => {
|
||||
const isClose = status === "CLOSED";
|
||||
if (!window.confirm(isClose ? t("adminPanel.postsConfirmClose") : t("adminPanel.postsConfirmReopen"))) return;
|
||||
setBusyId(postId);
|
||||
try {
|
||||
const updated = await updateAdminPostStatus(postId, status);
|
||||
// Сервер может вернуть неполный DTO — мержим, чтобы не потерять поля карточки.
|
||||
patchPost(postId, { ...(updated || {}), id: postId, status });
|
||||
showToast(isClose ? t("adminPanel.postsClosed") : t("adminPanel.postsReopened"));
|
||||
} catch {
|
||||
showToast(t("adminPanel.postsStatusFailed"), "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, [t, showToast, patchPost]);
|
||||
|
||||
const handleEditSaved = useCallback(() => {
|
||||
// Перезагружаем текущую страницу: сервер мог изменить статус или дату.
|
||||
load({ pageNum: page, silent: true });
|
||||
showToast(t("adminPanel.postsUpdated"));
|
||||
}, [load, page, showToast, t]);
|
||||
|
||||
const handleImageClick = useCallback((images, index, ad) => {
|
||||
if (images?.length) setViewer({ images, index, ad });
|
||||
}, []);
|
||||
|
||||
const contentStyle = useMemo(() => ({ opacity: busyId === null ? 1 : 0.75 }), [busyId]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="admp-toolbar admp-toolbar--filters">
|
||||
<input
|
||||
type="search"
|
||||
className="f-m-field admp-search"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t("adminPanel.postsSearchPlaceholder")}
|
||||
aria-label={t("adminPanel.postsSearchPlaceholder")}
|
||||
/>
|
||||
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsTypeFilter")}>
|
||||
{TYPE_FILTERS.map(({ value, labelKey }) => (
|
||||
<button
|
||||
key={value || "all"}
|
||||
type="button"
|
||||
className={`admp-chip${typeFilter === value ? " active" : ""}`}
|
||||
onClick={() => setTypeFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsStatusFilter")}>
|
||||
{STATUS_FILTERS.map(({ value, labelKey }) => (
|
||||
<button
|
||||
key={value || "all"}
|
||||
type="button"
|
||||
className={`admp-chip${statusFilter === value ? " active" : ""}`}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admp-btn admp-btn--ghost"
|
||||
onClick={() => load({ pageNum: 0 })}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("adminPanel.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admp-session-hint">
|
||||
{loading
|
||||
? t("adminPanel.postsLoading")
|
||||
: t("adminPanel.postsHint", { count: posts.length })}
|
||||
</div>
|
||||
{error && <div className="f-err-msg admp-load-error">{error}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="f-center admp-empty">
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="f-center admp-empty">
|
||||
<div className="f-center-icon">📋</div>
|
||||
<div className="f-center-title">{t("adminPanel.postsEmpty")}</div>
|
||||
<div className="f-center-sub">{t("adminPanel.postsEmptySub")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admp-posts-grid" style={contentStyle}>
|
||||
{posts.map((post) => (
|
||||
<PostOnFeed
|
||||
key={post.id}
|
||||
ad={post}
|
||||
isOwner
|
||||
allowEditClosed
|
||||
authorLabel={authorLabelOf(post)}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
onMarkFound={(id) => setStatus(id, "CLOSED")}
|
||||
onReopen={(id) => setStatus(id, "ACTIVE")}
|
||||
onImageClick={handleImageClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="admp-more">
|
||||
<button
|
||||
type="button"
|
||||
className="admp-btn admp-btn--ghost"
|
||||
disabled={loadingMore}
|
||||
onClick={() => load({ pageNum: page + 1, append: true })}
|
||||
>
|
||||
{loadingMore ? t("adminPanel.postsLoading") : t("adminPanel.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CreateUpdatePost
|
||||
adminMode
|
||||
initialData={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={handleEditSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewer && (
|
||||
<ImageViewer
|
||||
images={viewer.images}
|
||||
initialIndex={viewer.index}
|
||||
postData={viewer.ad}
|
||||
onClose={() => setViewer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user