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

108
src/api/adminContentApi.js Normal file
View File

@@ -0,0 +1,108 @@
// src/api/adminContentApi.js
//
// Админские эндпоинты для контента площадки (объявления). Здесь только то, что
// нельзя завернуть в общий хук: правка объявления идёт через usePostMutation
// с опцией adminMode — там же живёт сборка FormData и разбор ошибок модерации.
import { API_URL } from "../config/authConstants";
import { apiFetch } from "./apiClient";
const ADMIN_BASE = `${API_URL}/api/v1/admin`;
/**
* Ошибка админ-эндпоинтов контента (объявления). Несёт HTTP-статус,
* чтобы UI мог отличить 403 (нет роли ADMIN) от 404 и сетевых сбоев.
*/
export class AdminContentError extends Error {
constructor(status, message) {
super(message || `Admin content API error ${status}`);
this.name = "AdminContentError";
this.status = status;
}
}
async function readErrorMessage(res, fallback) {
try {
const body = await res.json();
if (body && typeof body.message === "string") return body.message;
if (body && typeof body.error === "string") return body.error;
} catch {
// тело не JSON (например, 204/пустой ответ) — используем fallback
}
return fallback;
}
/**
* Приводит объявление к тому виду, в котором его ждёт лента:
* type в нижний регистр ("loss"/"found"). Остальные хуки (useFeedPosts,
* useMyPosts) нормализуют так же — иначе карточка PostOnFeed отрисовалась бы
* с типом "находка" у потерянного объявления.
*/
function normalizePost(post) {
if (!post) return post;
return {
...post,
type: post.type ? String(post.type).toLowerCase() : "loss",
};
}
/** Достаёт элементы страницы: бэкенд отдаёт {content|last}, {items}, либо массив. */
function extractPage(body, size) {
if (Array.isArray(body)) {
return { items: body.map(normalizePost), last: body.length < size, total: body.length };
}
const rawItems = body?.content ?? body?.items ?? [];
const items = Array.isArray(rawItems) ? rawItems.map(normalizePost) : [];
const hasTotal = typeof body?.totalElements === "number";
const last = typeof body?.last === "boolean"
? body.last
// Без метаданных пагинации решаем по размеру страницы: недобор = это конец.
: hasTotal
? body.totalElements <= (body?.number ?? 0) + items.length
: items.length < size;
return { items, last, total: hasTotal ? body.totalElements : undefined };
}
/**
* GET /api/v1/admin/posts?page&size&search&type&status
*
* Постранично отдаёт ВСЕ объявления площадки вместе с данными автора.
* Возвращает { items, last, total } — items уже нормализованы.
*/
export async function getAdminPosts({ page = 0, size = 20, search = "", type = "", status = "" } = {}) {
const params = new URLSearchParams({ page, size });
if (search) params.set("search", search);
if (type) params.set("type", type);
if (status) params.set("status", status);
const res = await apiFetch(`${ADMIN_BASE}/posts?${params.toString()}`);
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to load posts"));
return extractPage(await res.json(), size);
}
/** DELETE /api/v1/admin/posts/{postId} — 204 и 200 одинаково допустимы. */
export async function deleteAdminPost(postId) {
const res = await apiFetch(`${ADMIN_BASE}/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
});
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to delete post"));
return true;
}
/**
* PATCH /api/v1/admin/posts/{postId}/status — "найдено" (CLOSED)
* либо возврат объявления в ленту (ACTIVE).
*/
export async function updateAdminPostStatus(postId, status) {
const res = await apiFetch(`${ADMIN_BASE}/posts/${encodeURIComponent(postId)}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to update status"));
try {
return normalizePost(await res.json());
} catch {
// Бэкенд может ответить без тела — тогда мержим только статус.
return { id: postId, status };
}
}

View File

@@ -123,7 +123,7 @@ export default function Sidebar({
</button> </button>
<button <button
type="button" type="button"
className="f-sb-item" className={`f-sb-item${activeNav === "admin-campaigns" ? " active" : ""}`}
onClick={() => { onClick={() => {
navigate("/admin/ads"); navigate("/admin/ads");
onClose(); onClose();
@@ -131,6 +131,16 @@ export default function Sidebar({
> >
{NavIcons.grid()} {t('adminPanel.campaignsNav')} {NavIcons.grid()} {t('adminPanel.campaignsNav')}
</button> </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> </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)} />}
</>
);
}

View File

@@ -11,15 +11,22 @@ import { apiFetch } from "../api/apiClient";
* id передан → PUT /api/v1/posts/:id (обновление) * id передан → PUT /api/v1/posts/:id (обновление)
* id не передан → POST /api/v1/posts (создание) * id не передан → POST /api/v1/posts (создание)
* *
* В режиме adminMode (правка чужого объявления из админ-панели) используется
* PUT /api/v1/admin/posts/:id. Создание через админку не поддерживается:
* приходит без id — падаем обратно на пользовательский POST.
*
* Авторизация (Authorization header + авто-обновление токена при истечении) * Авторизация (Authorization header + авто-обновление токена при истечении)
* обрабатывается внутри apiFetch. * обрабатывается внутри apiFetch.
* *
* save() возвращает: * save() возвращает:
* { data } — успешный ответ сервера * { ok: true, data } — успешный ответ сервера (data может быть null,
* если бэкенд ответил без тела)
* { blocked: true } — контент заблокирован модерацией * { blocked: true } — контент заблокирован модерацией
* null — серверная ошибка (свойство error содержит текст) * null — серверная ошибка (свойство error содержит текст)
*
* @param {{adminMode?: boolean}} [options]
*/ */
export function usePostMutation() { export function usePostMutation({ adminMode = false } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -27,13 +34,16 @@ export function usePostMutation() {
/** /**
* @param {string|null} id — id объявления при обновлении, null при создании * @param {string|null} id — id объявления при обновлении, null при создании
* @param {FormData} data — данные формы * @param {FormData} data — данные формы
* @returns {Promise<{data:object}|{blocked:boolean, reason:string}|null>} * @returns {Promise<{ok:true,data:object|null}|{blocked:boolean, reason:string}|null>}
*/ */
const save = async (id, data) => { const save = async (id, data) => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`; const isAdminEdit = adminMode && !!id;
const url = isAdminEdit
? `${API_URL}/api/v1/admin/posts/${id}`
: (id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`);
const method = id ? "PUT" : "POST"; const method = id ? "PUT" : "POST";
const res = await apiFetch(url, { const res = await apiFetch(url, {
method, method,
@@ -51,7 +61,9 @@ export function usePostMutation() {
throw new Error(body?.message || t('post.serverError', { status: res.status })); throw new Error(body?.message || t('post.serverError', { status: res.status }));
} }
return { data: await res.json() }; let payload = null;
try { payload = await res.json(); } catch { payload = null; }
return { ok: true, data: payload };
} catch (e) { } catch (e) {
setError(e.message || t('post.unknownError')); setError(e.message || t('post.unknownError'));
return null; return null;

View File

@@ -168,6 +168,7 @@
"foundStatus": "✓ Found", "foundStatus": "✓ Found",
"write": "Write", "write": "Write",
"itemFound": "✓ Returned to owner", "itemFound": "✓ Returned to owner",
"reopen": "↩ Return to feed",
"showOnMap": "Show on map — {{address}}", "showOnMap": "Show on map — {{address}}",
"contactError": "Unable to open the chat. Please try again", "contactError": "Unable to open the chat. Please try again",
"reward": "Reward" "reward": "Reward"
@@ -238,7 +239,38 @@
}, },
"adminPanel": { "adminPanel": {
"campaignsNav": "Ads (campaigns)", "campaignsNav": "Ads (campaigns)",
"postsNav": "All listings",
"tabsLabel": "Admin panel sections",
"tabCampaigns": "Advertising campaigns",
"tabPosts": "All listings",
"pageTitle": "Advertising campaigns", "pageTitle": "Advertising campaigns",
"postsPageTitle": "All listings",
"postsSearchPlaceholder": "Search by title, description, author",
"postsTypeFilter": "Listing type",
"postsStatusFilter": "Listing status",
"filterTypeAll": "All",
"filterStatusAll": "Any",
"statusActive": "Active",
"statusClosed": "Closed",
"postsLoading": "Loading listings...",
"postsHint": "Loaded listings: {{count}}",
"postsLoadError": "Failed to load listings",
"postsEmpty": "No listings",
"postsEmptySub": "Nothing found for the current filter.",
"postsUpdated": "Listing updated",
"postsDeleted": "Listing deleted",
"postsDeleteFailed": "Failed to delete the listing",
"postsClosed": "Listing closed",
"postsReopened": "Listing returned to the feed",
"postsStatusFailed": "Failed to change the listing status",
"postsConfirmDelete": "Delete this listing? This cannot be undone.",
"postsConfirmClose": "Close the listing (mark as found)?",
"postsConfirmReopen": "Return this listing to the feed?",
"postEditTitle": "Edit listing (admin)",
"postUpdatedModal": "Listing saved",
"postUpdatedModalSub": "The changes are already visible in the feed.",
"loadMore": "Load more",
"refresh": "Refresh",
"persistHint": "Campaigns and creatives are stored on the server", "persistHint": "Campaigns and creatives are stored on the server",
"campaignsLoading": "Loading campaigns...", "campaignsLoading": "Loading campaigns...",
"campaignsLoadError": "Failed to load campaigns", "campaignsLoadError": "Failed to load campaigns",

View File

@@ -169,6 +169,7 @@
"foundStatus": "✓ Найдено", "foundStatus": "✓ Найдено",
"write": "Написать", "write": "Написать",
"itemFound": "✓ Вернулось к владельцу", "itemFound": "✓ Вернулось к владельцу",
"reopen": "↩ Вернуть в ленту",
"showOnMap": "Показать на карте — {{address}}", "showOnMap": "Показать на карте — {{address}}",
"contactError": "Не удалось открыть чат. Попробуйте ещё раз", "contactError": "Не удалось открыть чат. Попробуйте ещё раз",
"reward": "Вознаграждение" "reward": "Вознаграждение"
@@ -239,7 +240,38 @@
}, },
"adminPanel": { "adminPanel": {
"campaignsNav": "Реклама (кампании)", "campaignsNav": "Реклама (кампании)",
"postsNav": "Все объявления",
"tabsLabel": "Разделы админ-панели",
"tabCampaigns": "Рекламные кампании",
"tabPosts": "Все объявления",
"pageTitle": "Рекламные кампании", "pageTitle": "Рекламные кампании",
"postsPageTitle": "Все объявления",
"postsSearchPlaceholder": "Поиск по названию, описанию, автору",
"postsTypeFilter": "Тип объявления",
"postsStatusFilter": "Статус объявления",
"filterTypeAll": "Все",
"filterStatusAll": "Любой",
"statusActive": "Активные",
"statusClosed": "Закрытые",
"postsLoading": "Загружаем объявления…",
"postsHint": "Загружено объявлений: {{count}}",
"postsLoadError": "Не удалось загрузить объявления",
"postsEmpty": "Объявлений нет",
"postsEmptySub": "Ничего не найдено по текущему фильтру.",
"postsUpdated": "Объявление обновлено",
"postsDeleted": "Объявление удалено",
"postsDeleteFailed": "Не удалось удалить объявление",
"postsClosed": "Объявление закрыто",
"postsReopened": "Объявление возвращено в ленту",
"postsStatusFailed": "Не удалось изменить статус объявления",
"postsConfirmDelete": "Удалить объявление? Это действие необратимо.",
"postsConfirmClose": "Закрыть объявление («найдено»)?",
"postsConfirmReopen": "Вернуть объявление в ленту?",
"postEditTitle": "Редактирование объявления (админ)",
"postUpdatedModal": "Объявление сохранено",
"postUpdatedModalSub": "Изменения уже видны в ленте.",
"loadMore": "Показать ещё",
"refresh": "Обновить",
"persistHint": "Кампании и объявления хранятся на сервере", "persistHint": "Кампании и объявления хранятся на сервере",
"campaignsLoading": "Загружаем кампании…", "campaignsLoading": "Загружаем кампании…",
"campaignsLoadError": "Не удалось загрузить кампании", "campaignsLoadError": "Не удалось загрузить кампании",

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, Navigate } from "react-router-dom"; import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuthContext } from "../context/AuthContext"; import { useAuthContext } from "../context/AuthContext";
import { useIsDesktop } from "../hooks/useIsDesktop"; import { useIsDesktop } from "../hooks/useIsDesktop";
@@ -17,6 +17,7 @@ import CampaignEditModal from "../components/admin/CampaignEditModal";
import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal"; import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal";
import AdvertisingEditModal from "../components/admin/AdvertisingEditModal"; import AdvertisingEditModal from "../components/admin/AdvertisingEditModal";
import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal"; import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal";
import PostsTab from "../components/admin/PostsTab";
import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi"; import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi";
import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus"; import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus";
import "../styles/admin.css"; import "../styles/admin.css";
@@ -24,6 +25,14 @@ import "../styles/admin.css";
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const MAX_POLL_ATTEMPTS = 100; // ≈ 5 минут const MAX_POLL_ATTEMPTS = 100; // ≈ 5 минут
/** Активная вкладка задаётся query-параметром ?tab=posts, иначе — реклама. */
const TAB_CAMPAIGNS = "campaigns";
const TAB_POSTS = "posts";
const TABS = [
{ key: TAB_CAMPAIGNS, labelKey: "adminPanel.tabCampaigns" },
{ key: TAB_POSTS, labelKey: "adminPanel.tabPosts" },
];
/** "2026-09-20T14:30:00" → "20.09.2026 14:30" */ /** "2026-09-20T14:30:00" → "20.09.2026 14:30" */
function formatLocalDateTime(value) { function formatLocalDateTime(value) {
if (!value) return "—"; if (!value) return "—";
@@ -33,7 +42,8 @@ function formatLocalDateTime(value) {
} }
/** /**
* Десктоп-only админ-панель управления рекламными кампаниями. * Десктоп-only админ-панель: две вкладки — рекламные кампании
* и все объявления площадки (редактирование, смена статуса, удаление).
* *
* Список кампаний подгружается с бэкенда при монтировании * Список кампаний подгружается с бэкенда при монтировании
* (GET /api/v1/admin/campaigns), а объявления конкретной кампании — * (GET /api/v1/admin/campaigns), а объявления конкретной кампании —
@@ -44,11 +54,20 @@ function formatLocalDateTime(value) {
export default function AdminAdsPage() { export default function AdminAdsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext(); const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext();
const { fetchProfile } = useProfile(); const { fetchProfile } = useProfile();
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const modals = useModals(); const modals = useModals();
const tab = searchParams.get("tab") === TAB_POSTS ? TAB_POSTS : TAB_CAMPAIGNS;
const isPostsTab = tab === TAB_POSTS;
const selectTab = useCallback((key) => {
// Для рекламы параметр не пишем — /admin/ads без query открывает именно её.
setSearchParams(key === TAB_CAMPAIGNS ? {} : { tab: key }, { replace: true });
}, [setSearchParams]);
const [campaigns, setCampaigns] = useState([]); // { id, name, status, startDate, endDate, dailyImpressionCap, ads: [], adsLoaded, adsLoading, adsLoadError, adsAttempted } const [campaigns, setCampaigns] = useState([]); // { id, name, status, startDate, endDate, dailyImpressionCap, ads: [], adsLoaded, adsLoading, adsLoadError, adsAttempted }
const [campaignsLoading, setCampaignsLoading] = useState(true); const [campaignsLoading, setCampaignsLoading] = useState(true);
const [campaignsLoadError, setCampaignsLoadError] = useState(""); const [campaignsLoadError, setCampaignsLoadError] = useState("");
@@ -83,8 +102,9 @@ export default function AdminAdsPage() {
}, [isAuthenticated, user?.id]); }, [isAuthenticated, user?.id]);
// Подгружаем реальный список кампаний при монтировании // Подгружаем реальный список кампаний при монтировании
// (и при каждом возврате на вкладку рекламы — данные могли устареть).
useEffect(() => { useEffect(() => {
if (!isAdmin) return; if (!isAdmin || isPostsTab) return;
let cancelled = false; let cancelled = false;
setCampaignsLoading(true); setCampaignsLoading(true);
setCampaignsLoadError(""); setCampaignsLoadError("");
@@ -110,7 +130,7 @@ export default function AdminAdsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isAdmin, t]); }, [isAdmin, isPostsTab, t]);
const showToast = useCallback((message, type = "success") => { const showToast = useCallback((message, type = "success") => {
setToast({ message, type }); setToast({ message, type });
@@ -278,7 +298,7 @@ export default function AdminAdsPage() {
<div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}> <div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}>
<Sidebar <Sidebar
open={sidebarOpen} open={sidebarOpen}
activeNav="home" activeNav={isPostsTab ? "admin-posts" : "admin-campaigns"}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
onNavClick={() => navigate("/feed")} onNavClick={() => navigate("/feed")}
onLogoClick={() => navigate("/feed")} onLogoClick={() => navigate("/feed")}
@@ -298,11 +318,34 @@ export default function AdminAdsPage() {
onProfileClick={() => modals.open("profile")} onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")} onLoginClick={() => modals.open("auth")}
hideWordmark hideWordmark
centerContent={<h1 className="admp-title">{t('adminPanel.pageTitle')}</h1>} centerContent={
<h1 className="admp-title">
{isPostsTab ? t('adminPanel.postsPageTitle') : t('adminPanel.pageTitle')}
</h1>
}
/> />
<div className="admp"> <div className="admp">
<div className="admp-inner"> <div className={`admp-inner${isPostsTab ? " admp-inner--posts" : ""}`}>
<div className="admp-tabs" role="tablist" aria-label={t('adminPanel.tabsLabel')}>
{TABS.map(({ key, labelKey }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={`admp-tab${tab === key ? " active" : ""}`}
onClick={() => selectTab(key)}
>
{t(labelKey)}
</button>
))}
</div>
{isPostsTab ? (
<PostsTab isAdmin={isAdmin} />
) : (
<>
<div className="admp-toolbar"> <div className="admp-toolbar">
<div> <div>
<div className="admp-session-hint"> <div className="admp-session-hint">
@@ -425,35 +468,37 @@ export default function AdminAdsPage() {
</div> </div>
); );
})} })}
</>
)}
</div> </div>
</div> </div>
</div> </div>
{modals.isOpen("campaign-create") && isAdmin && ( {modals.isOpen("campaign-create") && isAdmin && !isPostsTab && (
<CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} /> <CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} />
)} )}
{modals.isOpen("campaign-edit") && isAdmin && ( {modals.isOpen("campaign-edit") && isAdmin && !isPostsTab && (
<CampaignEditModal <CampaignEditModal
campaign={modals.modal.payload.campaign} campaign={modals.modal.payload.campaign}
onClose={modals.close} onClose={modals.close}
onUpdated={handleCampaignUpdated} onUpdated={handleCampaignUpdated}
/> />
)} )}
{modals.isOpen("advertising-create") && isAdmin && ( {modals.isOpen("advertising-create") && isAdmin && !isPostsTab && (
<AdvertisingCreateModal <AdvertisingCreateModal
campaignId={modals.modal.payload.campaignId} campaignId={modals.modal.payload.campaignId}
onClose={modals.close} onClose={modals.close}
onCreated={handleAdCreated} onCreated={handleAdCreated}
/> />
)} )}
{modals.isOpen("advertising-edit") && isAdmin && ( {modals.isOpen("advertising-edit") && isAdmin && !isPostsTab && (
<AdvertisingEditModal <AdvertisingEditModal
ad={modals.modal.payload.ad} ad={modals.modal.payload.ad}
onClose={modals.close} onClose={modals.close}
onUpdated={handleAdUpdated} onUpdated={handleAdUpdated}
/> />
)} )}
{modals.isOpen("campaign-status-update") && isAdmin && ( {modals.isOpen("campaign-status-update") && isAdmin && !isPostsTab && (
<CampaignStatusUpdateModal <CampaignStatusUpdateModal
campaignId={modals.modal.payload.campaignId} campaignId={modals.modal.payload.campaignId}
currentStatus={modals.modal.payload.currentStatus} currentStatus={modals.modal.payload.currentStatus}

View File

@@ -18,8 +18,10 @@ import ClearableField from "../components/ClearableField";
* @param {function} onClose — закрыть модалку без сохранения * @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту) * @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования * @param {object} [initialData] — если передан, форма работает в режиме редактирования
* @param {boolean} [adminMode] — правка чужого объявления из админ-панели:
* сохранение уходит в PUT /api/v1/admin/posts/{id}
*/ */
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { export default function CreateUpdatePost({ onClose, onSuccess, initialData, adminMode = false }) {
const isEdit = !!initialData; const isEdit = !!initialData;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -57,7 +59,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
getExistingUrlsToKeep, getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []); } = useImageUpload(isEdit ? initialData.images : []);
const { save, loading: saving, error: apiError } = usePostMutation(); const { save, loading: saving, error: apiError } = usePostMutation({ adminMode });
const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked" const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked"
@@ -157,7 +159,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
setResultModal("blocked"); setResultModal("blocked");
return; return;
} }
if (result.data) { if (result.ok) {
setResultModal("success"); setResultModal("success");
} }
}; };
@@ -172,7 +174,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title"> <span className="f-modal-title">
{isEdit ? t('post.editTitle') : t('post.newTitle')} {adminMode
? t('adminPanel.postEditTitle')
: isEdit ? t('post.editTitle') : t('post.newTitle')}
</span> </span>
<button className="f-close-btn" type="button" onClick={onClose}> <button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon /> <CloseIcon />
@@ -457,10 +461,14 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{resultModal === "blocked" ? "✕" : "✓"} {resultModal === "blocked" ? "✕" : "✓"}
</div> </div>
<h3 className="f-result-title"> <h3 className="f-result-title">
{resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')} {resultModal === "blocked"
? t('post.blockedTitle')
: adminMode ? t('adminPanel.postUpdatedModal') : t('post.successTitle')}
</h3> </h3>
<p className="f-result-text"> <p className="f-result-text">
{resultModal === "blocked" ? t('post.blockedText') : t('post.successText')} {resultModal === "blocked"
? t('post.blockedText')
: adminMode ? t('adminPanel.postUpdatedModalSub') : t('post.successText')}
</p> </p>
<button <button
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`} className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}

View File

@@ -11,7 +11,7 @@ import RewardPopup from "../components/feed/RewardPopup";
import Toast from "../components/Toast"; import Toast from "../components/Toast";
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onLocationClick, onAuthRequired, compact }) { export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen, isOwner, authorLabel, allowEditClosed, onImageClick, onLocationClick, onAuthRequired, compact }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showCalendar, setShowCalendar] = useState(false); const [showCalendar, setShowCalendar] = useState(false);
const [showReward, setShowReward] = useState(false); const [showReward, setShowReward] = useState(false);
@@ -69,6 +69,9 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
> >
{ad.title} {ad.title}
</div> </div>
{authorLabel && (
<div className="f-card-author-sub">{authorLabel}</div>
)}
</div> </div>
<div className="f-card-head-side"> <div className="f-card-head-side">
{typeBadge} {typeBadge}
@@ -102,7 +105,7 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
)} )}
</div> </div>
<div className="f-card-actions-right"> <div className="f-card-actions-right">
{onEdit && isOwner && !isClosed && ( {onEdit && isOwner && (!isClosed || allowEditClosed) && (
<button type="button" className="f-card-edit-btn" <button type="button" className="f-card-edit-btn"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -207,6 +210,17 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
</button> </button>
</div> </div>
)} )}
{onReopen && isOwner && isClosed && (
<div className="f-card-found-wrap">
<button type="button" className="f-card-found-btn f-card-reopen-btn"
onClick={e => {
e.stopPropagation();
onReopen(ad.id);
}}>
{t('postCard.reopen')}
</button>
</div>
)}
{contactError && ( {contactError && (
<Toast <Toast

View File

@@ -11,6 +11,43 @@
max-width: 860px; max-width: 860px;
margin: 0 auto; margin: 0 auto;
} }
/* Вкладки админ-панели: объявления сеткой нуждаются в более широком контейнере. */
.admp-inner--posts {
max-width: 1180px;
}
/* ── Переключатель вкладок ── */
.admp-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 12px;
}
.admp-tab {
flex: 1;
padding: 9px 14px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--muted);
font-family: var(--font);
font-size: .85rem;
font-weight: 800;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.admp-tab:hover {
color: var(--text-primary);
}
.admp-tab.active {
background: var(--bg-card);
color: var(--teal);
box-shadow: 0 1px 4px rgba(0,0,0,.08);
}
.admp-toolbar { .admp-toolbar {
display: flex; display: flex;
@@ -41,6 +78,59 @@
margin-top: 36px; margin-top: 36px;
} }
/* ── Фильтры вкладки «Все объявления» ── */
.admp-toolbar--filters {
justify-content: flex-start;
margin-bottom: 8px;
}
.admp-search {
flex: 1;
min-width: 220px;
max-width: 320px;
}
.admp-filter-group {
display: flex;
gap: 4px;
padding: 3px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
}
.admp-chip {
padding: 5px 12px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: var(--font);
font-size: .75rem;
font-weight: 800;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.admp-chip:hover {
color: var(--text-primary);
}
.admp-chip.active {
background: var(--bg-card);
color: var(--teal);
}
/* ── Сетка объявлений ── */
.admp-posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 12px;
transition: opacity .15s;
}
.admp-more {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* ── Карточка кампании ── */ /* ── Карточка кампании ── */
.admp-campaign { .admp-campaign {
background: var(--bg-card); background: var(--bg-card);

View File

@@ -53,6 +53,11 @@
color: var(--muted); font-size: .78rem; color: var(--muted); font-size: .78rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
/* Подпись автора — только в админ-панели, где видно, чьё это объявление. */
.f-card-author-sub {
color: var(--muted); font-size: .75rem; font-weight: 600;
max-width: 100%; word-break: break-word;
}
.f-card-head-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .f-card-head-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
/* ── Image panel ── */ /* ── Image panel ── */
@@ -327,6 +332,14 @@ button.f-card-reward-btn:focus-visible {
.f-card-found-btn:hover { .f-card-found-btn:hover {
background: rgba(48,168,94,.18); border-color: rgba(48,168,94,.5); background: rgba(48,168,94,.18); border-color: rgba(48,168,94,.5);
} }
/* Возврат закрытого объявления в ленту (только в админ-панели). */
.f-card-reopen-btn {
background: rgba(var(--teal-rgb),.1); border-color: rgba(var(--teal-rgb),.3);
color: var(--teal);
}
.f-card-reopen-btn:hover {
background: rgba(var(--teal-rgb),.18); border-color: rgba(var(--teal-rgb),.5);
}
/* ── Calendar Popup ── */ /* ── Calendar Popup ── */
.f-date-calendar { .f-date-calendar {