nice admin
This commit is contained in:
parent
fb1c015d9a
commit
26783c2d41
|
|
@ -15,9 +15,12 @@ import {
|
|||
useAdminOverview,
|
||||
useAdminStatus,
|
||||
useAdminStorage,
|
||||
useAdminStars,
|
||||
useAdminSyncSetLimits,
|
||||
useAdminSystem,
|
||||
useAdminUploads,
|
||||
useAdminLicenses,
|
||||
useAdminUsers,
|
||||
} from "~/shared/services/admin";
|
||||
import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin";
|
||||
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
|
||||
|
|
@ -42,6 +45,11 @@ const formatBytes = (input?: number | null) => {
|
|||
return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatStars = (input?: number | null) => {
|
||||
const value = Number(input ?? 0);
|
||||
return `${numberFormatter.format(value)} ⭑`;
|
||||
};
|
||||
|
||||
const formatDate = (iso?: string | null) => {
|
||||
if (!iso) {
|
||||
return "—";
|
||||
|
|
@ -121,6 +129,55 @@ const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: Bad
|
|||
);
|
||||
};
|
||||
|
||||
const PaginationControls = ({
|
||||
total,
|
||||
limit,
|
||||
page,
|
||||
onPageChange,
|
||||
}: {
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
onPageChange: (next: number) => void;
|
||||
}) => {
|
||||
const safeLimit = Math.max(limit, 1);
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit));
|
||||
const clampedPage = Math.min(Math.max(page, 0), totalPages - 1);
|
||||
const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1;
|
||||
const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit);
|
||||
const canGoPrev = clampedPage > 0;
|
||||
const canGoNext = clampedPage + 1 < totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-slate-400">
|
||||
{total === 0
|
||||
? 'Нет записей'
|
||||
: `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
||||
onClick={() => onPageChange(Math.max(clampedPage - 1, 0))}
|
||||
disabled={!canGoPrev}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-xs text-slate-300">Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
||||
onClick={() => onPageChange(Math.min(clampedPage + 1, totalPages - 1))}
|
||||
disabled={!canGoNext}
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FlashMessage = {
|
||||
type: "success" | "error" | "info";
|
||||
message: string;
|
||||
|
|
@ -132,6 +189,9 @@ const ADMIN_SECTIONS = [
|
|||
{ id: "overview", label: "Обзор" },
|
||||
{ id: "storage", label: "Хранилище" },
|
||||
{ id: "uploads", label: "Загрузки" },
|
||||
{ id: "users", label: "Пользователи" },
|
||||
{ id: "licenses", label: "Лицензии" },
|
||||
{ id: "stars", label: "Платежи Stars" },
|
||||
{ id: "system", label: "Система" },
|
||||
{ id: "blockchain", label: "Блокчейн" },
|
||||
{ id: "nodes", label: "Ноды" },
|
||||
|
|
@ -166,7 +226,545 @@ type UploadDecoration = {
|
|||
flags: AdminUploadsContentFlags | null;
|
||||
};
|
||||
|
||||
const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
|
||||
const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
|
||||
|
||||
type UsersSectionProps = {
|
||||
query: ReturnType<typeof useAdminUsers>;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
page: number;
|
||||
limit: number;
|
||||
onPageChange: (next: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
const UsersSection = ({ query, search, onSearchChange, page, limit, onPageChange, onRefresh }: UsersSectionProps) => {
|
||||
if (query.isLoading && !query.data) {
|
||||
return <Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">Загрузка…</Section>;
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return (
|
||||
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Пользователи», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, summary, total } = query.data;
|
||||
const summaryCards = [
|
||||
{ label: 'Всего пользователей', value: numberFormatter.format(total), helper: `На странице: ${numberFormatter.format(summary.users_returned)}` },
|
||||
{ label: 'Кошельки (активные/всего)', value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}` },
|
||||
{ label: 'Оплачено Stars', value: formatStars(summary.stars_amount_paid), helper: `${numberFormatter.format(summary.stars_paid)} платежей` },
|
||||
{ label: 'Неоплачено', value: formatStars(summary.stars_amount_unpaid), helper: `${numberFormatter.format(summary.stars_unpaid)} счетов` },
|
||||
];
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="users"
|
||||
title="Пользователи"
|
||||
description="Просмотр учетных записей, кошельков и активности"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
||||
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="ID, Telegram, username, адрес"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Пользователь</th>
|
||||
<th className="px-3 py-3">Кошельки</th>
|
||||
<th className="px-3 py-3">Stars</th>
|
||||
<th className="px-3 py-3">Лицензии</th>
|
||||
<th className="px-3 py-3">Активность</th>
|
||||
<th className="px-3 py-3">Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-900/60">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-6 text-center text-sm text-slate-400">Нет пользователей, удовлетворяющих условиям.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((user) => (
|
||||
<tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : 'Без username'}</div>
|
||||
<div className="text-xs text-slate-400">ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)}</div>
|
||||
{(user.first_name || user.last_name) ? (<div className="text-xs text-slate-500">{(user.first_name ?? '')} {(user.last_name ?? '')}</div>) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="text-xs text-slate-400">Активных {numberFormatter.format(user.wallets.active_count)} / всего {numberFormatter.format(user.wallets.total_count)}</div>
|
||||
<div className="mt-1 break-all text-xs text-slate-300">{user.wallets.primary_address ?? '—'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div>
|
||||
<div className="text-xs text-slate-400">Оплачено: {formatStars(user.stars.amount_paid)}</div>
|
||||
<div className="text-xs text-slate-400">Неоплачено: {formatStars(user.stars.amount_unpaid)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="text-xs text-slate-300">Всего {numberFormatter.format(user.licenses.total)}</div>
|
||||
<div className="text-xs text-slate-300">Активных {numberFormatter.format(user.licenses.active)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{user.ip_activity.last ? (
|
||||
<div className="text-xs text-slate-300">{user.ip_activity.last.ip ?? '—'} · {formatDate(user.ip_activity.last.seen_at)}</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">Нет данных</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
||||
<div>Создан: {formatDate(user.created_at)}</div>
|
||||
<div>Последний вход: {formatDate(user.last_use)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
type LicensesSectionProps = {
|
||||
query: ReturnType<typeof useAdminLicenses>;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
statusFilter: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
typeFilter: string;
|
||||
onTypeChange: (value: string) => void;
|
||||
licenseTypeFilter: string;
|
||||
onLicenseTypeChange: (value: string) => void;
|
||||
page: number;
|
||||
limit: number;
|
||||
onPageChange: (next: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
const LicensesSection = ({
|
||||
query,
|
||||
search,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusChange,
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
licenseTypeFilter,
|
||||
onLicenseTypeChange,
|
||||
page,
|
||||
limit,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
}: LicensesSectionProps) => {
|
||||
if (query.isLoading && !query.data) {
|
||||
return <Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">Загрузка…</Section>;
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return (
|
||||
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Лицензии», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, counts, total } = query.data;
|
||||
const statusOptions = Object.keys(counts.status || {});
|
||||
const typeOptions = Object.keys(counts.type || {});
|
||||
const kindOptions = Object.keys(counts.license_type || {});
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="licenses"
|
||||
title="Лицензии"
|
||||
description="Привязанные лицензии со сведениями о владельцах и контенте"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Адрес, владелец, hash"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => onStatusChange(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={`license-status-${option}`} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={`license-type-${option}`} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">license_type</label>
|
||||
<select
|
||||
value={licenseTypeFilter}
|
||||
onChange={(event) => onLicenseTypeChange(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{kindOptions.map((option) => (
|
||||
<option key={`license-kind-${option}`} value={option}>{option === 'unknown' ? 'Не указано' : option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
onStatusChange('all');
|
||||
onTypeChange('all');
|
||||
onLicenseTypeChange('all');
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Лицензия</th>
|
||||
<th className="px-3 py-3">On-chain</th>
|
||||
<th className="px-3 py-3">Пользователь</th>
|
||||
<th className="px-3 py-3">Контент</th>
|
||||
<th className="px-3 py-3">Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-900/60">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">Ничего не найдено.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((license) => (
|
||||
<tr key={`license-${license.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">#{numberFormatter.format(license.id)}</div>
|
||||
<div className="text-xs text-slate-400">{license.type ?? '—'} · {license.status ?? '—'}</div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">license_type: {license.license_type ?? '—'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.onchain_address ? (
|
||||
<a
|
||||
href={`https://tonviewer.com/${license.onchain_address}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-xs text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
{license.onchain_address}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">Владелец: {license.owner_address ?? '—'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.user ? (
|
||||
<div className="text-xs text-slate-300">ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)}</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.content ? (
|
||||
<>
|
||||
<div className="text-xs text-slate-300">{license.content.title}</div>
|
||||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">{license.content.hash}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
||||
<div>Создано: {formatDate(license.created_at)}</div>
|
||||
<div>Обновлено: {formatDate(license.updated_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
type StarsSectionProps = {
|
||||
query: ReturnType<typeof useAdminStars>;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
paidFilter: 'all' | 'paid' | 'unpaid';
|
||||
onPaidChange: (value: 'all' | 'paid' | 'unpaid') => void;
|
||||
typeFilter: string;
|
||||
onTypeChange: (value: string) => void;
|
||||
page: number;
|
||||
limit: number;
|
||||
onPageChange: (next: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
const StarsSection = ({
|
||||
query,
|
||||
search,
|
||||
onSearchChange,
|
||||
paidFilter,
|
||||
onPaidChange,
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
page,
|
||||
limit,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
}: StarsSectionProps) => {
|
||||
if (query.isLoading && !query.data) {
|
||||
return <Section id="stars" title="Платежи Stars" description="Инвойсы и статусы">Загрузка…</Section>;
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return (
|
||||
<Section id="stars" title="Платежи Stars" description="Инвойсы и статусы">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Платежи Stars», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, stats, total } = query.data;
|
||||
const typeOptions = Object.keys(stats.by_type || {});
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Всего платежей', value: numberFormatter.format(stats.total) },
|
||||
{ label: 'Оплачено', value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` },
|
||||
{ label: 'Неоплачено', value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="stars"
|
||||
title="Платежи Stars"
|
||||
description="Телеграм Stars счета и связанные пользователи"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="ID, ссылка, контент"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
||||
<select
|
||||
value={paidFilter}
|
||||
onChange={(event) => onPaidChange(event.target.value as 'all' | 'paid' | 'unpaid')}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="paid">Оплачено</option>
|
||||
<option value="unpaid">Не оплачено</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={`stars-type-${option}`} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
onPaidChange('all');
|
||||
onTypeChange('all');
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Инвойс</th>
|
||||
<th className="px-3 py-3">Сумма</th>
|
||||
<th className="px-3 py-3">Пользователь</th>
|
||||
<th className="px-3 py-3">Контент</th>
|
||||
<th className="px-3 py-3">Создан</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-900/60">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">Платежей не найдено.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((invoice) => (
|
||||
<tr key={`stars-${invoice.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">#{numberFormatter.format(invoice.id)} · {invoice.type ?? '—'}</div>
|
||||
<div className="text-xs text-slate-400 break-all">{invoice.external_id}</div>
|
||||
{invoice.invoice_url ? (
|
||||
<a
|
||||
href={invoice.invoice_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
Открыть счёт
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{formatStars(invoice.amount)}</div>
|
||||
<Badge tone={invoice.paid ? 'success' : 'warn'}>{invoice.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{invoice.user ? (
|
||||
<div className="text-xs text-slate-300">ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{invoice.content ? (
|
||||
<>
|
||||
<div className="text-xs text-slate-300">{invoice.content.title}</div>
|
||||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">{invoice.content.hash}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">{formatDate(invoice.created_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const AdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -176,6 +774,25 @@ export const AdminPage = () => {
|
|||
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
||||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||||
const [uploadsSearch, setUploadsSearch] = useState("");
|
||||
const [usersPage, setUsersPage] = useState(0);
|
||||
const [usersSearch, setUsersSearch] = useState("");
|
||||
const [licensesPage, setLicensesPage] = useState(0);
|
||||
const [licensesSearch, setLicensesSearch] = useState("");
|
||||
const [licensesStatusFilter, setLicensesStatusFilter] = useState("all");
|
||||
const [licensesTypeFilter, setLicensesTypeFilter] = useState("all");
|
||||
const [licensesLicenseTypeFilter, setLicensesLicenseTypeFilter] = useState("all");
|
||||
const [starsPage, setStarsPage] = useState(0);
|
||||
const [starsSearch, setStarsSearch] = useState("");
|
||||
const [starsPaidFilter, setStarsPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||
const [starsTypeFilter, setStarsTypeFilter] = useState("all");
|
||||
|
||||
const normalizedUploadsSearch = uploadsSearch.trim();
|
||||
const normalizedUsersSearch = usersSearch.trim();
|
||||
const normalizedLicensesSearch = licensesSearch.trim();
|
||||
const normalizedStarsSearch = starsSearch.trim();
|
||||
const isUploadsFilterActive = uploadsFilter !== "all";
|
||||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
||||
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) {
|
||||
|
|
@ -193,6 +810,18 @@ export const AdminPage = () => {
|
|||
}
|
||||
}, [authState, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsersPage(0);
|
||||
}, [normalizedUsersSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setLicensesPage(0);
|
||||
}, [normalizedLicensesSearch, licensesStatusFilter, licensesTypeFilter, licensesLicenseTypeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setStarsPage(0);
|
||||
}, [normalizedStarsSearch, starsPaidFilter, starsTypeFilter]);
|
||||
|
||||
const overviewQuery = useAdminOverview({
|
||||
enabled: authState !== "unauthorized",
|
||||
retry: false,
|
||||
|
|
@ -211,10 +840,6 @@ export const AdminPage = () => {
|
|||
});
|
||||
|
||||
const isDataEnabled = authState === "authorized";
|
||||
const normalizedUploadsSearch = uploadsSearch.trim();
|
||||
const isUploadsFilterActive = uploadsFilter !== "all";
|
||||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
||||
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
|
||||
|
||||
const storageQuery = useAdminStorage({
|
||||
enabled: isDataEnabled,
|
||||
|
|
@ -233,6 +858,55 @@ export const AdminPage = () => {
|
|||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
const usersLimit = 50;
|
||||
const usersQuery = useAdminUsers(
|
||||
{
|
||||
limit: usersLimit,
|
||||
offset: usersPage * usersLimit,
|
||||
search: normalizedUsersSearch || undefined,
|
||||
},
|
||||
{
|
||||
enabled: isDataEnabled && activeSection === "users",
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
const licensesLimit = 50;
|
||||
const licensesQuery = useAdminLicenses(
|
||||
{
|
||||
limit: licensesLimit,
|
||||
offset: licensesPage * licensesLimit,
|
||||
search: normalizedLicensesSearch || undefined,
|
||||
type: licensesTypeFilter !== "all" ? licensesTypeFilter : undefined,
|
||||
status: licensesStatusFilter !== "all" ? licensesStatusFilter : undefined,
|
||||
license_type: licensesLicenseTypeFilter !== "all" ? licensesLicenseTypeFilter : undefined,
|
||||
},
|
||||
{
|
||||
enabled: isDataEnabled && activeSection === "licenses",
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
const starsLimit = 50;
|
||||
const starsQuery = useAdminStars(
|
||||
{
|
||||
limit: starsLimit,
|
||||
offset: starsPage * starsLimit,
|
||||
search: normalizedStarsSearch || undefined,
|
||||
type: starsTypeFilter !== "all" ? starsTypeFilter : undefined,
|
||||
paid:
|
||||
starsPaidFilter === 'all'
|
||||
? undefined
|
||||
: starsPaidFilter === 'paid'
|
||||
? true
|
||||
: false,
|
||||
},
|
||||
{
|
||||
enabled: isDataEnabled && activeSection === "stars",
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
const systemQuery = useAdminSystem({
|
||||
enabled: isDataEnabled,
|
||||
refetchInterval: 60_000,
|
||||
|
|
@ -1239,6 +1913,76 @@ export const AdminPage = () => {
|
|||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUsers = () => (
|
||||
<UsersSection
|
||||
query={usersQuery}
|
||||
search={usersSearch}
|
||||
onSearchChange={(value) => {
|
||||
setUsersSearch(value);
|
||||
setUsersPage(0);
|
||||
}}
|
||||
page={usersPage}
|
||||
limit={usersLimit}
|
||||
onPageChange={setUsersPage}
|
||||
onRefresh={() => usersQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderLicenses = () => (
|
||||
<LicensesSection
|
||||
query={licensesQuery}
|
||||
search={licensesSearch}
|
||||
onSearchChange={(value) => {
|
||||
setLicensesSearch(value);
|
||||
setLicensesPage(0);
|
||||
}}
|
||||
statusFilter={licensesStatusFilter}
|
||||
onStatusChange={(value) => {
|
||||
setLicensesStatusFilter(value);
|
||||
setLicensesPage(0);
|
||||
}}
|
||||
typeFilter={licensesTypeFilter}
|
||||
onTypeChange={(value) => {
|
||||
setLicensesTypeFilter(value);
|
||||
setLicensesPage(0);
|
||||
}}
|
||||
licenseTypeFilter={licensesLicenseTypeFilter}
|
||||
onLicenseTypeChange={(value) => {
|
||||
setLicensesLicenseTypeFilter(value);
|
||||
setLicensesPage(0);
|
||||
}}
|
||||
page={licensesPage}
|
||||
limit={licensesLimit}
|
||||
onPageChange={setLicensesPage}
|
||||
onRefresh={() => licensesQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderStars = () => (
|
||||
<StarsSection
|
||||
query={starsQuery}
|
||||
search={starsSearch}
|
||||
onSearchChange={(value) => {
|
||||
setStarsSearch(value);
|
||||
setStarsPage(0);
|
||||
}}
|
||||
paidFilter={starsPaidFilter}
|
||||
onPaidChange={(value) => {
|
||||
setStarsPaidFilter(value);
|
||||
setStarsPage(0);
|
||||
}}
|
||||
typeFilter={starsTypeFilter}
|
||||
onTypeChange={(value) => {
|
||||
setStarsTypeFilter(value);
|
||||
setStarsPage(0);
|
||||
}}
|
||||
page={starsPage}
|
||||
limit={starsLimit}
|
||||
onPageChange={setStarsPage}
|
||||
onRefresh={() => starsQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
const renderSystem = () => {
|
||||
if (!systemQuery.data) {
|
||||
return systemQuery.isLoading ? (
|
||||
|
|
@ -1640,6 +2384,9 @@ export const AdminPage = () => {
|
|||
{renderOverview()}
|
||||
{renderStorage()}
|
||||
{renderUploads()}
|
||||
{renderUsers()}
|
||||
{renderLicenses()}
|
||||
{renderStars()}
|
||||
{renderSystem()}
|
||||
{renderBlockchain()}
|
||||
{renderNodes()}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,255 @@ export type AdminUploadsQueryParams = {
|
|||
|
||||
type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null];
|
||||
|
||||
export type AdminUserWalletConnectionSummary = {
|
||||
primary_address: string | null;
|
||||
active_count: number;
|
||||
total_count: number;
|
||||
last_connected_at: string | null;
|
||||
connections: Array<{
|
||||
id: number;
|
||||
address: string;
|
||||
network: string;
|
||||
invalidated: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AdminUserContentSummary = {
|
||||
total: number;
|
||||
onchain: number;
|
||||
local: number;
|
||||
disabled: number;
|
||||
};
|
||||
|
||||
export type AdminUserLicensesSummary = {
|
||||
total: number;
|
||||
active: number;
|
||||
by_type: Record<string, number>;
|
||||
by_status: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdminUserStarsSummary = {
|
||||
total: number;
|
||||
paid: number;
|
||||
unpaid: number;
|
||||
amount_total: number;
|
||||
amount_paid: number;
|
||||
amount_unpaid: number;
|
||||
};
|
||||
|
||||
export type AdminUserIpActivity = {
|
||||
last: {
|
||||
ip: string | null;
|
||||
type: string | null;
|
||||
seen_at: string | null;
|
||||
} | null;
|
||||
unique_ips: number;
|
||||
recent: Array<{
|
||||
ip: string | null;
|
||||
type: string | null;
|
||||
seen_at: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AdminUserItem = {
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
lang_code: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
last_use: string | null;
|
||||
meta: {
|
||||
ref_id?: string | null;
|
||||
referrer_id?: string | null;
|
||||
};
|
||||
wallets: AdminUserWalletConnectionSummary;
|
||||
content: AdminUserContentSummary;
|
||||
licenses: AdminUserLicensesSummary;
|
||||
stars: AdminUserStarsSummary;
|
||||
ip_activity: AdminUserIpActivity;
|
||||
};
|
||||
|
||||
export type AdminUsersSummary = {
|
||||
users_returned: number;
|
||||
wallets_total: number;
|
||||
wallets_active: number;
|
||||
licenses_total: number;
|
||||
licenses_active: number;
|
||||
stars_total: number;
|
||||
stars_paid: number;
|
||||
stars_unpaid: number;
|
||||
stars_amount_total: number;
|
||||
stars_amount_paid: number;
|
||||
stars_amount_unpaid: number;
|
||||
unique_ips_total: number;
|
||||
};
|
||||
|
||||
export type AdminUsersResponse = {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string | null;
|
||||
filters?: Record<string, unknown>;
|
||||
has_more: boolean;
|
||||
items: AdminUserItem[];
|
||||
summary: AdminUsersSummary;
|
||||
};
|
||||
|
||||
export type AdminUsersQueryParams = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
type AdminUsersQueryKey = ['admin', 'users', string];
|
||||
|
||||
export type AdminLicenseItem = {
|
||||
id: number;
|
||||
type: string | null;
|
||||
status: string | null;
|
||||
license_type: number | null;
|
||||
onchain_address: string | null;
|
||||
owner_address: string | null;
|
||||
user: {
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
} | null;
|
||||
wallet_connection: {
|
||||
id: number;
|
||||
address: string;
|
||||
network: string;
|
||||
invalidated: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
} | null;
|
||||
content: {
|
||||
id: number | null;
|
||||
hash: string | null;
|
||||
cid: string | null;
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
owner_address: string | null;
|
||||
onchain_index: number | null;
|
||||
user_id: number | null;
|
||||
download_url: string | null;
|
||||
} | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
meta: Record<string, unknown>;
|
||||
links: {
|
||||
tonviewer: string | null;
|
||||
content_view: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminLicensesCounts = {
|
||||
status: Record<string, number>;
|
||||
type: Record<string, number>;
|
||||
license_type: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdminLicensesResponse = {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string | null;
|
||||
filters?: Record<string, unknown>;
|
||||
has_more: boolean;
|
||||
items: AdminLicenseItem[];
|
||||
counts: AdminLicensesCounts;
|
||||
};
|
||||
|
||||
export type AdminLicensesQueryParams = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
type?: string | string[];
|
||||
status?: string | string[];
|
||||
license_type?: number | string | Array<number | string>;
|
||||
user_id?: number;
|
||||
owner?: string;
|
||||
address?: string;
|
||||
content_hash?: string;
|
||||
};
|
||||
|
||||
type AdminLicensesQueryKey = ['admin', 'licenses', string];
|
||||
|
||||
export type AdminStarsInvoiceItem = {
|
||||
id: number;
|
||||
external_id: string;
|
||||
type: string | null;
|
||||
amount: number;
|
||||
paid: boolean;
|
||||
invoice_url: string | null;
|
||||
created_at: string | null;
|
||||
status: 'paid' | 'pending';
|
||||
user: {
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
} | null;
|
||||
content: {
|
||||
id: number | null;
|
||||
hash: string | null;
|
||||
cid: string | null;
|
||||
title: string | null;
|
||||
onchain_index: number | null;
|
||||
owner_address: string | null;
|
||||
user_id: number | null;
|
||||
download_url: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminStarsStats = {
|
||||
total: number;
|
||||
paid: number;
|
||||
unpaid: number;
|
||||
amount_total: number;
|
||||
amount_paid: number;
|
||||
amount_unpaid: number;
|
||||
by_type: Record<string, {
|
||||
total: number;
|
||||
paid: number;
|
||||
unpaid: number;
|
||||
amount_total: number;
|
||||
amount_paid: number;
|
||||
amount_unpaid: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AdminStarsResponse = {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string | null;
|
||||
filters?: Record<string, unknown>;
|
||||
has_more: boolean;
|
||||
items: AdminStarsInvoiceItem[];
|
||||
stats: AdminStarsStats;
|
||||
};
|
||||
|
||||
export type AdminStarsQueryParams = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
type?: string | string[];
|
||||
paid?: boolean;
|
||||
user_id?: number;
|
||||
content_hash?: string;
|
||||
};
|
||||
|
||||
type AdminStarsQueryKey = ['admin', 'stars', string];
|
||||
|
||||
export type AdminSystemResponse = {
|
||||
env: Record<string, string | null | undefined>;
|
||||
service_config: Array<{
|
||||
|
|
@ -386,6 +635,144 @@ export const useAdminUploads = (
|
|||
);
|
||||
};
|
||||
|
||||
export const useAdminUsers = (
|
||||
params?: AdminUsersQueryParams,
|
||||
options?: QueryOptions<AdminUsersResponse, AdminUsersQueryKey>,
|
||||
) => {
|
||||
const normalizedSearch = params?.search?.trim() || null;
|
||||
const paramsKeyPayload = {
|
||||
limit: params?.limit ?? null,
|
||||
offset: params?.offset ?? null,
|
||||
search: normalizedSearch,
|
||||
};
|
||||
const paramsKey = JSON.stringify(paramsKeyPayload);
|
||||
|
||||
const queryParams: Record<string, string | number | undefined> = {
|
||||
limit: params?.limit,
|
||||
offset: params?.offset,
|
||||
search: normalizedSearch ?? undefined,
|
||||
};
|
||||
|
||||
return useQuery<AdminUsersResponse, AxiosError, AdminUsersResponse, AdminUsersQueryKey>(
|
||||
['admin', 'users', paramsKey],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminUsersResponse>('/admin.users', {
|
||||
params: queryParams,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminLicenses = (
|
||||
params?: AdminLicensesQueryParams,
|
||||
options?: QueryOptions<AdminLicensesResponse, AdminLicensesQueryKey>,
|
||||
) => {
|
||||
const normalizedSearch = params?.search?.trim() || null;
|
||||
const typeValue = Array.isArray(params?.type)
|
||||
? params.type.filter(Boolean).join(',')
|
||||
: params?.type ?? null;
|
||||
const statusValue = Array.isArray(params?.status)
|
||||
? params.status.filter(Boolean).join(',')
|
||||
: params?.status ?? null;
|
||||
const licenseTypeValue = Array.isArray(params?.license_type)
|
||||
? params.license_type.filter((value) => value !== undefined && value !== null).join(',')
|
||||
: params?.license_type ?? null;
|
||||
|
||||
const paramsKeyPayload = {
|
||||
limit: params?.limit ?? null,
|
||||
offset: params?.offset ?? null,
|
||||
search: normalizedSearch,
|
||||
type: typeValue,
|
||||
status: statusValue,
|
||||
license_type: licenseTypeValue,
|
||||
user_id: params?.user_id ?? null,
|
||||
owner: params?.owner ?? null,
|
||||
address: params?.address ?? null,
|
||||
content_hash: params?.content_hash ?? null,
|
||||
};
|
||||
const paramsKey = JSON.stringify(paramsKeyPayload);
|
||||
|
||||
const queryParams: Record<string, string | number | undefined> = {
|
||||
limit: params?.limit,
|
||||
offset: params?.offset,
|
||||
search: normalizedSearch ?? undefined,
|
||||
type: typeValue ?? undefined,
|
||||
status: statusValue ?? undefined,
|
||||
license_type: licenseTypeValue ?? undefined,
|
||||
user_id: params?.user_id,
|
||||
owner: params?.owner ?? undefined,
|
||||
address: params?.address ?? undefined,
|
||||
content_hash: params?.content_hash ?? undefined,
|
||||
};
|
||||
|
||||
return useQuery<AdminLicensesResponse, AxiosError, AdminLicensesResponse, AdminLicensesQueryKey>(
|
||||
['admin', 'licenses', paramsKey],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminLicensesResponse>('/admin.licenses', {
|
||||
params: queryParams,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminStars = (
|
||||
params?: AdminStarsQueryParams,
|
||||
options?: QueryOptions<AdminStarsResponse, AdminStarsQueryKey>,
|
||||
) => {
|
||||
const normalizedSearch = params?.search?.trim() || null;
|
||||
const typeValue = Array.isArray(params?.type)
|
||||
? params.type.filter(Boolean).join(',')
|
||||
: params?.type ?? null;
|
||||
|
||||
const paramsKeyPayload = {
|
||||
limit: params?.limit ?? null,
|
||||
offset: params?.offset ?? null,
|
||||
search: normalizedSearch,
|
||||
type: typeValue,
|
||||
paid: typeof params?.paid === 'boolean' ? params.paid : null,
|
||||
user_id: params?.user_id ?? null,
|
||||
content_hash: params?.content_hash ?? null,
|
||||
};
|
||||
const paramsKey = JSON.stringify(paramsKeyPayload);
|
||||
|
||||
const queryParams: Record<string, string | number | undefined> = {
|
||||
limit: params?.limit,
|
||||
offset: params?.offset,
|
||||
search: normalizedSearch ?? undefined,
|
||||
type: typeValue ?? undefined,
|
||||
user_id: params?.user_id,
|
||||
content_hash: params?.content_hash ?? undefined,
|
||||
};
|
||||
|
||||
if (typeof params?.paid === 'boolean') {
|
||||
queryParams.paid = params.paid ? 1 : 0;
|
||||
}
|
||||
|
||||
return useQuery<AdminStarsResponse, AxiosError, AdminStarsResponse, AdminStarsQueryKey>(
|
||||
['admin', 'stars', paramsKey],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminStarsResponse>('/admin.stars', {
|
||||
params: queryParams,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminSystem = (
|
||||
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
||||
) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue