|
|
@@ -1,18 +1,25 @@ |
|
|
|
import {NextPage} from 'next'; |
|
|
|
import * as React from 'react'; |
|
|
|
//import * as BlobReact from '@tesseract-design/web-blob-react'; |
|
|
|
|
|
|
|
import * as BlobBase from '@tesseract-design/web-base-blob'; |
|
|
|
import * as ButtonBase from '@tesseract-design/web-base-button'; |
|
|
|
import WaveSurfer from 'wavesurfer.js'; |
|
|
|
import LanguageDetect from 'languagedetect'; |
|
|
|
import Prism from 'prismjs'; |
|
|
|
import { languages } from 'prismjs/components'; |
|
|
|
|
|
|
|
console.log(languages); |
|
|
|
import WaveSurfer from 'wavesurfer.js'; |
|
|
|
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../../utils/numeral'; |
|
|
|
import { |
|
|
|
ContentType, |
|
|
|
getContentType, |
|
|
|
getMimeTypeDescription, |
|
|
|
readAsArrayBuffer, |
|
|
|
readAsDataURL, |
|
|
|
readAsText, |
|
|
|
} from '../../../utils/blob'; |
|
|
|
import {getImageMetadata} from '../../../utils/image'; |
|
|
|
import {getAudioMetadata} from '../../../utils/audio'; |
|
|
|
import {getTextMetadata} from '../../../utils/text'; |
|
|
|
|
|
|
|
interface FileWithPreview extends File { |
|
|
|
metadata?: Record<string, string | number>; |
|
|
|
metadata?: Record<string, string | number | ArrayBuffer>; |
|
|
|
internal?: Record<string, unknown>; |
|
|
|
} |
|
|
|
|
|
|
@@ -40,340 +47,118 @@ export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, |
|
|
|
hiddenLabel?: boolean, |
|
|
|
} |
|
|
|
|
|
|
|
const formatNumeral = (n?: number) => { |
|
|
|
if (typeof n !== 'number') { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
|
|
|
|
if (!Number.isFinite(n)) { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
|
|
|
|
return new Intl.NumberFormat().format(n); |
|
|
|
const augmentTextFile = async (f: File) => { |
|
|
|
const contents = await readAsText(f); |
|
|
|
const metadata = getTextMetadata(contents, f.name); |
|
|
|
return { |
|
|
|
...f, |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
contents, |
|
|
|
language: metadata.language, |
|
|
|
languageProbability: metadata.languageProbability, |
|
|
|
scheme: metadata.scheme, |
|
|
|
schemeTitle: metadata.schemeTitle, |
|
|
|
}, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const formatFileSize = (size?: number) => { |
|
|
|
if (typeof size !== 'number') { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
|
|
|
|
if (!Number.isFinite(size)) { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
|
|
|
|
if (size < (2 ** 10)) { |
|
|
|
return `${formatNumeral(size)} bytes`; |
|
|
|
} |
|
|
|
|
|
|
|
if (size < (2 ** 20)) { |
|
|
|
return `${(size / (2 ** 10)).toFixed(3)} kiB`; |
|
|
|
} |
|
|
|
|
|
|
|
if (size < (2 ** 30)) { |
|
|
|
return `${(size / (2 ** 20)).toFixed(3)} MiB`; |
|
|
|
} |
|
|
|
|
|
|
|
if (size < (2 ** 40)) { |
|
|
|
return `${(size / (2 ** 30)).toFixed(3)} GiB`; |
|
|
|
} |
|
|
|
const augmentImageFile = async (f: File) => { |
|
|
|
const previewUrl = await readAsDataURL(f); |
|
|
|
const imageMetadata = await getImageMetadata(previewUrl); |
|
|
|
return { |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
previewUrl, |
|
|
|
width: imageMetadata.naturalWidth, |
|
|
|
height: imageMetadata.naturalHeight, |
|
|
|
}, |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
const MIME_TYPE_DESCRIPTIONS = { |
|
|
|
'image/jpeg': 'JPEG Image', |
|
|
|
'image/png': 'PNG Image', |
|
|
|
'image/tiff': 'TIFF Image', |
|
|
|
'image/svg+xml': 'SVG Image', |
|
|
|
'audio/wav': 'WAVE Audio', |
|
|
|
'audio/ogg': 'OGG Audio', |
|
|
|
'audio/mpeg': 'MPEG Audio', |
|
|
|
'application/json': 'JSON Data', |
|
|
|
'application/xml': 'XML Data', |
|
|
|
} as const; |
|
|
|
|
|
|
|
const getMimeTypeDescription = (type?: string) => { |
|
|
|
if (typeof type !== 'string') { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
const augmentAudioFile = async (f: File) => { |
|
|
|
const previewUrl = await readAsDataURL(f); |
|
|
|
const audioExtensions = await getAudioMetadata(previewUrl); |
|
|
|
return { |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
previewUrl, |
|
|
|
duration: audioExtensions.duration, |
|
|
|
}, |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
if (type === 'application/octet-stream') { |
|
|
|
return type; |
|
|
|
const augmentBinaryFile = async (f: File) => { |
|
|
|
const arrayBuffer = await readAsArrayBuffer(f); |
|
|
|
return { |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
contents: arrayBuffer, |
|
|
|
}, |
|
|
|
} |
|
|
|
|
|
|
|
const { |
|
|
|
[type as keyof typeof MIME_TYPE_DESCRIPTIONS]: description = type, |
|
|
|
} = MIME_TYPE_DESCRIPTIONS; |
|
|
|
|
|
|
|
return description; |
|
|
|
} |
|
|
|
|
|
|
|
const formatSecondsDuration = (seconds: number) => { |
|
|
|
const secondsInt = Math.floor(seconds); |
|
|
|
const secondsFrac = seconds - secondsInt; |
|
|
|
const hh = Math.floor(secondsInt / 3600).toString().padStart(2, '0'); |
|
|
|
const mm = Math.floor(secondsInt / 60 % 60).toString().padStart(2, '0'); |
|
|
|
const ss = (secondsInt % 60).toString().padStart(2, '0'); |
|
|
|
const sss = Math.floor(secondsFrac * 1000).toString().padStart(3, '0'); |
|
|
|
return `${hh}:${mm}:${ss}.${sss}`; |
|
|
|
}; |
|
|
|
|
|
|
|
enum ContentType { |
|
|
|
TEXT = 'text', |
|
|
|
AUDIO = 'audio', |
|
|
|
VIDEO = 'video', |
|
|
|
IMAGE = 'image', |
|
|
|
BINARY = 'binary', |
|
|
|
const augmentVideoFile = async (f: File) => { |
|
|
|
const previewUrl = await readAsDataURL(f); |
|
|
|
return { |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
previewUrl, |
|
|
|
}, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const getContentType = (mimeType?: string) => { |
|
|
|
if (typeof mimeType !== 'string') { |
|
|
|
return ContentType.BINARY; |
|
|
|
} |
|
|
|
|
|
|
|
if ( |
|
|
|
mimeType === 'application/json' |
|
|
|
|| mimeType === 'application/xml' |
|
|
|
|| mimeType.startsWith('text/') |
|
|
|
) { |
|
|
|
return ContentType.TEXT; |
|
|
|
} |
|
|
|
|
|
|
|
if (mimeType.startsWith('video/')) { |
|
|
|
return ContentType.VIDEO; |
|
|
|
} |
|
|
|
|
|
|
|
if (mimeType.startsWith('audio/')) { |
|
|
|
return ContentType.AUDIO; |
|
|
|
} |
|
|
|
|
|
|
|
if (mimeType.startsWith('image/')) { |
|
|
|
return ContentType.IMAGE; |
|
|
|
} |
|
|
|
|
|
|
|
return ContentType.BINARY; |
|
|
|
} |
|
|
|
const CONTENT_TYPE_AUGMENT_FUNCTIONS: Record<ContentType, Function> = { |
|
|
|
[ContentType.TEXT]: augmentTextFile, |
|
|
|
[ContentType.IMAGE]: augmentImageFile, |
|
|
|
[ContentType.AUDIO]: augmentAudioFile, |
|
|
|
[ContentType.VIDEO]: augmentVideoFile, |
|
|
|
[ContentType.BINARY]: augmentBinaryFile, |
|
|
|
}; |
|
|
|
|
|
|
|
const useFilePreviews = (fileList?: FileList) => { |
|
|
|
const [selectedFiles, setSelectedFiles] = React.useState([] as Partial<FileWithPreview>[]); |
|
|
|
React.useEffect(() => { |
|
|
|
const loadFilePreviews = async (fileList?: FileList) => { |
|
|
|
if (!fileList) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const loadFilePreviews = async (fileList: FileList) => { |
|
|
|
const files = Array.from(fileList); |
|
|
|
const fileResult = await Promise.all( |
|
|
|
files.map((f) => new Promise<Partial<FileWithPreview>>((resolve, reject) => { |
|
|
|
const contentType = getContentType(f.type); |
|
|
|
|
|
|
|
switch (contentType) { |
|
|
|
case ContentType.TEXT: { |
|
|
|
const reader = new FileReader(); |
|
|
|
reader.addEventListener('error', () => { |
|
|
|
reject(); |
|
|
|
}); |
|
|
|
|
|
|
|
reader.addEventListener('load', async (loadEvent) => { |
|
|
|
const target = loadEvent.target as FileReader; |
|
|
|
const contents = target.result as string; |
|
|
|
const resolvedAliases = Object.fromEntries( |
|
|
|
Object |
|
|
|
.entries(languages) |
|
|
|
.reduce( |
|
|
|
(resolved, [languageId, languageDefinition]) => { |
|
|
|
if (Array.isArray(languageDefinition.alias)) { |
|
|
|
return [ |
|
|
|
...resolved, |
|
|
|
...(languageDefinition.alias.map((a: string) => [a, { title: languageDefinition.title, extension: `.${a}`} ])), |
|
|
|
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}], |
|
|
|
]; |
|
|
|
} |
|
|
|
|
|
|
|
if (typeof languageDefinition.alias === 'string') { |
|
|
|
return [ |
|
|
|
...resolved, |
|
|
|
[languageDefinition.alias, { title: languageDefinition.title, extension: `.${languageDefinition.alias}`}], |
|
|
|
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}], |
|
|
|
]; |
|
|
|
} |
|
|
|
|
|
|
|
return [ |
|
|
|
...resolved, |
|
|
|
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}], |
|
|
|
]; |
|
|
|
}, |
|
|
|
[] as [string, { title: string, extension: string }][] |
|
|
|
) |
|
|
|
); |
|
|
|
const metadata = Object |
|
|
|
.entries(resolvedAliases) |
|
|
|
.reduce( |
|
|
|
(theMetadata, [key, value]) => { |
|
|
|
if (typeof theMetadata.scheme === 'undefined' && f.name.endsWith(value.extension)) { |
|
|
|
return { |
|
|
|
...theMetadata, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
return theMetadata; |
|
|
|
}, |
|
|
|
{ |
|
|
|
contents, |
|
|
|
} as Record<string, number | string> |
|
|
|
); |
|
|
|
|
|
|
|
if (typeof metadata.scheme !== 'string') { |
|
|
|
const naturalLanguageDetector = new LanguageDetect(); |
|
|
|
const probableLanguages = naturalLanguageDetector.detect(contents); |
|
|
|
const [languageName, probability] = probableLanguages[0]; |
|
|
|
metadata.language = languageName; |
|
|
|
} else { |
|
|
|
await import(`prismjs/components/prism-${metadata.scheme}`); |
|
|
|
} |
|
|
|
return Promise.all( |
|
|
|
files.map(async (f) => { |
|
|
|
const contentType = getContentType(f.type, f.name); |
|
|
|
const { [contentType]: augmentFunction } = CONTENT_TYPE_AUGMENT_FUNCTIONS; |
|
|
|
|
|
|
|
resolve({ |
|
|
|
...f, |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata, |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
reader.readAsText(f); |
|
|
|
return; |
|
|
|
} |
|
|
|
case ContentType.IMAGE: { |
|
|
|
const reader = new FileReader(); |
|
|
|
reader.addEventListener('error', () => { |
|
|
|
reject(); |
|
|
|
}); |
|
|
|
|
|
|
|
reader.addEventListener('load', (loadEvent) => { |
|
|
|
const target = loadEvent.target as FileReader; |
|
|
|
const previewUrl = target.result as string; |
|
|
|
const image = new Image(); |
|
|
|
image.addEventListener('load', (imageLoadEvent) => { |
|
|
|
const thisImage = imageLoadEvent.currentTarget as HTMLImageElement; |
|
|
|
resolve({ |
|
|
|
...f, |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata: { |
|
|
|
previewUrl, |
|
|
|
width: thisImage.naturalWidth, |
|
|
|
height: thisImage.naturalHeight, |
|
|
|
}, |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
image.addEventListener('error', () => { |
|
|
|
reject(); |
|
|
|
}); |
|
|
|
|
|
|
|
image.src = previewUrl; |
|
|
|
}); |
|
|
|
|
|
|
|
reader.readAsDataURL(f); |
|
|
|
return; |
|
|
|
if (!augmentFunction) { |
|
|
|
return f; |
|
|
|
} |
|
|
|
case ContentType.AUDIO: { |
|
|
|
const reader = new FileReader(); |
|
|
|
reader.addEventListener('error', () => { |
|
|
|
reject(); |
|
|
|
}); |
|
|
|
|
|
|
|
reader.addEventListener('load', (loadEvent) => { |
|
|
|
const target = loadEvent.target as FileReader; |
|
|
|
const previewUrl = target.result as string; |
|
|
|
let mediaInstance = null as (WaveSurfer | null); |
|
|
|
|
|
|
|
const waveSurferInstance = WaveSurfer.create({ |
|
|
|
container: window.document.createElement('div'), |
|
|
|
}); |
|
|
|
|
|
|
|
waveSurferInstance.on('ready', async () => { |
|
|
|
const metadata = { |
|
|
|
duration: waveSurferInstance.getDuration(), |
|
|
|
}; |
|
|
|
|
|
|
|
waveSurferInstance.destroy(); |
|
|
|
resolve({ |
|
|
|
...f, |
|
|
|
name: f.name, |
|
|
|
type: f.type, |
|
|
|
size: f.size, |
|
|
|
lastModified: f.lastModified, |
|
|
|
metadata, |
|
|
|
internal: { |
|
|
|
createMediaInstance(container: HTMLElement) { |
|
|
|
if (mediaInstance instanceof WaveSurfer) { |
|
|
|
mediaInstance.destroy(); |
|
|
|
} |
|
|
|
mediaInstance = WaveSurfer.create({ |
|
|
|
container, |
|
|
|
url: previewUrl, |
|
|
|
height: container.clientHeight, |
|
|
|
interact: false, |
|
|
|
cursorWidth: 0, |
|
|
|
barWidth: 2, |
|
|
|
barGap: 2, |
|
|
|
barRadius: 1, |
|
|
|
normalize: true, |
|
|
|
}); |
|
|
|
}, |
|
|
|
destroyMediaInstance() { |
|
|
|
if (!(mediaInstance instanceof WaveSurfer)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
mediaInstance.destroy(); |
|
|
|
}, |
|
|
|
play() { |
|
|
|
if (!(mediaInstance instanceof WaveSurfer)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
mediaInstance.play(); |
|
|
|
}, |
|
|
|
pause() { |
|
|
|
if (!(mediaInstance instanceof WaveSurfer)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
mediaInstance.pause(); |
|
|
|
}, |
|
|
|
stop() { |
|
|
|
if (!(mediaInstance instanceof WaveSurfer)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
mediaInstance.stop(); |
|
|
|
}, |
|
|
|
playPause() { |
|
|
|
if (!(mediaInstance instanceof WaveSurfer)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
mediaInstance.playPause(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
}) |
|
|
|
}); |
|
|
|
|
|
|
|
waveSurferInstance.load(previewUrl); |
|
|
|
}); |
|
|
|
|
|
|
|
reader.readAsDataURL(f); |
|
|
|
return; |
|
|
|
} |
|
|
|
default: |
|
|
|
break; |
|
|
|
const augmentedFile = await augmentFunction(f); |
|
|
|
if (contentType === ContentType.TEXT && augmentedFile.metadata.scheme) { |
|
|
|
await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`); |
|
|
|
} |
|
|
|
resolve(f); |
|
|
|
})) |
|
|
|
return augmentedFile; |
|
|
|
}) |
|
|
|
); |
|
|
|
|
|
|
|
setSelectedFiles(fileResult); |
|
|
|
} |
|
|
|
|
|
|
|
loadFilePreviews(fileList); |
|
|
|
if (fileList) { |
|
|
|
loadFilePreviews(fileList).then((fileResult) => { |
|
|
|
setSelectedFiles(fileResult); |
|
|
|
}); |
|
|
|
} |
|
|
|
}, [fileList]); |
|
|
|
|
|
|
|
return React.useMemo(() => ({ |
|
|
@@ -386,42 +171,83 @@ const FilePreview = ({ |
|
|
|
}: { fileList?: FileList }) => { |
|
|
|
const { files } = useFilePreviews(fileList); |
|
|
|
const mediaContainerRef = React.useRef<HTMLDivElement>(null); |
|
|
|
const mediaControllerRef = React.useRef<WaveSurfer | HTMLVideoElement>(null); |
|
|
|
const [isPlaying, setIsPlaying] = React.useState(false); |
|
|
|
|
|
|
|
const playMedia = () => { |
|
|
|
if (files.length < 1) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const [theFile] = files; |
|
|
|
if (typeof theFile.internal?.playPause === 'function') { |
|
|
|
theFile.internal.playPause(); |
|
|
|
} |
|
|
|
|
|
|
|
setIsPlaying((p) => !p); |
|
|
|
}; |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
const { current: mediaController } = mediaControllerRef; |
|
|
|
if (!mediaController) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (isPlaying) { |
|
|
|
void mediaController.play(); |
|
|
|
return |
|
|
|
} |
|
|
|
mediaController.pause(); |
|
|
|
}, [isPlaying]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (files.length < 1) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const [theFile] = files; |
|
|
|
if (typeof theFile.internal?.createMediaInstance === 'function' && mediaContainerRef.current) { |
|
|
|
theFile.internal.createMediaInstance(mediaContainerRef.current); |
|
|
|
const contentType = getContentType(theFile.type, theFile.name); |
|
|
|
if ( |
|
|
|
contentType === ContentType.AUDIO |
|
|
|
&& typeof theFile.metadata?.previewUrl === 'string' |
|
|
|
&& mediaContainerRef.current !== null |
|
|
|
) { |
|
|
|
mediaContainerRef.current.innerHTML = ''; |
|
|
|
|
|
|
|
const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>; |
|
|
|
mediaControllerRefMutable.current = WaveSurfer.create({ |
|
|
|
container: mediaContainerRef.current, |
|
|
|
url: theFile.metadata.previewUrl, |
|
|
|
cursorWidth: 0, |
|
|
|
height: mediaContainerRef.current.offsetHeight, |
|
|
|
barWidth: 2, |
|
|
|
barGap: 2, |
|
|
|
barRadius: 1, |
|
|
|
}); |
|
|
|
|
|
|
|
mediaControllerRefMutable.current.on('finish', () => { |
|
|
|
setIsPlaying(false); |
|
|
|
mediaControllerRefMutable.current.seekTo(0); |
|
|
|
}); |
|
|
|
} else if ( |
|
|
|
contentType === ContentType.VIDEO |
|
|
|
&& typeof theFile.metadata?.previewUrl === 'string' |
|
|
|
) { |
|
|
|
(mediaControllerRef.current as HTMLVideoElement).addEventListener('ended', (e) => { |
|
|
|
const videoElement = e.currentTarget as HTMLVideoElement; |
|
|
|
setIsPlaying(false); |
|
|
|
videoElement.currentTime = 0; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
return () => { |
|
|
|
if (typeof theFile.internal?.destroyMediaInstance === 'function') { |
|
|
|
theFile.internal.destroyMediaInstance(); |
|
|
|
if (mediaControllerRef && mediaControllerRef.current && mediaControllerRef.current instanceof WaveSurfer) { |
|
|
|
mediaControllerRef.current.destroy(); |
|
|
|
} |
|
|
|
} |
|
|
|
}, [files, mediaContainerRef]); |
|
|
|
}, [files, mediaContainerRef, mediaControllerRef]); |
|
|
|
|
|
|
|
if (files.length < 1) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
const f = files[0]; |
|
|
|
const contentType = getContentType(f.type); |
|
|
|
|
|
|
|
console.log(f); |
|
|
|
const contentType = getContentType(f.type, f.name); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
@@ -435,16 +261,15 @@ const FilePreview = ({ |
|
|
|
<div className={`h-full w-1/3 flex-shrink-0`}> |
|
|
|
{ |
|
|
|
typeof f.metadata?.contents === 'string' |
|
|
|
&& typeof f.metadata?.scheme === 'string' |
|
|
|
&& ( |
|
|
|
<div |
|
|
|
data-testid="preview" |
|
|
|
role="presentation" |
|
|
|
className="w-full h-full select-none overflow-hidden" |
|
|
|
className="w-full h-full select-none overflow-hidden text-xs" |
|
|
|
> |
|
|
|
<pre className="overflow-visible"> |
|
|
|
{ |
|
|
|
f.metadata.scheme |
|
|
|
typeof f.metadata.scheme === 'string' |
|
|
|
&& ( |
|
|
|
<code |
|
|
|
dangerouslySetInnerHTML={{ |
|
|
@@ -452,7 +277,7 @@ const FilePreview = ({ |
|
|
|
f.metadata.contents, |
|
|
|
Prism.languages[f.metadata.scheme], |
|
|
|
f.metadata.scheme, |
|
|
|
), |
|
|
|
).split('\n').slice(0, 15).join('\n'), |
|
|
|
}} |
|
|
|
style={{ |
|
|
|
tabSize: 2, |
|
|
@@ -461,7 +286,7 @@ const FilePreview = ({ |
|
|
|
) |
|
|
|
} |
|
|
|
{ |
|
|
|
!f.metadata.scheme |
|
|
|
typeof f.metadata.scheme !== 'string' |
|
|
|
&& ( |
|
|
|
<code> |
|
|
|
{f.metadata.contents} |
|
|
@@ -564,7 +389,7 @@ const FilePreview = ({ |
|
|
|
title={f.type} |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
> |
|
|
|
{getMimeTypeDescription(f.type)} |
|
|
|
{getMimeTypeDescription(f.type, f.name)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div className="w-full"> |
|
|
@@ -602,11 +427,19 @@ const FilePreview = ({ |
|
|
|
contentType === ContentType.AUDIO |
|
|
|
&& ( |
|
|
|
<div className="flex flex-col gap-4 w-full h-full relative"> |
|
|
|
<div |
|
|
|
className={`h-2/5 flex-shrink-0 cursor-pointer`} |
|
|
|
ref={mediaContainerRef} |
|
|
|
onClick={playMedia} |
|
|
|
/> |
|
|
|
<div className="h-2/5 flex-shrink-0 cursor-pointer relative"> |
|
|
|
<div |
|
|
|
ref={mediaContainerRef} |
|
|
|
className="relative h-full w-full" |
|
|
|
/> |
|
|
|
<div className="absolute bottom-0 left-0 z-[2]"> |
|
|
|
<button |
|
|
|
onClick={playMedia} |
|
|
|
> |
|
|
|
{isPlaying ? 'Pause' : 'Play'} |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<dl className="h-3/5 flex-shrink-0 m-0 flex flex-col items-end" data-testid="infoBox"> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
@@ -661,6 +494,193 @@ const FilePreview = ({ |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
{ |
|
|
|
contentType === ContentType.VIDEO |
|
|
|
&& ( |
|
|
|
<div className="flex gap-4 w-full h-full relative"> |
|
|
|
<div className={`h-full w-1/3 flex-shrink-0`}> |
|
|
|
{ |
|
|
|
typeof f.metadata?.previewUrl === 'string' |
|
|
|
&& ( |
|
|
|
<div |
|
|
|
className="w-full h-full bg-black" |
|
|
|
data-testid="preview relative" |
|
|
|
> |
|
|
|
<video |
|
|
|
className="block w-full h-full object-center object-contain relative" |
|
|
|
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>} |
|
|
|
> |
|
|
|
<source |
|
|
|
src={f.metadata.previewUrl} |
|
|
|
type={f.type} |
|
|
|
/> |
|
|
|
</video> |
|
|
|
<div className="absolute bottom-0 left-0 hover:opacity-100 opacity-0"> |
|
|
|
<button |
|
|
|
onClick={playMedia} |
|
|
|
> |
|
|
|
{isPlaying ? 'Pause' : 'Play'} |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Name |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
title={f.name} |
|
|
|
> |
|
|
|
{f.name} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Type |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
title={f.type} |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
> |
|
|
|
{getMimeTypeDescription(f.type, f.name)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Size |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
title={`${formatNumeral(f.size ?? 0)} bytes`} |
|
|
|
> |
|
|
|
{formatFileSize(f.size)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
{ |
|
|
|
typeof f.metadata?.width === 'number' |
|
|
|
&& typeof f.metadata?.height === 'number' |
|
|
|
&& ( |
|
|
|
<div> |
|
|
|
<dt className="sr-only"> |
|
|
|
Pixel Dimensions |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
> |
|
|
|
{formatNumeral(f.metadata.width)}×{formatNumeral(f.metadata.height)} pixels |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</dl> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
{ |
|
|
|
contentType === ContentType.BINARY |
|
|
|
&& ( |
|
|
|
<div className="flex gap-4 w-full h-full relative"> |
|
|
|
<div className={`h-full w-1/3 flex-shrink-0`}> |
|
|
|
{ |
|
|
|
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) |
|
|
|
&& ( |
|
|
|
<div |
|
|
|
data-testid="preview" |
|
|
|
role="presentation" |
|
|
|
className="w-full h-full select-none overflow-hidden text-xs" |
|
|
|
> |
|
|
|
<pre className="overflow-visible"> |
|
|
|
<code> |
|
|
|
{ |
|
|
|
(Array.from(new Uint8Array((f.metadata.contents as ArrayBuffer).slice(0, 256))) as number[]) |
|
|
|
.reduce( |
|
|
|
(byteArray: number[][], byte: number, i) => { |
|
|
|
if (i % 16 === 0) { |
|
|
|
return [ |
|
|
|
...byteArray, |
|
|
|
[byte], |
|
|
|
] |
|
|
|
} |
|
|
|
|
|
|
|
const lastLine = byteArray.at(-1) as number[] |
|
|
|
|
|
|
|
return [ |
|
|
|
...(byteArray.slice(0, -1)), |
|
|
|
[...lastLine, byte], |
|
|
|
] |
|
|
|
}, |
|
|
|
[] as number[][], |
|
|
|
) |
|
|
|
.map((ba: number[]) => ba |
|
|
|
.map((a) => a.toString(16).padStart(2, '0')) |
|
|
|
.join(' ') |
|
|
|
) |
|
|
|
.join('\n') |
|
|
|
} |
|
|
|
</code> |
|
|
|
</pre> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Name |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
title={f.name} |
|
|
|
> |
|
|
|
{f.name} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Type |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
title={f.type} |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
> |
|
|
|
{getMimeTypeDescription(f.type, f.name)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div className="w-full"> |
|
|
|
<dt className="sr-only"> |
|
|
|
Size |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
title={`${formatNumeral(f.size ?? 0)} bytes`} |
|
|
|
> |
|
|
|
{formatFileSize(f.size)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
{ |
|
|
|
typeof f.metadata?.language === 'string' |
|
|
|
&& ( |
|
|
|
<div> |
|
|
|
<dt className="sr-only"> |
|
|
|
Language |
|
|
|
</dt> |
|
|
|
<dd |
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
> |
|
|
|
{f.metadata.language.slice(0, 1).toUpperCase()} |
|
|
|
{f.metadata.language.slice(1)} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</dl> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|