Files
IOPaint/web_app/src/components/FileManager.tsx
2024-01-01 16:06:21 +08:00

328 lines
9.4 KiB
TypeScript

import {
SyntheticEvent,
useEffect,
useState,
useCallback,
useRef,
FormEvent,
} from "react"
import _ from "lodash"
import PhotoAlbum from "react-photo-album"
import { BarsArrowDownIcon, BarsArrowUpIcon } from "@heroicons/react/24/outline"
import {
MagnifyingGlassIcon,
ViewHorizontalIcon,
ViewGridIcon,
} from "@radix-ui/react-icons"
import { useToggle } from "react-use"
import { useDebounce } from "@uidotdev/usehooks"
import Fuse from "fuse.js"
import { useToast } from "@/components/ui/use-toast"
import { API_ENDPOINT, getMedias } from "@/lib/api"
import { IconButton } from "./ui/button"
import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select"
import { ScrollArea } from "./ui/scroll-area"
import { DialogTrigger } from "@radix-ui/react-dialog"
import { useStore } from "@/lib/states"
import { Filename, SortBy, SortOrder } from "@/lib/types"
import { FolderClosed } from "lucide-react"
import useHotKey from "@/hooks/useHotkey"
interface Photo {
src: string
height: number
width: number
name: string
}
const SORT_BY_NAME = "Name"
const SORT_BY_CREATED_TIME = "Created time"
const SORT_BY_MODIFIED_TIME = "Modified time"
const IMAGE_TAB = "input"
const OUTPUT_TAB = "output"
const SortByMap = {
[SortBy.NAME]: SORT_BY_NAME,
[SortBy.CTIME]: SORT_BY_CREATED_TIME,
[SortBy.MTIME]: SORT_BY_MODIFIED_TIME,
}
interface Props {
onPhotoClick(tab: string, filename: string): void
photoWidth: number
}
export default function FileManager(props: Props) {
const { onPhotoClick, photoWidth } = props
const [open, toggleOpen] = useToggle(false)
const [fileManagerState, updateFileManagerState] = useStore((state) => [
state.fileManagerState,
state.updateFileManagerState,
])
const { toast } = useToast()
const [scrollTop, setScrollTop] = useState(0)
const [closeScrollTop, setCloseScrollTop] = useState(0)
const ref = useRef(null)
const debouncedSearchText = useDebounce(fileManagerState.searchText, 300)
const [tab, setTab] = useState(IMAGE_TAB)
const [photos, setPhotos] = useState<Photo[]>([])
const [photoIndex, setPhotoIndex] = useState(0)
useHotKey("f", () => {
toggleOpen()
})
useHotKey(
"left",
() => {
let newIndex = photoIndex
if (photoIndex > 0) {
newIndex = photoIndex - 1
}
setPhotoIndex(newIndex)
onPhotoClick(tab, photos[newIndex].name)
},
[photoIndex, photos]
)
useHotKey(
"right",
() => {
let newIndex = photoIndex
if (photoIndex < photos.length - 1) {
newIndex = photoIndex + 1
}
setPhotoIndex(newIndex)
onPhotoClick(tab, photos[newIndex].name)
},
[photoIndex, photos]
)
useEffect(() => {
if (!open) {
setCloseScrollTop(scrollTop)
}
}, [open, scrollTop])
const onRefChange = useCallback(
(node: HTMLDivElement) => {
if (node !== null) {
if (open) {
setTimeout(() => {
// TODO: without timeout, scrollTo not work, why?
node.scrollTo({ top: closeScrollTop, left: 0 })
}, 100)
}
}
},
[open, closeScrollTop]
)
useEffect(() => {
if (!open) {
return
}
const fetchData = async () => {
try {
const filenames = await getMedias(tab)
let filteredFilenames = filenames
if (debouncedSearchText) {
const fuse = new Fuse(filteredFilenames, {
keys: ["name"],
})
const items = fuse.search(debouncedSearchText)
filteredFilenames = items.map(
(item) => filteredFilenames[item.refIndex]
)
}
filteredFilenames = _.orderBy(
filteredFilenames,
fileManagerState.sortBy,
fileManagerState.sortOrder
)
const newPhotos = filteredFilenames.map((filename: Filename) => {
const width = photoWidth
const height = filename.height * (width / filename.width)
const src = `${API_ENDPOINT}/media_thumbnail_file?tab=${tab}&filename=${encodeURIComponent(
filename.name
)}&width=${Math.ceil(width)}&height=${Math.ceil(height)}`
return { src, height, width, name: filename.name }
})
setPhotos(newPhotos)
} catch (e: any) {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: e.message ? e.message : e.toString(),
})
}
}
fetchData()
}, [tab, debouncedSearchText, fileManagerState, photoWidth, open])
const onScroll = (event: SyntheticEvent) => {
setScrollTop(event.currentTarget.scrollTop)
}
const onClick = ({ index }: { index: number }) => {
toggleOpen()
setPhotoIndex(index)
onPhotoClick(tab, photos[index].name)
}
const renderTitle = () => {
return (
<div className="flex justify-start items-center gap-[12px]">
<div>{`Images (${photos.length})`}</div>
<div className="flex">
<IconButton
tooltip="Rows layout"
onClick={() => {
updateFileManagerState({ layout: "rows" })
}}
>
<ViewHorizontalIcon
className={fileManagerState.layout !== "rows" ? "opacity-50" : ""}
/>
</IconButton>
<IconButton
tooltip="Grid layout"
onClick={() => {
updateFileManagerState({ layout: "masonry" })
}}
>
<ViewGridIcon
className={
fileManagerState.layout !== "masonry" ? "opacity-50" : ""
}
/>
</IconButton>
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={toggleOpen}>
<DialogTrigger asChild>
<IconButton tooltip="File Manager">
<FolderClosed />
</IconButton>
</DialogTrigger>
<DialogContent className="h-4/5 max-w-6xl">
<DialogTitle>{renderTitle()}</DialogTitle>
<div className="flex justify-between gap-8 items-center">
<div className="flex relative justify-start items-center">
<MagnifyingGlassIcon className="absolute left-[8px]" />
<Input
ref={ref}
value={fileManagerState.searchText}
className="w-[250px] pl-[30px]"
tabIndex={-1}
onInput={(evt: FormEvent<HTMLInputElement>) => {
evt.preventDefault()
evt.stopPropagation()
const target = evt.target as HTMLInputElement
updateFileManagerState({ searchText: target.value })
}}
placeholder="Search by file name"
/>
</div>
<Tabs defaultValue={tab} onValueChange={(val) => setTab(val)}>
<TabsList aria-label="Manage your account">
<TabsTrigger value={IMAGE_TAB}>Image Directory</TabsTrigger>
<TabsTrigger value={OUTPUT_TAB}>Output Directory</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<div className="flex gap-1">
<Select
value={SortByMap[fileManagerState.sortBy]}
onValueChange={(val) => {
switch (val) {
case SORT_BY_NAME:
updateFileManagerState({ sortBy: SortBy.NAME })
break
case SORT_BY_CREATED_TIME:
updateFileManagerState({ sortBy: SortBy.CTIME })
break
case SORT_BY_MODIFIED_TIME:
updateFileManagerState({ sortBy: SortBy.MTIME })
break
default:
break
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(SortByMap).map((val) => {
return (
<SelectItem value={val} key={val}>
{val}
</SelectItem>
)
})}
</SelectContent>
</Select>
{fileManagerState.sortOrder === SortOrder.DESCENDING ? (
<IconButton
tooltip="Descending Order"
onClick={() => {
updateFileManagerState({ sortOrder: SortOrder.ASCENDING })
}}
>
<BarsArrowDownIcon />
</IconButton>
) : (
<IconButton
tooltip="Ascending Order"
onClick={() => {
updateFileManagerState({ sortOrder: SortOrder.DESCENDING })
}}
>
<BarsArrowUpIcon />
</IconButton>
)}
</div>
</div>
</div>
<ScrollArea
className="w-full h-full rounded-md"
onScroll={onScroll}
ref={onRefChange}
>
<PhotoAlbum
layout={fileManagerState.layout}
photos={photos}
spacing={12}
padding={0}
onClick={onClick}
/>
</ScrollArea>
</DialogContent>
</Dialog>
)
}