Update filemanager (🚧 in construction)
This commit is contained in:
parent
0fe469b5c6
commit
11dc6b2132
14 changed files with 925 additions and 223 deletions
271
client-electron/src/hooks/filemanager-android.ts
Normal file
271
client-electron/src/hooks/filemanager-android.ts
Normal 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
|
||||||
|
|
@ -4,9 +4,9 @@ import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
// <React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
// </React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (window.electronRuntime) {
|
if (window.electronRuntime) {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const AndroidPage: React.FC = () => {
|
||||||
<ShellTab adb={adb} />
|
<ShellTab adb={adb} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="file-manager">
|
<TabsContent value="file-manager">
|
||||||
<FileManagerTab adb={adb} />
|
<FileManagerTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,97 @@
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import useFileManager, { type AndroidFile } from '@/hooks/filemanager-android'
|
||||||
import { LinuxFileType, type Adb } from '@yume-chan/adb'
|
import { useEffect, useState } from 'react'
|
||||||
import { Consumable, WritableStream, ReadableStream } from '@yume-chan/stream-extra'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import JSZip from 'jszip'
|
import DataTable from './filemanager-table/data-table'
|
||||||
import { useCallback, useState } from 'react'
|
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 {
|
export const FileManagerTab: React.FC = () => {
|
||||||
adb: Adb | undefined
|
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 = {
|
useEffect(() => {
|
||||||
file: string
|
console.log('scanning path', currentPath)
|
||||||
indent: number
|
scanPath(currentPath).then(files => {
|
||||||
blob?: Blob
|
console.log(files)
|
||||||
files?: filesType[]
|
setFiles(files)
|
||||||
}
|
setIsLoading(false)
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
}
|
}, [currentPath])
|
||||||
|
|
||||||
const download = useCallback(() => {
|
const [files, setFiles] = useState<AndroidFile[] | undefined>(undefined)
|
||||||
const readFiles = async (adb: Adb) => {
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const sync = await adb.sync()
|
|
||||||
try {
|
|
||||||
const folder: filesType = {
|
|
||||||
file: path.split('/').pop() || 'scannedFiles',
|
|
||||||
indent: 0,
|
|
||||||
files: []
|
|
||||||
}
|
|
||||||
|
|
||||||
const TypeBlob: {
|
const columns: ColumnDef<AndroidFile>[] = [
|
||||||
[key: string]: string
|
{
|
||||||
} = {
|
id: 'select',
|
||||||
mp4: 'video/mp4',
|
header: ({ table }) => (
|
||||||
png: 'image/png',
|
<Checkbox
|
||||||
jpg: 'image/jpeg',
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
|
||||||
jpeg: 'image/jpeg',
|
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
||||||
gif: 'image/gif'
|
aria-label="Select all"
|
||||||
}
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
// scan all files in the folder
|
),
|
||||||
const scanFiles = async (folder: filesType, path: string, indent: number) => {
|
cell: ({ row }) => (
|
||||||
const entries = await sync.readdir(path)
|
<Checkbox
|
||||||
for (const entry of entries) {
|
checked={row.getIsSelected()}
|
||||||
if (entry.name.startsWith('.')) {
|
onCheckedChange={value => row.toggleSelected(!!value)}
|
||||||
continue
|
aria-label="Select row"
|
||||||
}
|
className="translate-y-[2px]"
|
||||||
if (entry.type === LinuxFileType.Directory) {
|
/>
|
||||||
const newFolder: filesType = {
|
),
|
||||||
file: entry.name,
|
enableSorting: false,
|
||||||
indent: indent,
|
enableHiding: false
|
||||||
files: []
|
},
|
||||||
}
|
{
|
||||||
folder.files?.push(newFolder)
|
id: 'filename',
|
||||||
await scanFiles(newFolder, `${path}/${entry.name}`, indent + 1)
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
|
||||||
} else {
|
cell: ({ row }) => {
|
||||||
const buffer: Uint8Array[] = []
|
return (
|
||||||
const fileStream = sync.read(`${path}/${entry.name}`)
|
<div
|
||||||
await fileStream.pipeTo(
|
className="flex hover:cursor-pointer"
|
||||||
new WritableStream({
|
onClick={() => {
|
||||||
write(chunk) {
|
if (row.original.type !== LinuxFileType.File) pushPath(row.original.filename)
|
||||||
buffer.push(chunk)
|
}}
|
||||||
}
|
>
|
||||||
})
|
{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}
|
||||||
const blob = new Blob(buffer, { type: TypeBlob[entry.name.split('.').pop()!] })
|
</span>
|
||||||
folder.files?.push({
|
</div>
|
||||||
file: entry.name,
|
)
|
||||||
blob: blob,
|
},
|
||||||
indent: indent
|
enableSorting: true,
|
||||||
})
|
enableHiding: false
|
||||||
}
|
},
|
||||||
}
|
{
|
||||||
}
|
id: 'size',
|
||||||
console.log('Scanning files...')
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
|
||||||
await scanFiles(folder, path, 1)
|
cell: ({ row }) => <div>{row.original.size.toString()}</div>
|
||||||
console.log('Scanned files')
|
},
|
||||||
setFiles(folder)
|
{
|
||||||
console.log(folder)
|
id: 'dateModified',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||||
// Zip files
|
cell: ({ row }) => <div>{formatDate(row.original.dateModified, 'dd MMM yyyy HH:mm:ss')}</div>
|
||||||
zipFiles(folder)
|
},
|
||||||
} finally {
|
{
|
||||||
await sync.dispose()
|
id: 'actions',
|
||||||
}
|
cell: ({ row }) => <DataTableRowActions row={row} />
|
||||||
}
|
}
|
||||||
|
]
|
||||||
if (adb) {
|
|
||||||
readFiles(adb)
|
|
||||||
}
|
|
||||||
}, [adb, path])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -161,46 +100,34 @@ export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
|
||||||
<CardDescription>Manage files in Android</CardDescription>
|
<CardDescription>Manage files in Android</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex w-full flex-col items-center justify-end py-3">
|
<div className="flex space-x-3">
|
||||||
<div className="flex w-full items-center justify-around space-x-10">
|
<span className="hover:underline" onClick={() => setCurrentPath('')}>
|
||||||
<div className="flex space-x-5">
|
ROOT
|
||||||
<Input placeholder="folder to download" value={path} onChange={e => setPath(e.target.value)} />
|
</span>
|
||||||
<Button variant={'default'} onClick={download}>
|
{currentPath &&
|
||||||
Download
|
currentPath.split('/').map((path, index) => {
|
||||||
</Button>
|
return (
|
||||||
</div>
|
<div key={index} className="flex justify-center items-center space-x-3">
|
||||||
<div className="flex space-x-5">
|
<span
|
||||||
<Input placeholder="path to push file" value={pushPath} onChange={e => setPushPath(e.target.value)} />
|
className="hover:underline"
|
||||||
<Input type="file" accept="*" dir="ltr" onChange={e => setPushFile(e.target.files?.item(0))} />
|
onClick={() =>
|
||||||
<Button
|
setCurrentPath(
|
||||||
variant={'default'}
|
currentPath
|
||||||
onClick={
|
.split('/')
|
||||||
() => pushFiles(pushFile?.name || 'unname', pushFile as Blob, pushPath)
|
.slice(0, index + 1)
|
||||||
//testPushFile
|
.join('/')
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
Push
|
>
|
||||||
</Button>
|
{path}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
{currentPath.split('/').length - 1 !== index && <ChevronRightIcon />}
|
||||||
<div className="max-h-96 w-full overflow-y-auto">{files && <FileTree files={files} />}</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<DataTable data={files ?? []} columns={columns} isLoading={isLoading} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -8,52 +7,23 @@ import {
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
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 { type Column } from '@tanstack/react-table'
|
||||||
import { DateRange } from 'react-day-picker'
|
|
||||||
|
|
||||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
column: Column<TData, TValue>
|
column: Column<TData, TValue>
|
||||||
title: string
|
title: string
|
||||||
isDate?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataTableColumnHeader = <TData, TValue>({
|
const DataTableColumnHeader = <TData, TValue>({
|
||||||
column,
|
column,
|
||||||
title,
|
title,
|
||||||
isDate,
|
|
||||||
className
|
className
|
||||||
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
||||||
if (!column.getCanSort() && !isDate) {
|
if (!column.getCanSort()) {
|
||||||
return <div className={cn(className)}>{title}</div>
|
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 (
|
return (
|
||||||
<div className={cn('flex items-center space-x-2', className)}>
|
<div className={cn('flex items-center space-x-2', className)}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) =
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuRadioGroup value={task.label}>
|
<DropdownMenuRadioGroup value={task.name}>
|
||||||
{labels.map(label => (
|
{labels.map(label => (
|
||||||
<DropdownMenuRadioItem key={label.value} value={label.value}>
|
<DropdownMenuRadioItem key={label.value} value={label.value}>
|
||||||
{label.label}
|
{label.label}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
||||||
import DataTablePagination from './data-table-pagination'
|
import DataTablePagination from './data-table-pagination'
|
||||||
import { ReloadIcon } from '@radix-ui/react-icons'
|
import { ReloadIcon } from '@radix-ui/react-icons'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||||
// Rank the item
|
// Rank the item
|
||||||
const itemRank = rankItem(row.getValue(columnId), value)
|
const itemRank = rankItem(row.getValue(columnId), value)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue