first commit
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { useState, type ElementType } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
import { formatTime, relativeDate } from '../../utils/format-date'
|
||||
import { fileUrl } from '../../utils/fileUrl'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import { LinkedFileIcon } from './file-view-icons'
|
||||
import { BinaryFile, hasProvider, LinkedFile } from '../types/binary-file'
|
||||
import FileViewRefreshButton from './file-view-refresh-button'
|
||||
import FileViewRefreshError from './file-view-refresh-error'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
const tprFileViewInfo = importOverleafModules('tprFileViewInfo') as {
|
||||
import: { TPRFileViewInfo: ElementType }
|
||||
path: string
|
||||
}[]
|
||||
|
||||
const tprFileViewNotOriginalImporter = importOverleafModules(
|
||||
'tprFileViewNotOriginalImporter'
|
||||
) as {
|
||||
import: { TPRFileViewNotOriginalImporter: ElementType }
|
||||
path: string
|
||||
}[]
|
||||
|
||||
const MAX_URL_LENGTH = 60
|
||||
const FRONT_OF_URL_LENGTH = 35
|
||||
const FILLER = '...'
|
||||
const TAIL_OF_URL_LENGTH = MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
|
||||
|
||||
function shortenedUrl(url: string) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
if (url.length > MAX_URL_LENGTH) {
|
||||
const front = url.slice(0, FRONT_OF_URL_LENGTH)
|
||||
const tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
|
||||
return front + FILLER + tail
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
type FileViewHeaderProps = {
|
||||
file: BinaryFile
|
||||
}
|
||||
|
||||
export default function FileViewHeader({ file }: FileViewHeaderProps) {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [refreshError, setRefreshError] = useState<Nullable<string>>(null)
|
||||
|
||||
let fileInfo
|
||||
if (file.linkedFileData) {
|
||||
if (hasProvider(file, 'url')) {
|
||||
fileInfo = <UrlProvider file={file} />
|
||||
} else if (hasProvider(file, 'project_file')) {
|
||||
fileInfo = <ProjectFilePathProvider file={file} />
|
||||
} else if (hasProvider(file, 'project_output_file')) {
|
||||
fileInfo = <ProjectOutputFileProvider file={file} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.linkedFileData && fileInfo}
|
||||
{file.linkedFileData &&
|
||||
tprFileViewInfo.map(({ import: { TPRFileViewInfo }, path }) => (
|
||||
<TPRFileViewInfo key={path} file={file} />
|
||||
))}
|
||||
<div className="file-view-buttons">
|
||||
{file.linkedFileData && !fileTreeReadOnly && (
|
||||
<FileViewRefreshButton
|
||||
file={file}
|
||||
setRefreshError={setRefreshError}
|
||||
/>
|
||||
)}
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
download={file.name}
|
||||
href={fileUrl(projectId, file.id, file.hash)}
|
||||
>
|
||||
<MaterialIcon type="download" className="align-middle" />{' '}
|
||||
<span>{t('download')}</span>
|
||||
</OLButton>
|
||||
</div>
|
||||
{file.linkedFileData &&
|
||||
tprFileViewNotOriginalImporter.map(
|
||||
({ import: { TPRFileViewNotOriginalImporter }, path }) => (
|
||||
<TPRFileViewNotOriginalImporter key={path} file={file} />
|
||||
)
|
||||
)[0]}
|
||||
{refreshError && (
|
||||
<FileViewRefreshError file={file} refreshError={refreshError} />
|
||||
)}
|
||||
|
||||
{/* Workaround for Safari issue: https://github.com/overleaf/internal/issues/21363
|
||||
* The editor behind a file view receives key events and updates the file even if Codemirror view is not focused.
|
||||
* Changing the focus to a hidden textarea prevents this
|
||||
*/}
|
||||
<textarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
aria-label="Invisible element to manage focus and prevent unintended behavior"
|
||||
tabIndex={-1}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type UrlProviderProps = {
|
||||
file: LinkedFile<'url'>
|
||||
}
|
||||
|
||||
function UrlProvider({ file }: UrlProviderProps) {
|
||||
return (
|
||||
<p>
|
||||
<LinkedFileIcon />
|
||||
|
||||
<Trans
|
||||
i18nKey="imported_from_external_provider_at_date"
|
||||
components={
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
||||
[<a href={file.linkedFileData.url} />]
|
||||
}
|
||||
values={{
|
||||
shortenedUrl: shortenedUrl(file.linkedFileData.url),
|
||||
formattedDate: formatTime(file.created),
|
||||
relativeDate: relativeDate(file.created),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
type ProjectFilePathProviderProps = {
|
||||
file: LinkedFile<'project_file'>
|
||||
}
|
||||
|
||||
function ProjectFilePathProvider({ file }: ProjectFilePathProviderProps) {
|
||||
/* eslint-disable jsx-a11y/anchor-has-content, react/jsx-key */
|
||||
return (
|
||||
<p>
|
||||
<LinkedFileIcon />{' '}
|
||||
<Trans
|
||||
i18nKey="imported_from_another_project_at_date"
|
||||
components={
|
||||
file.linkedFileData.v1_source_doc_id
|
||||
? [<span />]
|
||||
: [
|
||||
<a
|
||||
href={`/project/${file.linkedFileData.source_project_id}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
/>,
|
||||
]
|
||||
}
|
||||
values={{
|
||||
sourceEntityPath: file.linkedFileData.source_entity_path.slice(1),
|
||||
formattedDate: formatTime(file.created),
|
||||
relativeDate: relativeDate(file.created),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</p>
|
||||
/* esline-enable jsx-a11y/anchor-has-content, react/jsx-key */
|
||||
)
|
||||
}
|
||||
|
||||
type ProjectOutputFileProviderProps = {
|
||||
file: LinkedFile<'project_output_file'>
|
||||
}
|
||||
|
||||
function ProjectOutputFileProvider({ file }: ProjectOutputFileProviderProps) {
|
||||
return (
|
||||
<p>
|
||||
<LinkedFileIcon />
|
||||
|
||||
<Trans
|
||||
i18nKey="imported_from_the_output_of_another_project_at_date"
|
||||
components={
|
||||
file.linkedFileData.v1_source_doc_id
|
||||
? [<span />]
|
||||
: [
|
||||
<a
|
||||
href={`/project/${file.linkedFileData.source_project_id}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
/>,
|
||||
]
|
||||
}
|
||||
values={{
|
||||
sourceOutputFilePath: file.linkedFileData.source_output_file_path,
|
||||
formattedDate: formatTime(file.created),
|
||||
relativeDate: relativeDate(file.created),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
export const LinkedFileIcon = props => {
|
||||
return (
|
||||
<MaterialIcon
|
||||
type="open_in_new"
|
||||
modifier="rotate-180"
|
||||
className="align-middle linked-file-icon"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
import { fileUrl } from '../../utils/fileUrl'
|
||||
|
||||
export default function FileViewImage({
|
||||
file,
|
||||
onLoad,
|
||||
onError,
|
||||
}: {
|
||||
file: BinaryFile
|
||||
onLoad: () => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
return (
|
||||
<img
|
||||
src={fileUrl(projectId, file.id, file.hash)}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
alt={file.name}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -0,0 +1,78 @@
|
||||
import { FC, useCallback } from 'react'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
|
||||
const FileViewPdf: FC<{
|
||||
fileId: string
|
||||
onLoad: () => void
|
||||
onError: () => void
|
||||
}> = ({ fileId, onLoad, onError }) => {
|
||||
const mountedRef = useIsMounted()
|
||||
|
||||
const { previewByPath, pathInFolder } = useFileTreePathContext()
|
||||
|
||||
const handleContainer = useCallback(
|
||||
async (element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
const { loadPdfDocumentFromUrl } = await import(
|
||||
'@/features/pdf-preview/util/pdf-js'
|
||||
)
|
||||
|
||||
// bail out if loading PDF.js took too long
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = pathInFolder(fileId)
|
||||
const preview = path ? previewByPath(path) : null
|
||||
|
||||
if (!preview) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
const pdf = await loadPdfDocumentFromUrl(preview.url).promise
|
||||
|
||||
// bail out if loading the PDF took too long
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
element.textContent = '' // ensure the element is empty
|
||||
|
||||
const scale = window.devicePixelRatio || 1
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i)
|
||||
|
||||
// bail out if the component has been unmounted
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const viewport = page.getViewport({ scale })
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.classList.add('pdf-page')
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
canvas.style.width = `${viewport.width / scale}px`
|
||||
canvas.style.height = `${viewport.height / scale}px`
|
||||
|
||||
element.append(canvas)
|
||||
page.render({
|
||||
canvasContext: canvas.getContext('2d')!,
|
||||
viewport,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad()
|
||||
}
|
||||
},
|
||||
[mountedRef, pathInFolder, fileId, previewByPath, onLoad, onError]
|
||||
)
|
||||
|
||||
return <div className="file-view-pdf" ref={handleContainer} />
|
||||
}
|
||||
|
||||
export default FileViewPdf
|
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
type ElementType,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import type { BinaryFile } from '../types/binary-file'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
|
||||
type FileViewRefreshButtonProps = {
|
||||
setRefreshError: Dispatch<SetStateAction<Nullable<string>>>
|
||||
file: BinaryFile
|
||||
}
|
||||
|
||||
const tprFileViewRefreshButton = importOverleafModules(
|
||||
'tprFileViewRefreshButton'
|
||||
) as {
|
||||
import: { TPRFileViewRefreshButton: ElementType }
|
||||
path: string
|
||||
}[]
|
||||
|
||||
export default function FileViewRefreshButton({
|
||||
setRefreshError,
|
||||
file,
|
||||
}: FileViewRefreshButtonProps) {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const isMountedRef = useIsMounted()
|
||||
|
||||
const refreshFile = useCallback(
|
||||
(isTPR: Nullable<boolean>) => {
|
||||
setRefreshing(true)
|
||||
// Replacement of the file handled by the file tree
|
||||
window.expectingLinkedFileRefreshedSocketFor = file.name
|
||||
const body = {
|
||||
shouldReindexReferences: isTPR || /\.bib$/.test(file.name),
|
||||
}
|
||||
postJSON(`/project/${projectId}/linked_file/${file.id}/refresh`, {
|
||||
body,
|
||||
})
|
||||
.then(() => {
|
||||
if (isMountedRef.current) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
sendMB('refresh-linked-file', {
|
||||
provider: file.linkedFileData?.provider,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
if (isMountedRef.current) {
|
||||
setRefreshing(false)
|
||||
setRefreshError(err.data?.message || err.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
[file, projectId, setRefreshError, isMountedRef]
|
||||
)
|
||||
|
||||
if (tprFileViewRefreshButton.length > 0) {
|
||||
return tprFileViewRefreshButton.map(
|
||||
({ import: { TPRFileViewRefreshButton }, path }) => (
|
||||
<TPRFileViewRefreshButton
|
||||
key={path}
|
||||
file={file}
|
||||
refreshFile={refreshFile}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)
|
||||
)[0]
|
||||
} else {
|
||||
return (
|
||||
<FileViewRefreshButtonDefault
|
||||
refreshFile={refreshFile}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type FileViewRefreshButtonDefaultProps = {
|
||||
refreshFile: (isTPR: Nullable<boolean>) => void
|
||||
refreshing: boolean
|
||||
}
|
||||
|
||||
function FileViewRefreshButtonDefault({
|
||||
refreshFile,
|
||||
refreshing,
|
||||
}: FileViewRefreshButtonDefaultProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => refreshFile(null)}
|
||||
disabled={refreshing}
|
||||
isLoading={refreshing}
|
||||
loadingLabel={t('refreshing')}
|
||||
>
|
||||
{t('refresh')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
import type { ElementType } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import { BinaryFile } from '../types/binary-file'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
type FileViewRefreshErrorProps = {
|
||||
file: BinaryFile
|
||||
refreshError: string
|
||||
}
|
||||
|
||||
const tprFileViewRefreshError = importOverleafModules(
|
||||
'tprFileViewRefreshError'
|
||||
) as {
|
||||
import: { TPRFileViewRefreshError: ElementType }
|
||||
path: string
|
||||
}[]
|
||||
|
||||
export default function FileViewRefreshError({
|
||||
file,
|
||||
refreshError,
|
||||
}: FileViewRefreshErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (tprFileViewRefreshError.length > 0) {
|
||||
return tprFileViewRefreshError.map(
|
||||
({ import: { TPRFileViewRefreshError }, path }) => (
|
||||
<TPRFileViewRefreshError
|
||||
key={path}
|
||||
file={file}
|
||||
refreshError={refreshError}
|
||||
/>
|
||||
)
|
||||
)[0]
|
||||
} else {
|
||||
return (
|
||||
<div className="file-view-error">
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
<span>
|
||||
{t('access_denied')}: {refreshError}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
import { fileUrl } from '../../utils/fileUrl'
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024
|
||||
|
||||
export default function FileViewText({
|
||||
file,
|
||||
onLoad,
|
||||
onError,
|
||||
}: {
|
||||
file: BinaryFile
|
||||
onLoad: () => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const [textPreview, setTextPreview] = useState('')
|
||||
const [shouldShowDots, setShouldShowDots] = useState(false)
|
||||
const [inFlight, setInFlight] = useState(false)
|
||||
|
||||
const fetchContentLengthController = useAbortController()
|
||||
const fetchDataController = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
if (inFlight) {
|
||||
return
|
||||
}
|
||||
const path = fileUrl(projectId, file.id, file.hash)
|
||||
const fetchContentLengthTimeout = setTimeout(
|
||||
() => fetchContentLengthController.abort(),
|
||||
10000
|
||||
)
|
||||
let fetchDataTimeout: number | undefined
|
||||
fetch(path, { method: 'HEAD', signal: fetchContentLengthController.signal })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('HTTP Error Code: ' + response.status)
|
||||
return response.headers.get('Content-Length')
|
||||
})
|
||||
.then(fileSize => {
|
||||
let truncated = false
|
||||
const headers = new Headers()
|
||||
if (fileSize && Number(fileSize) > MAX_FILE_SIZE) {
|
||||
truncated = true
|
||||
headers.set('Range', `bytes=0-${MAX_FILE_SIZE}`)
|
||||
}
|
||||
fetchDataTimeout = window.setTimeout(
|
||||
() => fetchDataController.abort(),
|
||||
60000
|
||||
)
|
||||
const signal = fetchDataController.signal
|
||||
return fetch(path, { signal, headers }).then(response => {
|
||||
return response.text().then(text => {
|
||||
if (truncated) {
|
||||
text = text.replace(/\n.*$/, '')
|
||||
}
|
||||
|
||||
setTextPreview(text)
|
||||
onLoad()
|
||||
setShouldShowDots(truncated)
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error('Error fetching file contents', err)
|
||||
onError()
|
||||
})
|
||||
.finally(() => {
|
||||
setInFlight(false)
|
||||
clearTimeout(fetchContentLengthTimeout)
|
||||
clearTimeout(fetchDataTimeout)
|
||||
})
|
||||
}, [
|
||||
projectId,
|
||||
file.id,
|
||||
file.hash,
|
||||
onError,
|
||||
onLoad,
|
||||
inFlight,
|
||||
fetchContentLengthController,
|
||||
fetchDataController,
|
||||
])
|
||||
return (
|
||||
Boolean(textPreview) && (
|
||||
<div className="text-preview">
|
||||
<div className="scroll-container">
|
||||
<p>{textPreview}</p>
|
||||
{shouldShowDots && <p>...</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
@@ -0,0 +1,90 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import FileViewHeader from './file-view-header'
|
||||
import FileViewImage from './file-view-image'
|
||||
import FileViewPdf from './file-view-pdf'
|
||||
import FileViewText from './file-view-text'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
|
||||
|
||||
export default function FileView({ file }) {
|
||||
const [contentLoading, setContentLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { textExtensions, editableFilenames } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
|
||||
const isEditableTextFile =
|
||||
textExtensions.includes(extension) ||
|
||||
editableFilenames.includes(file.name.toLowerCase())
|
||||
|
||||
const isImageFile = imageExtensions.includes(extension)
|
||||
const isPdfFile = extension === 'pdf'
|
||||
const isUnpreviewableFile = !isEditableTextFile && !isImageFile && !isPdfFile
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setContentLoading(false)
|
||||
}, [])
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
if (!hasError) {
|
||||
setContentLoading(false)
|
||||
setHasError(true)
|
||||
}
|
||||
}, [hasError])
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<FileViewHeader file={file} />
|
||||
{isImageFile && (
|
||||
<FileViewImage file={file} onLoad={handleLoad} onError={handleError} />
|
||||
)}
|
||||
{isEditableTextFile && (
|
||||
<FileViewText file={file} onLoad={handleLoad} onError={handleError} />
|
||||
)}
|
||||
{isPdfFile && (
|
||||
<FileViewPdf
|
||||
fileId={file.id}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="file-view full-size">
|
||||
{!hasError && content}
|
||||
{!isUnpreviewableFile && contentLoading && <FileViewLoadingIndicator />}
|
||||
{(isUnpreviewableFile || hasError) && (
|
||||
<p className="no-preview">{t('no_preview_available')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileViewLoadingIndicator() {
|
||||
return (
|
||||
<div
|
||||
className="loading-panel loading-panel-file-view"
|
||||
data-testid="loading-panel-file-view"
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FileView.propTypes = {
|
||||
file: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
hash: PropTypes.string,
|
||||
}).isRequired,
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
export type LinkedFileData = {
|
||||
url: {
|
||||
provider: 'url'
|
||||
url: string
|
||||
}
|
||||
project_file: {
|
||||
provider: 'project_file'
|
||||
v1_source_doc_id?: string
|
||||
source_project_id: string
|
||||
source_entity_path: string
|
||||
}
|
||||
project_output_file: {
|
||||
provider: 'project_output_file'
|
||||
v1_source_doc_id?: string
|
||||
source_project_id: string
|
||||
source_output_file_path: string
|
||||
}
|
||||
}
|
||||
|
||||
export type BinaryFile<T extends keyof LinkedFileData = keyof LinkedFileData> =
|
||||
{
|
||||
_id: string
|
||||
name: string
|
||||
created: Date
|
||||
id: string
|
||||
type: string
|
||||
selected: boolean
|
||||
linkedFileData?: LinkedFileData[T]
|
||||
hash: string
|
||||
}
|
||||
|
||||
export type LinkedFile<T extends keyof LinkedFileData> = Required<BinaryFile<T>>
|
||||
|
||||
export const hasProvider = <T extends keyof LinkedFileData>(
|
||||
file: BinaryFile,
|
||||
provider: T
|
||||
): file is LinkedFile<T> => file.linkedFileData?.provider === provider
|
Reference in New Issue
Block a user