first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import {ImageFilePreview} from './file-preview/image-file-preview';
import {FileEntry} from '../file-entry';
import {DefaultFilePreview} from './file-preview/default-file-preview';
import {TextFilePreview} from './file-preview/text-file-preview';
import {VideoFilePreview} from './file-preview/video-file-preview';
import {AudioFilePreview} from './file-preview/audio-file-preview';
import {PdfFilePreview} from './file-preview/pdf-file-preview';
import {WordDocumentFilePreview} from './file-preview/word-document-file-preview';
export const AvailablePreviews = {
text: TextFilePreview,
video: VideoFilePreview,
audio: AudioFilePreview,
image: ImageFilePreview,
pdf: PdfFilePreview,
spreadsheet: WordDocumentFilePreview,
powerPoint: WordDocumentFilePreview,
word: WordDocumentFilePreview,
'text/rtf': DefaultFilePreview,
} as const;
export function getPreviewForEntry(entry: FileEntry) {
const mime = entry?.mime as keyof typeof AvailablePreviews;
const type = entry?.type as keyof typeof AvailablePreviews;
return (
AvailablePreviews[mime] || AvailablePreviews[type] || DefaultFilePreview
);
}

View File

@@ -0,0 +1,209 @@
import {AnimatePresence, m} from 'framer-motion';
import {Fragment, ReactNode, useContext, useMemo} from 'react';
import clsx from 'clsx';
import {getPreviewForEntry} from './available-previews';
import {FileEntry} from '../file-entry';
import {FilePreviewContext} from './file-preview-context';
import {IconButton} from '../../ui/buttons/icon-button';
import {ChevronLeftIcon} from '../../icons/material/ChevronLeft';
import {ChevronRightIcon} from '../../icons/material/ChevronRight';
import {FileDownloadIcon} from '../../icons/material/FileDownload';
import {downloadFileFromUrl} from '../utils/download-file-from-url';
import {useFileEntryUrls} from '../hooks/file-entry-urls';
import {Trans} from '../../i18n/trans';
import {Button} from '../../ui/buttons/button';
import {CloseIcon} from '../../icons/material/Close';
import {FileThumbnail} from '../file-type-icon/file-thumbnail';
import {useMediaQuery} from '../../utils/hooks/use-media-query';
import {KeyboardArrowLeftIcon} from '../../icons/material/KeyboardArrowLeft';
import {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';
import {useControlledState} from '@react-stately/utils';
import {opacityAnimation} from '../../ui/animation/opacity-animation';
export interface FilePreviewContainerProps {
entries: FileEntry[];
activeIndex?: number;
defaultActiveIndex?: number;
onActiveIndexChange?: (index: number) => void;
onClose?: () => void;
showHeader?: boolean;
headerActionsLeft?: ReactNode;
className?: string;
allowDownload?: boolean;
}
export function FilePreviewContainer({
entries,
onClose,
showHeader = true,
className,
headerActionsLeft,
allowDownload = true,
...props
}: FilePreviewContainerProps) {
const isMobile = useMediaQuery('(max-width: 1024px)');
const [activeIndex, setActiveIndex] = useControlledState(
props.activeIndex,
props.defaultActiveIndex || 0,
props.onActiveIndexChange
);
const activeEntry = entries[activeIndex];
const contextValue = useMemo(() => {
return {entries, activeIndex};
}, [entries, activeIndex]);
const Preview = getPreviewForEntry(activeEntry);
if (!activeEntry) {
onClose?.();
return null;
}
const canOpenNext = entries.length - 1 > activeIndex;
const openNext = () => {
setActiveIndex(activeIndex + 1);
};
const canOpenPrevious = activeIndex > 0;
const openPrevious = () => {
setActiveIndex(activeIndex - 1);
};
return (
<FilePreviewContext.Provider value={contextValue}>
{showHeader && (
<Header
actionsLeft={headerActionsLeft}
isMobile={isMobile}
onClose={onClose}
onNext={canOpenNext ? openNext : undefined}
onPrevious={canOpenPrevious ? openPrevious : undefined}
allowDownload={allowDownload}
/>
)}
<div className={clsx('overflow-hidden relative flex-auto', className)}>
{isMobile && (
<IconButton
size="lg"
className="text-muted absolute left-0 top-1/2 transform -translate-y-1/2 z-10"
disabled={!canOpenPrevious}
onClick={openPrevious}
>
<KeyboardArrowLeftIcon />
</IconButton>
)}
<AnimatePresence initial={false}>
<m.div
className="absolute inset-0 flex items-center justify-center"
key={activeEntry.id}
{...opacityAnimation}
>
<Preview
className="max-h-[calc(100%-30px)]"
entry={activeEntry}
allowDownload={allowDownload}
/>
</m.div>
</AnimatePresence>
{isMobile && (
<IconButton
size="lg"
className="text-muted absolute right-0 top-1/2 transform -translate-y-1/2 z-10"
disabled={!canOpenNext}
onClick={openNext}
>
<KeyboardArrowRightIcon />
</IconButton>
)}
</div>
</FilePreviewContext.Provider>
);
}
interface HeaderProps {
onNext?: () => void;
onPrevious?: () => void;
onClose?: () => void;
isMobile: boolean | null;
actionsLeft?: ReactNode;
allowDownload?: boolean;
}
function Header({
onNext,
onPrevious,
onClose,
isMobile,
actionsLeft,
allowDownload,
}: HeaderProps) {
const {entries, activeIndex} = useContext(FilePreviewContext);
const activeEntry = entries[activeIndex];
const {downloadUrl} = useFileEntryUrls(activeEntry);
const desktopDownloadButton = (
<Button
startIcon={<FileDownloadIcon />}
variant="text"
onClick={() => {
if (downloadUrl) {
downloadFileFromUrl(downloadUrl);
}
}}
>
<Trans message="Download" />
</Button>
);
const mobileDownloadButton = (
<IconButton
onClick={() => {
if (downloadUrl) {
downloadFileFromUrl(downloadUrl);
}
}}
>
<FileDownloadIcon />
</IconButton>
);
const downloadButton = isMobile
? mobileDownloadButton
: desktopDownloadButton;
return (
<div className="flex items-center justify-between gap-20 bg-paper border-b flex-shrink-0 text-sm min-h-50 px-10 text-muted">
<div className="flex items-center gap-4 w-1/3 justify-start">
{actionsLeft}
{allowDownload ? downloadButton : undefined}
</div>
<div className="flex items-center gap-10 w-1/3 justify-center flex-nowrap text-main">
<FileThumbnail
file={activeEntry}
iconClassName="w-16 h-16"
showImage={false}
/>
<div className="whitespace-nowrap overflow-hidden overflow-ellipsis">
{activeEntry.name}
</div>
</div>
<div className="w-1/3 flex items-center gap-10 justify-end whitespace-nowrap">
{!isMobile && (
<Fragment>
<IconButton disabled={!onPrevious} onClick={onPrevious}>
<ChevronLeftIcon />
</IconButton>
<div>{activeIndex + 1}</div>
<div>/</div>
<div>{entries.length}</div>
<IconButton disabled={!onNext} onClick={onNext}>
<ChevronRightIcon />
</IconButton>
<div className="bg-divider w-1 h-24 mx-20" />
</Fragment>
)}
<IconButton radius="rounded-none" onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import {FileEntry} from '../file-entry';
export interface FilePreviewContextValue {
entries: FileEntry[];
activeIndex: number;
}
export const FilePreviewContext = React.createContext<FilePreviewContextValue>(
null!
);

View File

@@ -0,0 +1,24 @@
import {
FilePreviewContainer,
FilePreviewContainerProps,
} from './file-preview-container';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Dialog} from '../../ui/overlays/dialog/dialog';
interface Props extends Omit<FilePreviewContainerProps, 'onClose'> {}
export function FilePreviewDialog(props: Props) {
return (
<Dialog
size="fullscreenTakeover"
background="bg-alt"
className="flex flex-col"
>
<Content {...props} />
</Dialog>
);
}
function Content(props: Props) {
const {close} = useDialogContext();
return <FilePreviewContainer onClose={close} {...props} />;
}

View File

@@ -0,0 +1,37 @@
import {FilePreviewProps} from './file-preview-props';
import {DefaultFilePreview} from './default-file-preview';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
import {useEffect, useRef, useState} from 'react';
export function AudioFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {previewUrl} = useFileEntryUrls(entry);
const ref = useRef<HTMLAudioElement>(null);
const [mediaInvalid, setMediaInvalid] = useState(false);
useEffect(() => {
setMediaInvalid(!ref.current?.canPlayType(entry.mime));
}, [entry]);
if (mediaInvalid || !previewUrl) {
return <DefaultFilePreview {...props} />;
}
return (
<audio
className={className}
ref={ref}
controls
controlsList="nodownload noremoteplayback"
autoPlay
>
<source
src={previewUrl}
type={entry.mime}
onError={() => {
setMediaInvalid(true);
}}
/>
</audio>
);
}

View File

@@ -0,0 +1,43 @@
import {ReactNode, useContext} from 'react';
import clsx from 'clsx';
import {Button} from '../../../ui/buttons/button';
import {downloadFileFromUrl} from '../../utils/download-file-from-url';
import {FilePreviewContext} from '../file-preview-context';
import {Trans} from '../../../i18n/trans';
import {FilePreviewProps} from './file-preview-props';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
interface Props extends FilePreviewProps {
message?: ReactNode;
}
export function DefaultFilePreview({message, className, allowDownload}: Props) {
const {entries, activeIndex} = useContext(FilePreviewContext);
const activeEntry = entries[activeIndex];
const content = message || <Trans message="No file preview available" />;
const {downloadUrl} = useFileEntryUrls(activeEntry);
return (
<div
className={clsx(
className,
'shadow bg-paper max-w-400 w-[calc(100%-40px)] text-center p-40 rounded'
)}
>
<div className="text-lg">{content}</div>
{allowDownload && (
<div className="block mt-20 text-center">
<Button
variant="flat"
color="primary"
onClick={() => {
if (downloadUrl) {
downloadFileFromUrl(downloadUrl);
}
}}
>
<Trans message="Download" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import {FileEntry} from '../../file-entry';
export interface FilePreviewProps {
entry: FileEntry;
className?: string;
allowDownload?: boolean;
}

View File

@@ -0,0 +1,26 @@
import clsx from 'clsx';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
import {useTrans} from '../../../i18n/use-trans';
import {FilePreviewProps} from './file-preview-props';
import {DefaultFilePreview} from './default-file-preview';
export function ImageFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {trans} = useTrans();
const {previewUrl} = useFileEntryUrls(entry);
if (!previewUrl) {
return <DefaultFilePreview {...props} />;
}
return (
<img
className={clsx(className, 'shadow')}
src={previewUrl}
alt={trans({
message: 'Preview for :name',
values: {name: entry.name},
})}
/>
);
}

View File

@@ -0,0 +1,26 @@
import clsx from 'clsx';
import {FilePreviewProps} from './file-preview-props';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
import {useTrans} from '../../../i18n/use-trans';
import {DefaultFilePreview} from './default-file-preview';
export function PdfFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {trans} = useTrans();
const {previewUrl} = useFileEntryUrls(entry);
if (!previewUrl) {
return <DefaultFilePreview {...props} />;
}
return (
<iframe
title={trans({
message: 'Preview for :name',
values: {name: entry.name},
})}
className={clsx(className, 'w-full h-full')}
src={`${previewUrl}#toolbar=0`}
/>
);
}

View File

@@ -0,0 +1,91 @@
import {useEffect, useState} from 'react';
import clsx from 'clsx';
import {FilePreviewProps} from './file-preview-props';
import {DefaultFilePreview} from './default-file-preview';
import {ProgressCircle} from '@common/ui/progress/progress-circle';
import {useFileEntryUrls} from '@common/uploads/hooks/file-entry-urls';
import {useTrans} from '@common/i18n/use-trans';
import {Trans} from '@common/i18n/trans';
import {apiClient} from '@common/http/query-client';
const FIVE_MB = 5242880;
export function TextFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {trans} = useTrans();
const [tooLarge, setTooLarge] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isFailed, setIsFailed] = useState(false);
const [contents, setContents] = useState<string | null>(null);
const {previewUrl} = useFileEntryUrls(entry);
useEffect(() => {
if (!entry) return;
if (!previewUrl) {
setIsFailed(true);
} else if (entry.file_size! >= FIVE_MB) {
setTooLarge(true);
setIsLoading(false);
} else {
getFileContents(previewUrl)
.then(response => {
setContents(response.data);
})
.catch(() => {
setIsFailed(true);
})
.finally(() => {
setIsLoading(false);
});
}
}, [entry, previewUrl]);
if (isLoading) {
return (
<ProgressCircle
isIndeterminate
aria-label={trans({message: 'Loading file contents'})}
/>
);
}
if (tooLarge) {
return (
<DefaultFilePreview
{...props}
message={<Trans message="This file is too large to preview." />}
/>
);
}
if (isFailed) {
return (
<DefaultFilePreview
{...props}
message={<Trans message="There was an issue previewing this file" />}
/>
);
}
return (
<pre
className={clsx(
'rounded bg-paper p-20 text-sm whitespace-pre-wrap break-words h-full overflow-y-auto w-full',
className
)}
>
<div className="container mx-auto">{`${contents}`}</div>
</pre>
);
}
function getFileContents(src: string) {
return apiClient.get(src, {
responseType: 'text',
// required for s3 presigned url to work
withCredentials: false,
headers: {
Accept: 'text/plain',
},
});
}

View File

@@ -0,0 +1,38 @@
import {useEffect, useRef, useState} from 'react';
import {FilePreviewProps} from './file-preview-props';
import {DefaultFilePreview} from './default-file-preview';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
export function VideoFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {previewUrl} = useFileEntryUrls(entry);
const ref = useRef<HTMLVideoElement>(null);
const [mediaInvalid, setMediaInvalid] = useState(false);
useEffect(() => {
setMediaInvalid(!ref.current?.canPlayType(entry.mime));
}, [entry]);
if (mediaInvalid || !previewUrl) {
return <DefaultFilePreview {...props} />;
}
return (
<video
className={className}
ref={ref}
controls
controlsList="nodownload noremoteplayback"
playsInline
autoPlay
>
<source
src={previewUrl}
type={entry.mime}
onError={() => {
setMediaInvalid(true);
}}
/>
</video>
);
}

View File

@@ -0,0 +1,87 @@
import clsx from 'clsx';
import {useEffect, useRef, useState} from 'react';
import {FilePreviewProps} from './file-preview-props';
import {DefaultFilePreview} from './default-file-preview';
import {ProgressCircle} from '../../../ui/progress/progress-circle';
import {FileEntry} from '../../file-entry';
import {useFileEntryUrls} from '../../hooks/file-entry-urls';
import {useTrans} from '../../../i18n/use-trans';
import {apiClient} from '../../../http/query-client';
export function WordDocumentFilePreview(props: FilePreviewProps) {
const {entry, className} = props;
const {trans} = useTrans();
const ref = useRef<HTMLIFrameElement>(null);
const [showDefault, setShowDefault] = useState(false);
const timeoutId = useRef<any>();
const [isLoading, setIsLoading] = useState(false);
const {previewUrl} = useFileEntryUrls(entry);
useEffect(() => {
// Google Docs viewer only supports files up to 25MB
if (!previewUrl) {
setShowDefault(true);
} else if (entry.file_size && entry.file_size > 25000000) {
setShowDefault(true);
} else if (ref.current) {
ref.current.onload = () => {
clearTimeout(timeoutId.current);
setIsLoading(false);
};
buildPreviewUrl(previewUrl, entry).then(url => {
if (ref.current) {
ref.current.src = url;
}
});
// if preview iframe is not loaded
// after 5 seconds, bail and show default preview
timeoutId.current = setTimeout(() => {
setShowDefault(true);
}, 5000);
}
}, [entry, previewUrl]);
if (showDefault) {
return <DefaultFilePreview {...props} />;
}
return (
<div className={clsx(className, 'w-full h-full')}>
{isLoading && <ProgressCircle />}
<iframe
ref={ref}
title={trans({
message: 'Preview for :name',
values: {name: entry.name},
})}
className={clsx('w-full h-full', isLoading && 'hidden')}
/>
</div>
);
}
async function buildPreviewUrl(
urlString: string,
entry: FileEntry
): Promise<string> {
const url = new URL(urlString);
// if we're not trying to preview shareable link we will need to generate
// preview token, otherwise it won't be publicly accessible
if (!url.searchParams.has('shareable_link')) {
const {data} = await apiClient.post(
`file-entries/${entry.id}/add-preview-token`
);
url.searchParams.append('preview_token', data.preview_token);
}
return buildOfficeLivePreviewUrl(url);
}
function buildOfficeLivePreviewUrl(url: URL) {
// https://docs.google.com/gview?embedded=true&url=
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
url.toString()
)}`;
}