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>
|
||||||
<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>
|
||||||
|
|||||||
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 передан → 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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Не удалось загрузить кампании",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"}`}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user