⚡️ feat: Optimize file lists using react-virtual.
This commit is contained in:
parent
7a30f1a112
commit
7330c45c76
6 changed files with 201 additions and 105 deletions
45
web/package-lock.json
generated
45
web/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/modifiers": "^8.0.0",
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
|
@ -2787,6 +2788,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz",
|
||||
"integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz",
|
||||
"integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
|
|
@ -9858,6 +9886,19 @@
|
|||
"@t3-oss/env-core": "0.10.1"
|
||||
}
|
||||
},
|
||||
"@tanstack/react-virtual": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz",
|
||||
"integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==",
|
||||
"requires": {
|
||||
"@tanstack/virtual-core": "3.13.0"
|
||||
}
|
||||
},
|
||||
"@tanstack/virtual-core": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz",
|
||||
"integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g=="
|
||||
},
|
||||
"@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
"use client";
|
||||
import { FileCard } from "./file-card";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, LoaderCircle, LoaderPinwheel } from "lucide-react";
|
||||
import { useFiles } from "@/hooks/use-files";
|
||||
import { FileFilters } from "@/components/file-filters";
|
||||
import { useRowHeightLocalStorage } from "@/components/table-row-height-switch";
|
||||
import {
|
||||
getRowHeightPX,
|
||||
useRowHeightLocalStorage,
|
||||
} from "@/components/table-row-height-switch";
|
||||
import { type Column } from "@/components/table-column-filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FileNotFount from "@/components/file-not-found";
|
||||
|
|
@ -22,6 +17,7 @@ import useIsMobile from "@/hooks/use-is-mobile";
|
|||
import useSWRMutation from "swr/mutation";
|
||||
import { POST } from "@/lib/api";
|
||||
import FileRow from "@/components/file-row";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
interface FileListProps {
|
||||
accountId: string;
|
||||
|
|
@ -29,21 +25,26 @@ interface FileListProps {
|
|||
}
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ id: "content", label: "Content", isVisible: true },
|
||||
{
|
||||
id: "content",
|
||||
label: "Content",
|
||||
isVisible: true,
|
||||
className: "text-center",
|
||||
},
|
||||
{ id: "type", label: "Type", isVisible: true, className: "w-16 text-center" },
|
||||
{
|
||||
id: "size",
|
||||
label: "Size",
|
||||
isVisible: true,
|
||||
className: "w-20 max-w-20 text-center",
|
||||
className: "w-20 text-center",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
isVisible: true,
|
||||
className: "w-16 text-center",
|
||||
className: "w-32 text-center",
|
||||
},
|
||||
{ id: "extra", label: "Extra", isVisible: true },
|
||||
{ id: "extra", label: "Extra", isVisible: true, className: "flex-1" },
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
|
|
@ -56,6 +57,7 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
const isMobile = useIsMobile();
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
|
||||
const observerTarget = useRef(null);
|
||||
const tableParentRef = useRef(null);
|
||||
const [columns, setColumns] = useState<Column[]>(COLUMNS);
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"telegramFileList",
|
||||
|
|
@ -85,6 +87,42 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
},
|
||||
},
|
||||
);
|
||||
const rowVirtual = useVirtualizer({
|
||||
count: files.length,
|
||||
getScrollElement: () => tableParentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const file = files[index]!;
|
||||
const height = getRowHeightPX(rowHeight);
|
||||
|
||||
if (
|
||||
file.downloadStatus === "idle" ||
|
||||
file.downloadStatus === "completed" ||
|
||||
file.size === 0
|
||||
) {
|
||||
return height;
|
||||
}
|
||||
return height + 8;
|
||||
},
|
||||
paddingStart: 1,
|
||||
paddingEnd: 1,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
rowVirtual.measure();
|
||||
}, [rowHeight, rowVirtual]);
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...rowVirtual.getVirtualItems()].reverse();
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= files.length - 1) {
|
||||
void handleLoadMore();
|
||||
}
|
||||
//eslint-disable-next-line
|
||||
}, [files.length, handleLoadMore, rowVirtual.getVirtualItems()]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
|
|
@ -108,7 +146,7 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
case "s":
|
||||
return {
|
||||
content: "h-6 w-6",
|
||||
contentCell: "w-4",
|
||||
contentCell: "w-16",
|
||||
};
|
||||
case "m":
|
||||
return {
|
||||
|
|
@ -194,7 +232,7 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="h-[calc(100vh-13rem)] space-y-4 overflow-hidden">
|
||||
{selectedFiles.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
|
||||
<span className="text-sm">
|
||||
|
|
@ -234,61 +272,75 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative min-h-[calc(100vh-14rem)] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px] text-center">
|
||||
<Checkbox
|
||||
checked={selectedFiles.size === files.length}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{columns.map((col) =>
|
||||
col.isVisible ? (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
col.className ?? "",
|
||||
col.id === "content" ? dynamicClass.contentCell : "",
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr:last-child]:border-b">
|
||||
{files.map((file, index) => (
|
||||
<FileRow
|
||||
file={file}
|
||||
checked={selectedFiles.has(file.id)}
|
||||
onCheckedChange={() => handleSelectFile(file.id)}
|
||||
properties={{ rowHeight, dynamicClass, columns }}
|
||||
key={`${file.messageId}-${file.uniqueId}-${index}`}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<TableRow className="border-b-0">
|
||||
<TableCell colSpan={columns.length + 1}>
|
||||
<FileNotFount />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-accent bg-opacity-90">
|
||||
<div
|
||||
className="relative h-full overflow-auto rounded-md border"
|
||||
ref={tableParentRef}
|
||||
>
|
||||
<div className="sticky top-0 z-20 flex h-10 items-center border-b bg-background/90 text-sm text-muted-foreground backdrop-blur-sm">
|
||||
<div className="w-[30px] text-center">
|
||||
<Checkbox
|
||||
checked={selectedFiles.size === files.length}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
{columns.map((col) =>
|
||||
col.isVisible ? (
|
||||
<div
|
||||
key={col.id}
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
col.className ?? "",
|
||||
col.id === "content" ? dynamicClass.contentCell : "",
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
{files.length === 0 && isLoading && (
|
||||
<div className="sticky left-1/2 top-0 z-10 flex h-full w-full items-center justify-center bg-accent">
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full">
|
||||
<div
|
||||
className={cn("relative w-full")}
|
||||
style={{ height: `${rowVirtual.getTotalSize()}px` }}
|
||||
>
|
||||
{files.length !== 0 &&
|
||||
rowVirtual.getVirtualItems().map((virtualRow) => {
|
||||
const file = files[virtualRow.index]!;
|
||||
return (
|
||||
<FileRow
|
||||
index={virtualRow.index}
|
||||
className={cn(
|
||||
"absolute left-0 top-0 flex w-full items-center",
|
||||
)}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={rowVirtual.measureElement}
|
||||
file={file}
|
||||
checked={selectedFiles.has(file.id)}
|
||||
onCheckedChange={() => handleSelectFile(file.id)}
|
||||
properties={{
|
||||
rowHeight: rowHeight,
|
||||
dynamicClass,
|
||||
columns,
|
||||
}}
|
||||
key={`${file.messageId}-${file.uniqueId}-${virtualRow.index}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!isLoading && files.length === 0 && <FileNotFount />}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={observerTarget} className="h-4"></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getRowHeightTailwindClass,
|
||||
type RowHeight,
|
||||
} from "@/components/table-row-height-switch";
|
||||
import { type RowHeight } from "@/components/table-row-height-switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import FileProgress from "@/components/file-progress";
|
||||
import React, { memo, type ReactNode, useState } from "react";
|
||||
|
|
@ -26,6 +22,10 @@ import { type Column } from "./table-column-filter";
|
|||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
|
||||
interface FileRowProps {
|
||||
index: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
ref?: React.Ref<HTMLTableRowElement>;
|
||||
file: TelegramFile;
|
||||
checked: boolean;
|
||||
properties: {
|
||||
|
|
@ -40,6 +40,10 @@ interface FileRowProps {
|
|||
}
|
||||
|
||||
export default function FileRow({
|
||||
index,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
file,
|
||||
checked,
|
||||
properties,
|
||||
|
|
@ -112,13 +116,13 @@ export default function FileRow({
|
|||
),
|
||||
type: (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="capitalize">{file.type}</span>
|
||||
<span className="text-sm capitalize">{file.type}</span>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<span className="text-xs">{file.id}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
size: <span>{prettyBytes(file.size)}</span>,
|
||||
size: <span className="text-sm">{prettyBytes(file.size)}</span>,
|
||||
status: <FileStatus file={file} />,
|
||||
extra: <FileExtra file={file} rowHeight={rowHeight} />,
|
||||
actions: (
|
||||
|
|
@ -131,22 +135,25 @@ export default function FileRow({
|
|||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow
|
||||
className={cn(getRowHeightTailwindClass(rowHeight), "border-b-0")}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<div
|
||||
data-index={index}
|
||||
className={cn("flex w-full flex-col border-b", className)}
|
||||
style={style}
|
||||
ref={ref}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="flex w-full flex-1 items-center hover:bg-accent">
|
||||
<div className="w-[30px] text-center">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={file.downloadStatus !== "idle"}
|
||||
/>
|
||||
</TableCell>
|
||||
</div>
|
||||
{columns.map((col) =>
|
||||
col.isVisible ? (
|
||||
<TableCell
|
||||
<div
|
||||
key={col.id}
|
||||
className={cn(
|
||||
col.className ?? "",
|
||||
|
|
@ -154,16 +161,14 @@ export default function FileRow({
|
|||
)}
|
||||
>
|
||||
{columnRenders[col.id]}
|
||||
</TableCell>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="h-px p-0">
|
||||
<FileProgress file={file} downloadProgress={downloadProgress} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<FileProgress file={file} downloadProgress={downloadProgress} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
|
|
@ -14,7 +12,7 @@ import {
|
|||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronDown, Columns, Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -84,9 +82,7 @@ export default function TableColumnFilter({
|
|||
onColumnConfigChange,
|
||||
}: TableColumnFilterProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [columnConfig, setColumnConfig] = useState<Column[]>(
|
||||
columns.map((col) => ({ id: col.id, label: col.label, isVisible: true })),
|
||||
);
|
||||
const [columnConfig, setColumnConfig] = useState<Column[]>(columns);
|
||||
|
||||
const handleToggleVisibility = (id: string) => {
|
||||
setColumnConfig((prev) =>
|
||||
|
|
@ -107,10 +103,9 @@ export default function TableColumnFilter({
|
|||
}
|
||||
};
|
||||
|
||||
const applyChanges = () => {
|
||||
const visibleColumns = columnConfig.filter((col) => col.isVisible);
|
||||
onColumnConfigChange(visibleColumns);
|
||||
};
|
||||
useEffect(() => {
|
||||
onColumnConfigChange(columnConfig);
|
||||
}, [columnConfig, onColumnConfigChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen}>
|
||||
|
|
@ -151,10 +146,6 @@ export default function TableColumnFilter({
|
|||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={applyChanges}>
|
||||
Apply Changes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,19 +7,22 @@ const heightOptions = [
|
|||
{
|
||||
id: "s",
|
||||
label: "Small",
|
||||
value: "h-6",
|
||||
value: "h-12",
|
||||
px: 48,
|
||||
icon: <Rows4 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "m",
|
||||
label: "Medium",
|
||||
value: "h-24",
|
||||
px: 96,
|
||||
icon: <Rows3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "l",
|
||||
label: "Large",
|
||||
value: "h-64",
|
||||
px: 256,
|
||||
icon: <Rows2 className="h-4 w-4" />,
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -27,7 +30,10 @@ const heightOptions = [
|
|||
export type RowHeight = (typeof heightOptions)[number]["id"];
|
||||
|
||||
export const getRowHeightTailwindClass = (rowHeight: RowHeight | undefined) =>
|
||||
heightOptions.find((h) => h.id === rowHeight)?.value;
|
||||
heightOptions.find((h) => h.id === rowHeight)?.value ?? heightOptions[1].value;
|
||||
|
||||
export const getRowHeightPX = (rowHeight: RowHeight | undefined) =>
|
||||
heightOptions.find((h) => h.id === rowHeight)?.px ?? heightOptions[1].px;
|
||||
|
||||
export function useRowHeightLocalStorage(
|
||||
tableName: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue