💄 style: Update file list style and add file name display

This commit is contained in:
jarvis2f 2025-01-10 15:54:04 +08:00
parent b12a6eed1e
commit 6d1f17e99d
10 changed files with 462 additions and 330 deletions

View file

@ -34,31 +34,39 @@ import { Separator } from "./ui/separator";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import FileStatus from "@/components/file-status"; import FileStatus from "@/components/file-status";
import { cn } from "@/lib/utils";
import { useFileSpeed } from "@/hooks/use-file-speed";
interface FileCardProps { interface FileCardProps {
file: TelegramFile; file: TelegramFile;
} }
export function FileCard({ file }: FileCardProps) { export function FileCard({ file }: FileCardProps) {
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
return ( return (
<FileDrawer file={file}> <FileDrawer file={file} downloadProgress={downloadProgress}>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<FileAvatar file={file} /> <FileAvatar file={file} />
<div className="flex-1"> <div className="flex-1">
<h3 className="mb-1 font-medium">{file.name}</h3> <h3 className="max-w-40 truncate font-medium">{file.fileName}</h3>
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground"> <div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<span> <span>
{prettyBytes(file.size)} {file.type} {prettyBytes(file.size)} {file.type}
</span> </span>
<FileStatus status={file.downloadStatus} /> <FileStatus status={file.downloadStatus} />
</div> </div>
<div className="mb-2 w-full overflow-hidden"> <div
<FileProgress file={file} showSize={false} /> className={cn(
"mb-2 w-full overflow-hidden",
file.downloadStatus === "idle" && "hidden",
)}
>
<FileProgress file={file} downloadProgress={downloadProgress} />
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<FileControl file={file} /> <FileControl file={file} downloadSpeed={downloadSpeed} isMobile={true}/>
</div> </div>
</div> </div>
</div> </div>
@ -96,14 +104,14 @@ function FileAvatar({
{loadPreview ? ( {loadPreview ? (
<PhotoPreview <PhotoPreview
thumbnail={file.thumbnail} thumbnail={file.thumbnail}
name={file.name} name={file.fileName}
chatId={file.chatId} chatId={file.chatId}
messageId={file.messageId} messageId={file.messageId}
/> />
) : ( ) : (
<Image <Image
src={`data:image/jpeg;base64,${file.thumbnail}`} src={`data:image/jpeg;base64,${file.thumbnail}`}
alt={file.name ?? "File thumbnail"} alt={file.fileName ?? "File thumbnail"}
width={32} width={32}
height={32} height={32}
className="inline-block h-16 w-16 rounded object-cover" className="inline-block h-16 w-16 rounded object-cover"
@ -126,9 +134,11 @@ function FileAvatar({
function FileDrawer({ function FileDrawer({
file, file,
downloadProgress,
children, children,
}: { }: {
file: TelegramFile; file: TelegramFile;
downloadProgress: number;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@ -139,21 +149,24 @@ function FileDrawer({
aria-describedby={undefined} aria-describedby={undefined}
> >
<DrawerHeader className="text-center"> <DrawerHeader className="text-center">
<DrawerTitle className="flex items-center justify-center gap-2 text-xl"> <DrawerTitle className="flex flex-col items-center justify-center gap-2">
<FileAvatar file={file} loadPreview={true} /> <FileAvatar file={file} loadPreview={true} />
<span className="max-w-md truncate">{file.name}</span> <span className="max-w-64 truncate">{file.fileName}</span>
</DrawerTitle> </DrawerTitle>
<div className="py-1"> <div className="py-1">
<FileStatus status={file.downloadStatus} /> <FileStatus status={file.downloadStatus} />
</div> </div>
{file.caption && ( {file.caption && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}> <SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<DrawerDescription className="mx-auto mt-2 max-w-md"> <DrawerDescription
{file.caption} className="mx-auto mt-2 max-h-48 max-w-md overflow-y-auto text-start"
</DrawerDescription> dangerouslySetInnerHTML={{
__html: file.caption.replaceAll("\n", "<br />"),
}}
></DrawerDescription>
</SpoiledWrapper> </SpoiledWrapper>
)} )}
<FileProgress file={file} showSize={false} /> <FileProgress file={file} downloadProgress={downloadProgress} />
</DrawerHeader> </DrawerHeader>
<div className="px-2"> <div className="px-2">
@ -204,7 +217,7 @@ function FileDrawer({
</Badge> </Badge>
</div> </div>
{(file.downloadStatus !== 'idle' && file.startDate !== 0) && ( {file.downloadStatus !== "idle" && file.startDate !== 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock10 className="h-4 w-4" /> <Clock10 className="h-4 w-4" />

View file

@ -1,4 +1,4 @@
import { type TelegramFile } from "@/lib/types"; import { type DownloadStatus, type TelegramFile } from "@/lib/types";
import { useFileControl } from "@/hooks/use-file-control"; import { useFileControl } from "@/hooks/use-file-control";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowDown, Loader2, Pause, SquareX, StepForward } from "lucide-react"; import { ArrowDown, Loader2, Pause, SquareX, StepForward } from "lucide-react";
@ -8,8 +8,50 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { type ReactNode } from "react";
import prettyBytes from "pretty-bytes";
import { AnimatePresence, motion } from "framer-motion";
interface ActionButtonProps {
tooltipText: string;
icon: ReactNode;
onClick: () => void;
loading: boolean;
}
const ActionButton = ({
tooltipText,
icon,
onClick,
loading,
}: ActionButtonProps) => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="xs" onClick={onClick}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
export default function FileControl({
file,
downloadSpeed,
hovered,
isMobile,
}: {
file: TelegramFile;
downloadSpeed: number;
hovered?: boolean;
isMobile?: boolean;
}) {
const showDownloadInfo =
!hovered &&
(file.downloadStatus === "downloading" || file.downloadStatus === "paused");
export default function FileControl({ file }: { file: TelegramFile }) {
const { start, starting, togglePause, togglingPause, cancel, cancelling } = const { start, starting, togglePause, togglingPause, cancel, cancelling } =
useFileControl(file); useFileControl(file);
@ -17,87 +59,103 @@ export default function FileControl({ file }: { file: TelegramFile }) {
return null; return null;
} }
const statusMapping: Record<DownloadStatus, ActionButtonProps[]> = {
idle: [
{
onClick: () => start(file.id),
tooltipText: "Start Download",
icon: <ArrowDown className="h-4 w-4" />,
loading: starting,
},
],
error: [
{
onClick: () => start(file.id),
tooltipText: "Retry",
icon: <ArrowDown className="h-4 w-4" />,
loading: starting,
},
],
downloading: [
{
onClick: () => togglePause(file.id),
tooltipText: "Pause",
icon: <Pause className="h-4 w-4" />,
loading: togglingPause,
},
{
onClick: () => cancel(file.id),
tooltipText: "Cancel",
icon: <SquareX className="h-4 w-4" />,
loading: cancelling,
},
],
paused: [
{
onClick: () => togglePause(file.id),
tooltipText: "Resume",
icon: <StepForward className="h-4 w-4" />,
loading: togglingPause,
},
{
onClick: () => cancel(file.id),
tooltipText: "Cancel",
icon: <SquareX className="h-4 w-4" />,
loading: cancelling,
},
],
completed: [],
};
const actionButtons = (
<div className="w-full">
<div
className="flex w-full items-center justify-end space-x-2 md:justify-around"
onClick={(e) => e.preventDefault()}
>
{statusMapping[file.downloadStatus].map((btnProps, index) => (
<ActionButton key={index} {...btnProps} />
))}
</div>
</div>
);
if (isMobile) {
return <TooltipProvider>{actionButtons}</TooltipProvider>;
}
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex w-full justify-end md:justify-around"> <div className="relative h-6 overflow-hidden">
{(file.downloadStatus === "idle" || <AnimatePresence mode="wait">
file.downloadStatus === "error") && ( {showDownloadInfo ? (
<Tooltip> <motion.div
<TooltipTrigger asChild> key="downloadInfo"
<Button variant="ghost" size="xs" onClick={() => start(file.id)}> initial={{ y: 20, opacity: 0 }}
{starting ? ( animate={{ y: 0, opacity: 1 }}
<Loader2 className="h-4 w-4 animate-spin" /> exit={{ y: -20, opacity: 0 }}
) : ( transition={{ duration: 0.2, ease: "easeOut" }}
<ArrowDown className="h-4 w-4" /> className="absolute w-full"
)} >
</Button> <span className="text-nowrap text-xs">
</TooltipTrigger> {file.downloadStatus === "downloading"
<TooltipContent> ? `${prettyBytes(downloadSpeed, { bits: true })}/s`
<p> : prettyBytes(file.downloadedSize)}
{file.downloadStatus === "idle" </span>
? "Start Download" </motion.div>
: file.downloadStatus === "error" ) : (
? "Retry" <motion.div
: "Downloading"} key="actionButtons"
</p> initial={{ y: 20, opacity: 0 }}
</TooltipContent> animate={{ y: 0, opacity: 1 }}
</Tooltip> exit={{ y: -20, opacity: 0 }}
)} transition={{ duration: 0.2, ease: "easeOut" }}
{(file.downloadStatus === "downloading" || className="absolute w-full"
file.downloadStatus === "paused") && ( >
<> {actionButtons}
<Tooltip> </motion.div>
<TooltipTrigger asChild> )}
<Button </AnimatePresence>
variant="ghost"
size="xs"
onClick={() => togglePause(file.id)}
>
{togglingPause ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : file.downloadStatus === "downloading" ? (
<Pause className="h-4 w-4" />
) : (
<StepForward className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{file.downloadStatus === "downloading"
? "Pause"
: file.downloadStatus === "paused"
? "Resume"
: "Paused"}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={() => cancel(file.id)}
>
{cancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SquareX className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{file.downloadStatus === "downloading" ||
file.downloadStatus === "paused" ||
file.downloadStatus === "error"
? "Cancel"
: "Cancelled"}
</p>
</TooltipContent>
</Tooltip>
</>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View file

@ -1,4 +1,11 @@
import { Captions, Clock, ClockArrowDown, Copy, FileCheck } from "lucide-react"; import {
Captions,
Clock,
ClockArrowDown,
Copy,
FileCheck,
Mountain,
} from "lucide-react";
import SpoiledWrapper from "@/components/spoiled-wrapper"; import SpoiledWrapper from "@/components/spoiled-wrapper";
import { import {
Tooltip, Tooltip,
@ -11,6 +18,7 @@ import { type TelegramFile } from "@/lib/types";
import { type RowHeight } from "@/components/table-row-height-switch"; import { type RowHeight } from "@/components/table-row-height-switch";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
interface FileExtraProps { interface FileExtraProps {
file: TelegramFile; file: TelegramFile;
@ -23,21 +31,39 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
return ( return (
<div className="relative flex flex-col space-y-1 overflow-hidden"> <div className="relative flex flex-col space-y-1 overflow-hidden">
<TooltipProvider> <TooltipProvider>
{file.fileName && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<p className="flex items-center gap-2">
<Mountain className="h-4 w-4" />
<span className="rounded px-1 text-sm hover:bg-gray-100">
{file.fileName}
</span>
</p>
</SpoiledWrapper>
)}
{rowHeight !== "s" && file.caption && ( {rowHeight !== "s" && file.caption && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}> <SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Captions className="h-4 w-4 flex-shrink-0" /> <Captions className="h-4 w-4 flex-shrink-0" />
<p className="line-clamp-2 overflow-hidden truncate text-ellipsis text-wrap text-sm"> <p
className={cn(
rowHeight !== "l" && "line-clamp-1",
"overflow-hidden truncate text-ellipsis text-wrap text-start text-sm",
)}
>
{file.caption} {file.caption}
</p> </p>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent asChild> <TooltipContent>
<div className="max-w-80 text-wrap rounded p-2"> <p
{file.caption} className="max-w-80 text-wrap rounded p-2"
</div> dangerouslySetInnerHTML={{
__html: file.caption.replaceAll("\n", "<br />"),
}}
></p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</SpoiledWrapper> </SpoiledWrapper>
@ -63,31 +89,14 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
<div className="flex items-center gap-2"> {rowHeight !== "s" && (
<Tooltip> <div className="flex items-center gap-2">
<TooltipTrigger asChild>
<p className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
{formatDistanceToNow(new Date(file.date * 1000), {
addSuffix: true,
})}
</span>
</p>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-80 text-wrap rounded p-2">
{`Message received at ${new Date(file.date * 1000).toLocaleString()}`}
</div>
</TooltipContent>
</Tooltip>
{file.completionDate && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
<ClockArrowDown className="h-4 w-4" /> <Clock className="h-4 w-4" />
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100"> <span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
{formatDistanceToNow(new Date(file.completionDate), { {formatDistanceToNow(new Date(file.date * 1000), {
addSuffix: true, addSuffix: true,
})} })}
</span> </span>
@ -95,12 +104,31 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="max-w-80 text-wrap rounded p-2"> <div className="max-w-80 text-wrap rounded p-2">
{`File downloaded at ${new Date(file.completionDate).toLocaleString()}`} {`Message received at ${new Date(file.date * 1000).toLocaleString()}`}
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} {file.completionDate && (
</div> <Tooltip>
<TooltipTrigger asChild>
<p className="flex items-center gap-2">
<ClockArrowDown className="h-4 w-4" />
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
{formatDistanceToNow(new Date(file.completionDate), {
addSuffix: true,
})}
</span>
</p>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-80 text-wrap rounded p-2">
{`File downloaded at ${new Date(file.completionDate).toLocaleString()}`}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
)}
</TooltipProvider> </TooltipProvider>
</div> </div>
); );

View file

@ -1,14 +1,6 @@
"use client"; "use client";
import { type TelegramFile } from "@/lib/types";
import { FileCard } from "./file-card"; import { FileCard } from "./file-card";
import React, { import React, { useEffect, useMemo, useRef, useState } from "react";
memo,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { import {
Table, Table,
TableBody, TableBody,
@ -19,41 +11,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Download, LoaderCircle, LoaderPinwheel } from "lucide-react";
Download,
FileAudioIcon,
FileIcon,
ImageIcon,
LoaderCircle,
LoaderPinwheel,
VideoIcon,
} from "lucide-react";
import { useFiles } from "@/hooks/use-files"; import { useFiles } from "@/hooks/use-files";
import { FileFilters } from "@/components/file-filters"; import { FileFilters } from "@/components/file-filters";
import { import { useRowHeightLocalStorage } from "@/components/table-row-height-switch";
getRowHeightTailwindClass,
useRowHeightLocalStorage,
} from "@/components/table-row-height-switch";
import { type Column } from "@/components/table-column-filter"; import { type Column } from "@/components/table-column-filter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import PhotoPreview from "@/components/photo-preview";
import SpoiledWrapper from "@/components/spoiled-wrapper";
import Image from "next/image";
import FileControl from "@/components/file-control";
import prettyBytes from "pretty-bytes";
import FileProgress from "@/components/file-progress";
import FileNotFount from "@/components/file-not-found"; import FileNotFount from "@/components/file-not-found";
import FileExtra from "@/components/file-extra";
import useIsMobile from "@/hooks/use-is-mobile"; import useIsMobile from "@/hooks/use-is-mobile";
import FileStatus from "@/components/file-status";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
import { POST } from "@/lib/api"; import { POST } from "@/lib/api";
import FileRow from "@/components/file-row";
interface FileListProps { interface FileListProps {
accountId: string; accountId: string;
@ -84,26 +52,6 @@ const COLUMNS: Column[] = [
}, },
]; ];
const PhotoColumnImage = memo(function PhotoColumnImage({
thumbnail,
name,
wh,
}: {
thumbnail: string;
name: string;
wh: string;
}) {
return (
<Image
src={`data:image/jpeg;base64,${thumbnail}`}
alt={name ?? "File thumbnail"}
width={32}
height={32}
className={cn(wh, "rounded object-cover")}
/>
);
});
export function FileList({ accountId, chatId }: FileListProps) { export function FileList({ accountId, chatId }: FileListProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
@ -155,12 +103,12 @@ export function FileList({ accountId, chatId }: FileListProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, [handleLoadMore]); }, [handleLoadMore]);
const dynamicClass = useCallback(() => { const dynamicClass = useMemo(() => {
switch (rowHeight) { switch (rowHeight) {
case "s": case "s":
return { return {
content: "h-6 w-6", content: "h-6 w-6",
contentCell: "w-6", contentCell: "w-4",
}; };
case "m": case "m":
return { return {
@ -234,84 +182,6 @@ export function FileList({ accountId, chatId }: FileListProps) {
setSelectedFiles(newSelected); setSelectedFiles(newSelected);
}; };
const getFileIcon = (type: TelegramFile["type"]) => {
let icon;
switch (type) {
case "photo":
icon = <ImageIcon className="h-4 w-4" />;
break;
case "video":
icon = <VideoIcon className="h-4 w-4" />;
break;
case "audio":
icon = <FileAudioIcon className="h-4 w-4" />;
break;
default:
icon = <FileIcon className="h-4 w-4" />;
}
return (
<div
className={cn(
dynamicClass().content,
"flex items-center justify-center rounded bg-muted",
)}
>
{icon}
</div>
);
};
const columnRenders: Record<
string,
(file: TelegramFile, index: number) => ReactNode
> = {
content: (file: TelegramFile) => (
<div className="flex items-center gap-2">
{file.type === "photo" || file.type === "video" ? (
file.thumbnail ? (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<PhotoColumnImage
thumbnail={file.thumbnail}
name={file.name}
wh={dynamicClass().content}
/>
</TooltipTrigger>
<TooltipContent asChild>
<PhotoPreview
thumbnail={file.thumbnail}
name={file.name}
chatId={file.chatId}
messageId={file.messageId}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SpoiledWrapper>
) : (
getFileIcon(file.type)
)
) : (
getFileIcon(file.type)
)}
</div>
),
type: (file: TelegramFile) => (
<div className="flex flex-col items-center">
<span className="capitalize">{file.type}</span>
<span className="text-xs">{file.id}</span>
</div>
),
size: (file: TelegramFile) => <span>{prettyBytes(file.size)}</span>,
status: (file: TelegramFile) => <FileStatus status={file.downloadStatus} />,
extra: (file: TelegramFile) => (
<FileExtra file={file} rowHeight={rowHeight} />
),
actions: (file: TelegramFile) => <FileControl file={file} />,
};
return ( return (
<> <>
<FileFilters <FileFilters
@ -381,7 +251,7 @@ export function FileList({ accountId, chatId }: FileListProps) {
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
col.className ?? "", col.className ?? "",
col.id === "content" ? dynamicClass().contentCell : "", col.id === "content" ? dynamicClass.contentCell : "",
)} )}
> >
{col.label} {col.label}
@ -392,47 +262,13 @@ export function FileList({ accountId, chatId }: FileListProps) {
</TableHeader> </TableHeader>
<TableBody className="[&_tr:last-child]:border-b"> <TableBody className="[&_tr:last-child]:border-b">
{files.map((file, index) => ( {files.map((file, index) => (
<React.Fragment <FileRow
file={file}
checked={selectedFiles.has(file.id)}
onCheckedChange={() => handleSelectFile(file.id)}
properties={{ rowHeight, dynamicClass, columns }}
key={`${file.messageId}-${file.uniqueId}-${index}`} key={`${file.messageId}-${file.uniqueId}-${index}`}
> />
<TableRow
className={cn(
getRowHeightTailwindClass(rowHeight),
"border-b-0",
)}
>
<TableCell className="text-center">
<Checkbox
checked={selectedFiles.has(file.id)}
onCheckedChange={() => handleSelectFile(file.id)}
disabled={file.downloadStatus !== "idle"}
/>
</TableCell>
{columns.map((col) =>
col.isVisible ? (
<TableCell
key={col.id}
className={cn(
col.className ?? "",
col.id === "content"
? dynamicClass().contentCell
: "",
)}
>
{columnRenders[col.id]!(file, index)}
</TableCell>
) : null,
)}
</TableRow>
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-px p-0"
>
<FileProgress file={file} showSize={true} />
</TableCell>
</TableRow>
</React.Fragment>
))} ))}
{!isLoading && files.length === 0 && ( {!isLoading && files.length === 0 && (
<TableRow className="border-b-0"> <TableRow className="border-b-0">

View file

@ -1,18 +1,14 @@
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import prettyBytes from "pretty-bytes";
import { type TelegramFile } from "@/lib/types"; import { type TelegramFile } from "@/lib/types";
import { useFileSpeed } from "@/hooks/use-file-speed";
import { useMemo } from "react"; import { useMemo } from "react";
import { ClockArrowDown, Zap } from "lucide-react";
export default function FileProgress({ export default function FileProgress({
file, file,
showSize, downloadProgress,
}: { }: {
file: TelegramFile; file: TelegramFile;
showSize: boolean; downloadProgress: number;
}) { }) {
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
const progress = useMemo(() => { const progress = useMemo(() => {
const fileDownloadProgress = const fileDownloadProgress =
file.size > 0 file.size > 0
@ -37,22 +33,30 @@ export default function FileProgress({
return ( return (
<div className="flex items-end justify-between gap-2"> <div className="flex items-end justify-between gap-2">
{showSize && file.downloadedSize > 0 && ( {/*<div className="hidden min-w-20 md:flex">*/}
<div className="flex min-w-32 items-center gap-1 bg-gray-100 px-1"> {/* <div className="group flex items-center gap-1 px-1">*/}
<ClockArrowDown className="h-3 w-3" /> {/* <AnimatePresence mode="wait">*/}
<span className="text-nowrap text-xs"> {/* <motion.div*/}
{prettyBytes(file.downloadedSize)} {/* key="content"*/}
</span> {/* className="flex items-center gap-1"*/}
</div> {/* initial={{ opacity: 1 }}*/}
)} {/* exit={{ opacity: 0 }}*/}
<div className="hidden min-w-32 items-center gap-1 bg-gray-100 px-1 md:flex"> {/* >*/}
<Zap className="h-3 w-3" /> {/* <Zap className="h-3 w-3 group-hover:hidden" />*/}
<span className="text-nowrap text-xs"> {/* <ClockArrowDown className="hidden h-3 w-3 group-hover:block" />*/}
{file.downloadStatus === "downloading" {/* <span className="text-nowrap text-xs">*/}
? `${prettyBytes(downloadSpeed, { bits: true })}/s` {/* <span className="group-hover:hidden">*/}
: "0/s"} {/* {`${prettyBytes(downloadSpeed, { bits: true })}/s`}*/}
</span> {/* </span>*/}
</div> {/* <span className="hidden group-hover:inline">*/}
{/* {prettyBytes(file.downloadedSize)}*/}
{/* </span>*/}
{/* </span>*/}
{/* </motion.div>*/}
{/* </AnimatePresence>*/}
{/* </div>*/}
{/*</div>*/}
<Progress value={progress} className="flex-1 rounded-none md:w-32" /> <Progress value={progress} className="flex-1 rounded-none md:w-32" />
</div> </div>
); );

View file

@ -0,0 +1,189 @@
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
getRowHeightTailwindClass,
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";
import { type TelegramFile } from "@/lib/types";
import SpoiledWrapper from "@/components/spoiled-wrapper";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import PhotoPreview from "@/components/photo-preview";
import { env } from "@/env";
import prettyBytes from "pretty-bytes";
import FileStatus from "@/components/file-status";
import FileExtra from "@/components/file-extra";
import FileControl from "@/components/file-control";
import { FileAudioIcon, FileIcon, ImageIcon, VideoIcon } from "lucide-react";
import Image from "next/image";
import { type Column } from "./table-column-filter";
import { useFileSpeed } from "@/hooks/use-file-speed";
interface FileRowProps {
file: TelegramFile;
checked: boolean;
properties: {
rowHeight: RowHeight;
dynamicClass: {
content: string;
contentCell: string;
};
columns: Column[];
};
onCheckedChange: (checked: boolean) => void;
}
export default function FileRow({
file,
checked,
properties,
onCheckedChange,
}: FileRowProps) {
const { rowHeight, dynamicClass, columns } = properties;
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
const [hovered, setHovered] = useState(false);
const getFileIcon = (type: TelegramFile["type"]) => {
let icon;
switch (type) {
case "photo":
icon = <ImageIcon className="h-4 w-4" />;
break;
case "video":
icon = <VideoIcon className="h-4 w-4" />;
break;
case "audio":
icon = <FileAudioIcon className="h-4 w-4" />;
break;
default:
icon = <FileIcon className="h-4 w-4" />;
}
return (
<div
className={cn(
dynamicClass.content,
"flex items-center justify-center rounded border",
)}
>
{icon}
</div>
);
};
const columnRenders: Record<string, ReactNode> = {
content: (
<div className="flex items-center justify-center gap-2">
{file.type === "photo" || file.type === "video" ? (
file.thumbnail ? (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<PhotoColumnImage
thumbnail={file.thumbnail}
name={file.fileName}
wh={dynamicClass.content}
/>
</TooltipTrigger>
<TooltipContent asChild>
<PhotoPreview
thumbnail={file.thumbnail}
name={file.fileName}
chatId={file.chatId}
messageId={file.messageId}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SpoiledWrapper>
) : (
getFileIcon(file.type)
)
) : (
getFileIcon(file.type)
)}
</div>
),
type: (
<div className="flex flex-col items-center">
<span className="capitalize">{file.type}</span>
{env.NEXT_PUBLIC_MOCK_DATA === true && (
<span className="text-xs">{file.id}</span>
)}
</div>
),
size: <span>{prettyBytes(file.size)}</span>,
status: <FileStatus status={file.downloadStatus} />,
extra: <FileExtra file={file} rowHeight={rowHeight} />,
actions: (
<FileControl
file={file}
downloadSpeed={downloadSpeed}
hovered={hovered}
/>
),
};
return (
<React.Fragment>
<TableRow
className={cn(getRowHeightTailwindClass(rowHeight), "border-b-0")}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<TableCell className="text-center">
<Checkbox
checked={checked}
onCheckedChange={onCheckedChange}
disabled={file.downloadStatus !== "idle"}
/>
</TableCell>
{columns.map((col) =>
col.isVisible ? (
<TableCell
key={col.id}
className={cn(
col.className ?? "",
col.id === "content" ? dynamicClass.contentCell : "",
)}
>
{columnRenders[col.id]}
</TableCell>
) : null,
)}
</TableRow>
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-px p-0">
<FileProgress file={file} downloadProgress={downloadProgress} />
</TableCell>
</TableRow>
</React.Fragment>
);
}
const PhotoColumnImage = memo(function PhotoColumnImage({
thumbnail,
name,
wh,
}: {
thumbnail: string;
name: string;
wh: string;
}) {
return (
<Image
src={`data:image/jpeg;base64,${thumbnail}`}
alt={name ?? "File thumbnail"}
width={32}
height={32}
className={cn(wh, "rounded object-cover")}
/>
);
});

View file

@ -27,7 +27,11 @@ export default function FileStatus({ status }: { status: DownloadStatus }) {
}, },
}; };
return <Badge className={cn("text-xs", STATUS[status].className)}> return (
{STATUS[status].text} <Badge
</Badge>; className={cn("text-xs hover:bg-gray-200", STATUS[status].className)}
>
{STATUS[status].text}
</Badge>
);
} }

View file

@ -246,7 +246,7 @@ export default function Proxys({
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 peer-checked:border-primary peer-checked:bg-primary"> <div className="mr-2 flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 peer-checked:border-primary peer-checked:bg-primary">
<div className="h-2.5 w-2.5 rounded-full bg-white"></div> <div className="h-2.5 w-2.5 rounded-full bg-white"></div>
</div> </div>
<span className="text-gray-700 peer-checked:text-primary"> <span className="text-gray-400 peer-checked:text-primary">
HTTP HTTP
</span> </span>
</label> </label>

View file

@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground",
className, className,
)} )}
{...props} {...props}

View file

@ -36,7 +36,7 @@ export type TelegramFile = {
uniqueId: string; uniqueId: string;
messageId: number; messageId: number;
chatId: number; chatId: number;
name: string; fileName: string;
type: FileType; type: FileType;
size: number; size: number;
downloadedSize: number; downloadedSize: number;