From 841bf091d41636dc54351574d8ce88c8cd52e3f4 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Fri, 2 Oct 2026 01:50:42 +0300 Subject: [PATCH] added all feeds for admin --- src/api/adminContentApi.js | 108 +++++++++++ src/components/Sidebar.jsx | 12 +- src/components/admin/PostsTab.jsx | 273 ++++++++++++++++++++++++++++ src/hooks/usePostMutation.js | 26 ++- src/locales/en.json | 32 ++++ src/locales/ru.json | 32 ++++ src/pages/AdminAdsPage.jsx | 289 +++++++++++++++++------------- src/pages/CreateUpdatePost.jsx | 26 ++- src/pages/PostOnFeed.jsx | 18 +- src/styles/admin.css | 90 ++++++++++ src/styles/card.css | 13 ++ 11 files changed, 778 insertions(+), 141 deletions(-) create mode 100644 src/api/adminContentApi.js create mode 100644 src/components/admin/PostsTab.jsx 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 && (