onedrive/components/FileListing.tsx

545 lines
19 KiB
TypeScript
Raw Normal View History

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'
import { useClipboard } from 'use-clipboard-copy'
2021-06-23 20:27:51 +00:00
import { ParsedUrlQuery } from 'querystring'
2021-11-18 22:36:23 +00:00
import { FunctionComponent, useEffect, useRef, useState } from 'react'
2021-06-24 13:54:59 +00:00
import { ImageDecorator } from 'react-viewer/lib/ViewerProps'
2021-06-23 22:51:23 +00:00
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { getExtension, getFileIcon, hasKey } from '../utils/getFileIcon'
import { extensions, preview } from '../utils/getPreviewType'
import { getBaseUrl, saveFiles, useProtectedSWRInfinite } from '../utils/tools'
2021-06-24 13:54:59 +00:00
import { VideoPreview } from './previews/VideoPreview'
import { AudioPreview } from './previews/AudioPreview'
import Loading 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'
2021-06-29 20:00:05 +00:00
import DownloadBtn from './DownloadBtn'
// Disabling SSR for some previews (image gallery view, and PDF view)
2021-06-23 22:51:23 +00:00
const ReactViewer = dynamic(() => import('react-viewer'), { ssr: false })
const PDFPreview = dynamic(() => import('./previews/PDFPreview'), { ssr: false })
2021-10-05 21:28:40 +00:00
const EPUBPreview = dynamic(() => import('./previews/EPUBPreview'), { ssr: false })
2021-06-23 20:27:51 +00:00
2021-06-23 22:51:23 +00:00
/**
* Convert raw bits file/folder size into a human readable string
*
* @param size File or folder size, in raw bits
* @returns Human readable form of the file or folder size
*/
2021-06-22 23:15:19 +00:00
const humanFileSize = (size: number) => {
if (size < 1024) return size + ' B'
const i = Math.floor(Math.log(size) / Math.log(1024))
const num = size / Math.pow(1024, i)
const round = Math.round(num)
const formatted = round < 10 ? num.toFixed(2) : round < 100 ? num.toFixed(1) : round
return `${formatted} ${'KMGTPEZY'[i - 1]}B`
}
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
}
2021-06-23 22:51:23 +00:00
const FileListItem: FunctionComponent<{
fileContent: { id: string; name: string; size: number; file: Object; lastModifiedDateTime: string }
}> = ({ fileContent: c }) => {
2021-06-30 12:09:37 +00:00
const emojiIcon = emojiRegex().exec(c.name)
const renderEmoji = emojiIcon && !emojiIcon.index
2021-06-23 22:51:23 +00:00
return (
<div className="grid items-center grid-cols-10 p-3 space-x-2 cursor-pointer">
<div className="md:col-span-6 flex items-center col-span-10 space-x-2 truncate">
2021-06-23 22:51:23 +00:00
{/* <div>{c.file ? c.file.mimeType : 'folder'}</div> */}
2021-09-04 14:15:09 +00:00
<div className="flex-shrink-0 w-5 text-center">
2021-06-30 12:09:37 +00:00
{renderEmoji ? (
<span>{emojiIcon ? emojiIcon[0] : '📁'}</span>
) : (
<FontAwesomeIcon icon={c.file ? getFileIcon(c.name) : ['far', 'folder']} />
)}
</div>
<div className="truncate">
{renderEmoji ? c.name.replace(emojiIcon ? emojiIcon[0] : '', '').trim() : c.name}
2021-06-23 22:51:23 +00:00
</div>
</div>
2021-09-04 14:15:09 +00:00
<div className="md:block dark:text-gray-500 flex-shrink-0 hidden col-span-3 font-mono text-sm text-gray-700">
2021-08-15 19:13:08 +00:00
{new Date(c.lastModifiedDateTime).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
2021-06-23 22:51:23 +00:00
})}
</div>
2021-09-04 14:15:09 +00:00
<div className="md:block dark:text-gray-500 flex-shrink-0 hidden col-span-1 font-mono text-sm text-gray-700 truncate">
2021-08-23 15:14:08 +00:00
{humanFileSize(c.size)}
</div>
2021-06-23 22:51:23 +00:00
</div>
)
}
2021-11-18 22:36:23 +00:00
const Checkbox: FunctionComponent<{
2021-11-27 08:22:44 +00:00
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)
useEffect(() => {
if (ref.current) {
ref.current.checked = Boolean(checked)
if (indeterminate) {
ref.current.indeterminate = checked == 1
}
}
}, [ref, checked, indeterminate])
2021-11-27 08:22:44 +00:00
const handleClick = () => {
if (ref.current) ref.current.click()
}
2021-11-18 22:36:23 +00:00
return (
<span
title={title}
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
onClick={handleClick}
>
<input
className="form-check-input cursor-pointer"
type="checkbox"
value={checked ? '1' : ''}
ref={ref}
aria-label={title}
onChange={onChange}
/>
</span>
)
}
2021-06-23 20:27:51 +00:00
const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) => {
2021-06-23 22:51:23 +00:00
const [imageViewerVisible, setImageViewerVisibility] = useState(false)
const [activeImageIdx, setActiveImageIdx] = useState(0)
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)
2021-06-23 22:51:23 +00:00
const router = useRouter()
const clipboard = useClipboard()
2021-06-23 22:51:23 +00:00
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) {
return (
2021-09-04 14:15:09 +00:00
<div className="dark:bg-gray-900 p-3 bg-white rounded shadow">
2021-08-30 00:35:52 +00:00
{error.message.includes('401') ? <Auth redirect={path} /> : <FourOhFour errorMsg={error.message} />}
2021-06-25 14:15:00 +00:00
</div>
)
}
2021-06-23 22:51:23 +00:00
if (!data) {
return (
2021-09-04 14:15:09 +00:00
<div className="dark:bg-gray-900 p-3 bg-white rounded shadow">
<Loading loadingText="Loading ..." />
2021-06-22 23:15:19 +00:00
</div>
)
2021-06-23 22:51:23 +00:00
}
2021-06-23 22:51:23 +00:00
const fileIsImage = (fileName: string) => {
const fileExtension = getExtension(fileName)
if (hasKey(extensions, fileExtension)) {
if (extensions[fileExtension] === preview.image) {
return true
}
}
return false
}
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]) {
2021-06-30 11:53:17 +00:00
// Image preview rendering preparations
2021-06-23 22:51:23 +00:00
const imagesInFolder: ImageDecorator[] = []
const imageIndexDict: { [key: string]: number } = {}
let imageIndex = 0
2021-06-30 11:53:17 +00:00
// README rendering preparations
let renderReadme = false
let readmeFile = null
// Expand list of API returns into flattened file data
const children = [].concat(...responses.map(r => r.folder.value))
children.forEach((c: any) => {
2021-06-23 22:51:23 +00:00
if (fileIsImage(c.name)) {
imagesInFolder.push({
src: c['@microsoft.graph.downloadUrl'],
alt: c.name,
downloadUrl: c['@microsoft.graph.downloadUrl'],
})
imageIndexDict[c.id] = imageIndex
imageIndex += 1
}
2021-06-30 11:53:17 +00:00
if (c.name.toLowerCase() === 'readme.md') {
renderReadme = true
readmeFile = c
}
2021-06-23 22:51:23 +00:00
})
2021-11-19 13:31:07 +00:00
// Filtered file list helper
2021-11-27 08:22:44 +00:00
const getFiles = () => children.filter((c: any) => !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: any) => 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)
}
const toggleItemSelected = (id: string) => {
let val
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))
}
const toggleTotalSelected = () => {
if (genTotalSelected(selected) == 2) {
setSelected({})
setTotalSelected(0)
} else {
2021-11-27 08:22:44 +00:00
setSelected(Object.fromEntries(getFiles().map((c: any) => [c.id, true])))
2021-11-18 22:36:23 +00:00
setTotalSelected(2)
}
}
// Selected file download
const handleSelectedDownload = () => {
const folderName = path.substr(path.lastIndexOf('/') + 1)
const folder = folderName ? folderName : undefined
2021-11-19 13:31:07 +00:00
const files = getFiles()
2021-11-18 23:51:28 +00:00
.filter((c: any) => selected[c.id])
.map((c: any) => ({ name: c.name, url: c['@microsoft.graph.downloadUrl'] }))
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)
saveFiles(files, folder).then(() => setTotalGenerating(false))
}
}
2021-06-23 22:51:23 +00:00
return (
2021-09-04 14:15:09 +00:00
<div className="dark:bg-gray-900 dark:text-gray-100 bg-white rounded shadow">
<div className="dark:border-gray-700 grid items-center grid-cols-12 p-3 space-x-2 border-b border-gray-200">
<div className="md:col-span-6 col-span-12 font-bold">Name</div>
2021-09-04 14:15:09 +00:00
<div className="md:block hidden col-span-3 font-bold">Last Modified</div>
<div className="md:block hidden font-bold">Size</div>
<div className="md:block hidden font-bold">Actions</div>
<div className="md:block hidden font-bold">
<div className="md:flex dark:text-gray-400 hidden p-1 text-gray-700">
2021-11-18 22:36:23 +00:00
<Checkbox
checked={totalSelected}
onChange={toggleTotalSelected}
indeterminate={true}
title={"Select files"}
/>
{totalGenerating ? (
<span
title="Downloading selected files, refresh page to cancel"
className="p-2 rounded"
role="status"
>
<svg
// 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="animate-spin w-4 h-4 inline-block svg-inline--fa"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
) : (totalSelected ? (
<span
title="Download selected files"
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
onClick={handleSelectedDownload}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</span>
) : '')}
</div>
</div>
2021-06-23 22:51:23 +00:00
</div>
2021-08-29 21:31:42 +00:00
<Toaster
toastOptions={{
style: {
background: '#316C23',
color: '#ffffff',
},
}}
/>
2021-06-23 22:51:23 +00:00
{imagesInFolder.length !== 0 && (
<ReactViewer
2021-06-30 11:34:08 +00:00
zIndex={99}
2021-06-23 22:51:23 +00:00
visible={imageViewerVisible}
activeIndex={activeImageIdx}
images={imagesInFolder}
drag={false}
rotatable={false}
noClose={true}
scalable={false}
2021-06-29 20:00:05 +00:00
zoomSpeed={0.2}
2021-06-23 22:51:23 +00:00
downloadable={true}
downloadInNewWindow={true}
onMaskClick={() => {
setImageViewerVisibility(false)
}}
customToolbar={toolbars => {
toolbars[0].render = <FontAwesomeIcon icon="plus" />
toolbars[1].render = <FontAwesomeIcon icon="minus" />
toolbars[2].render = <FontAwesomeIcon icon="arrow-left" />
2021-06-29 20:00:05 +00:00
toolbars[3].render = <FontAwesomeIcon icon="undo" />
toolbars[4].render = <FontAwesomeIcon icon="arrow-right" />
toolbars[9].render = <FontAwesomeIcon icon="download" />
return toolbars.concat([
{
key: 'copy',
render: <FontAwesomeIcon icon={['fas', 'copy']} />,
onClick: i => {
clipboard.copy(i.alt ? `${getBaseUrl()}/api?path=${path + '/' + i.alt}&raw=true` : '')
toast.success('Copied image permanent link to clipboard.')
},
},
])
}}
2021-06-23 22:51:23 +00:00
/>
)}
{children.map((c: any) => (
2021-09-04 14:15:09 +00:00
<div className="hover:bg-gray-100 dark:hover:bg-gray-850 grid grid-cols-12" key={c.id}>
<div
className="col-span-10"
onClick={e => {
e.preventDefault()
if (!c.folder && fileIsImage(c.name)) {
setActiveImageIdx(imageIndexDict[c.id])
setImageViewerVisibility(true)
} else {
router.push(`${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`)
}
}}
>
<FileListItem fileContent={c} />
</div>
{c.folder ? (
2021-09-04 14:15:09 +00:00
<div className="md:flex dark:text-gray-400 hidden p-1 text-gray-700">
<span
title="Copy folder permalink"
2021-09-04 14:15:09 +00:00
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
onClick={() => {
clipboard.copy(`${getBaseUrl()}${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`)
2021-08-29 21:31:42 +00:00
toast.success('Copied folder permalink.')
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
</div>
) : (
2021-09-04 14:15:09 +00:00
<div className="md:flex dark:text-gray-400 hidden p-1 text-gray-700">
<span
title="Copy raw file permalink"
2021-09-04 14:15:09 +00:00
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
onClick={() => {
clipboard.copy(`${getBaseUrl()}/api?path=${path === '/' ? '' : path}/${c.name}&raw=true`)
2021-08-29 21:31:42 +00:00
toast.success('Copied raw file permalink.')
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
<a
title="Download file"
2021-09-04 14:15:09 +00:00
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
href={c['@microsoft.graph.downloadUrl']}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</a>
</div>
)}
<div className="md:flex dark:text-gray-400 hidden p-1 text-gray-700">
{c.folder || c.name === '.password' ? '' : (
2021-11-18 23:51:28 +00:00
<Checkbox
checked={selected[c.id] ? 2 : 0}
onChange={() => toggleItemSelected(c.id)}
title="Select file"
/>
)}
</div>
2021-06-23 22:51:23 +00:00
</div>
))}
2021-06-30 11:53:17 +00:00
{!onlyOnePage && (
<div>
<div className="dark:border-gray-700 p-3 font-mono text-sm text-center text-gray-400 border-b border-gray-200">
- showing {size} page{size > 1 ? 's' : ''} of {isLoadingMore ? '...' : children.length} files -
</div>
<button
className={`flex items-center justify-center w-full p-3 space-x-2 ${
isLoadingMore || isReachingEnd ? 'opacity-60' : 'hover:bg-gray-100 dark:hover:bg-gray-850'
}`}
onClick={() => setSize(size + 1)}
disabled={isLoadingMore || isReachingEnd}
>
{isLoadingMore ? (
<>
<span>Loading ...</span>{' '}
<svg
className="animate-spin w-5 h-5 mr-3 -ml-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</>
) : isReachingEnd ? (
<span>No more files</span>
) : (
<>
<span>Load more</span>
<FontAwesomeIcon icon="chevron-circle-down" />
</>
)}
</button>
</div>
)}
2021-06-30 11:53:17 +00:00
{renderReadme && (
2021-09-04 14:15:09 +00:00
<div className="dark:border-gray-700 border-t">
<MarkdownPreview file={readmeFile} path={path} standalone={false} />
2021-06-30 11:53:17 +00:00
</div>
)}
2021-06-22 23:15:19 +00:00
</div>
2021-06-23 22:51:23 +00:00
)
}
if ('file' in responses[0] && responses.length === 1) {
const { file } = responses[0]
const downloadUrl = file['@microsoft.graph.downloadUrl']
const fileName = file.name
2021-06-23 22:51:23 +00:00
const fileExtension = fileName.slice(((fileName.lastIndexOf('.') - 1) >>> 0) + 2).toLowerCase()
if (hasKey(extensions, fileExtension)) {
switch (extensions[fileExtension]) {
case preview.image:
return (
2021-09-04 14:15:09 +00:00
<div className="w-full p-3 bg-white rounded shadow">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="mx-auto" src={downloadUrl} alt={fileName} />
</div>
)
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} />
2021-06-23 22:51:23 +00:00
default:
2021-09-04 14:15:09 +00:00
return <div className="dark:bg-gray-900 bg-white rounded shadow">{fileName}</div>
2021-06-23 22:51:23 +00:00
}
}
2021-06-29 20:00:05 +00:00
return (
<>
2021-09-04 14:15:09 +00:00
<div className="dark:bg-gray-900 p-3 bg-white rounded shadow">
2021-06-29 20:00:05 +00:00
<FourOhFour
errorMsg={`Preview for file ${fileName} is not available, download directly with the button below.`}
2021-06-29 20:00:05 +00:00
/>
</div>
<div className="mt-4">
<DownloadBtn downloadUrl={downloadUrl} />
</div>
</>
)
2021-06-23 22:51:23 +00:00
}
2021-06-25 14:15:00 +00:00
return (
2021-09-04 14:15:09 +00:00
<div className="dark:bg-gray-900 p-3 bg-white rounded shadow">
<FourOhFour errorMsg={`Cannot preview ${path}`} />
2021-06-25 14:15:00 +00:00
</div>
)
}
export default FileListing