583 lines
26 KiB
TypeScript
583 lines
26 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import clsx from "clsx";
|
||
|
||
import {
|
||
useAdminUploads,
|
||
type AdminUploadsContent,
|
||
type AdminUploadsContentFlags,
|
||
} from "~/shared/services/admin";
|
||
import { Section, Badge, InfoRow } from "../components";
|
||
import { useAdminContext } from "../context";
|
||
import { formatBytes, formatDate, numberFormatter } from "../utils/format";
|
||
|
||
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 numberCompact = new Intl.NumberFormat("ru-RU", { notation: "compact" });
|
||
|
||
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 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}`);
|
||
}
|
||
return messages;
|
||
};
|
||
|
||
const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
||
const { item, flags } = decoration;
|
||
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
||
const derivativeDownloads = item.links.download_derivatives ?? [];
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3>
|
||
<p className="text-xs text-slate-400">{item.description || "Описание не задано"}</p>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500">
|
||
{flags?.ready ? <Badge tone="success">готово</Badge> : null}
|
||
{flags?.issues ? <Badge tone="danger">проблемы</Badge> : null}
|
||
{flags?.processing ? <Badge tone="warn">в обработке</Badge> : null}
|
||
{flags?.unindexed ? <Badge tone="neutral">без индекса</Badge> : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-1 text-xs text-slate-400">
|
||
<span>{formatDate(item.created_at)}</span>
|
||
<span>{formatDate(item.updated_at)}</span>
|
||
{item.size.plain ? <span>{formatBytes(item.size.plain)}</span> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.3fr_1fr]">
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">On-chain</h4>
|
||
<div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2">
|
||
<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 ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Badge tone="success">indexed</Badge>
|
||
{item.status.onchain?.onchain_index ?? "—"}
|
||
</span>
|
||
) : (
|
||
<Badge tone="warn">не опубликовано</Badge>
|
||
)}
|
||
</InfoRow>
|
||
<InfoRow label="On-chain адрес">
|
||
<span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span>
|
||
</InfoRow>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Статусы</h4>
|
||
<div className="mt-2 grid gap-3 text-xs text-slate-300 md:grid-cols-2">
|
||
<div>
|
||
<p className="font-semibold text-slate-200">Загрузка</p>
|
||
<p>{item.status.upload_state ?? "—"}</p>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-slate-200">Конверсия</p>
|
||
<p>{item.status.conversion_state ?? "—"}</p>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-slate-200">IPFS</p>
|
||
<p>{item.status.ipfs_state ?? "—"}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl bg-slate-900/60 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-2 text-xs text-slate-300">
|
||
{item.upload_history.map((event) => (
|
||
<li key={`${item.encrypted_cid}-${event.filename}-${event.state}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="font-semibold text-slate-200">{event.state}</span>
|
||
<span className="text-[11px] text-slate-500">{formatDate(event.at)}</span>
|
||
</div>
|
||
{event.error ? <p className="mt-1 text-rose-300">{event.error}</p> : null}
|
||
{event.filename ? <p className="mt-1 text-slate-400">{event.filename}</p> : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
|
||
{problemMessages.length > 0 ? (
|
||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-xs text-rose-100">
|
||
<h4 className="font-semibold uppercase tracking-wide">Проблемы</h4>
|
||
<ul className="mt-2 space-y-1">
|
||
{problemMessages.map((message, index) => (
|
||
<li key={`${item.encrypted_cid}-issue-${index}`}>{message}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</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">Деривативы</h4>
|
||
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
||
{item.derivatives.map((derivative) => (
|
||
<li key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-semibold text-slate-200">{derivative.kind}</span>
|
||
<Badge tone={derivative.status === "ready" ? "success" : derivative.status === "failed" ? "danger" : "warn"}>
|
||
{derivative.status}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-1 space-y-1 text-[11px] text-slate-400">
|
||
<div>Размер: {formatBytes(derivative.size_bytes)}</div>
|
||
<div>Попытки: {derivative.attempts}</div>
|
||
<div>Создан: {formatDate(derivative.created_at)}</div>
|
||
<div>Обновлён: {formatDate(derivative.updated_at)}</div>
|
||
{derivative.error ? <div className="text-rose-300">{derivative.error}</div> : null}
|
||
{derivative.download_url ? (
|
||
<a
|
||
href={derivative.download_url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center text-sky-300 hover:text-sky-200"
|
||
>
|
||
Скачать
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</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">IPFS</h4>
|
||
{item.ipfs ? (
|
||
<div className="mt-2 text-xs text-slate-300">
|
||
<div>Статус: {item.ipfs.pin_state ?? "—"}</div>
|
||
<div>Ошибка: {item.ipfs.pin_error ?? "—"}</div>
|
||
<div>Fetched: {formatBytes(item.ipfs.bytes_fetched)}</div>
|
||
<div>Total: {formatBytes(item.ipfs.bytes_total)}</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>
|
||
);
|
||
};
|
||
|
||
export const AdminUploadsPage = () => {
|
||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||
const [uploadsSearch, setUploadsSearch] = useState("");
|
||
|
||
const normalizedUploadsSearch = uploadsSearch.trim();
|
||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
||
const isUploadsFilterActive = uploadsFilter !== "all";
|
||
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
|
||
|
||
const uploadsQuery = useAdminUploads(
|
||
{
|
||
filter: isUploadsFilterActive ? uploadsFilter : undefined,
|
||
search: hasUploadsSearch ? normalizedUploadsSearch : undefined,
|
||
limit: 40,
|
||
scan: uploadsScanLimit,
|
||
},
|
||
{
|
||
enabled: isAuthorized,
|
||
keepPreviousData: true,
|
||
refetchInterval: 30_000,
|
||
onError: (error) => handleRequestError(error, "Не удалось загрузить список загрузок"),
|
||
},
|
||
);
|
||
|
||
const uploadContents = uploadsQuery.data?.contents ?? [];
|
||
const decoratedContents = useMemo(() => uploadContents.map((item) => classifyUpload(item)), [uploadContents]);
|
||
|
||
if (uploadsQuery.isLoading && !uploadsQuery.data) {
|
||
return (
|
||
<Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">
|
||
Загрузка…
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
if (!uploadsQuery.data) {
|
||
return null;
|
||
}
|
||
|
||
const { states, total, recent, 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 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")).slice(0, 3);
|
||
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("");
|
||
};
|
||
|
||
return (
|
||
<Section
|
||
id="uploads"
|
||
title="Загрузки"
|
||
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
|
||
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={() => uploadsQuery.refetch()}
|
||
disabled={uploadsQuery.isFetching}
|
||
>
|
||
{uploadsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||
</button>
|
||
}
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
{Object.entries(states ?? {}).map(([key, value]) => (
|
||
<div key={key} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||
<p className="text-xs uppercase tracking-wide text-slate-500">{key}</p>
|
||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p>
|
||
</div>
|
||
))}
|
||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||
<p className="text-xs uppercase tracking-wide text-slate-500">Всего</p>
|
||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
|
||
<p className="mt-1 text-xs text-slate-500">Отфильтровано: {numberFormatter.format(filteredContents.length)}</p>
|
||
</div>
|
||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||
<p className="text-xs uppercase tracking-wide text-slate-500">Активные задачи</p>
|
||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberCompact.format(categoryCounts.processing)}</p>
|
||
<p className="mt-1 text-xs text-slate-500">{numberFormatter.format(categoryCounts.issues)} с ошибками</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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-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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{issueEntries.length > 0 ? (
|
||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-xs text-amber-100">
|
||
<h3 className="text-sm font-semibold text-amber-50">Последние проблемы</h3>
|
||
<ul className="mt-2 space-y-1">
|
||
{issueEntries.map((entry) => (
|
||
<li key={`issue-${entry.item.encrypted_cid}`} className="flex items-center justify-between gap-2">
|
||
<span className="truncate text-sm text-amber-50">{entry.item.title || entry.item.encrypted_cid}</span>
|
||
<span className="text-[11px] text-amber-200">{formatDate(entry.item.updated_at)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
|
||
{noMatches ? (
|
||
<div className="rounded-xl border border-slate-800 bg-slate-950/40 p-6 text-sm text-slate-400">
|
||
Под подходящие условия не найдено загрузок.
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-5">
|
||
{filteredEntries.map((entry) => (
|
||
<UploadCard key={entry.item.encrypted_cid} decoration={entry} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-xl bg-slate-950/40 p-4 ring-1 ring-slate-800">
|
||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Последние события</h3>
|
||
<div className="mt-2 overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-slate-800 text-left text-xs">
|
||
<thead className="bg-slate-950/70 text-[11px] uppercase tracking-wide text-slate-400">
|
||
<tr>
|
||
<th className="px-3 py-2">ID</th>
|
||
<th className="px-3 py-2">Файл</th>
|
||
<th className="px-3 py-2">Размер</th>
|
||
<th className="px-3 py-2">Состояние</th>
|
||
<th className="px-3 py-2">CID</th>
|
||
<th className="px-3 py-2">Ошибка</th>
|
||
<th className="px-3 py-2">Обновлено</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-900/60">
|
||
{recent.map((task) => (
|
||
<tr key={task.id} className="hover:bg-slate-900/50">
|
||
<td className="px-3 py-2 font-mono text-[10px] text-slate-400">{task.id}</td>
|
||
<td className="px-3 py-2">{task.filename ?? "—"}</td>
|
||
<td className="px-3 py-2">{formatBytes(task.size_bytes)}</td>
|
||
<td className="px-3 py-2">
|
||
<Badge tone={task.state === "done" ? "success" : task.state === "failed" ? "danger" : "warn"}>
|
||
{task.state}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-[10px] text-slate-500">{task.encrypted_cid ?? "—"}</td>
|
||
<td className="px-3 py-2 text-rose-200">{task.error ?? "—"}</td>
|
||
<td className="px-3 py-2 text-slate-400">{formatDate(task.updated_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
);
|
||
};
|