nice admin
This commit is contained in:
parent
fb1c015d9a
commit
26783c2d41
|
|
@ -15,9 +15,12 @@ import {
|
||||||
useAdminOverview,
|
useAdminOverview,
|
||||||
useAdminStatus,
|
useAdminStatus,
|
||||||
useAdminStorage,
|
useAdminStorage,
|
||||||
|
useAdminStars,
|
||||||
useAdminSyncSetLimits,
|
useAdminSyncSetLimits,
|
||||||
useAdminSystem,
|
useAdminSystem,
|
||||||
useAdminUploads,
|
useAdminUploads,
|
||||||
|
useAdminLicenses,
|
||||||
|
useAdminUsers,
|
||||||
} from "~/shared/services/admin";
|
} from "~/shared/services/admin";
|
||||||
import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin";
|
import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin";
|
||||||
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
|
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]}`;
|
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) => {
|
const formatDate = (iso?: string | null) => {
|
||||||
if (!iso) {
|
if (!iso) {
|
||||||
return "—";
|
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 FlashMessage = {
|
||||||
type: "success" | "error" | "info";
|
type: "success" | "error" | "info";
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -132,6 +189,9 @@ const ADMIN_SECTIONS = [
|
||||||
{ id: "overview", label: "Обзор" },
|
{ id: "overview", label: "Обзор" },
|
||||||
{ id: "storage", label: "Хранилище" },
|
{ id: "storage", label: "Хранилище" },
|
||||||
{ id: "uploads", label: "Загрузки" },
|
{ id: "uploads", label: "Загрузки" },
|
||||||
|
{ id: "users", label: "Пользователи" },
|
||||||
|
{ id: "licenses", label: "Лицензии" },
|
||||||
|
{ id: "stars", label: "Платежи Stars" },
|
||||||
{ id: "system", label: "Система" },
|
{ id: "system", label: "Система" },
|
||||||
{ id: "blockchain", label: "Блокчейн" },
|
{ id: "blockchain", label: "Блокчейн" },
|
||||||
{ id: "nodes", label: "Ноды" },
|
{ id: "nodes", label: "Ноды" },
|
||||||
|
|
@ -166,7 +226,545 @@ type UploadDecoration = {
|
||||||
flags: AdminUploadsContentFlags | null;
|
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 = () => {
|
export const AdminPage = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -176,6 +774,25 @@ export const AdminPage = () => {
|
||||||
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
||||||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||||||
const [uploadsSearch, setUploadsSearch] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!flash) {
|
if (!flash) {
|
||||||
|
|
@ -193,6 +810,18 @@ export const AdminPage = () => {
|
||||||
}
|
}
|
||||||
}, [authState, queryClient]);
|
}, [authState, queryClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsersPage(0);
|
||||||
|
}, [normalizedUsersSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLicensesPage(0);
|
||||||
|
}, [normalizedLicensesSearch, licensesStatusFilter, licensesTypeFilter, licensesLicenseTypeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStarsPage(0);
|
||||||
|
}, [normalizedStarsSearch, starsPaidFilter, starsTypeFilter]);
|
||||||
|
|
||||||
const overviewQuery = useAdminOverview({
|
const overviewQuery = useAdminOverview({
|
||||||
enabled: authState !== "unauthorized",
|
enabled: authState !== "unauthorized",
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|
@ -211,10 +840,6 @@ export const AdminPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDataEnabled = authState === "authorized";
|
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({
|
const storageQuery = useAdminStorage({
|
||||||
enabled: isDataEnabled,
|
enabled: isDataEnabled,
|
||||||
|
|
@ -233,6 +858,55 @@ export const AdminPage = () => {
|
||||||
keepPreviousData: true,
|
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({
|
const systemQuery = useAdminSystem({
|
||||||
enabled: isDataEnabled,
|
enabled: isDataEnabled,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
|
@ -1239,6 +1913,76 @@ export const AdminPage = () => {
|
||||||
</Section>
|
</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 = () => {
|
const renderSystem = () => {
|
||||||
if (!systemQuery.data) {
|
if (!systemQuery.data) {
|
||||||
return systemQuery.isLoading ? (
|
return systemQuery.isLoading ? (
|
||||||
|
|
@ -1640,6 +2384,9 @@ export const AdminPage = () => {
|
||||||
{renderOverview()}
|
{renderOverview()}
|
||||||
{renderStorage()}
|
{renderStorage()}
|
||||||
{renderUploads()}
|
{renderUploads()}
|
||||||
|
{renderUsers()}
|
||||||
|
{renderLicenses()}
|
||||||
|
{renderStars()}
|
||||||
{renderSystem()}
|
{renderSystem()}
|
||||||
{renderBlockchain()}
|
{renderBlockchain()}
|
||||||
{renderNodes()}
|
{renderNodes()}
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,255 @@ export type AdminUploadsQueryParams = {
|
||||||
|
|
||||||
type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null];
|
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 = {
|
export type AdminSystemResponse = {
|
||||||
env: Record<string, string | null | undefined>;
|
env: Record<string, string | null | undefined>;
|
||||||
service_config: Array<{
|
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 = (
|
export const useAdminSystem = (
|
||||||
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue