improve system
This commit is contained in:
parent
abbd6ec9be
commit
214ac28926
|
|
@ -19,6 +19,7 @@ import {
|
||||||
useAdminSystem,
|
useAdminSystem,
|
||||||
useAdminUploads,
|
useAdminUploads,
|
||||||
} from "~/shared/services/admin";
|
} 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";
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
||||||
|
|
@ -151,6 +152,20 @@ type SyncLimitsFormValues = {
|
||||||
disk_low_watermark_pct: number;
|
disk_low_watermark_pct: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UploadCategory = "issues" | "processing" | "ready" | "unindexed";
|
||||||
|
type UploadFilter = "all" | UploadCategory;
|
||||||
|
|
||||||
|
type UploadDecoration = {
|
||||||
|
item: AdminUploadsContent;
|
||||||
|
categories: Set<UploadCategory>;
|
||||||
|
searchText: string;
|
||||||
|
hasIssue: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
isUnindexed: boolean;
|
||||||
|
flags: AdminUploadsContentFlags | null;
|
||||||
|
};
|
||||||
|
|
||||||
const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
|
const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
|
||||||
|
|
||||||
export const AdminPage = () => {
|
export const AdminPage = () => {
|
||||||
|
|
@ -159,6 +174,8 @@ export const AdminPage = () => {
|
||||||
const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview");
|
const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
||||||
|
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||||||
|
const [uploadsSearch, setUploadsSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!flash) {
|
if (!flash) {
|
||||||
|
|
@ -194,15 +211,28 @@ 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,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
const uploadsQuery = useAdminUploads({
|
const uploadsQuery = useAdminUploads(
|
||||||
enabled: isDataEnabled,
|
{
|
||||||
refetchInterval: 30_000,
|
filter: isUploadsFilterActive ? uploadsFilter : undefined,
|
||||||
});
|
search: hasUploadsSearch ? normalizedUploadsSearch : undefined,
|
||||||
|
limit: 40,
|
||||||
|
scan: uploadsScanLimit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isDataEnabled,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
const systemQuery = useAdminSystem({
|
const systemQuery = useAdminSystem({
|
||||||
enabled: isDataEnabled,
|
enabled: isDataEnabled,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
|
@ -509,8 +539,8 @@ export const AdminPage = () => {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-lg font-semibold text-slate-100">IPFS</h3>
|
<h3 className="text-lg font-semibold text-slate-100">IPFS</h3>
|
||||||
<div className="grid gap-3 rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
<div className="grid gap-3 rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||||
<InfoRow label="ID">{(ipfs.identity as Record<string, unknown>)?.ID ?? "—"}</InfoRow>
|
<InfoRow label="ID">{formatUnknown((ipfs.identity as Record<string, unknown>)?.ID)}</InfoRow>
|
||||||
<InfoRow label="Agent">{(ipfs.identity as Record<string, unknown>)?.AgentVersion ?? "—"}</InfoRow>
|
<InfoRow label="Agent">{formatUnknown((ipfs.identity as Record<string, unknown>)?.AgentVersion)}</InfoRow>
|
||||||
<InfoRow label="Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow>
|
<InfoRow label="Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow>
|
||||||
<InfoRow label="Repo size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow>
|
<InfoRow label="Repo size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow>
|
||||||
<InfoRow label="Storage max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow>
|
<InfoRow label="Storage max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow>
|
||||||
|
|
@ -615,17 +645,437 @@ export const AdminPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderUploads = () => {
|
const renderUploads = () => {
|
||||||
if (!uploadsQuery.data) {
|
if (uploadsQuery.isLoading && !uploadsQuery.data) {
|
||||||
return uploadsQuery.isLoading ? (
|
return <Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">Загрузка…</Section>;
|
||||||
<Section id="uploads" title="Загрузки" description="Статистика сессий">Загрузка…</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
}
|
||||||
const { states, total, recent } = uploadsQuery.data;
|
if (!uploadsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { states, total, recent, contents = [], matching_total: matchingTotalRaw, category_totals: categoryTotalsRaw } = uploadsQuery.data;
|
||||||
|
|
||||||
|
const categoryTotals = categoryTotalsRaw ?? {};
|
||||||
|
const categoryCounts: Record<UploadCategory, number> = {
|
||||||
|
issues: categoryTotals.issues ?? 0,
|
||||||
|
processing: categoryTotals.processing ?? 0,
|
||||||
|
ready: categoryTotals.ready ?? 0,
|
||||||
|
unindexed: categoryTotals.unindexed ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeFlags = (item: AdminUploadsContent): AdminUploadsContentFlags => {
|
||||||
|
const normalize = (value: string | null | undefined) => (value ?? '').toLowerCase();
|
||||||
|
const uploadState = normalize(item.status.upload_state);
|
||||||
|
const conversionState = normalize(item.status.conversion_state);
|
||||||
|
const ipfsState = normalize(item.status.ipfs_state);
|
||||||
|
const pinState = normalize(item.ipfs?.pin_state);
|
||||||
|
const derivativeStates = item.derivatives.map((derivative) => normalize(derivative.status));
|
||||||
|
const statusValues = [uploadState, conversionState, ipfsState, pinState, ...derivativeStates];
|
||||||
|
|
||||||
|
const hasIssue =
|
||||||
|
statusValues.some((value) => value.includes('fail') || value.includes('error') || value.includes('timeout')) ||
|
||||||
|
item.upload_history.some((event) => Boolean(event.error)) ||
|
||||||
|
item.derivatives.some((derivative) => Boolean(derivative.error)) ||
|
||||||
|
Boolean(item.ipfs?.pin_error);
|
||||||
|
|
||||||
|
const isOnchainIndexed = item.status.onchain?.indexed ?? false;
|
||||||
|
const isUnindexed = !isOnchainIndexed;
|
||||||
|
|
||||||
|
const conversionDone =
|
||||||
|
conversionState.includes('converted') ||
|
||||||
|
conversionState.includes('ready') ||
|
||||||
|
derivativeStates.some((state) => state.includes('ready') || state.includes('converted') || state.includes('complete'));
|
||||||
|
|
||||||
|
const ipfsDone =
|
||||||
|
ipfsState.includes('pinned') ||
|
||||||
|
ipfsState.includes('ready') ||
|
||||||
|
pinState.includes('pinned') ||
|
||||||
|
pinState.includes('ready');
|
||||||
|
|
||||||
|
const isReady = !hasIssue && conversionDone && ipfsDone && isOnchainIndexed;
|
||||||
|
|
||||||
|
const hasProcessingKeywords =
|
||||||
|
uploadState.includes('pending') ||
|
||||||
|
uploadState.includes('process') ||
|
||||||
|
uploadState.includes('queue') ||
|
||||||
|
uploadState.includes('upload') ||
|
||||||
|
conversionState.includes('pending') ||
|
||||||
|
conversionState.includes('process') ||
|
||||||
|
conversionState.includes('queue') ||
|
||||||
|
conversionState.includes('convert') ||
|
||||||
|
ipfsState.includes('pin') ||
|
||||||
|
ipfsState.includes('sync') ||
|
||||||
|
pinState.includes('pin') ||
|
||||||
|
pinState.includes('sync') ||
|
||||||
|
derivativeStates.some((state) => state.includes('pending') || state.includes('process') || state.includes('queue'));
|
||||||
|
|
||||||
|
const isProcessingCandidate = !isReady && !hasIssue && hasProcessingKeywords;
|
||||||
|
const isProcessing = isProcessingCandidate || (!isReady && !hasIssue && !isProcessingCandidate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues: hasIssue,
|
||||||
|
processing: isProcessing,
|
||||||
|
ready: isReady,
|
||||||
|
unindexed: isUnindexed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
|
||||||
|
const flags = item.flags ?? computeFlags(item);
|
||||||
|
const categories = new Set<UploadCategory>();
|
||||||
|
if (flags.issues) {
|
||||||
|
categories.add('issues');
|
||||||
|
}
|
||||||
|
if (flags.processing) {
|
||||||
|
categories.add('processing');
|
||||||
|
}
|
||||||
|
if (flags.ready) {
|
||||||
|
categories.add('ready');
|
||||||
|
}
|
||||||
|
if (flags.unindexed) {
|
||||||
|
categories.add('unindexed');
|
||||||
|
}
|
||||||
|
if (categories.size === 0) {
|
||||||
|
if (!(item.status.onchain?.indexed ?? false)) {
|
||||||
|
categories.add('unindexed');
|
||||||
|
}
|
||||||
|
categories.add('processing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParts: Array<string | number | null | undefined> = [
|
||||||
|
item.title,
|
||||||
|
item.description,
|
||||||
|
item.encrypted_cid,
|
||||||
|
item.metadata_cid,
|
||||||
|
item.content_hash,
|
||||||
|
item.status.onchain?.item_address,
|
||||||
|
item.stored?.owner_address,
|
||||||
|
];
|
||||||
|
if (item.stored?.user) {
|
||||||
|
const user = item.stored.user;
|
||||||
|
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
|
||||||
|
}
|
||||||
|
const searchText = searchParts
|
||||||
|
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
||||||
|
.map((value) => `${value}`.toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
categories,
|
||||||
|
searchText,
|
||||||
|
hasIssue: Boolean(flags.issues),
|
||||||
|
isReady: Boolean(flags.ready),
|
||||||
|
isProcessing: Boolean(flags.processing),
|
||||||
|
isUnindexed: Boolean(flags.unindexed),
|
||||||
|
flags: item.flags ?? flags,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decoratedContents = contents.map((item) => classifyUpload(item));
|
||||||
|
|
||||||
|
const searchNeedle = normalizedUploadsSearch.toLowerCase();
|
||||||
|
const filteredEntries = decoratedContents.filter((entry) => {
|
||||||
|
if (uploadsFilter !== 'all' && !entry.categories.has(uploadsFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (searchNeedle && !entry.searchText.includes(searchNeedle)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredContents = filteredEntries.map((entry) => entry.item);
|
||||||
|
const totalMatches = typeof matchingTotalRaw === 'number' ? matchingTotalRaw : filteredEntries.length;
|
||||||
|
const filtersActive = isUploadsFilterActive || hasUploadsSearch;
|
||||||
|
const noMatches = totalMatches === 0;
|
||||||
|
const issueEntries = filteredEntries.filter((entry) => entry.categories.has('issues'));
|
||||||
|
const filterOptions: Array<{ id: UploadFilter; label: string; count: number }> = [
|
||||||
|
{ id: 'all', label: 'Все', count: totalMatches },
|
||||||
|
{ id: 'issues', label: 'Ошибки', count: categoryCounts.issues },
|
||||||
|
{ id: 'processing', label: 'В обработке', count: categoryCounts.processing },
|
||||||
|
{ id: 'ready', label: 'Готово', count: categoryCounts.ready },
|
||||||
|
{ id: 'unindexed', label: 'Без on-chain', count: categoryCounts.unindexed },
|
||||||
|
];
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setUploadsFilter('all');
|
||||||
|
setUploadsSearch('');
|
||||||
|
};
|
||||||
|
const getProblemMessages = (item: AdminUploadsContent): string[] => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (let index = item.upload_history.length - 1; index >= 0; index -= 1) {
|
||||||
|
const event = item.upload_history[index];
|
||||||
|
if (event.error) {
|
||||||
|
messages.push(`Загрузка: ${event.error}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const derivativeError = item.derivatives.find((derivative) => Boolean(derivative.error));
|
||||||
|
if (derivativeError?.error) {
|
||||||
|
messages.push(`Дериватив ${derivativeError.kind}: ${derivativeError.error}`);
|
||||||
|
}
|
||||||
|
if (item.ipfs?.pin_error) {
|
||||||
|
messages.push(`IPFS: ${item.ipfs.pin_error}`);
|
||||||
|
}
|
||||||
|
const conversionState = item.status.conversion_state;
|
||||||
|
if (!messages.length && conversionState && conversionState.toLowerCase().includes('fail')) {
|
||||||
|
messages.push(`Конверсия: ${conversionState}`);
|
||||||
|
}
|
||||||
|
const uploadState = item.status.upload_state;
|
||||||
|
if (!messages.length && uploadState && uploadState.toLowerCase().includes('fail')) {
|
||||||
|
messages.push(`Загрузка: ${uploadState}`);
|
||||||
|
}
|
||||||
|
if (!messages.length) {
|
||||||
|
messages.push('Причина не указана. Проверьте логи.');
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toneByState = (state: string | null | undefined): BadgeTone => {
|
||||||
|
const normalized = (state ?? '').toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
if (normalized.includes('fail') || normalized.includes('error') || normalized.includes('timeout')) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes('process') ||
|
||||||
|
normalized.includes('pending') ||
|
||||||
|
normalized.includes('pin') ||
|
||||||
|
normalized.includes('upload') ||
|
||||||
|
normalized.includes('queue')
|
||||||
|
) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes('ready') ||
|
||||||
|
normalized.includes('pinned') ||
|
||||||
|
normalized.includes('converted') ||
|
||||||
|
normalized.includes('indexed')
|
||||||
|
) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContentCard = (item: AdminUploadsContent) => {
|
||||||
|
const statusBadges = [
|
||||||
|
{ label: 'Загрузка', value: item.status.upload_state },
|
||||||
|
{ label: 'Конверсия', value: item.status.conversion_state },
|
||||||
|
{ label: 'IPFS', value: item.status.ipfs_state },
|
||||||
|
{
|
||||||
|
label: 'On-chain',
|
||||||
|
value: item.status.onchain?.indexed ? `Индекс ${item.status.onchain.onchain_index ?? '≥8'}` : 'Локально',
|
||||||
|
toneOverride: item.status.onchain?.indexed ? ('success' as BadgeTone) : ('neutral' as BadgeTone),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionButtons: Array<{ label: string; href: string; download?: boolean }> = [];
|
||||||
|
if (item.links.web_view) {
|
||||||
|
actionButtons.push({ label: 'Открыть', href: item.links.web_view });
|
||||||
|
}
|
||||||
|
if (item.links.api_view) {
|
||||||
|
actionButtons.push({ label: 'API', href: item.links.api_view });
|
||||||
|
}
|
||||||
|
if (item.links.download_primary) {
|
||||||
|
actionButtons.push({ label: 'Скачать', href: item.links.download_primary, download: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivativeDownloads = item.links.download_derivatives.filter(
|
||||||
|
(entry, index, arr) => entry.url && arr.findIndex((comp) => comp.url === entry.url) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.encrypted_cid} className="rounded-2xl bg-slate-950/60 p-5 shadow-inner shadow-black/40 ring-1 ring-slate-800">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-semibold text-slate-100">
|
||||||
|
{item.title || item.encrypted_cid.slice(0, 12)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{item.content_type} · {formatBytes(item.size.encrypted ?? 0)} enc · {formatBytes(item.size.plain ?? 0)} plain
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusBadges.map((badge) => (
|
||||||
|
<Badge key={`${item.encrypted_cid}-${badge.label}`} tone={badge.toneOverride ?? toneByState(badge.value)}>
|
||||||
|
{badge.label}: {badge.value ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Обновлено {formatDate(item.updated_at)} · Создано {formatDate(item.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actionButtons.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{actionButtons.map((button) => (
|
||||||
|
<a
|
||||||
|
key={`${item.encrypted_cid}-${button.label}`}
|
||||||
|
href={button.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={button.download}
|
||||||
|
className="inline-flex items-center justify-center 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"
|
||||||
|
>
|
||||||
|
{button.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Идентификаторы</h4>
|
||||||
|
<dl className="mt-2 space-y-1 text-xs text-slate-300">
|
||||||
|
<InfoRow label="Encrypted CID"><span className="break-all font-mono">{item.encrypted_cid}</span></InfoRow>
|
||||||
|
<InfoRow label="Metadata CID"><span className="break-all font-mono">{item.metadata_cid ?? '—'}</span></InfoRow>
|
||||||
|
<InfoRow label="Content hash"><span className="break-all font-mono">{item.content_hash ?? '—'}</span></InfoRow>
|
||||||
|
<InfoRow label="On-chain индекс">
|
||||||
|
{item.status.onchain?.indexed
|
||||||
|
? `#${item.status.onchain.onchain_index ?? '≥8'} · лицензий ${item.status.onchain.license_count ?? 0}`
|
||||||
|
: 'Не опубликован'}
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow label="On-chain адрес">
|
||||||
|
<span className="break-all font-mono">{item.status.onchain?.item_address ?? '—'}</span>
|
||||||
|
</InfoRow>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">История загрузок</h4>
|
||||||
|
{item.upload_history.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Нет событий.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-slate-300">
|
||||||
|
{item.upload_history.map((event, index) => (
|
||||||
|
<li key={`${item.encrypted_cid}-upload-${index}`} className="flex items-start justify-between gap-3">
|
||||||
|
<span className="font-semibold">
|
||||||
|
<Badge tone={toneByState(event.state)}>{event.state}</Badge>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
<div>{formatDate(event.at)}</div>
|
||||||
|
{event.filename ? <div className="text-slate-400">{event.filename}</div> : null}
|
||||||
|
{event.error ? <div className="text-rose-300">{event.error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Деривативы</h4>
|
||||||
|
{item.derivatives.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Деривативы отсутствуют.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-2 text-xs text-slate-300">
|
||||||
|
{item.derivatives.map((derivative) => (
|
||||||
|
<div key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg bg-slate-950/50 p-3 ring-1 ring-slate-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-slate-100">{derivative.kind}</div>
|
||||||
|
<Badge tone={toneByState(derivative.status)}>{derivative.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
|
||||||
|
<span>Размер: {derivative.size_bytes ? formatBytes(derivative.size_bytes) : '—'}</span>
|
||||||
|
<span>Обновлено: {formatDate(derivative.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
{derivative.error ? <div className="mt-1 text-rose-300">{derivative.error}</div> : null}
|
||||||
|
{derivative.download_url ? (
|
||||||
|
<a
|
||||||
|
href={derivative.download_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">IPFS</h4>
|
||||||
|
{item.ipfs ? (
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-slate-300">
|
||||||
|
<div>
|
||||||
|
<Badge tone={toneByState(item.ipfs.pin_state)}>{item.ipfs.pin_state ?? '—'}</Badge>
|
||||||
|
</div>
|
||||||
|
{item.ipfs.pin_error ? <div className="text-rose-300">{item.ipfs.pin_error}</div> : null}
|
||||||
|
<div>
|
||||||
|
{formatBytes(item.ipfs.bytes_fetched ?? 0)} / {formatBytes(item.ipfs.bytes_total ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500">{formatDate(item.ipfs.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Нет информации.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Хранение</h4>
|
||||||
|
{item.stored ? (
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-slate-300">
|
||||||
|
<div>ID: {item.stored.stored_id ?? '—'}</div>
|
||||||
|
<div>Владелец: {item.stored.owner_address ?? '—'}</div>
|
||||||
|
<div>Тип: {item.stored.type ?? '—'}</div>
|
||||||
|
{item.stored.user ? (
|
||||||
|
<div className="text-slate-400">
|
||||||
|
Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.stored.download_url ? (
|
||||||
|
<a
|
||||||
|
href={item.stored.download_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
Скачать оригинал
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">В хранилище не найден.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{derivativeDownloads.length > 0 ? (
|
||||||
|
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Прямые ссылки</h4>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-slate-300">
|
||||||
|
{derivativeDownloads.map((entry) => (
|
||||||
|
<li key={`${item.encrypted_cid}-link-${entry.kind}`}>
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sky-300 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
{entry.kind} · {entry.size_bytes ? formatBytes(entry.size_bytes) : 'размер неизвестен'}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="uploads"
|
id="uploads"
|
||||||
title="Загрузки"
|
title="Загрузки"
|
||||||
description="Статистика по состояниям и последние сессии"
|
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -636,13 +1086,133 @@ export const AdminPage = () => {
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{filterOptions.map((option) => {
|
||||||
|
const isActive = option.id === uploadsFilter;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`upload-filter-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadsFilter(option.id)}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold transition',
|
||||||
|
isActive
|
||||||
|
? 'border-sky-500 bg-sky-500/20 text-sky-100 shadow-inner shadow-sky-500/20'
|
||||||
|
: 'border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
<span className="rounded-full bg-slate-900/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-slate-300">
|
||||||
|
{numberFormatter.format(option.count)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtersActive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
className="inline-flex items-center rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-slate-500 hover:text-slate-100"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full md:w-72">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={uploadsSearch}
|
||||||
|
onChange={(event) => setUploadsSearch(event.target.value)}
|
||||||
|
placeholder="Поиск по названию, CID или адресу"
|
||||||
|
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100 shadow-inner shadow-black/40 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
||||||
|
/>
|
||||||
|
{uploadsSearch ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadsSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-slate-800 px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:text-slate-100"
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-slate-400">
|
||||||
|
Найдено {numberFormatter.format(filteredContents.length)} из {numberFormatter.format(totalMatches)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<MetricCard label="Всего сессий" value={numberFormatter.format(total)} />
|
<MetricCard label="Всего сессий" value={numberFormatter.format(total)} />
|
||||||
{Object.entries(states).map(([state, count]) => (
|
{Object.entries(states).map(([state, count]) => (
|
||||||
<MetricCard key={state} label={state} value={numberFormatter.format(count)} />
|
<MetricCard key={state} label={state} value={numberFormatter.format(count)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<MetricCard label="Ошибки" value={numberFormatter.format(categoryCounts.issues)} />
|
||||||
|
<MetricCard label="В обработке" value={numberFormatter.format(categoryCounts.processing)} />
|
||||||
|
<MetricCard label="Готово" value={numberFormatter.format(categoryCounts.ready)} />
|
||||||
|
<MetricCard label="Без on-chain" value={numberFormatter.format(categoryCounts.unindexed)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issueEntries.length > 0 ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-rose-800/40 bg-rose-900/15 p-4 shadow-inner shadow-rose-900/40">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold text-rose-100">
|
||||||
|
Проблемные загрузки · {numberFormatter.format(issueEntries.length)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadsFilter('issues')}
|
||||||
|
className="inline-flex items-center rounded-full border border-rose-700 px-3 py-1 text-xs font-semibold text-rose-100 transition hover:border-rose-500 hover:text-rose-50"
|
||||||
|
>
|
||||||
|
Показать все ошибки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{issueEntries.slice(0, 5).map(({ item }) => {
|
||||||
|
const problems = getProblemMessages(item);
|
||||||
|
return (
|
||||||
|
<div key={`issue-${item.encrypted_cid}`} className="rounded-xl bg-rose-900/25 p-3 ring-1 ring-rose-800/50">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-rose-100">{item.title || `${item.encrypted_cid.slice(0, 8)}…`}</span>
|
||||||
|
<span className="text-xs text-rose-200">{formatDate(item.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all text-[11px] text-rose-200">CID: {item.encrypted_cid}</div>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-rose-100">
|
||||||
|
{problems.map((problem, index) => (
|
||||||
|
<li key={`${item.encrypted_cid}-problem-${index}`}>{problem}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{issueEntries.length > 5 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadsFilter('issues')}
|
||||||
|
className="text-xs font-semibold text-rose-200 transition hover:text-rose-100"
|
||||||
|
>
|
||||||
|
Показать оставшиеся {numberFormatter.format(issueEntries.length - 5)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
{noMatches ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{filtersActive ? 'Нет контента по выбранным фильтрам.' : 'Контент ещё не загружался.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredContents.map(renderContentCard)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
<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">
|
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -657,9 +1227,9 @@ export const AdminPage = () => {
|
||||||
{recent.map((item) => (
|
{recent.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-slate-900/50">
|
<tr key={item.id} className="hover:bg-slate-900/50">
|
||||||
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.id}</td>
|
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.id}</td>
|
||||||
<td className="px-3 py-2">{item.filename || "—"}</td>
|
<td className="px-3 py-2">{item.filename || '—'}</td>
|
||||||
<td className="px-3 py-2">{item.size_bytes ? formatBytes(item.size_bytes) : "—"}</td>
|
<td className="px-3 py-2">{item.size_bytes ? formatBytes(item.size_bytes) : '—'}</td>
|
||||||
<td className="px-3 py-2"><Badge tone={item.state === "completed" ? "success" : item.state === "failed" ? "danger" : "neutral"}>{item.state}</Badge></td>
|
<td className="px-3 py-2"><Badge tone={toneByState(item.state)}>{item.state}</Badge></td>
|
||||||
<td className="px-3 py-2">{formatDate(item.updated_at)}</td>
|
<td className="px-3 py-2">{formatDate(item.updated_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -669,7 +1239,6 @@ export const AdminPage = () => {
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSystem = () => {
|
const renderSystem = () => {
|
||||||
if (!systemQuery.data) {
|
if (!systemQuery.data) {
|
||||||
return systemQuery.isLoading ? (
|
return systemQuery.isLoading ? (
|
||||||
|
|
@ -875,7 +1444,7 @@ export const AdminPage = () => {
|
||||||
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">Загрузка…</Section>
|
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">Загрузка…</Section>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
const { ipfs, pin_counts, derivatives, convert_backlog, limits } = statusQuery.data;
|
const { ipfs, pin_counts, derivatives, convert_backlog } = statusQuery.data;
|
||||||
return (
|
return (
|
||||||
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
|
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">
|
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { AudioPlayer } from '~/shared/ui/audio-player';
|
||||||
import { useAuth } from '~/shared/services/auth';
|
import { useAuth } from '~/shared/services/auth';
|
||||||
import { CongratsModal } from './components/congrats-modal';
|
import { CongratsModal } from './components/congrats-modal';
|
||||||
import { ErrorModal } from './components/error-modal';
|
import { ErrorModal } from './components/error-modal';
|
||||||
|
import { resolveStartPayload } from '~/shared/utils/start-payload';
|
||||||
|
|
||||||
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
|
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
|
||||||
|
|
||||||
|
|
@ -21,9 +22,10 @@ interface InvoiceEvent {
|
||||||
|
|
||||||
export const ViewContentPage = () => {
|
export const ViewContentPage = () => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
|
const { contentId } = resolveStartPayload();
|
||||||
|
|
||||||
const { data: content, refetch: refetchContent } = useViewContent(
|
const { data: content, refetch: refetchContent } = useViewContent(
|
||||||
WebApp.initDataUnsafe?.start_param
|
contentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: purchaseContent } = usePurchaseContent();
|
const { mutateAsync: purchaseContent } = usePurchaseContent();
|
||||||
|
|
@ -34,7 +36,32 @@ export const ViewContentPage = () => {
|
||||||
const [isCongratsModal, setIsCongratsModal] = useState(false);
|
const [isCongratsModal, setIsCongratsModal] = useState(false);
|
||||||
const [isErrorModal, setIsErrorModal] = useState(false);
|
const [isErrorModal, setIsErrorModal] = useState(false);
|
||||||
|
|
||||||
|
const statusState = content?.data?.status?.state ?? "uploaded";
|
||||||
|
const conversionState = content?.data?.conversion?.state;
|
||||||
|
const uploadState = content?.data?.upload?.state;
|
||||||
|
const statusLabel = useMemo(() => {
|
||||||
|
switch (statusState) {
|
||||||
|
case "ready":
|
||||||
|
return "Готово";
|
||||||
|
case "processing":
|
||||||
|
return "Обрабатывается";
|
||||||
|
case "failed":
|
||||||
|
return "Ошибка";
|
||||||
|
default:
|
||||||
|
return "Загружено";
|
||||||
|
}
|
||||||
|
}, [statusState]);
|
||||||
|
|
||||||
|
const mediaUrl = content?.data?.display_options?.content_url ?? null;
|
||||||
|
const isAudio = Boolean(content?.data?.content_type?.startsWith('audio'));
|
||||||
|
const isReady = Boolean(mediaUrl);
|
||||||
|
const metadataName = content?.data?.display_options?.metadata?.name;
|
||||||
|
|
||||||
const handleBuyContentTON = useCallback(async () => {
|
const handleBuyContentTON = useCallback(async () => {
|
||||||
|
if (!contentId) {
|
||||||
|
console.error('No content identifier available for purchase');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Если не подключен, начинаем процесс подключения через auth
|
// Если не подключен, начинаем процесс подключения через auth
|
||||||
if (!tonConnectUI.connected) {
|
if (!tonConnectUI.connected) {
|
||||||
|
|
@ -79,7 +106,7 @@ export const ViewContentPage = () => {
|
||||||
// Теперь продолжаем с покупкой
|
// Теперь продолжаем с покупкой
|
||||||
console.log('DEBUG: Proceeding with purchase');
|
console.log('DEBUG: Proceeding with purchase');
|
||||||
const contentResponse = await purchaseContent({
|
const contentResponse = await purchaseContent({
|
||||||
content_address: WebApp.initDataUnsafe?.start_param,
|
content_address: contentId,
|
||||||
license_type: 'resale',
|
license_type: 'resale',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -106,7 +133,7 @@ export const ViewContentPage = () => {
|
||||||
setIsErrorModal(true);
|
setIsErrorModal(true);
|
||||||
console.error('Error handling Ton Connect subscription:', error);
|
console.error('Error handling Ton Connect subscription:', error);
|
||||||
}
|
}
|
||||||
}, [auth, purchaseContent, refetchContent, tonConnectUI, WebApp.initDataUnsafe?.start_param]);
|
}, [auth, contentId, purchaseContent, refetchContent, tonConnectUI]);
|
||||||
|
|
||||||
const handleBuyContentStars = useCallback(async () => {
|
const handleBuyContentStars = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -157,12 +184,16 @@ export const ViewContentPage = () => {
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!contentId) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void refetchContent();
|
void refetchContent();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [contentId, refetchContent]);
|
||||||
|
|
||||||
const handleConfirmCongrats = () => {
|
const handleConfirmCongrats = () => {
|
||||||
setIsCongratsModal(!isCongratsModal);
|
setIsCongratsModal(!isCongratsModal);
|
||||||
|
|
@ -190,7 +221,7 @@ export const ViewContentPage = () => {
|
||||||
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4 '}>
|
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4 '}>
|
||||||
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
||||||
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
||||||
{content?.data?.content_type.startsWith('audio') &&
|
{isAudio &&
|
||||||
content?.data?.display_options?.metadata?.image && (
|
content?.data?.display_options?.metadata?.image && (
|
||||||
<div className={'mt-[30px] h-[314px] w-full'}>
|
<div className={'mt-[30px] h-[314px] w-full'}>
|
||||||
<img
|
<img
|
||||||
|
|
@ -201,31 +232,45 @@ export const ViewContentPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content?.data?.content_type.startsWith('audio') ? (
|
{isReady ? (
|
||||||
<AudioPlayer src={content?.data?.display_options?.content_url} />
|
isAudio ? (
|
||||||
) : (
|
<AudioPlayer src={mediaUrl ?? ''} />
|
||||||
<ReactPlayer
|
) : (
|
||||||
playsinline={true}
|
<ReactPlayer
|
||||||
controls={true}
|
playsinline={true}
|
||||||
width="100%"
|
controls={true}
|
||||||
config={{
|
width="100%"
|
||||||
file: {
|
config={{
|
||||||
attributes: {
|
file: {
|
||||||
playsInline: true,
|
attributes: {
|
||||||
autoPlay: true,
|
playsInline: true,
|
||||||
poster:
|
autoPlay: true,
|
||||||
content?.data?.display_options?.metadata?.image || undefined,
|
poster: content?.data?.display_options?.metadata?.image || undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
url={mediaUrl}
|
||||||
url={content?.data?.display_options?.content_url}
|
/>
|
||||||
/>
|
)
|
||||||
|
) : (
|
||||||
|
<div className={'rounded-xl bg-slate-900/60 p-4 text-center text-sm text-slate-200'}>
|
||||||
|
<p>{statusLabel}. Конвертация может занять несколько минут.</p>
|
||||||
|
{conversionState && (
|
||||||
|
<p className={'mt-2 text-xs text-slate-400'}>
|
||||||
|
Статус конвертера: {conversionState}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{uploadState && (
|
||||||
|
<p className={'mt-1 text-xs text-slate-400'}>
|
||||||
|
Загрузка: {uploadState}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={'flex flex-col'}>
|
<section className={'flex flex-col'}>
|
||||||
<h1 className={'text-[20px] font-bold'}>
|
<h1 className={'text-[20px] font-bold'}>{metadataName}</h1>
|
||||||
{content?.data?.display_options?.metadata?.name}
|
<span className={'mt-1 text-xs text-slate-400'}>{statusLabel}</span>
|
||||||
</h1>
|
|
||||||
{/*<h2>Russian</h2>*/}
|
{/*<h2>Russian</h2>*/}
|
||||||
{/*<h2>2022</h2>*/}
|
{/*<h2>2022</h2>*/}
|
||||||
<p className={'mt-2 text-[12px]'}>
|
<p className={'mt-2 text-[12px]'}>
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,106 @@ export type AdminStorageResponse = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContentDerivative = {
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
size_bytes: number | null;
|
||||||
|
error: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
attempts: number;
|
||||||
|
download_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContentUploadHistoryItem = {
|
||||||
|
state: string;
|
||||||
|
at: string | null;
|
||||||
|
error: string | null;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContentIpfs = {
|
||||||
|
pin_state: string | null;
|
||||||
|
pin_error: string | null;
|
||||||
|
bytes_total: number | null;
|
||||||
|
bytes_fetched: number | null;
|
||||||
|
pinned_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type AdminUploadsContentStored = {
|
||||||
|
stored_id: number | null;
|
||||||
|
type: string | null;
|
||||||
|
owner_address: string | null;
|
||||||
|
user_id: number | null;
|
||||||
|
status: string | null;
|
||||||
|
content_url: string | null;
|
||||||
|
download_url: string | null;
|
||||||
|
created: string | null;
|
||||||
|
updated: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
telegram_id: number;
|
||||||
|
username: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type AdminUploadsContentLinks = {
|
||||||
|
web_view: string | null;
|
||||||
|
start_app: string | null;
|
||||||
|
api_view: string | null;
|
||||||
|
download_primary: string | null;
|
||||||
|
download_derivatives: Array<{
|
||||||
|
kind: string;
|
||||||
|
url: string;
|
||||||
|
size_bytes: number | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContentFlags = {
|
||||||
|
issues: boolean;
|
||||||
|
processing: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
unindexed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContentStatus = {
|
||||||
|
upload_state: string | null;
|
||||||
|
conversion_state: string | null;
|
||||||
|
ipfs_state: string | null;
|
||||||
|
onchain: {
|
||||||
|
indexed: boolean;
|
||||||
|
onchain_index: number | null;
|
||||||
|
item_address: string | null;
|
||||||
|
license_count?: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsContent = {
|
||||||
|
encrypted_cid: string;
|
||||||
|
metadata_cid: string | null;
|
||||||
|
content_hash: string | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
content_type: string;
|
||||||
|
size: {
|
||||||
|
encrypted: number | null;
|
||||||
|
plain: number | null;
|
||||||
|
};
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
status: AdminUploadsContentStatus;
|
||||||
|
upload_history: AdminUploadsContentUploadHistoryItem[];
|
||||||
|
derivative_summary: Record<string, number>;
|
||||||
|
derivatives: AdminUploadsContentDerivative[];
|
||||||
|
ipfs: AdminUploadsContentIpfs;
|
||||||
|
stored: AdminUploadsContentStored;
|
||||||
|
links: AdminUploadsContentLinks;
|
||||||
|
flags?: AdminUploadsContentFlags;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminUploadsResponse = {
|
export type AdminUploadsResponse = {
|
||||||
total: number;
|
total: number;
|
||||||
states: Record<string, number>;
|
states: Record<string, number>;
|
||||||
|
|
@ -91,8 +191,25 @@ export type AdminUploadsResponse = {
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}>;
|
}>;
|
||||||
|
contents: AdminUploadsContent[];
|
||||||
|
matching_total?: number;
|
||||||
|
filter?: string[];
|
||||||
|
search?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
scan?: number;
|
||||||
|
scanned?: number;
|
||||||
|
category_totals?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminUploadsQueryParams = {
|
||||||
|
filter?: string | string[];
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
scan?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null];
|
||||||
|
|
||||||
export type AdminSystemResponse = {
|
export type AdminSystemResponse = {
|
||||||
env: Record<string, string | null | undefined>;
|
env: Record<string, string | null | undefined>;
|
||||||
service_config: Array<{
|
service_config: Array<{
|
||||||
|
|
@ -232,12 +349,34 @@ export const useAdminStorage = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAdminUploads = (
|
export const useAdminUploads = (
|
||||||
options?: QueryOptions<AdminUploadsResponse, ['admin', 'uploads']>,
|
params?: AdminUploadsQueryParams,
|
||||||
|
options?: QueryOptions<AdminUploadsResponse, AdminUploadsQueryKey>,
|
||||||
) => {
|
) => {
|
||||||
return useQuery<AdminUploadsResponse, AxiosError, AdminUploadsResponse, ['admin', 'uploads']>(
|
const filterParts = Array.isArray(params?.filter)
|
||||||
['admin', 'uploads'],
|
? params.filter.filter(Boolean)
|
||||||
|
: params?.filter
|
||||||
|
? [params.filter]
|
||||||
|
: [];
|
||||||
|
const filterValue = filterParts.length > 0 ? filterParts.join(',') : null;
|
||||||
|
const searchValue = params?.search?.trim() || null;
|
||||||
|
const limitValue = params?.limit ?? null;
|
||||||
|
const scanValue = params?.scan ?? null;
|
||||||
|
|
||||||
|
const queryKey: AdminUploadsQueryKey = ['admin', 'uploads', filterValue, searchValue, limitValue, scanValue];
|
||||||
|
|
||||||
|
const queryParams: Record<string, string | number | undefined> = {
|
||||||
|
filter: filterValue ?? undefined,
|
||||||
|
search: searchValue ?? undefined,
|
||||||
|
limit: limitValue ?? undefined,
|
||||||
|
scan: scanValue ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return useQuery<AdminUploadsResponse, AxiosError, AdminUploadsResponse, AdminUploadsQueryKey>(
|
||||||
|
queryKey,
|
||||||
async () => {
|
async () => {
|
||||||
const { data } = await request.get<AdminUploadsResponse>('/admin.uploads');
|
const { data } = await request.get<AdminUploadsResponse>('/admin.uploads', {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
import { request } from '~/shared/libs';
|
import { request } from '~/shared/libs';
|
||||||
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
|
import { appendReferral } from '~/shared/utils/start-payload';
|
||||||
|
|
||||||
const sessionStorageKey = 'auth_v1_token';
|
const sessionStorageKey = 'auth_v1_token';
|
||||||
const tonProofStorageKey = 'stored_ton_proof';
|
const tonProofStorageKey = 'stored_ton_proof';
|
||||||
|
|
@ -47,7 +48,7 @@ export const useAuth = () => {
|
||||||
ton_balance: string;
|
ton_balance: string;
|
||||||
};
|
};
|
||||||
auth_v1_token: string;
|
auth_v1_token: string;
|
||||||
}>('/auth.twa', params);
|
}>('/auth.twa', appendReferral(params));
|
||||||
|
|
||||||
if (res?.data?.auth_v1_token) {
|
if (res?.data?.auth_v1_token) {
|
||||||
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
|
|
@ -95,9 +96,9 @@ export const useAuth = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the payload/token from backend
|
// Get the payload/token from backend
|
||||||
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', {
|
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', appendReferral({
|
||||||
twa_data: WebApp.initData,
|
twa_data: WebApp.initData,
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (value?.data?.auth_v1_token) {
|
if (value?.data?.auth_v1_token) {
|
||||||
console.log('DEBUG: Got token for connect params');
|
console.log('DEBUG: Got token for connect params');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { request } from "~/shared/libs";
|
import { request } from "~/shared/libs";
|
||||||
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { appendReferral } from "~/shared/utils/start-payload";
|
||||||
|
|
||||||
const sessionStorageKey = "auth_v1_token";
|
const sessionStorageKey = "auth_v1_token";
|
||||||
|
|
||||||
|
|
@ -10,9 +11,9 @@ export const useAuthTwa = () => {
|
||||||
const makeAuthRequest = async () => {
|
const makeAuthRequest = async () => {
|
||||||
const res = await request.post<{
|
const res = await request.post<{
|
||||||
auth_v1_token: string;
|
auth_v1_token: string;
|
||||||
}>("/auth.twa", {
|
}>("/auth.twa", appendReferral({
|
||||||
twa_data: WebApp.initData,
|
twa_data: WebApp.initData,
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (res?.data?.auth_v1_token) {
|
if (res?.data?.auth_v1_token) {
|
||||||
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
|
|
@ -23,4 +24,4 @@ export const useAuthTwa = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return useMutation(["auth"], makeAuthRequest);
|
return useMutation(["auth"], makeAuthRequest);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,20 @@ export const useCreateNewContent = () => {
|
||||||
// );
|
// );
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export const useViewContent = (contentId: string) => {
|
export const useViewContent = (contentId: string | null | undefined) => {
|
||||||
return useQuery(["view", "content", contentId], () => {
|
return useQuery(
|
||||||
return request.get(`/content.view/${contentId}`, {
|
["view", "content", contentId],
|
||||||
headers: {
|
() => {
|
||||||
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
return request.get(`/content.view/${contentId}`, {
|
||||||
}
|
headers: {
|
||||||
});
|
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: Boolean(contentId),
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePurchaseContent = () => {
|
export const usePurchaseContent = () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue