️ feat: Optimize file lists using react-virtual.

This commit is contained in:
jarvis2f 2025-02-08 18:15:47 +08:00
parent 7a30f1a112
commit 7330c45c76
6 changed files with 201 additions and 105 deletions

45
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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>
</>
);

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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,