diff --git a/src/api/adminContentApi.js b/src/api/adminContentApi.js
new file mode 100644
index 0000000..8ebdd10
--- /dev/null
+++ b/src/api/adminContentApi.js
@@ -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 };
+ }
+}
\ No newline at end of file
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 93bbec5..d9cf083 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -123,7 +123,7 @@ export default function Sidebar({
+
>
)}
diff --git a/src/components/admin/PostsTab.jsx b/src/components/admin/PostsTab.jsx
new file mode 100644
index 0000000..c459654
--- /dev/null
+++ b/src/components/admin/PostsTab.jsx
@@ -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 (
+ <>
+
+
setSearchInput(e.target.value)}
+ placeholder={t("adminPanel.postsSearchPlaceholder")}
+ aria-label={t("adminPanel.postsSearchPlaceholder")}
+ />
+
+ {TYPE_FILTERS.map(({ value, labelKey }) => (
+
+ ))}
+
+
+ {STATUS_FILTERS.map(({ value, labelKey }) => (
+
+ ))}
+
+
+
+
+
+ {loading
+ ? t("adminPanel.postsLoading")
+ : t("adminPanel.postsHint", { count: posts.length })}
+
+ {error && {error}
}
+
+ {loading && (
+
+ )}
+
+ {!loading && posts.length === 0 && (
+
+
📋
+
{t("adminPanel.postsEmpty")}
+
{t("adminPanel.postsEmptySub")}
+
+ )}
+
+
+ {posts.map((post) => (
+
setStatus(id, "CLOSED")}
+ onReopen={(id) => setStatus(id, "ACTIVE")}
+ onImageClick={handleImageClick}
+ />
+ ))}
+
+
+ {!loading && hasMore && (
+
+
+
+ )}
+
+ {editing && (
+ setEditing(null)}
+ onSuccess={handleEditSaved}
+ />
+ )}
+
+ {viewer && (
+ setViewer(null)}
+ />
+ )}
+
+ {toast && setToast(null)} />}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/usePostMutation.js b/src/hooks/usePostMutation.js
index cdd75b3..68707ca 100644
--- a/src/hooks/usePostMutation.js
+++ b/src/hooks/usePostMutation.js
@@ -11,15 +11,22 @@ import { apiFetch } from "../api/apiClient";
* id передан → PUT /api/v1/posts/:id (обновление)
* id не передан → POST /api/v1/posts (создание)
*
+ * В режиме adminMode (правка чужого объявления из админ-панели) используется
+ * PUT /api/v1/admin/posts/:id. Создание через админку не поддерживается:
+ * приходит без id — падаем обратно на пользовательский POST.
+ *
* Авторизация (Authorization header + авто-обновление токена при истечении)
* обрабатывается внутри apiFetch.
*
* save() возвращает:
- * { data } — успешный ответ сервера
- * { blocked: true } — контент заблокирован модерацией
- * null — серверная ошибка (свойство error содержит текст)
+ * { ok: true, data } — успешный ответ сервера (data может быть null,
+ * если бэкенд ответил без тела)
+ * { blocked: true } — контент заблокирован модерацией
+ * null — серверная ошибка (свойство error содержит текст)
+ *
+ * @param {{adminMode?: boolean}} [options]
*/
-export function usePostMutation() {
+export function usePostMutation({ adminMode = false } = {}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -27,13 +34,16 @@ export function usePostMutation() {
/**
* @param {string|null} id — id объявления при обновлении, null при создании
* @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) => {
setLoading(true);
setError("");
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 res = await apiFetch(url, {
method,
@@ -51,7 +61,9 @@ export function usePostMutation() {
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) {
setError(e.message || t('post.unknownError'));
return null;
diff --git a/src/locales/en.json b/src/locales/en.json
index b7c94eb..a06a2b2 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -168,6 +168,7 @@
"foundStatus": "✓ Found",
"write": "Write",
"itemFound": "✓ Returned to owner",
+ "reopen": "↩ Return to feed",
"showOnMap": "Show on map — {{address}}",
"contactError": "Unable to open the chat. Please try again",
"reward": "Reward"
@@ -238,7 +239,38 @@
},
"adminPanel": {
"campaignsNav": "Ads (campaigns)",
+ "postsNav": "All listings",
+ "tabsLabel": "Admin panel sections",
+ "tabCampaigns": "Advertising campaigns",
+ "tabPosts": "All listings",
"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",
"campaignsLoading": "Loading campaigns...",
"campaignsLoadError": "Failed to load campaigns",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index c3208c0..55949c8 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -169,6 +169,7 @@
"foundStatus": "✓ Найдено",
"write": "Написать",
"itemFound": "✓ Вернулось к владельцу",
+ "reopen": "↩ Вернуть в ленту",
"showOnMap": "Показать на карте — {{address}}",
"contactError": "Не удалось открыть чат. Попробуйте ещё раз",
"reward": "Вознаграждение"
@@ -239,7 +240,38 @@
},
"adminPanel": {
"campaignsNav": "Реклама (кампании)",
+ "postsNav": "Все объявления",
+ "tabsLabel": "Разделы админ-панели",
+ "tabCampaigns": "Рекламные кампании",
+ "tabPosts": "Все объявления",
"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": "Кампании и объявления хранятся на сервере",
"campaignsLoading": "Загружаем кампании…",
"campaignsLoadError": "Не удалось загрузить кампании",
diff --git a/src/pages/AdminAdsPage.jsx b/src/pages/AdminAdsPage.jsx
index a3baba4..a25581d 100644
--- a/src/pages/AdminAdsPage.jsx
+++ b/src/pages/AdminAdsPage.jsx
@@ -1,5 +1,5 @@
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 { useAuthContext } from "../context/AuthContext";
import { useIsDesktop } from "../hooks/useIsDesktop";
@@ -17,6 +17,7 @@ import CampaignEditModal from "../components/admin/CampaignEditModal";
import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal";
import AdvertisingEditModal from "../components/admin/AdvertisingEditModal";
import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal";
+import PostsTab from "../components/admin/PostsTab";
import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi";
import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus";
import "../styles/admin.css";
@@ -24,6 +25,14 @@ import "../styles/admin.css";
const POLL_INTERVAL_MS = 3000;
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" */
function formatLocalDateTime(value) {
if (!value) return "—";
@@ -33,7 +42,8 @@ function formatLocalDateTime(value) {
}
/**
- * Десктоп-only админ-панель управления рекламными кампаниями.
+ * Десктоп-only админ-панель: две вкладки — рекламные кампании
+ * и все объявления площадки (редактирование, смена статуса, удаление).
*
* Список кампаний подгружается с бэкенда при монтировании
* (GET /api/v1/admin/campaigns), а объявления конкретной кампании —
@@ -44,11 +54,20 @@ function formatLocalDateTime(value) {
export default function AdminAdsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext();
const { fetchProfile } = useProfile();
const isDesktop = useIsDesktop();
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 [campaignsLoading, setCampaignsLoading] = useState(true);
const [campaignsLoadError, setCampaignsLoadError] = useState("");
@@ -83,8 +102,9 @@ export default function AdminAdsPage() {
}, [isAuthenticated, user?.id]);
// Подгружаем реальный список кампаний при монтировании
+ // (и при каждом возврате на вкладку рекламы — данные могли устареть).
useEffect(() => {
- if (!isAdmin) return;
+ if (!isAdmin || isPostsTab) return;
let cancelled = false;
setCampaignsLoading(true);
setCampaignsLoadError("");
@@ -110,7 +130,7 @@ export default function AdminAdsPage() {
return () => {
cancelled = true;
};
- }, [isAdmin, t]);
+ }, [isAdmin, isPostsTab, t]);
const showToast = useCallback((message, type = "success") => {
setToast({ message, type });
@@ -278,7 +298,7 @@ export default function AdminAdsPage() {
setSidebarOpen(false)}
onNavClick={() => navigate("/feed")}
onLogoClick={() => navigate("/feed")}
@@ -298,162 +318,187 @@ export default function AdminAdsPage() {
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
hideWordmark
- centerContent={{t('adminPanel.pageTitle')}
}
+ centerContent={
+
+ {isPostsTab ? t('adminPanel.postsPageTitle') : t('adminPanel.pageTitle')}
+
+ }
/>
-
-
-
-
- {campaignsLoading
- ? t('adminPanel.campaignsLoading')
- : t('adminPanel.persistHint')}
-
- {campaignsLoadError &&
{campaignsLoadError}
}
-
-
+
+
+ {TABS.map(({ key, labelKey }) => (
+
+ ))}
- {!campaignsLoading && campaigns.length === 0 && (
-
-
📣
-
{t('adminPanel.noCampaigns')}
-
{t('adminPanel.noCampaignsSub')}
+ {isPostsTab ? (
+
+ ) : (
+ <>
+
+
+
+ {campaignsLoading
+ ? t('adminPanel.campaignsLoading')
+ : t('adminPanel.persistHint')}
+
+ {campaignsLoadError &&
{campaignsLoadError}
}
+
+
- )}
- {campaigns.map((campaign) => {
- const allowedNext = getNextCampaignStatuses(campaign.status);
- return (
-
-
-
- {campaign.name}
-
- {campaign.status}
-
-
-
-
-
- {allowedNext.length > 0 && (
+ {!campaignsLoading && campaigns.length === 0 && (
+
+
📣
+
{t('adminPanel.noCampaigns')}
+
{t('adminPanel.noCampaignsSub')}
+
+ )}
+
+ {campaigns.map((campaign) => {
+ const allowedNext = getNextCampaignStatuses(campaign.status);
+ return (
+
+
+
+ {campaign.name}
+
+ {campaign.status}
+
+
+
- )}
-
-
-
+
+ {allowedNext.length > 0 && (
+
+ )}
+
+
+
-
- {t('adminPanel.dates')}: {formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)}
- {t('adminPanel.dailyCap')}: {campaign.dailyImpressionCap}
-
+
+ {t('adminPanel.dates')}: {formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)}
+ {t('adminPanel.dailyCap')}: {campaign.dailyImpressionCap}
+
- {expandedCampaignIds.has(campaign.id) && (
- <>
-
{t('adminPanel.ads')}
- {campaign.adsLoading ? (
-
{t('adminPanel.adsLoading')}
- ) : campaign.adsLoadError ? (
-
{campaign.adsLoadError}
- ) : campaign.ads.length === 0 ? (
-
{t('adminPanel.noAds')}
- ) : (
-
- {campaign.ads.map((ad) => (
-
-
{ad.type}
-
-
{ad.targetUrl}
-
- {t('adminPanel.weight')}: {ad.weight}
+ {expandedCampaignIds.has(campaign.id) && (
+ <>
+
{t('adminPanel.ads')}
+ {campaign.adsLoading ? (
+
{t('adminPanel.adsLoading')}
+ ) : campaign.adsLoadError ? (
+
{campaign.adsLoadError}
+ ) : campaign.ads.length === 0 ? (
+
{t('adminPanel.noAds')}
+ ) : (
+
+ {campaign.ads.map((ad) => (
+
+
{ad.type}
+
+
{ad.targetUrl}
+
+ {t('adminPanel.weight')}: {ad.weight}
+
+
+
+ {transcodingBadgeText(ad, t)}
+
+
-
-
- {transcodingBadgeText(ad, t)}
-
-
-
- ))}
-
- )}
- >
- )}
-
- );
- })}
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+ })}
+ >
+ )}
- {modals.isOpen("campaign-create") && isAdmin && (
+ {modals.isOpen("campaign-create") && isAdmin && !isPostsTab && (
)}
- {modals.isOpen("campaign-edit") && isAdmin && (
+ {modals.isOpen("campaign-edit") && isAdmin && !isPostsTab && (
)}
- {modals.isOpen("advertising-create") && isAdmin && (
+ {modals.isOpen("advertising-create") && isAdmin && !isPostsTab && (
)}
- {modals.isOpen("advertising-edit") && isAdmin && (
+ {modals.isOpen("advertising-edit") && isAdmin && !isPostsTab && (
)}
- {modals.isOpen("campaign-status-update") && isAdmin && (
+ {modals.isOpen("campaign-status-update") && isAdmin && !isPostsTab && (
- {isEdit ? t('post.editTitle') : t('post.newTitle')}
+ {adminMode
+ ? t('adminPanel.postEditTitle')
+ : isEdit ? t('post.editTitle') : t('post.newTitle')}
- {resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')}
+ {resultModal === "blocked"
+ ? t('post.blockedTitle')
+ : adminMode ? t('adminPanel.postUpdatedModal') : t('post.successTitle')}
- {resultModal === "blocked" ? t('post.blockedText') : t('post.successText')}
+ {resultModal === "blocked"
+ ? t('post.blockedText')
+ : adminMode ? t('adminPanel.postUpdatedModalSub') : t('post.successText')}
+ {authorLabel && (
+
{authorLabel}
+ )}
{typeBadge}
@@ -102,7 +105,7 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
)}
- {onEdit && isOwner && !isClosed && (
+ {onEdit && isOwner && (!isClosed || allowEditClosed) && (
)}
+ {onReopen && isOwner && isClosed && (
+
+
+
+ )}
{contactError && (