Merge pull request #74 from SlimusMinus/fix-admin_panel
added all feeds for admin
This commit is contained in:
108
src/api/adminContentApi.js
Normal file
108
src/api/adminContentApi.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export default function Sidebar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="f-sb-item"
|
||||
className={`f-sb-item${activeNav === "admin-campaigns" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
navigate("/admin/ads");
|
||||
onClose();
|
||||
@@ -131,6 +131,16 @@ export default function Sidebar({
|
||||
>
|
||||
{NavIcons.grid()} {t('adminPanel.campaignsNav')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`f-sb-item${activeNav === "admin-posts" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
navigate("/admin/ads?tab=posts");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{NavIcons.rules()} {t('adminPanel.postsNav')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
273
src/components/admin/PostsTab.jsx
Normal file
273
src/components/admin/PostsTab.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
// src/components/admin/PostsTab.jsx
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PostOnFeed from "../../pages/PostOnFeed";
|
||||
import CreateUpdatePost from "../../pages/CreateUpdatePost";
|
||||
import ImageViewer from "../ImageViewer";
|
||||
import Toast from "../Toast";
|
||||
import {
|
||||
getAdminPosts,
|
||||
deleteAdminPost,
|
||||
updateAdminPostStatus,
|
||||
} from "../../api/adminContentApi";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const SEARCH_DEBOUNCE_MS = 400;
|
||||
|
||||
const TYPE_FILTERS = [
|
||||
{ value: "", labelKey: "adminPanel.filterTypeAll" },
|
||||
{ value: "LOSS", labelKey: "postCard.loss" },
|
||||
{ value: "FOUND", labelKey: "postCard.found" },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: "", labelKey: "adminPanel.filterStatusAll" },
|
||||
{ value: "ACTIVE", labelKey: "adminPanel.statusActive" },
|
||||
{ value: "CLOSED", labelKey: "adminPanel.statusClosed" },
|
||||
];
|
||||
|
||||
/** Подпись автора объявления: у бэкенда поле называется по-разному в разных DTO. */
|
||||
function authorLabelOf(post) {
|
||||
const name = post.ownerName || post.authorName || post.userName ||
|
||||
[post.ownerFirstName, post.ownerLastName].filter(Boolean).join(" ");
|
||||
const login = post.ownerLogin || post.authorLogin || post.userLogin;
|
||||
const parts = [name, login ? `@${login}` : null].filter(Boolean);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вкладка «Все объявления» админ-панели.
|
||||
*
|
||||
* Показывает ВСЕ объявления площадки с тем же набором действий, что и вкладка
|
||||
* «Мои объявления» у обычного пользователя (редактирование, удаление, отметка
|
||||
* «найдено»), плюс возможность вернуть закрытое объявление обратно в ленту.
|
||||
*
|
||||
* Все мутации идут через /api/v1/admin/posts — сервер проверяет роль ADMIN
|
||||
* независимо от того, что интерфейс вообще показал админские кнопки.
|
||||
*
|
||||
* Разметка рассчитана на то, что страница уже нарисовала .admp > .admp-inner,
|
||||
* поэтому собственного скролл-контейнера у вкладки нет.
|
||||
*/
|
||||
export default function PostsTab({ isAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [viewer, setViewer] = useState(null);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = useCallback((message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
}, []);
|
||||
|
||||
// Дебаунс поиска, чтобы не дёргать сервер на каждое нажатие клавиши
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
// Смена фильтра сбрасывает пагинацию, поэтому load всегда стартует с 0-й страницы.
|
||||
const load = useCallback(async ({ pageNum = 0, append = false, silent = false } = {}) => {
|
||||
if (!isAdmin) return;
|
||||
if (append) setLoadingMore(true);
|
||||
else if (!silent) setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await getAdminPosts({ page: pageNum, size: PAGE_SIZE, search, type: typeFilter, status: statusFilter });
|
||||
setPosts((prev) => (append ? [...prev, ...result.items] : result.items));
|
||||
setHasMore(!result.last);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
setError(t("adminPanel.postsLoadError"));
|
||||
if (!append) setPosts([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [isAdmin, search, typeFilter, statusFilter, t]);
|
||||
|
||||
// Первичная загрузка + перезагрузка при смене фильтров
|
||||
useEffect(() => {
|
||||
load({ pageNum: 0 });
|
||||
}, [load]);
|
||||
|
||||
const patchPost = useCallback((postId, patch) => {
|
||||
setPosts((prev) => prev.map((p) => (p.id === postId ? { ...p, ...patch } : p)));
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (postId) => {
|
||||
if (!window.confirm(t("adminPanel.postsConfirmDelete"))) return;
|
||||
setBusyId(postId);
|
||||
try {
|
||||
await deleteAdminPost(postId);
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId));
|
||||
showToast(t("adminPanel.postsDeleted"));
|
||||
} catch {
|
||||
showToast(t("adminPanel.postsDeleteFailed"), "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
|
||||
const setStatus = useCallback(async (postId, status) => {
|
||||
const isClose = status === "CLOSED";
|
||||
if (!window.confirm(isClose ? t("adminPanel.postsConfirmClose") : t("adminPanel.postsConfirmReopen"))) return;
|
||||
setBusyId(postId);
|
||||
try {
|
||||
const updated = await updateAdminPostStatus(postId, status);
|
||||
// Сервер может вернуть неполный DTO — мержим, чтобы не потерять поля карточки.
|
||||
patchPost(postId, { ...(updated || {}), id: postId, status });
|
||||
showToast(isClose ? t("adminPanel.postsClosed") : t("adminPanel.postsReopened"));
|
||||
} catch {
|
||||
showToast(t("adminPanel.postsStatusFailed"), "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, [t, showToast, patchPost]);
|
||||
|
||||
const handleEditSaved = useCallback(() => {
|
||||
// Перезагружаем текущую страницу: сервер мог изменить статус или дату.
|
||||
load({ pageNum: page, silent: true });
|
||||
showToast(t("adminPanel.postsUpdated"));
|
||||
}, [load, page, showToast, t]);
|
||||
|
||||
const handleImageClick = useCallback((images, index, ad) => {
|
||||
if (images?.length) setViewer({ images, index, ad });
|
||||
}, []);
|
||||
|
||||
const contentStyle = useMemo(() => ({ opacity: busyId === null ? 1 : 0.75 }), [busyId]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="admp-toolbar admp-toolbar--filters">
|
||||
<input
|
||||
type="search"
|
||||
className="f-m-field admp-search"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t("adminPanel.postsSearchPlaceholder")}
|
||||
aria-label={t("adminPanel.postsSearchPlaceholder")}
|
||||
/>
|
||||
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsTypeFilter")}>
|
||||
{TYPE_FILTERS.map(({ value, labelKey }) => (
|
||||
<button
|
||||
key={value || "all"}
|
||||
type="button"
|
||||
className={`admp-chip${typeFilter === value ? " active" : ""}`}
|
||||
onClick={() => setTypeFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsStatusFilter")}>
|
||||
{STATUS_FILTERS.map(({ value, labelKey }) => (
|
||||
<button
|
||||
key={value || "all"}
|
||||
type="button"
|
||||
className={`admp-chip${statusFilter === value ? " active" : ""}`}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admp-btn admp-btn--ghost"
|
||||
onClick={() => load({ pageNum: 0 })}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("adminPanel.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admp-session-hint">
|
||||
{loading
|
||||
? t("adminPanel.postsLoading")
|
||||
: t("adminPanel.postsHint", { count: posts.length })}
|
||||
</div>
|
||||
{error && <div className="f-err-msg admp-load-error">{error}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="f-center admp-empty">
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="f-center admp-empty">
|
||||
<div className="f-center-icon">📋</div>
|
||||
<div className="f-center-title">{t("adminPanel.postsEmpty")}</div>
|
||||
<div className="f-center-sub">{t("adminPanel.postsEmptySub")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admp-posts-grid" style={contentStyle}>
|
||||
{posts.map((post) => (
|
||||
<PostOnFeed
|
||||
key={post.id}
|
||||
ad={post}
|
||||
isOwner
|
||||
allowEditClosed
|
||||
authorLabel={authorLabelOf(post)}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
onMarkFound={(id) => setStatus(id, "CLOSED")}
|
||||
onReopen={(id) => setStatus(id, "ACTIVE")}
|
||||
onImageClick={handleImageClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="admp-more">
|
||||
<button
|
||||
type="button"
|
||||
className="admp-btn admp-btn--ghost"
|
||||
disabled={loadingMore}
|
||||
onClick={() => load({ pageNum: page + 1, append: true })}
|
||||
>
|
||||
{loadingMore ? t("adminPanel.postsLoading") : t("adminPanel.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CreateUpdatePost
|
||||
adminMode
|
||||
initialData={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={handleEditSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewer && (
|
||||
<ImageViewer
|
||||
images={viewer.images}
|
||||
initialIndex={viewer.index}
|
||||
postData={viewer.ad}
|
||||
onClose={() => setViewer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 } — успешный ответ сервера
|
||||
* { 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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не удалось загрузить кампании",
|
||||
|
||||
@@ -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() {
|
||||
<div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}>
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav="home"
|
||||
activeNav={isPostsTab ? "admin-posts" : "admin-campaigns"}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={() => navigate("/feed")}
|
||||
onLogoClick={() => navigate("/feed")}
|
||||
@@ -298,11 +318,34 @@ export default function AdminAdsPage() {
|
||||
onProfileClick={() => modals.open("profile")}
|
||||
onLoginClick={() => modals.open("auth")}
|
||||
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-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>
|
||||
<div className="admp-session-hint">
|
||||
@@ -425,35 +468,37 @@ export default function AdminAdsPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modals.isOpen("campaign-create") && isAdmin && (
|
||||
{modals.isOpen("campaign-create") && isAdmin && !isPostsTab && (
|
||||
<CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} />
|
||||
)}
|
||||
{modals.isOpen("campaign-edit") && isAdmin && (
|
||||
{modals.isOpen("campaign-edit") && isAdmin && !isPostsTab && (
|
||||
<CampaignEditModal
|
||||
campaign={modals.modal.payload.campaign}
|
||||
onClose={modals.close}
|
||||
onUpdated={handleCampaignUpdated}
|
||||
/>
|
||||
)}
|
||||
{modals.isOpen("advertising-create") && isAdmin && (
|
||||
{modals.isOpen("advertising-create") && isAdmin && !isPostsTab && (
|
||||
<AdvertisingCreateModal
|
||||
campaignId={modals.modal.payload.campaignId}
|
||||
onClose={modals.close}
|
||||
onCreated={handleAdCreated}
|
||||
/>
|
||||
)}
|
||||
{modals.isOpen("advertising-edit") && isAdmin && (
|
||||
{modals.isOpen("advertising-edit") && isAdmin && !isPostsTab && (
|
||||
<AdvertisingEditModal
|
||||
ad={modals.modal.payload.ad}
|
||||
onClose={modals.close}
|
||||
onUpdated={handleAdUpdated}
|
||||
/>
|
||||
)}
|
||||
{modals.isOpen("campaign-status-update") && isAdmin && (
|
||||
{modals.isOpen("campaign-status-update") && isAdmin && !isPostsTab && (
|
||||
<CampaignStatusUpdateModal
|
||||
campaignId={modals.modal.payload.campaignId}
|
||||
currentStatus={modals.modal.payload.currentStatus}
|
||||
|
||||
@@ -18,8 +18,10 @@ import ClearableField from "../components/ClearableField";
|
||||
* @param {function} onClose — закрыть модалку без сохранения
|
||||
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
|
||||
* @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 { t } = useTranslation();
|
||||
|
||||
@@ -57,7 +59,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
getExistingUrlsToKeep,
|
||||
} = 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"
|
||||
|
||||
@@ -157,7 +159,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
setResultModal("blocked");
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
if (result.ok) {
|
||||
setResultModal("success");
|
||||
}
|
||||
};
|
||||
@@ -172,7 +174,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
<div className="f-modal-head">
|
||||
<span className="f-modal-title">
|
||||
{isEdit ? t('post.editTitle') : t('post.newTitle')}
|
||||
{adminMode
|
||||
? t('adminPanel.postEditTitle')
|
||||
: isEdit ? t('post.editTitle') : t('post.newTitle')}
|
||||
</span>
|
||||
<button className="f-close-btn" type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
@@ -457,10 +461,14 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
{resultModal === "blocked" ? "✕" : "✓"}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}
|
||||
|
||||
@@ -11,7 +11,7 @@ import RewardPopup from "../components/feed/RewardPopup";
|
||||
import Toast from "../components/Toast";
|
||||
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 [showCalendar, setShowCalendar] = useState(false);
|
||||
const [showReward, setShowReward] = useState(false);
|
||||
@@ -69,6 +69,9 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
|
||||
>
|
||||
{ad.title}
|
||||
</div>
|
||||
{authorLabel && (
|
||||
<div className="f-card-author-sub">{authorLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="f-card-head-side">
|
||||
{typeBadge}
|
||||
@@ -102,7 +105,7 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
|
||||
)}
|
||||
</div>
|
||||
<div className="f-card-actions-right">
|
||||
{onEdit && isOwner && !isClosed && (
|
||||
{onEdit && isOwner && (!isClosed || allowEditClosed) && (
|
||||
<button type="button" className="f-card-edit-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
@@ -207,6 +210,17 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
|
||||
</button>
|
||||
</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 && (
|
||||
<Toast
|
||||
|
||||
@@ -11,6 +11,43 @@
|
||||
max-width: 860px;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -41,6 +78,59 @@
|
||||
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 {
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
color: var(--muted); font-size: .78rem;
|
||||
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; }
|
||||
|
||||
/* ── Image panel ── */
|
||||
@@ -327,6 +332,14 @@ button.f-card-reward-btn:focus-visible {
|
||||
.f-card-found-btn:hover {
|
||||
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 ── */
|
||||
.f-date-calendar {
|
||||
|
||||
Reference in New Issue
Block a user