onedrive/components/FileListing.tsx

443 lines
14 KiB
TypeScript
Raw Normal View History

2022-02-14 11:33:19 +00:00
import type { OdFileObject, OdFolderChildren, OdFolderObject } from '../types'
import { ParsedUrlQuery } from 'querystring'
import { FC, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from 'react'
2021-06-23 20:27:51 +00:00
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import toast, { Toaster } from 'react-hot-toast'
2021-06-30 12:09:37 +00:00
import emojiRegex from 'emoji-regex'
2021-06-23 20:27:51 +00:00
2021-06-23 22:51:23 +00:00
import dynamic from 'next/dynamic'
2022-02-05 09:25:46 +00:00
import { useRouter } from 'next/router'
2022-02-05 22:55:38 +00:00
import { useTranslation } from 'next-i18next'
2022-02-05 09:25:46 +00:00
import useLocalStorage from '../utils/useLocalStorage'
import { getPreviewType, preview } from '../utils/getPreviewType'
import { useProtectedSWRInfinite } from '../utils/fetchWithSWR'
import { getExtension, getRawExtension, getFileIcon } from '../utils/getFileIcon'
2022-02-14 11:33:19 +00:00
import { getStoredToken } from '../utils/protectedRouteHandler'
import {
DownloadingToast,
downloadMultipleFiles,
downloadTreelikeMultipleFiles,
traverseFolder,
} from './MultiFileDownloader'
2022-02-08 16:02:29 +00:00
import { layouts } from './SwitchLayout'
import Loading, { LoadingIcon } from './Loading'
2021-06-25 14:15:00 +00:00
import FourOhFour from './FourOhFour'
2021-08-30 00:35:52 +00:00
import Auth from './Auth'
2021-06-25 14:15:00 +00:00
import TextPreview from './previews/TextPreview'
2021-06-25 15:08:04 +00:00
import MarkdownPreview from './previews/MarkdownPreview'
2021-06-25 15:13:31 +00:00
import CodePreview from './previews/CodePreview'
2021-06-29 15:20:35 +00:00
import OfficePreview from './previews/OfficePreview'
import AudioPreview from './previews/AudioPreview'
import VideoPreview from './previews/VideoPreview'
2021-12-28 18:13:47 +00:00
import PDFPreview from './previews/PDFPreview'
import URLPreview from './previews/URLPreview'
2022-02-05 13:16:57 +00:00
import ImagePreview from './previews/ImagePreview'
import DefaultPreview from './previews/DefaultPreview'
2022-02-05 13:16:57 +00:00
import { PreviewContainer } from './previews/Containers'
2022-02-05 13:16:57 +00:00
import FolderListLayout from './FolderListLayout'
2022-02-05 09:25:46 +00:00
import FolderGridLayout from './FolderGridLayout'
2022-02-05 09:25:46 +00:00
// Disabling SSR for some previews
const EPUBPreview = dynamic(() => import('./previews/EPUBPreview'), {
ssr: false,
})
2021-06-23 20:27:51 +00:00
2021-06-24 13:54:59 +00:00
/**
* Convert url query into path string
*
* @param query Url query property
* @returns Path string
*/
2021-06-23 20:27:51 +00:00
const queryToPath = (query?: ParsedUrlQuery) => {
if (query) {
const { path } = query
if (!path) return '/'
2021-08-15 19:38:51 +00:00
if (typeof path === 'string') return `/${encodeURIComponent(path)}`
return `/${path.map(p => encodeURIComponent(p)).join('/')}`
2021-06-22 23:15:19 +00:00
}
2021-06-23 20:27:51 +00:00
return '/'
2021-06-22 23:15:19 +00:00
}
2022-02-05 13:16:57 +00:00
// Render the icon of a folder child (may be a file or a folder), use emoji if the name of the child contains emoji
const renderEmoji = (name: string) => {
const emoji = emojiRegex().exec(name)
return { render: emoji && !emoji.index, emoji }
}
const formatChildName = (name: string) => {
2022-02-05 13:16:57 +00:00
const { render, emoji } = renderEmoji(name)
return render ? name.replace(emoji ? emoji[0] : '', '').trim() : name
}
export const ChildName: FC<{ name: string; folder?: boolean }> = ({ name, folder }) => {
2022-02-12 08:29:18 +00:00
const original = formatChildName(name)
const extension = folder ? '' : getRawExtension(original)
const prename = folder ? original : original.substring(0, original.length - extension.length)
return (
<span className="truncate before:float-right before:content-[attr(data-tail)]" data-tail={extension}>
2022-02-12 08:29:18 +00:00
{prename}
</span>
)
}
2022-02-05 13:16:57 +00:00
export const ChildIcon: FC<{ child: OdFolderChildren }> = ({ child }) => {
const { render, emoji } = renderEmoji(child.name)
return render ? (
<span>{emoji ? emoji[0] : '📁'}</span>
) : (
<FontAwesomeIcon icon={child.file ? getFileIcon(child.name, { video: Boolean(child.video) }) : ['far', 'folder']} />
)
}
2022-02-05 09:25:46 +00:00
export const Checkbox: FC<{
checked: 0 | 1 | 2
onChange: () => void
title: string
indeterminate?: boolean
2021-11-18 22:36:23 +00:00
}> = ({ checked, onChange, title, indeterminate }) => {
const ref = useRef<HTMLInputElement>(null)
2021-11-18 22:36:23 +00:00
useEffect(() => {
if (ref.current) {
ref.current.checked = Boolean(checked)
if (indeterminate) {
ref.current.indeterminate = checked == 1
}
}
}, [ref, checked, indeterminate])
const handleClick: MouseEventHandler = e => {
if (ref.current) {
if (e.target === ref.current) {
e.stopPropagation()
} else {
ref.current.click()
}
}
2021-11-27 08:22:44 +00:00
}
2021-11-18 22:36:23 +00:00
return (
<span
title={title}
className="inline-flex cursor-pointer items-center rounded p-1.5 hover:bg-gray-300 dark:hover:bg-gray-600"
2021-11-18 22:36:23 +00:00
onClick={handleClick}
>
<input
className="form-check-input cursor-pointer"
type="checkbox"
value={checked ? '1' : ''}
ref={ref}
aria-label={title}
onChange={onChange}
/>
</span>
)
}
2022-02-14 11:33:19 +00:00
export const Downloading: FC<{ title: string; style: string }> = ({ title, style }) => {
return (
2022-02-14 11:33:19 +00:00
<span title={title} className={`${style} rounded`} role="status">
<LoadingIcon
// Use fontawesome far theme via class `svg-inline--fa` to get style `vertical-align` only
// for consistent icon alignment, as class `align-*` cannot satisfy it
className="svg-inline--fa inline-block h-4 w-4 animate-spin"
/>
</span>
)
}
2022-01-06 11:25:10 +00:00
const FileListing: FC<{ query?: ParsedUrlQuery }> = ({ query }) => {
2021-11-27 08:22:44 +00:00
const [selected, setSelected] = useState<{ [key: string]: boolean }>({})
const [totalSelected, setTotalSelected] = useState<0 | 1 | 2>(0)
const [totalGenerating, setTotalGenerating] = useState<boolean>(false)
const [folderGenerating, setFolderGenerating] = useState<{
[key: string]: boolean
}>({})
2021-06-23 22:51:23 +00:00
const router = useRouter()
2022-02-14 11:33:19 +00:00
const hashedToken = getStoredToken(router.asPath)
2022-02-05 09:25:46 +00:00
const [layout, _] = useLocalStorage('preferredLayout', layouts[0])
2021-06-23 22:51:23 +00:00
2022-02-05 22:55:38 +00:00
const { t } = useTranslation()
2021-06-23 20:27:51 +00:00
const path = queryToPath(query)
const { data, error, size, setSize } = useProtectedSWRInfinite(path)
2021-06-25 14:15:00 +00:00
if (error) {
// If error includes 403 which means the user has not completed initial setup, redirect to OAuth page
2022-01-10 08:36:45 +00:00
if (error.status === 403) {
router.push('/onedrive-vercel-index-oauth/step-1')
2022-02-05 09:25:46 +00:00
return <div />
}
2021-06-25 14:15:00 +00:00
return (
<PreviewContainer>
2022-01-10 08:36:45 +00:00
{error.status === 401 ? <Auth redirect={path} /> : <FourOhFour errorMsg={JSON.stringify(error.message)} />}
</PreviewContainer>
2021-06-25 14:15:00 +00:00
)
}
2021-06-23 22:51:23 +00:00
if (!data) {
return (
<PreviewContainer>
2022-02-05 22:55:38 +00:00
<Loading loadingText={t('Loading ...')} />
</PreviewContainer>
)
2021-06-23 22:51:23 +00:00
}
const responses: any[] = data ? [].concat(...data) : []
const isLoadingInitialData = !data && !error
const isLoadingMore = isLoadingInitialData || (size > 0 && data && typeof data[size - 1] === 'undefined')
const isEmpty = data?.[0]?.length === 0
const isReachingEnd = isEmpty || (data && typeof data[data.length - 1]?.next === 'undefined')
const onlyOnePage = data && typeof data[0].next === 'undefined'
2021-06-30 11:53:17 +00:00
if ('folder' in responses[0]) {
// Expand list of API returns into flattened file data
2022-02-05 09:25:46 +00:00
const folderChildren = [].concat(...responses.map(r => r.folder.value)) as OdFolderObject['value']
2022-02-05 07:46:11 +00:00
// Find README.md file to render
2022-02-05 09:25:46 +00:00
const readmeFile = folderChildren.find(c => c.name.toLowerCase() === 'readme.md')
2021-06-23 22:51:23 +00:00
2021-11-19 13:31:07 +00:00
// Filtered file list helper
2022-02-05 09:25:46 +00:00
const getFiles = () => folderChildren.filter(c => !c.folder && c.name !== '.password')
2021-11-19 13:31:07 +00:00
2021-11-18 22:36:23 +00:00
// File selection
2021-11-27 08:22:44 +00:00
const genTotalSelected = (selected: { [key: string]: boolean }) => {
const selectInfo = getFiles().map(c => Boolean(selected[c.id]))
2021-11-18 22:36:23 +00:00
const [hasT, hasF] = [selectInfo.some(i => i), selectInfo.some(i => !i)]
return hasT && hasF ? 1 : !hasF ? 2 : 0
2021-11-18 22:36:23 +00:00
}
2021-11-18 22:36:23 +00:00
const toggleItemSelected = (id: string) => {
let val: SetStateAction<{ [key: string]: boolean }>
2021-11-18 22:36:23 +00:00
if (selected[id]) {
2021-11-27 08:22:44 +00:00
val = { ...selected }
2021-11-18 22:36:23 +00:00
delete val[id]
} else {
2021-11-27 08:22:44 +00:00
val = { ...selected, [id]: true }
2021-11-18 22:36:23 +00:00
}
setSelected(val)
setTotalSelected(genTotalSelected(val))
}
2021-11-18 22:36:23 +00:00
const toggleTotalSelected = () => {
if (genTotalSelected(selected) == 2) {
setSelected({})
setTotalSelected(0)
} else {
setSelected(Object.fromEntries(getFiles().map(c => [c.id, true])))
2021-11-18 22:36:23 +00:00
setTotalSelected(2)
}
}
// Selected file download
const handleSelectedDownload = () => {
const folderName = path.substring(path.lastIndexOf('/') + 1)
const folder = folderName ? decodeURIComponent(folderName) : undefined
2021-11-19 13:31:07 +00:00
const files = getFiles()
.filter(c => selected[c.id])
2022-02-14 11:33:19 +00:00
.map(c => ({
name: c.name,
url: `/api/raw/?path=${path}/${encodeURIComponent(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`,
2022-02-14 11:33:19 +00:00
}))
if (files.length == 1) {
const el = document.createElement('a')
el.style.display = 'none'
document.body.appendChild(el)
el.href = files[0].url
el.click()
el.remove()
} else if (files.length > 1) {
setTotalGenerating(true)
const toastId = toast.loading(<DownloadingToast router={router} />)
downloadMultipleFiles({ toastId, router, files, folder })
.then(() => {
setTotalGenerating(false)
2022-02-05 22:55:38 +00:00
toast.success(t('Finished downloading selected files.'), {
id: toastId,
})
})
.catch(() => {
setTotalGenerating(false)
2022-02-05 22:55:38 +00:00
toast.error(t('Failed to download selected files.'), { id: toastId })
})
}
}
2022-09-20 08:42:20 +00:00
// Get selected file permalink
const handleSelectedPermalink = (baseUrl: string) => {
return getFiles()
.filter(c => selected[c.id])
.map(
c =>
`${baseUrl}/api/raw/?path=${path}/${encodeURIComponent(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`
)
2022-11-05 11:16:31 +00:00
.join('\n')
2022-09-20 08:42:20 +00:00
}
2021-11-22 18:13:11 +00:00
// Folder recursive download
2021-11-28 11:42:16 +00:00
const handleFolderDownload = (path: string, id: string, name?: string) => () => {
2021-11-27 10:15:52 +00:00
const files = (async function* () {
2022-01-24 14:00:08 +00:00
for await (const { meta: c, path: p, isFolder, error } of traverseFolder(path)) {
if (error) {
2022-02-05 22:55:38 +00:00
toast.error(
t('Failed to download folder {{path}}: {{status}} {{message}} Skipped it to continue.', {
path: p,
status: error.status,
2022-02-08 15:30:12 +00:00
message: error.message,
2022-02-05 22:55:38 +00:00
})
)
2022-01-24 14:00:08 +00:00
continue
}
2022-02-14 11:33:19 +00:00
const hashedTokenForPath = getStoredToken(p)
2021-11-27 10:15:52 +00:00
yield {
name: c?.name,
2022-02-14 11:33:19 +00:00
url: `/api/raw/?path=${p}${hashedTokenForPath ? `&odpt=${hashedTokenForPath}` : ''}`,
2021-11-27 10:15:52 +00:00
path: p,
isFolder,
}
}
})()
2021-11-28 13:25:38 +00:00
setFolderGenerating({ ...folderGenerating, [id]: true })
const toastId = toast.loading(<DownloadingToast router={router} />)
downloadTreelikeMultipleFiles({
toastId,
router,
files,
basePath: path,
folder: name,
})
.then(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
2022-02-05 22:55:38 +00:00
toast.success(t('Finished downloading folder.'), { id: toastId })
})
.catch(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
2022-02-05 22:55:38 +00:00
toast.error(t('Failed to download folder.'), { id: toastId })
})
2021-11-22 18:13:11 +00:00
}
2022-02-05 09:25:46 +00:00
// Folder layout component props
const folderProps = {
toast,
path,
folderChildren,
selected,
toggleItemSelected,
totalSelected,
toggleTotalSelected,
totalGenerating,
handleSelectedDownload,
folderGenerating,
2022-09-20 08:42:20 +00:00
handleSelectedPermalink,
2022-02-05 09:25:46 +00:00
handleFolderDownload,
}
2021-06-23 22:51:23 +00:00
return (
<>
2022-02-05 07:46:11 +00:00
<Toaster />
2022-02-05 09:25:46 +00:00
{layout.name === 'Grid' ? <FolderGridLayout {...folderProps} /> : <FolderListLayout {...folderProps} />}
2021-08-29 21:31:42 +00:00
2022-02-05 09:25:46 +00:00
{!onlyOnePage && (
<div className="rounded-b bg-white dark:bg-gray-900 dark:text-gray-100">
<div className="border-b border-gray-200 p-3 text-center font-mono text-sm text-gray-400 dark:border-gray-700">
2022-02-06 12:09:59 +00:00
{t('- showing {{count}} page(s) ', {
count: size,
2022-02-08 15:30:12 +00:00
totalFileNum: isLoadingMore ? '...' : folderChildren.length,
2022-02-06 12:09:59 +00:00
}) +
(isLoadingMore
? t('of {{count}} file(s) -', { count: folderChildren.length, context: 'loading' })
: t('of {{count}} file(s) -', { count: folderChildren.length, context: 'loaded' }))}
2022-02-05 09:25:46 +00:00
</div>
<button
className={`flex w-full items-center justify-center space-x-2 p-3 disabled:cursor-not-allowed ${
isLoadingMore || isReachingEnd ? 'opacity-60' : 'hover:bg-gray-100 dark:hover:bg-gray-850'
}`}
onClick={() => setSize(size + 1)}
disabled={isLoadingMore || isReachingEnd}
>
{isLoadingMore ? (
<>
<LoadingIcon className="inline-block h-4 w-4 animate-spin" />
2022-02-05 22:55:38 +00:00
<span>{t('Loading ...')}</span>{' '}
2022-02-05 09:25:46 +00:00
</>
) : isReachingEnd ? (
2022-02-05 22:55:38 +00:00
<span>{t('No more files')}</span>
) : (
2022-02-05 09:25:46 +00:00
<>
2022-02-05 22:55:38 +00:00
<span>{t('Load more')}</span>
2022-02-05 09:25:46 +00:00
<FontAwesomeIcon icon="chevron-circle-down" />
</>
2021-11-18 23:51:28 +00:00
)}
2022-02-05 09:25:46 +00:00
</button>
</div>
)}
2022-02-05 07:46:11 +00:00
{readmeFile && (
<div className="mt-4">
<MarkdownPreview file={readmeFile} path={path} standalone={false} proxy={true} />
2021-06-30 11:53:17 +00:00
</div>
)}
</>
2021-06-23 22:51:23 +00:00
)
}
if ('file' in responses[0] && responses.length === 1) {
const file = responses[0].file as OdFileObject
2022-02-11 07:36:42 +00:00
const previewType = getPreviewType(getExtension(file.name), { video: Boolean(file.video) })
if (previewType) {
switch (previewType) {
2021-06-23 22:51:23 +00:00
case preview.image:
return <ImagePreview file={file} />
2021-06-23 22:51:23 +00:00
2021-06-24 13:54:59 +00:00
case preview.text:
return <TextPreview file={file} />
2021-06-24 13:54:59 +00:00
case preview.code:
return <CodePreview file={file} />
2021-06-24 13:54:59 +00:00
case preview.markdown:
return <MarkdownPreview file={file} path={path} />
2021-06-24 13:54:59 +00:00
case preview.video:
return <VideoPreview file={file} />
2021-06-24 23:47:57 +00:00
2021-06-24 13:54:59 +00:00
case preview.audio:
return <AudioPreview file={file} />
2021-06-24 13:54:59 +00:00
case preview.pdf:
return <PDFPreview file={file} />
2021-06-24 13:54:59 +00:00
2021-06-29 15:20:35 +00:00
case preview.office:
return <OfficePreview file={file} />
2021-06-29 15:20:35 +00:00
2021-10-05 21:28:40 +00:00
case preview.epub:
return <EPUBPreview file={file} />
case preview.url:
return <URLPreview file={file} />
2021-06-23 22:51:23 +00:00
default:
return <DefaultPreview file={file} />
2021-06-23 22:51:23 +00:00
}
} else {
return <DefaultPreview file={file} />
2021-06-23 22:51:23 +00:00
}
}
2021-06-25 14:15:00 +00:00
return (
<PreviewContainer>
<FourOhFour errorMsg={t('Cannot preview {{path}}', { path })} />
</PreviewContainer>
2021-06-25 14:15:00 +00:00
)
}
export default FileListing