diff --git a/web/package-lock.json b/web/package-lock.json index aba855c..75a34cf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 569b2ea..ac1b711 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/file-list.tsx b/web/src/components/file-list.tsx index e512845..6504062 100644 --- a/web/src/components/file-list.tsx +++ b/web/src/components/file-list.tsx @@ -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>(new Set()); const observerTarget = useRef(null); + const tableParentRef = useRef(null); const [columns, setColumns] = useState(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} /> -
+
{selectedFiles.size > 0 && (
@@ -234,61 +272,75 @@ export function FileList({ accountId, chatId }: FileListProps) {
)} -
- - - - - - - {columns.map((col) => - col.isVisible ? ( - - {col.label} - - ) : null, - )} - - - - {files.map((file, index) => ( - handleSelectFile(file.id)} - properties={{ rowHeight, dynamicClass, columns }} - key={`${file.messageId}-${file.uniqueId}-${index}`} - /> - ))} - {!isLoading && files.length === 0 && ( - - - - - - )} - -
- {isLoading && ( -
+
+
+
+ +
+ {columns.map((col) => + col.isVisible ? ( +
+ {col.label} +
+ ) : null, + )} +
+ {files.length === 0 && isLoading && ( +
)} +
+
+ {files.length !== 0 && + rowVirtual.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]!; + return ( + handleSelectFile(file.id)} + properties={{ + rowHeight: rowHeight, + dynamicClass, + columns, + }} + key={`${file.messageId}-${file.uniqueId}-${virtualRow.index}`} + /> + ); + })} +
+ {!isLoading && files.length === 0 && } +
-
); diff --git a/web/src/components/file-row.tsx b/web/src/components/file-row.tsx index e8aa405..9887c44 100644 --- a/web/src/components/file-row.tsx +++ b/web/src/components/file-row.tsx @@ -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; 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: (
- {file.type} + {file.type} {process.env.NODE_ENV === "development" && ( {file.id} )}
), - size: {prettyBytes(file.size)}, + size: {prettyBytes(file.size)}, status: , extra: , actions: ( @@ -131,22 +135,25 @@ export default function FileRow({ }; return ( - - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+
- +
{columns.map((col) => col.isVisible ? ( - {columnRenders[col.id]} - +
) : null, )} - - - - - - - +
+
+ +
+
); } diff --git a/web/src/components/table-column-filter.tsx b/web/src/components/table-column-filter.tsx index 272e721..98c59f0 100644 --- a/web/src/components/table-column-filter.tsx +++ b/web/src/components/table-column-filter.tsx @@ -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( - columns.map((col) => ({ id: col.id, label: col.label, isVisible: true })), - ); + const [columnConfig, setColumnConfig] = useState(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 ( @@ -151,10 +146,6 @@ export default function TableColumnFilter({ ))} - - - Apply Changes - ); diff --git a/web/src/components/table-row-height-switch.tsx b/web/src/components/table-row-height-switch.tsx index ba7c0a5..df69a5b 100644 --- a/web/src/components/table-row-height-switch.tsx +++ b/web/src/components/table-row-height-switch.tsx @@ -7,19 +7,22 @@ const heightOptions = [ { id: "s", label: "Small", - value: "h-6", + value: "h-12", + px: 48, icon: , }, { id: "m", label: "Medium", value: "h-24", + px: 96, icon: , }, { id: "l", label: "Large", value: "h-64", + px: 256, icon: , }, ] 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,