added all feeds for admin

This commit is contained in:
SlimusMinus
2026-10-02 01:50:42 +03:00
parent 510bfc06f2
commit 841bf091d4
11 changed files with 778 additions and 141 deletions

View File

@@ -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>

View 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)} />}
</>
);
}