feat: add Telegram integration with forum topic support and creator tracking

Adds full Telegram ZIP ingestion pipeline: TDLib worker service scans source
channels for archive files, deduplicates by content hash, extracts metadata,
uploads to archive channel, and indexes in Postgres. Forum supergroups are
scanned per-topic with topic names used as creator. Filename-based creator
extraction (e.g. "Mammoth Factory - 2026-01.zip") serves as fallback.

Includes admin UI for managing accounts/channels, simplified account setup
(API credentials via env vars), auth code/password submission dialog,
package browser with creator column, and live ingestion activity tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xCyanGrizzly
2026-02-24 16:02:06 +01:00
parent beb9cfb312
commit b427193d17
70 changed files with 8627 additions and 2 deletions

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, CheckCircle2, XCircle, CloudOff } from "lucide-react";
import { cn } from "@/lib/utils";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface IngestionStatusProps {
initialStatus: IngestionAccountStatus[];
}
/**
* Polls /api/ingestion/status every 3 seconds while a run is active,
* or every 30 seconds when idle. Shows a compact status banner with
* a spinning throbber when ingestion is running.
*/
export function IngestionStatus({ initialStatus }: IngestionStatusProps) {
const [accounts, setAccounts] = useState(initialStatus);
const [error, setError] = useState(false);
// Determine if any account is currently running
const activeRun = accounts.find((a) => a.currentRun);
const isRunning = !!activeRun;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let mounted = true;
const poll = async () => {
try {
const res = await fetch("/api/ingestion/status");
if (!res.ok) throw new Error("fetch failed");
const data = await res.json();
if (mounted) {
setAccounts(data.accounts ?? []);
setError(false);
}
} catch {
if (mounted) setError(true);
}
if (mounted) {
// Poll fast while running, slow when idle
const interval = accounts.some((a) => a.currentRun) ? 3_000 : 30_000;
timer = setTimeout(poll, interval);
}
};
// Start polling after a short delay to avoid double-fetching on mount
timer = setTimeout(poll, 3_000);
return () => {
mounted = false;
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRunning]);
// Nothing to show if no accounts configured
if (accounts.length === 0 && !error) return null;
// If we can't reach the API, show a muted offline badge
if (error) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
<CloudOff className="h-3.5 w-3.5" />
<span>Sync status unavailable</span>
</div>
);
}
// Active run — show throbber with live activity
if (activeRun?.currentRun) {
const run = activeRun.currentRun;
return (
<div className="flex items-center gap-3 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary">
{run.currentActivity ?? "Syncing..."}
</p>
{run.downloadPercent != null && run.downloadPercent > 0 && (
<div className="mt-1 flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-primary/20">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(100, run.downloadPercent)}%` }}
/>
</div>
<span className="text-[10px] text-primary/70">{run.downloadPercent}%</span>
</div>
)}
</div>
{run.totalFiles != null && run.currentFileNum != null && (
<span className="shrink-0 text-[10px] text-primary/60">
{run.currentFileNum}/{run.totalFiles}
</span>
)}
</div>
);
}
// All idle — show last run summary
const lastCompleted = accounts
.filter((a) => a.lastRun)
.sort(
(a, b) =>
new Date(b.lastRun!.finishedAt ?? b.lastRun!.startedAt).getTime() -
new Date(a.lastRun!.finishedAt ?? a.lastRun!.startedAt).getTime()
)[0];
if (!lastCompleted?.lastRun) return null;
const last = lastCompleted.lastRun;
const isFailed = last.status === "FAILED";
const timeAgo = getTimeAgo(last.finishedAt ?? last.startedAt);
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs",
isFailed
? "border-red-500/20 bg-red-500/5 text-red-400"
: "border-border bg-card text-muted-foreground"
)}
>
{isFailed ? (
<XCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
<span className="truncate">
{isFailed
? `Last sync failed ${timeAgo}`
: `Last sync ${timeAgo}${last.zipsIngested} new, ${last.zipsDuplicate} skipped`}
</span>
</div>
);
}
function getTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

View File

@@ -0,0 +1,154 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { FileArchive, Eye, ImageIcon } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export interface PackageRow {
id: string;
fileName: string;
fileSize: string;
contentHash: string;
archiveType: "ZIP" | "RAR";
fileCount: number;
isMultipart: boolean;
hasPreview: boolean;
creator: string | null;
indexedAt: string;
sourceChannel: {
id: string;
title: string;
};
}
interface PackageColumnsProps {
onViewFiles: (pkg: PackageRow) => void;
}
function formatBytes(bytesStr: string): string {
const bytes = Number(bytesStr);
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function PreviewCell({ pkg }: { pkg: PackageRow }) {
if (pkg.hasPreview) {
return (
<img
src={`/api/zips/${pkg.id}/preview`}
alt=""
className="h-9 w-9 rounded-md object-cover bg-muted"
loading="lazy"
/>
);
}
return (
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
<FileArchive className="h-4 w-4 text-muted-foreground" />
</div>
);
}
export function getPackageColumns({
onViewFiles,
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
return [
{
id: "preview",
header: "",
cell: ({ row }) => <PreviewCell pkg={row.original} />,
enableHiding: false,
enableSorting: false,
size: 52,
},
{
accessorKey: "fileName",
header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
{row.original.isMultipart && (
<Badge variant="outline" className="text-[10px] shrink-0">
Multi
</Badge>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: "archiveType",
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant="secondary" className="text-[10px]">
{row.original.archiveType}
</Badge>
),
},
{
accessorKey: "fileSize",
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatBytes(row.original.fileSize)}
</span>
),
},
{
accessorKey: "fileCount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />,
cell: ({ row }) => (
<span className="text-sm">
{row.original.fileCount.toLocaleString()}
</span>
),
},
{
accessorKey: "creator",
header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground truncate max-w-[160px] block">
{row.original.creator ?? "\u2014"}
</span>
),
},
{
id: "channel",
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground truncate max-w-[160px] block">
{row.original.sourceChannel.title}
</span>
),
accessorFn: (row) => row.sourceChannel.title,
},
{
accessorKey: "indexedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Indexed" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.indexedAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onViewFiles(row.original)}
>
<Eye className="h-4 w-4" />
</Button>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,412 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import {
FileText,
Folder,
FolderOpen,
Loader2,
Search,
ChevronDown,
ChevronRight,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { PackageRow } from "./package-columns";
interface FileItem {
id: string;
path: string;
fileName: string;
extension: string | null;
compressedSize: string;
uncompressedSize: string;
crc32: string | null;
}
interface TreeNode {
name: string;
isFolder: boolean;
children: Map<string, TreeNode>;
file?: FileItem;
}
interface PackageFilesDrawerProps {
pkg: PackageRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatBytes(bytesStr: string): string {
const bytes = Number(bytesStr);
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const EXTENSION_COLORS: Record<string, string> = {
stl: "bg-blue-500/15 text-blue-400 border-blue-500/30",
obj: "bg-violet-500/15 text-violet-400 border-violet-500/30",
"3mf": "bg-cyan-500/15 text-cyan-400 border-cyan-500/30",
gcode: "bg-amber-500/15 text-amber-400 border-amber-500/30",
png: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
jpg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
jpeg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
pdf: "bg-red-500/15 text-red-400 border-red-500/30",
txt: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
lys: "bg-pink-500/15 text-pink-400 border-pink-500/30",
};
function getExtBadgeClass(ext: string | null): string {
if (!ext) return "bg-zinc-500/15 text-zinc-400 border-zinc-500/30";
return EXTENSION_COLORS[ext.toLowerCase()] ?? "bg-zinc-500/15 text-zinc-400 border-zinc-500/30";
}
/**
* Build a tree structure from flat file paths.
*/
function buildFileTree(files: FileItem[]): TreeNode {
const root: TreeNode = { name: "", isFolder: true, children: new Map() };
for (const file of files) {
// Normalize path separators (Windows RAR archives may use backslashes)
const parts = file.path.replace(/\\/g, "/").split("/").filter(Boolean);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
if (!current.children.has(part)) {
current.children.set(part, {
name: part,
isFolder: !isLast,
children: new Map(),
file: isLast ? file : undefined,
});
}
current = current.children.get(part)!;
}
}
return root;
}
/**
* Recursively renders a file tree node with indentation.
*/
function TreeNodeView({
node,
depth,
search,
defaultOpen,
}: {
node: TreeNode;
depth: number;
search: string;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
// Sort children: folders first, then files, alphabetical within each group
const sortedChildren = useMemo(() => {
const arr = Array.from(node.children.values());
return arr.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [node.children]);
// If searching, force all open
useEffect(() => {
if (search) setOpen(true);
}, [search]);
if (node.isFolder && node.children.size > 0) {
return (
<div>
{/* Don't render a row for the root node */}
{depth >= 0 && (
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-sm hover:bg-muted/50 transition-colors"
style={{ paddingLeft: `${Math.max(0, depth) * 16 + 4}px` }}
onClick={() => setOpen(!open)}
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{open ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-primary/70" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-primary/70" />
)}
<span className="text-sm font-medium truncate">{node.name}</span>
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{countFiles(node)}
</span>
</button>
)}
{open &&
sortedChildren.map((child) => (
<TreeNodeView
key={child.name}
node={child}
depth={depth + 1}
search={search}
defaultOpen={depth < 1} // Auto-expand first 2 levels
/>
))}
</div>
);
}
// File node
if (node.file) {
return (
<div
className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/50 transition-colors"
style={{ paddingLeft: `${Math.max(0, depth) * 16 + 4}px` }}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate flex-1 min-w-0" title={node.file.path}>
{node.name}
</span>
{node.file.extension && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${getExtBadgeClass(node.file.extension)}`}
>
.{node.file.extension}
</Badge>
)}
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatBytes(node.file.uncompressedSize)}
</span>
</div>
);
}
return null;
}
function countFiles(node: TreeNode): number {
if (!node.isFolder) return 1;
let count = 0;
for (const child of node.children.values()) {
count += countFiles(child);
}
return count;
}
const PAGE_SIZE = 100;
export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDrawerProps) {
const [files, setFiles] = useState<FileItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const fetchFiles = useCallback(
async (pageNum: number, append: boolean) => {
if (!pkg) return;
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
try {
const params = new URLSearchParams({
page: String(pageNum),
limit: String(PAGE_SIZE),
});
const res = await fetch(`/api/zips/${pkg.id}/files?${params}`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json();
setFiles((prev) => (append ? [...prev, ...data.items] : data.items));
setTotal(data.pagination.total);
} catch {
// Silently handle
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[pkg]
);
// Reset and fetch when package changes
useEffect(() => {
if (open && pkg) {
setFiles([]);
setTotal(0);
setSearch("");
setPage(1);
fetchFiles(1, false);
}
}, [open, pkg, fetchFiles]);
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchFiles(nextPage, true);
};
const hasMore = files.length < total;
// Client-side search filter (over loaded files)
const filtered = search
? files.filter(
(f) =>
f.fileName.toLowerCase().includes(search.toLowerCase()) ||
f.path.toLowerCase().includes(search.toLowerCase())
)
: files;
// Build tree from filtered files
const tree = useMemo(() => buildFileTree(filtered), [filtered]);
// If all files are in root (no folders), skip the tree and show flat list
const hasNesting = useMemo(() => {
return filtered.some((f) => f.path.replace(/\\/g, "/").includes("/"));
}, [filtered]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
{/* Preview image + title row */}
<div className="flex gap-4">
{pkg?.hasPreview && (
<img
src={`/api/zips/${pkg.id}/preview`}
alt=""
className="h-20 w-20 rounded-lg object-cover bg-muted shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<DialogTitle className="truncate pr-8">
{pkg?.fileName ?? "Package Files"}
</DialogTitle>
<DialogDescription className="mt-1">
{total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive
</DialogDescription>
</div>
</div>
{/* Search within file list */}
{files.length > 0 && (
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter files..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
)}
</DialogHeader>
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 py-3">
{loading ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Loading files...</span>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<FileText className="h-6 w-6 text-muted-foreground/50" />
<span className="text-sm text-muted-foreground">
{search ? "No matching files" : "No files indexed"}
</span>
</div>
) : hasNesting ? (
<>
{/* Render as folder tree */}
{Array.from(tree.children.values())
.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((child) => (
<TreeNodeView
key={child.name}
node={child}
depth={0}
search={search}
defaultOpen={true}
/>
))}
</>
) : (
<>
{/* Flat list for archives without folders */}
{filtered.map((file) => (
<div
key={file.id}
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50 transition-colors"
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm truncate" title={file.path}>
{file.fileName}
</p>
</div>
{file.extension && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${getExtBadgeClass(file.extension)}`}
>
.{file.extension}
</Badge>
)}
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatBytes(file.uncompressedSize)}
</span>
</div>
))}
</>
)}
{/* Load more button */}
{hasMore && !search && (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={loadingMore}
className="gap-1"
>
{loadingMore ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
Load more ({files.length} of {total.toLocaleString()})
</Button>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Search, FileBox } from "lucide-react";
import { useDataTable } from "@/hooks/use-data-table";
import { getPackageColumns, type PackageRow } from "./package-columns";
import { PackageFilesDrawer } from "./package-files-drawer";
import { IngestionStatus } from "./ingestion-status";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { PageHeader } from "@/components/shared/page-header";
import { Input } from "@/components/ui/input";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface StlTableProps {
data: PackageRow[];
pageCount: number;
totalCount: number;
ingestionStatus: IngestionAccountStatus[];
}
export function StlTable({
data,
pageCount,
totalCount,
ingestionStatus,
}: StlTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
const updateSearch = useCallback(
(value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const columns = getPackageColumns({
onViewFiles: (pkg) => setViewPkg(pkg),
});
const { table } = useDataTable({ data, columns, pageCount });
return (
<div className="space-y-4">
<PageHeader
title="STL Files"
description="Browse indexed archive packages from Telegram channels"
>
<IngestionStatus initialStatus={ingestionStatus} />
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search packages or files..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableViewOptions table={table} />
</div>
<DataTable
table={table}
emptyMessage="No packages found. Archives will appear here after ingestion."
/>
<DataTablePagination table={table} totalCount={totalCount} />
<PackageFilesDrawer
pkg={viewPkg}
open={!!viewPkg}
onOpenChange={(open) => {
if (!open) setViewPkg(null);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { listPackages, searchPackages, getIngestionStatus } from "@/lib/telegram/queries";
import { StlTable } from "./_components/stl-table";
interface Props {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function StlFilesPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const page = Number(params.page) || 1;
const perPage = Number(params.perPage) || 20;
const sort = (params.sort as string) ?? "indexedAt";
const order = (params.order as "asc" | "desc") ?? "desc";
const search = (params.search as string) ?? "";
const creator = (params.creator as string) || undefined;
// Fetch packages and ingestion status in parallel
const [result, ingestionStatus] = await Promise.all([
search
? searchPackages({
query: search,
page,
limit: perPage,
searchIn: "both",
})
: listPackages({
page,
limit: perPage,
creator,
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
order,
}),
getIngestionStatus(),
]);
return (
<StlTable
data={result.items}
pageCount={result.pagination.totalPages}
totalCount={result.pagination.total}
ingestionStatus={ingestionStatus}
/>
);
}