Spaces:
Configuration error
Configuration error
import React, { | |
SyntheticEvent, | |
useEffect, | |
useState, | |
useCallback, | |
useRef, | |
FormEvent, | |
} from 'react' | |
import _ from 'lodash' | |
import * as Tabs from '@radix-ui/react-tabs' | |
import { useRecoilState, useSetRecoilState } from 'recoil' | |
import PhotoAlbum from 'react-photo-album' | |
import { BarsArrowDownIcon, BarsArrowUpIcon } from '@heroicons/react/24/outline' | |
import { | |
MagnifyingGlassIcon, | |
ViewHorizontalIcon, | |
ViewGridIcon, | |
} from '@radix-ui/react-icons' | |
import { useDebounce } from 'react-use' | |
import { Id, Index, IndexSearchResult } from 'flexsearch' | |
import * as ScrollArea from '@radix-ui/react-scroll-area' | |
import Modal from '../shared/Modal' | |
import Flex from '../shared/Layout' | |
import { | |
fileManagerLayout, | |
fileManagerSearchText, | |
fileManagerSortBy, | |
fileManagerSortOrder, | |
SortBy, | |
SortOrder, | |
toastState, | |
} from '../../store/Atoms' | |
import { getMedias } from '../../adapters/inpainting' | |
import Selector from '../shared/Selector' | |
import Button from '../shared/Button' | |
import TextInput from '../shared/Input' | |
interface Photo { | |
src: string | |
height: number | |
width: number | |
name: string | |
} | |
interface Filename { | |
name: string | |
height: number | |
width: number | |
ctime: number | |
mtime: number | |
} | |
const SORT_BY_NAME = 'Name' | |
const SORT_BY_CREATED_TIME = 'Created time' | |
const SORT_BY_MODIFIED_TIME = 'Modified time' | |
const IMAGE_TAB = 'image' | |
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 { | |
show: boolean | |
onClose: () => void | |
onPhotoClick(tab: string, filename: string): void | |
photoWidth: number | |
} | |
export default function FileManager(props: Props) { | |
const { show, onClose, onPhotoClick, photoWidth } = props | |
const [scrollTop, setScrollTop] = useState(0) | |
const [closeScrollTop, setCloseScrollTop] = useState(0) | |
const setToastState = useSetRecoilState(toastState) | |
const [sortBy, setSortBy] = useRecoilState<SortBy>(fileManagerSortBy) | |
const [sortOrder, setSortOrder] = useRecoilState(fileManagerSortOrder) | |
const [layout, setLayout] = useRecoilState(fileManagerLayout) | |
const [debouncedSearchText, setDebouncedSearchText] = useRecoilState( | |
fileManagerSearchText | |
) | |
const ref = useRef(null) | |
const [searchText, setSearchText] = useState(debouncedSearchText) | |
const [tab, setTab] = useState(IMAGE_TAB) | |
const [photos, setPhotos] = useState<Photo[]>([]) | |
const [, cancel] = useDebounce( | |
() => { | |
setDebouncedSearchText(searchText) | |
}, | |
500, | |
[searchText] | |
) | |
useEffect(() => { | |
if (!show) { | |
setCloseScrollTop(scrollTop) | |
} | |
}, [show, scrollTop]) | |
const onRefChange = useCallback( | |
(node: HTMLDivElement) => { | |
if (node !== null) { | |
if (show) { | |
setTimeout(() => { | |
// TODO: without timeout, scrollTo not work, why? | |
node.scrollTo({ top: closeScrollTop, left: 0 }) | |
}, 100) | |
} | |
} | |
}, | |
[show, closeScrollTop] | |
) | |
useEffect(() => { | |
if (!show) { | |
return | |
} | |
const fetchData = async () => { | |
try { | |
const filenames = await getMedias(tab) | |
let filteredFilenames = filenames | |
if (debouncedSearchText) { | |
const index = new Index() | |
filenames.forEach((filename: Filename, id: number) => | |
index.add(id, filename.name) | |
) | |
const results: IndexSearchResult = index.search(debouncedSearchText) | |
filteredFilenames = results.map((id: Id) => filenames[id as number]) | |
} | |
filteredFilenames = _.orderBy(filteredFilenames, sortBy, sortOrder) | |
const newPhotos = filteredFilenames.map((filename: Filename) => { | |
const width = photoWidth | |
const height = filename.height * (width / filename.width) | |
const src = `/media_thumbnail/${tab}/${filename.name}?width=${width}&height=${height}` | |
return { src, height, width, name: filename.name } | |
}) | |
setPhotos(newPhotos) | |
} catch (e: any) { | |
setToastState({ | |
open: true, | |
desc: e.message ? e.message : e.toString(), | |
state: 'error', | |
duration: 2000, | |
}) | |
} | |
} | |
fetchData() | |
}, [ | |
setToastState, | |
tab, | |
debouncedSearchText, | |
sortBy, | |
sortOrder, | |
photoWidth, | |
show, | |
]) | |
const onScroll = (event: SyntheticEvent) => { | |
setScrollTop(event.currentTarget.scrollTop) | |
} | |
const onClick = ({ index }: { index: number }) => { | |
onPhotoClick(tab, photos[index].name) | |
} | |
const renderTitle = () => { | |
return ( | |
<Flex | |
style={{ justifyContent: 'flex-start', alignItems: 'center', gap: 12 }} | |
> | |
<div>{`Images (${photos.length})`}</div> | |
<Flex> | |
<Button | |
icon={<ViewHorizontalIcon />} | |
toolTip="Rows layout" | |
onClick={() => { | |
setLayout('rows') | |
}} | |
className={layout !== 'rows' ? 'sort-btn-inactive' : ''} | |
/> | |
<Button | |
icon={<ViewGridIcon />} | |
toolTip="Grid layout" | |
onClick={() => { | |
setLayout('masonry') | |
}} | |
className={layout !== 'masonry' ? 'sort-btn-inactive' : ''} | |
/> | |
</Flex> | |
</Flex> | |
) | |
} | |
return ( | |
<Modal | |
onClose={onClose} | |
// TODO:layout switch 放到标题中 | |
title={renderTitle()} | |
className="file-manager-modal" | |
show={show} | |
> | |
<Flex style={{ justifyContent: 'space-between', gap: 8 }}> | |
<Tabs.Root | |
className="TabsRoot" | |
defaultValue={tab} | |
onValueChange={val => setTab(val)} | |
> | |
<Tabs.List className="TabsList" aria-label="Manage your account"> | |
<Tabs.Trigger className="TabsTrigger" value={IMAGE_TAB}> | |
Image Directory | |
</Tabs.Trigger> | |
<Tabs.Trigger className="TabsTrigger" value={OUTPUT_TAB}> | |
Output Directory | |
</Tabs.Trigger> | |
</Tabs.List> | |
</Tabs.Root> | |
<Flex style={{ gap: 8 }}> | |
<Flex | |
style={{ | |
position: 'relative', | |
justifyContent: 'start', | |
}} | |
> | |
<MagnifyingGlassIcon style={{ position: 'absolute', left: 8 }} /> | |
<TextInput | |
ref={ref} | |
value={searchText} | |
className="file-search-input" | |
tabIndex={-1} | |
onInput={(evt: FormEvent<HTMLInputElement>) => { | |
evt.preventDefault() | |
evt.stopPropagation() | |
const target = evt.target as HTMLInputElement | |
setSearchText(target.value) | |
}} | |
placeholder="Search by file name" | |
/> | |
</Flex> | |
<Flex style={{ gap: 8 }}> | |
<Selector | |
width={140} | |
value={SortByMap[sortBy]} | |
options={Object.values(SortByMap)} | |
onChange={val => { | |
switch (val) { | |
case SORT_BY_NAME: | |
setSortBy(SortBy.NAME) | |
break | |
case SORT_BY_CREATED_TIME: | |
setSortBy(SortBy.CTIME) | |
break | |
case SORT_BY_MODIFIED_TIME: | |
setSortBy(SortBy.MTIME) | |
break | |
default: | |
break | |
} | |
}} | |
chevronDirection="down" | |
/> | |
<Button | |
icon={<BarsArrowDownIcon />} | |
toolTip="Descending order" | |
onClick={() => { | |
setSortOrder(SortOrder.DESCENDING) | |
}} | |
className={ | |
sortOrder !== SortOrder.DESCENDING ? 'sort-btn-inactive' : '' | |
} | |
/> | |
<Button | |
icon={<BarsArrowUpIcon />} | |
toolTip="Ascending order" | |
onClick={() => { | |
setSortOrder(SortOrder.ASCENDING) | |
}} | |
className={ | |
sortOrder !== SortOrder.ASCENDING ? 'sort-btn-inactive' : '' | |
} | |
/> | |
</Flex> | |
</Flex> | |
</Flex> | |
<ScrollArea.Root className="ScrollAreaRoot"> | |
<ScrollArea.Viewport | |
className="ScrollAreaViewport" | |
onScroll={onScroll} | |
ref={onRefChange} | |
> | |
<PhotoAlbum | |
layout={layout} | |
photos={photos} | |
spacing={8} | |
padding={0} | |
onClick={onClick} | |
/> | |
</ScrollArea.Viewport> | |
<ScrollArea.Scrollbar | |
className="ScrollAreaScrollbar" | |
orientation="vertical" | |
> | |
<ScrollArea.Thumb className="ScrollAreaThumb" /> | |
</ScrollArea.Scrollbar> | |
{/* <ScrollArea.Scrollbar | |
className="ScrollAreaScrollbar" | |
orientation="horizontal" | |
> | |
<ScrollArea.Thumb className="ScrollAreaThumb" /> | |
</ScrollArea.Scrollbar> */} | |
<ScrollArea.Corner className="ScrollAreaCorner" /> | |
</ScrollArea.Root> | |
</Modal> | |
) | |
} | |