Update filemanager (🚧 in construction)

This commit is contained in:
Kenta420 2024-02-19 18:07:10 +07:00
parent 0fe469b5c6
commit 11dc6b2132
14 changed files with 925 additions and 223 deletions

View file

@ -0,0 +1,271 @@
import { create } from 'zustand'
import useAdb from './useAdb'
import { toast } from '@/components/ui/use-toast'
import { type LinuxFileType } from '@yume-chan/adb'
import { fromUnixTime } from 'date-fns'
import { Consumable, ReadableStream, WritableStream } from '@yume-chan/stream-extra'
import JSZip from 'jszip'
export interface AndroidFile {
filename: string
type: LinuxFileType
size: bigint
dateModified: Date
}
interface FileManagerAndroidHook {
rootPath: string
currentPath: string
pushPath: (path: string) => void
popPath: () => void
setCurrentPath: (path: string) => void
scanPath: (path?: string) => Promise<AndroidFile[] | undefined>
pushFile: (file: File, targetPath: string) => Promise<void>
pushFiles: (files: File[], targetPath: string) => Promise<void>
createDirectory: (dirName: string) => Promise<void>
delete: (filename: string) => Promise<void>
rename: (filename: string, newName: string) => Promise<void>
download: (files: AndroidFile[]) => Promise<void>
}
const useFileManager = create<FileManagerAndroidHook>((set, get) => ({
rootPath: '/mnt/sdcard/coffeevending',
currentPath: '',
pushPath(path) {
set({ currentPath: get().currentPath + '/' + path })
console.log('currentPath', get().currentPath)
},
popPath() {
set({ currentPath: get().currentPath.slice(0, get().currentPath.lastIndexOf('/')) })
console.log('currentPath', get().currentPath)
},
setCurrentPath(path) {
set({ currentPath: path })
},
async scanPath(path) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const sync = await adb.sync()
try {
console.log('scanning path', get().rootPath + path)
const entries = await sync.readdir(get().rootPath + path)
return entries.reduce<AndroidFile[]>((acc, entry) => {
if (entry.name === '.' || entry.name === '..') {
return acc
}
return [
...acc,
{
filename: entry.name,
type: entry.type,
size: entry.size,
dateModified: fromUnixTime(Number(entry.mtime))
}
]
}, [])
} finally {
await sync.dispose()
}
},
async pushFile(file, targetPath) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const buffer = await file.arrayBuffer()
const sync = await adb.sync()
try {
await sync.write({
filename: targetPath + '/' + file.name,
file: new ReadableStream({
start(controller) {
controller.enqueue(new Consumable(new Uint8Array(buffer)))
controller.close()
}
})
})
} finally {
await sync.dispose()
}
},
async pushFiles(files, targetPath) {
for (const file of files) {
await get().pushFile(file, targetPath)
}
},
async createDirectory(name) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const process = await adb.subprocess.spawn('mkdir ' + get().rootPath + '/' + name)
process.stderr.pipeTo(
new WritableStream({
write(chunk) {
console.error(chunk)
}
})
)
process.stdout.pipeTo(
new WritableStream({
write(chunk) {
console.log(chunk)
}
})
)
if ((await process.exit) != 0) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to create directory',
description: 'Please try again'
})
}
},
async delete(filename) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const process = await adb.subprocess.spawn('rm ' + get().rootPath + '/' + filename)
process.stderr.pipeTo(
new WritableStream({
write(chunk) {
console.error(chunk)
}
})
)
process.stdout.pipeTo(
new WritableStream({
write(chunk) {
console.log(chunk)
}
})
)
if ((await process.exit) != 0) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to delete file',
description: 'Please try again'
})
}
},
async rename(filename, newName) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const process = await adb.subprocess.spawn(
'mv ' + get().rootPath + '/' + filename + ' ' + get().rootPath + '/' + newName
)
process.stderr.pipeTo(
new WritableStream({
write(chunk) {
console.error(chunk)
}
})
)
process.stdout.pipeTo(
new WritableStream({
write(chunk) {
console.log(chunk)
}
})
)
if ((await process.exit) != 0) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to rename file',
description: 'Please try again'
})
}
},
async download(files) {
const adb = useAdb.getState().adb
if (!adb) {
toast({
duration: 3000,
variant: 'destructive',
title: 'Failed to connect to device',
description: 'Please connect Adb first'
})
return
}
const sync = await adb.sync()
try {
const zip = new JSZip()
for (const file of files) {
const buffer: Uint8Array[] = []
const fileStream = sync.read(get().rootPath + '/' + file.filename)
await fileStream.pipeTo(
new WritableStream({
write(chunk) {
buffer.push(chunk)
}
})
)
zip.file(file.filename, new Blob(buffer))
}
const blob = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'download.zip'
a.click()
URL.revokeObjectURL(url)
} finally {
await sync.dispose()
}
}
}))
export default useFileManager

View file

@ -4,9 +4,9 @@ import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
// <React.StrictMode>
<App />
// </React.StrictMode>
)
if (window.electronRuntime) {

View file

@ -36,7 +36,7 @@ const AndroidPage: React.FC = () => {
<ShellTab adb={adb} />
</TabsContent>
<TabsContent value="file-manager">
<FileManagerTab adb={adb} />
<FileManagerTab />
</TabsContent>
</Tabs>
<Toaster />

View file

@ -1,158 +1,97 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { LinuxFileType, type Adb } from '@yume-chan/adb'
import { Consumable, WritableStream, ReadableStream } from '@yume-chan/stream-extra'
import JSZip from 'jszip'
import { useCallback, useState } from 'react'
import useFileManager, { type AndroidFile } from '@/hooks/filemanager-android'
import { useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import DataTable from './filemanager-table/data-table'
import { ChevronRightIcon, FileIcon } from '@radix-ui/react-icons'
import { type ColumnDef } from '@tanstack/react-table'
import { LinuxFileType } from '@yume-chan/adb'
import { formatDate } from 'date-fns'
import DataTableColumnHeader from './filemanager-table/data-table-column-header'
import DataTableRowActions from './filemanager-table/data-table-row-actions'
import { Checkbox } from '@/components/ui/checkbox'
interface FileManagerTabProps {
adb: Adb | undefined
}
export const FileManagerTab: React.FC = () => {
const { currentPath, pushPath, popPath, setCurrentPath, scanPath } = useFileManager(
useShallow(state => ({
currentPath: state.currentPath,
pushPath: state.pushPath,
popPath: state.popPath,
setCurrentPath: state.setCurrentPath,
scanPath: state.scanPath
}))
)
type filesType = {
file: string
indent: number
blob?: Blob
files?: filesType[]
}
export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
const [path, setPath] = useState<string>('')
const [pushPath, setPushPath] = useState<string>('')
const [pushFile, setPushFile] = useState<File | null>()
const [files, setFiles] = useState<filesType>()
const pushFiles = async (filename: string, blob: Blob, targetPath: string) => {
if (!adb) return
const buffer = await blob.arrayBuffer()
console.log(blob, buffer, targetPath, filename)
const sync = await adb.sync()
try {
await sync.write({
filename: targetPath + '/' + filename,
file: new ReadableStream({
start(controller) {
controller.enqueue(new Consumable(new Uint8Array(buffer)))
controller.close()
}
})
})
} finally {
await sync.dispose()
}
}
const zipFiles = (files: filesType) => {
const zip = new JSZip()
const addFiles = (folder: filesType, parent: JSZip | null) => {
folder.files?.forEach(file => {
if (file.files) {
let newFolder
if (parent) {
newFolder = parent.folder(file.file)
} else {
newFolder = zip.folder(file.file)
}
addFiles(file, newFolder)
} else {
if (!file.blob) {
return
}
if (parent) {
parent.file(file.file, file.blob)
} else {
zip.file(file.file, file.blob)
}
}
})
}
addFiles(files, zip.folder(files.file))
zip.generateAsync({ type: 'blob' }).then(content => {
const url = URL.createObjectURL(content)
const a = document.createElement('a')
a.href = url
a.download = files.file + '.zip'
a.click()
useEffect(() => {
console.log('scanning path', currentPath)
scanPath(currentPath).then(files => {
console.log(files)
setFiles(files)
setIsLoading(false)
})
}
}, [currentPath])
const download = useCallback(() => {
const readFiles = async (adb: Adb) => {
const sync = await adb.sync()
try {
const folder: filesType = {
file: path.split('/').pop() || 'scannedFiles',
indent: 0,
files: []
}
const [files, setFiles] = useState<AndroidFile[] | undefined>(undefined)
const [isLoading, setIsLoading] = useState(true)
const TypeBlob: {
[key: string]: string
} = {
mp4: 'video/mp4',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif'
}
// scan all files in the folder
const scanFiles = async (folder: filesType, path: string, indent: number) => {
const entries = await sync.readdir(path)
for (const entry of entries) {
if (entry.name.startsWith('.')) {
continue
}
if (entry.type === LinuxFileType.Directory) {
const newFolder: filesType = {
file: entry.name,
indent: indent,
files: []
}
folder.files?.push(newFolder)
await scanFiles(newFolder, `${path}/${entry.name}`, indent + 1)
} else {
const buffer: Uint8Array[] = []
const fileStream = sync.read(`${path}/${entry.name}`)
await fileStream.pipeTo(
new WritableStream({
write(chunk) {
buffer.push(chunk)
}
})
)
const blob = new Blob(buffer, { type: TypeBlob[entry.name.split('.').pop()!] })
folder.files?.push({
file: entry.name,
blob: blob,
indent: indent
})
}
}
}
console.log('Scanning files...')
await scanFiles(folder, path, 1)
console.log('Scanned files')
setFiles(folder)
console.log(folder)
// Zip files
zipFiles(folder)
} finally {
await sync.dispose()
}
const columns: ColumnDef<AndroidFile>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false
},
{
id: 'filename',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
return (
<div
className="flex hover:cursor-pointer"
onClick={() => {
if (row.original.type !== LinuxFileType.File) pushPath(row.original.filename)
}}
>
{row.original.type === LinuxFileType.File && <FileIcon className="mr-2 h-4 w-4" />}
<span className={row.original.type !== LinuxFileType.File ? 'hover:underline' : 'ml-2'}>
{row.original.filename}
</span>
</div>
)
},
enableSorting: true,
enableHiding: false
},
{
id: 'size',
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
cell: ({ row }) => <div>{row.original.size.toString()}</div>
},
{
id: 'dateModified',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => <div>{formatDate(row.original.dateModified, 'dd MMM yyyy HH:mm:ss')}</div>
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />
}
if (adb) {
readFiles(adb)
}
}, [adb, path])
]
return (
<Card>
@ -161,46 +100,34 @@ export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
<CardDescription>Manage files in Android</CardDescription>
</CardHeader>
<CardContent>
<div className="flex w-full flex-col items-center justify-end py-3">
<div className="flex w-full items-center justify-around space-x-10">
<div className="flex space-x-5">
<Input placeholder="folder to download" value={path} onChange={e => setPath(e.target.value)} />
<Button variant={'default'} onClick={download}>
Download
</Button>
</div>
<div className="flex space-x-5">
<Input placeholder="path to push file" value={pushPath} onChange={e => setPushPath(e.target.value)} />
<Input type="file" accept="*" dir="ltr" onChange={e => setPushFile(e.target.files?.item(0))} />
<Button
variant={'default'}
onClick={
() => pushFiles(pushFile?.name || 'unname', pushFile as Blob, pushPath)
//testPushFile
}
>
Push
</Button>
</div>
</div>
<div className="max-h-96 w-full overflow-y-auto">{files && <FileTree files={files} />}</div>
<div className="flex space-x-3">
<span className="hover:underline" onClick={() => setCurrentPath('')}>
ROOT
</span>
{currentPath &&
currentPath.split('/').map((path, index) => {
return (
<div key={index} className="flex justify-center items-center space-x-3">
<span
className="hover:underline"
onClick={() =>
setCurrentPath(
currentPath
.split('/')
.slice(0, index + 1)
.join('/')
)
}
>
{path}
</span>
{currentPath.split('/').length - 1 !== index && <ChevronRightIcon />}
</div>
)
})}
</div>
<DataTable data={files ?? []} columns={columns} isLoading={isLoading} />
</CardContent>
</Card>
)
}
const FileTree = ({ files }: { files: filesType }) => {
return (
<div className="pl-5">
{files.files?.map((file, index) => {
return (
<div key={index}>
<span>{file.file}</span>
<div>{file.files && <FileTree files={file} />}</div>
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,62 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
const DataTableColumnHeader = <TData, TValue>({
column,
title,
className
}: DataTableColumnHeaderProps<TData, TValue>) => {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<CaretSortIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default DataTableColumnHeader

View file

@ -0,0 +1,125 @@
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { PlusCircledIcon, CheckIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command'
interface DataTableFacetedFilterProps<TData, TValue, TOption> {
column?: Column<TData, TValue>
title?: string
options: TOption extends { value: string; label: string; icon?: React.ComponentType<{ className?: string }> }
? TOption[]
: never
}
const DataTableFacetedFilter = <TData, TValue, TOption>({
column,
title,
options
}: DataTableFacetedFilterProps<TData, TValue, TOption>) => {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircledIcon className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter(option => selectedValues.has(option.value))
.map(option => (
<Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map(option => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(filterValues.length ? filterValues : undefined)
}}
>
<div
className={cn(
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon className={cn('h-4 w-4')} />
</div>
{option.icon && <option.icon className="text-muted-foreground mr-2 h-4 w-4" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export default DataTableFacetedFilter

View file

@ -0,0 +1,83 @@
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DoubleArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
interface DataTablePaginationProps<TData> {
table: Table<TData>
}
const DataTablePagination = <TData,>({ table }: DataTablePaginationProps<TData>) => {
return (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={value => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map(pageSize => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
export default DataTablePagination

View file

@ -0,0 +1,62 @@
import { type Row } from '@tanstack/react-table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { labels } from '@/models/data'
interface DataTableRowActionsProps<TData> {
row: Row<TData>
}
const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) => {
const task = {
label: 'none'
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="data-[state=open]:bg-muted flex h-8 w-8 p-0">
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={task.label}>
{labels.map(label => (
<DropdownMenuRadioItem key={label.value} value={label.value}>
{label.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default DataTableRowActions

View file

@ -0,0 +1,32 @@
import { Button } from '@/components/ui/button'
import { type Table } from '@tanstack/react-table'
import DataTableViewOptions from './data-table-view-options'
import { Cross2Icon } from '@radix-ui/react-icons'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
const DataTableToolbar = <TData,>({ table }: DataTableToolbarProps<TData>) => {
const isFiltered = table.getState().columnFilters.length > 0
return (
<div className="flex items-center justify-between">
<div className="flex w-full flex-col space-y-4 rounded-lg bg-white p-3 shadow-md">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
{isFiltered && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
Reset
<Cross2Icon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
</div>
</div>
)
}
export default DataTableToolbar

View file

@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
interface DataTableViewOptionsProps<TData> {
table: Table<TData>
}
const DataTableViewOptions = <TData,>({ table }: DataTableViewOptionsProps<TData>) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map(column => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={value => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default DataTableViewOptions

View file

@ -0,0 +1,120 @@
import type { VisibilityState, SortingState, ColumnDef, FilterFn, ColumnFiltersState } from '@tanstack/react-table'
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
flexRender
} from '@tanstack/react-table'
import { rankItem } from '@tanstack/match-sorter-utils'
import { useState } from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import DataTablePagination from './data-table-pagination'
import { ReloadIcon } from '@radix-ui/react-icons'
import DataTableToolbar from './data-table-toolbar'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
return itemRank.passed
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
isLoading: boolean
}
const DataTable = <TData, TValue>({ columns, data, isLoading }: DataTableProps<TData, TValue>) => {
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
filterFns: {
fuzzy: fuzzyFilter
},
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters
},
globalFilterFn: fuzzyFilter,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues()
})
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="flex h-24 space-x-5 text-center">
<span>Loading...</span>
<ReloadIcon className="h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
)
}
export default DataTable

View file

@ -1,5 +1,4 @@
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
DropdownMenu,
DropdownMenuContent,
@ -8,52 +7,23 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { ArrowDownIcon, ArrowUpIcon, CalendarIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { DateRange } from 'react-day-picker'
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
isDate?: boolean
}
const DataTableColumnHeader = <TData, TValue>({
column,
title,
isDate,
className
}: DataTableColumnHeaderProps<TData, TValue>) => {
if (!column.getCanSort() && !isDate) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
if (isDate) {
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
<span>{title}</span>
<CalendarIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<Calendar
mode="range"
defaultMonth={new Date('2022-01-01')}
selected={column.getFilterValue() as DateRange}
onSelect={date => column.setFilterValue(date)}
numberOfMonths={2}
disabled={date => date > new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>

View file

@ -39,7 +39,7 @@ const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) =
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={task.label}>
<DropdownMenuRadioGroup value={task.name}>
{labels.map(label => (
<DropdownMenuRadioItem key={label.value} value={label.value}>
{label.label}

View file

@ -16,6 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import DataTablePagination from './data-table-pagination'
import { ReloadIcon } from '@radix-ui/react-icons'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)