|
@@ -1,19 +1,957 @@ |
|
|
import {NextPage} from 'next'; |
|
|
import {NextPage} from 'next'; |
|
|
import * as React from 'react'; |
|
|
import * as React from 'react'; |
|
|
import * as BlobReact from '@tesseract-design/web-blob-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); |
|
|
|
|
|
|
|
|
|
|
|
interface FileWithPreview extends File { |
|
|
|
|
|
metadata?: Record<string, string | number>; |
|
|
|
|
|
internal?: Record<string, unknown>; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { |
|
|
|
|
|
/** |
|
|
|
|
|
* Should the component display a border? |
|
|
|
|
|
*/ |
|
|
|
|
|
border?: boolean, |
|
|
|
|
|
/** |
|
|
|
|
|
* Should the component occupy the whole width of its parent? |
|
|
|
|
|
*/ |
|
|
|
|
|
block?: boolean, |
|
|
|
|
|
/** |
|
|
|
|
|
* Short textual description indicating the nature of the component's value. |
|
|
|
|
|
*/ |
|
|
|
|
|
label?: React.ReactNode, |
|
|
|
|
|
/** |
|
|
|
|
|
* Short textual description as guidelines for valid input values. |
|
|
|
|
|
*/ |
|
|
|
|
|
hint?: React.ReactNode, |
|
|
|
|
|
enhanced?: boolean, |
|
|
|
|
|
/** |
|
|
|
|
|
* Is the label hidden? |
|
|
|
|
|
*/ |
|
|
|
|
|
hiddenLabel?: boolean, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const formatNumeral = (n?: number) => { |
|
|
|
|
|
if (typeof n !== 'number') { |
|
|
|
|
|
return ''; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(n)) { |
|
|
|
|
|
return ''; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return new Intl.NumberFormat().format(n); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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 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 ''; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (type === 'application/octet-stream') { |
|
|
|
|
|
return type; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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 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 useFilePreviews = (fileList?: FileList) => { |
|
|
|
|
|
const [selectedFiles, setSelectedFiles] = React.useState([] as Partial<FileWithPreview>[]); |
|
|
|
|
|
React.useEffect(() => { |
|
|
|
|
|
const loadFilePreviews = async (fileList?: FileList) => { |
|
|
|
|
|
if (!fileList) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
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}`); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
} |
|
|
|
|
|
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; |
|
|
|
|
|
} |
|
|
|
|
|
resolve(f); |
|
|
|
|
|
})) |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
setSelectedFiles(fileResult); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
loadFilePreviews(fileList); |
|
|
|
|
|
}, [fileList]); |
|
|
|
|
|
|
|
|
|
|
|
return React.useMemo(() => ({ |
|
|
|
|
|
files: selectedFiles, |
|
|
|
|
|
}), [selectedFiles]); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const FilePreview = ({ |
|
|
|
|
|
fileList: fileList, |
|
|
|
|
|
}: { fileList?: FileList }) => { |
|
|
|
|
|
const { files } = useFilePreviews(fileList); |
|
|
|
|
|
const mediaContainerRef = React.useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
|
|
|
|
const playMedia = () => { |
|
|
|
|
|
if (files.length < 1) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
const [theFile] = files; |
|
|
|
|
|
if (typeof theFile.internal?.playPause === 'function') { |
|
|
|
|
|
theFile.internal.playPause(); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
|
|
if (files.length < 1) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const [theFile] = files; |
|
|
|
|
|
if (typeof theFile.internal?.createMediaInstance === 'function' && mediaContainerRef.current) { |
|
|
|
|
|
theFile.internal.createMediaInstance(mediaContainerRef.current); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return () => { |
|
|
|
|
|
if (typeof theFile.internal?.destroyMediaInstance === 'function') { |
|
|
|
|
|
theFile.internal.destroyMediaInstance(); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
}, [files, mediaContainerRef]); |
|
|
|
|
|
|
|
|
|
|
|
if (files.length < 1) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const f = files[0]; |
|
|
|
|
|
const contentType = getContentType(f.type); |
|
|
|
|
|
|
|
|
|
|
|
console.log(f); |
|
|
|
|
|
|
|
|
const BlobPage: NextPage = () => { |
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<div className="z-10 pb-12"> |
|
|
|
|
|
<BlobReact.FileSelectBox |
|
|
|
|
|
border |
|
|
|
|
|
enhanced |
|
|
|
|
|
label="vro" |
|
|
|
|
|
hint="Select any files here" |
|
|
|
|
|
multiple |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
<div |
|
|
|
|
|
className={`w-full h-full`} |
|
|
|
|
|
> |
|
|
|
|
|
<div data-testid="selectedFileItem" className={`h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`}> |
|
|
|
|
|
{ |
|
|
|
|
|
contentType === ContentType.TEXT |
|
|
|
|
|
&& ( |
|
|
|
|
|
<div className="flex gap-4 w-full h-full relative"> |
|
|
|
|
|
<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" |
|
|
|
|
|
> |
|
|
|
|
|
<pre className="overflow-visible"> |
|
|
|
|
|
{ |
|
|
|
|
|
f.metadata.scheme |
|
|
|
|
|
&& ( |
|
|
|
|
|
<code |
|
|
|
|
|
dangerouslySetInnerHTML={{ |
|
|
|
|
|
__html: Prism.highlight( |
|
|
|
|
|
f.metadata.contents, |
|
|
|
|
|
Prism.languages[f.metadata.scheme], |
|
|
|
|
|
f.metadata.scheme, |
|
|
|
|
|
), |
|
|
|
|
|
}} |
|
|
|
|
|
style={{ |
|
|
|
|
|
tabSize: 2, |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
!f.metadata.scheme |
|
|
|
|
|
&& ( |
|
|
|
|
|
<code> |
|
|
|
|
|
{f.metadata.contents} |
|
|
|
|
|
</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" |
|
|
|
|
|
> |
|
|
|
|
|
{typeof f.metadata?.schemeTitle === 'string' ? `${f.metadata.schemeTitle} Source` : 'Text File'} |
|
|
|
|
|
</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> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
contentType === ContentType.IMAGE |
|
|
|
|
|
&& ( |
|
|
|
|
|
<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' |
|
|
|
|
|
&& ( |
|
|
|
|
|
<img |
|
|
|
|
|
className="block w-full h-full object-center object-cover" |
|
|
|
|
|
src={f.metadata.previewUrl} |
|
|
|
|
|
alt={f.name} |
|
|
|
|
|
data-testid="preview" |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</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)} |
|
|
|
|
|
</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.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} |
|
|
|
|
|
/> |
|
|
|
|
|
<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"> |
|
|
|
|
|
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)} |
|
|
|
|
|
</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?.duration === 'number' |
|
|
|
|
|
&& ( |
|
|
|
|
|
<div className="w-full"> |
|
|
|
|
|
<dt className="sr-only"> |
|
|
|
|
|
Duration |
|
|
|
|
|
</dt> |
|
|
|
|
|
<dd |
|
|
|
|
|
className="m-0 w-full text-ellipsis overflow-hidden" |
|
|
|
|
|
title={`${formatNumeral(f.metadata.duration)} seconds`} |
|
|
|
|
|
> |
|
|
|
|
|
{formatSecondsDuration(f.metadata.duration)} |
|
|
|
|
|
</dd> |
|
|
|
|
|
</div> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</dl> |
|
|
|
|
|
</div> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const FilePreviewGrid = ({ |
|
|
|
|
|
fileList, |
|
|
|
|
|
}: { fileList?: FileList }) => { |
|
|
|
|
|
const { files } = useFilePreviews(fileList); |
|
|
|
|
|
const mediaContainerRef = React.useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
|
|
|
|
const playMedia = (index: number) => () => { |
|
|
|
|
|
if (files.length < 1) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
const theFile = files[index]; |
|
|
|
|
|
if (typeof theFile.internal?.playPause === 'function') { |
|
|
|
|
|
theFile.internal.playPause(); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
|
|
if (files.length < 1) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
files.forEach((theFile, i) => { |
|
|
|
|
|
if (typeof theFile.internal?.createMediaInstance === 'function' && mediaContainerRef.current) { |
|
|
|
|
|
theFile.internal.createMediaInstance(mediaContainerRef.current.children[i].children[0]); |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
return () => { |
|
|
|
|
|
files.forEach((theFile) => { |
|
|
|
|
|
if (typeof theFile.internal?.destroyMediaInstance === 'function') { |
|
|
|
|
|
theFile.internal.destroyMediaInstance(); |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
}, [files, mediaContainerRef]); |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<div className="w-full h-full overflow-auto -mx-4 px-4"> |
|
|
|
|
|
<div |
|
|
|
|
|
className={`w-full grid gap-4 grid-cols-3`} |
|
|
|
|
|
ref={mediaContainerRef} |
|
|
|
|
|
> |
|
|
|
|
|
{files.map((f, i) => ( |
|
|
|
|
|
<div |
|
|
|
|
|
data-testid="selectedFileItem" |
|
|
|
|
|
key={i} |
|
|
|
|
|
className={`w-full aspect-square rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`} |
|
|
|
|
|
title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} |
|
|
|
|
|
> |
|
|
|
|
|
{ |
|
|
|
|
|
f.type?.startsWith('image/') |
|
|
|
|
|
&& typeof f.metadata?.previewUrl === 'string' |
|
|
|
|
|
&& ( |
|
|
|
|
|
<img |
|
|
|
|
|
className="block w-full h-full object-center object-cover" |
|
|
|
|
|
src={f.metadata.previewUrl} |
|
|
|
|
|
alt={f.name} |
|
|
|
|
|
data-testid="preview" |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
f.type?.startsWith('audio/') |
|
|
|
|
|
&& ( |
|
|
|
|
|
<div |
|
|
|
|
|
className="absolute top-0 left-0 w-full h-full cursor-pointer" |
|
|
|
|
|
onClick={playMedia(i)} |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</div> |
|
|
|
|
|
))} |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>( |
|
|
|
|
|
( |
|
|
|
|
|
{ |
|
|
|
|
|
label = '', |
|
|
|
|
|
hint = '', |
|
|
|
|
|
border = false, |
|
|
|
|
|
block = false, |
|
|
|
|
|
enhanced = false, |
|
|
|
|
|
hiddenLabel = false, |
|
|
|
|
|
multiple = false, |
|
|
|
|
|
onChange, |
|
|
|
|
|
disabled = false, |
|
|
|
|
|
className: _className, |
|
|
|
|
|
placeholder: _placeholder, |
|
|
|
|
|
as: _as, |
|
|
|
|
|
...etcProps |
|
|
|
|
|
}: FileButtonProps, |
|
|
|
|
|
forwardedRef, |
|
|
|
|
|
) => { |
|
|
|
|
|
const [isEnhanced, setIsEnhanced] = React.useState(false); |
|
|
|
|
|
const [fileList, setFileList] = React.useState<FileList>(); |
|
|
|
|
|
const defaultRef = React.useRef<HTMLInputElement>(null); |
|
|
|
|
|
const ref = forwardedRef ?? defaultRef; |
|
|
|
|
|
|
|
|
|
|
|
const addFile: React.ChangeEventHandler<HTMLInputElement> = (e) => { |
|
|
|
|
|
if (!enhanced) { |
|
|
|
|
|
onChange?.(e); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setFileList(e.currentTarget.files as FileList); |
|
|
|
|
|
onChange?.(e); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const deleteFiles: React.MouseEventHandler<HTMLButtonElement> = () => { |
|
|
|
|
|
if (typeof ref === 'object' && ref.current) { |
|
|
|
|
|
ref.current.value = ''; |
|
|
|
|
|
setFileList(undefined); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const cancelEvent = (e: React.DragEvent) => { |
|
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
e.preventDefault(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const handleDropZone: React.DragEventHandler<HTMLDivElement> = async (e) => { |
|
|
|
|
|
cancelEvent(e); |
|
|
|
|
|
const { dataTransfer } = e; |
|
|
|
|
|
if (typeof ref === 'object' && ref.current) { |
|
|
|
|
|
const { files } = dataTransfer; |
|
|
|
|
|
setFileList(ref.current.files = files); |
|
|
|
|
|
ref.current.dispatchEvent(new Event('change')); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
|
|
setIsEnhanced(enhanced); |
|
|
|
|
|
}, [enhanced]); |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<div |
|
|
|
|
|
className={BlobBase.Root({ |
|
|
|
|
|
border, |
|
|
|
|
|
block, |
|
|
|
|
|
})} |
|
|
|
|
|
onDragEnter={cancelEvent} |
|
|
|
|
|
onDragOver={cancelEvent} |
|
|
|
|
|
onDrop={handleDropZone} |
|
|
|
|
|
data-testid="root" |
|
|
|
|
|
> |
|
|
|
|
|
<label |
|
|
|
|
|
className="block absolute top-0 left-0 w-full h-full cursor-pointer" |
|
|
|
|
|
data-testid="clickArea" |
|
|
|
|
|
> |
|
|
|
|
|
<input |
|
|
|
|
|
{...etcProps} |
|
|
|
|
|
disabled={disabled} |
|
|
|
|
|
ref={ref} |
|
|
|
|
|
type="file" |
|
|
|
|
|
className={`${enhanced ? 'sr-only' : ''}`} |
|
|
|
|
|
onChange={addFile} |
|
|
|
|
|
multiple={multiple} |
|
|
|
|
|
data-testid="input" |
|
|
|
|
|
/> |
|
|
|
|
|
</label> |
|
|
|
|
|
{ |
|
|
|
|
|
border && ( |
|
|
|
|
|
<span |
|
|
|
|
|
data-testid="border" |
|
|
|
|
|
className={ |
|
|
|
|
|
BlobBase.Border({ |
|
|
|
|
|
border, |
|
|
|
|
|
block, |
|
|
|
|
|
}) |
|
|
|
|
|
} |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
label |
|
|
|
|
|
&& !hiddenLabel |
|
|
|
|
|
&& ( |
|
|
|
|
|
<div |
|
|
|
|
|
data-testid="label" |
|
|
|
|
|
className={BlobBase.LabelWrapper({ |
|
|
|
|
|
border, |
|
|
|
|
|
block, |
|
|
|
|
|
})} |
|
|
|
|
|
> |
|
|
|
|
|
{label} |
|
|
|
|
|
</div> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
(fileList?.length ?? 0) < 1 |
|
|
|
|
|
&& isEnhanced |
|
|
|
|
|
&& hint |
|
|
|
|
|
&& ( |
|
|
|
|
|
<div |
|
|
|
|
|
data-testid="hint" |
|
|
|
|
|
className="absolute top-0 left-0 w-full h-full pointer-events-none box-border overflow-hidden pt-4" |
|
|
|
|
|
> |
|
|
|
|
|
<div className="flex items-center justify-center w-full h-full"> |
|
|
|
|
|
{hint} |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
(fileList?.length ?? 0) > 0 |
|
|
|
|
|
&& isEnhanced |
|
|
|
|
|
&& ( |
|
|
|
|
|
<> |
|
|
|
|
|
<div className={`absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8`}> |
|
|
|
|
|
<div className={`pointer-events-auto w-full h-full px-4 pb-4 box-border`}> |
|
|
|
|
|
{ |
|
|
|
|
|
multiple |
|
|
|
|
|
&& ( |
|
|
|
|
|
<FilePreviewGrid fileList={fileList} /> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
{ |
|
|
|
|
|
!multiple |
|
|
|
|
|
&& ( |
|
|
|
|
|
<FilePreview fileList={fileList} /> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="pointer-events-none absolute bottom-0 left-0 w-full text-center h-12 box-border flex"> |
|
|
|
|
|
<div className="w-0 flex-auto flex flex-col items-center justify-center h-full"> |
|
|
|
|
|
<span |
|
|
|
|
|
className={ButtonBase.Button({ |
|
|
|
|
|
size: ButtonBase.ButtonSize.MEDIUM, |
|
|
|
|
|
border: false, |
|
|
|
|
|
block: true, |
|
|
|
|
|
variant: ButtonBase.ButtonVariant.OUTLINE, |
|
|
|
|
|
disabled, |
|
|
|
|
|
compact: false, |
|
|
|
|
|
menuItem: false, |
|
|
|
|
|
})} |
|
|
|
|
|
> |
|
|
|
|
|
Reselect |
|
|
|
|
|
</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="pointer-events-auto w-0 flex-auto flex flex-col items-center justify-center h-full"> |
|
|
|
|
|
<button |
|
|
|
|
|
data-testid="clear" |
|
|
|
|
|
type="button" |
|
|
|
|
|
onClick={deleteFiles} |
|
|
|
|
|
className={ButtonBase.Button({ |
|
|
|
|
|
size: ButtonBase.ButtonSize.MEDIUM, |
|
|
|
|
|
border: false, |
|
|
|
|
|
block: true, |
|
|
|
|
|
variant: ButtonBase.ButtonVariant.OUTLINE, |
|
|
|
|
|
disabled, |
|
|
|
|
|
compact: false, |
|
|
|
|
|
menuItem: false, |
|
|
|
|
|
})} |
|
|
|
|
|
> |
|
|
|
|
|
{multiple ? 'Clear' : 'Delete'} |
|
|
|
|
|
</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
</div> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
FileSelectBox.displayName = 'FileSelectBox'; |
|
|
|
|
|
|
|
|
|
|
|
const BlobReact = { |
|
|
|
|
|
FileSelectBox |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const BlobPage: NextPage = () => { |
|
|
|
|
|
return ( |
|
|
|
|
|
<BlobReact.FileSelectBox |
|
|
|
|
|
border |
|
|
|
|
|
enhanced |
|
|
|
|
|
label="Primary Image" |
|
|
|
|
|
hint="Select any files here" |
|
|
|
|
|
block |
|
|
|
|
|
/> |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
export default BlobPage; |
|
|
export default BlobPage; |