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,25 @@
import {Tag} from '../tags/tag';
export interface FileEntry {
id: number;
name: string;
mime: string;
url: string;
hash: string;
extension?: string;
type: 'folder' | string;
public?: boolean;
description?: string;
path: string;
tags?: Tag[];
file_name: string;
file_size?: number;
parent_id: number | null;
thumbnail?: boolean;
parent?: FileEntry;
deleted_at?: string;
created_at?: string;
updated_at?: string;
children?: FileEntry[];
users?: {id: number; email: string}[];
}

View File

@@ -0,0 +1,47 @@
.image-file-color {
color: #f44336;
}
.audio-file-color {
color: #ff9800;
}
.video-file-color {
color: #f44336;
}
.text-file-color {
color: #2196f3;
}
.default-file-color {
color: #757575;
}
.folder-file-color {
color: #fbc02d;
}
.shared-folder-file-color {
color: #fbc02d;
}
.archive-file-color {
color: #fbc02d;
}
.pdf-file-color {
color: #f44336;
}
.power-point-file-color {
color: #f57f17;
}
.word-file-color {
color: #2196f3;
}
.spreadsheet-file-color {
color: #00897b;
}

View File

@@ -0,0 +1,44 @@
import clsx from 'clsx';
import {FileTypeIcon} from './file-type-icon';
import {useFileEntryUrls} from '../hooks/file-entry-urls';
import {useTrans} from '../../i18n/use-trans';
import {FileEntry} from '../file-entry';
const TwoMB = 2 * 1024 * 1024;
interface Props {
file: FileEntry;
className?: string;
iconClassName?: string;
showImage?: boolean;
}
export function FileThumbnail({
file,
className,
iconClassName,
showImage = true,
}: Props) {
const {trans} = useTrans();
const {previewUrl} = useFileEntryUrls(file, {thumbnail: true});
// don't show images for files larger than 2MB, if thumbnail was not generated to avoid ui lag
if (file.file_size && file.file_size > TwoMB && !file.thumbnail) {
showImage = false;
}
if (showImage && file.type === 'image' && previewUrl) {
const alt = trans({
message: ':fileName thumbnail',
values: {fileName: file.name},
});
return (
<img
className={clsx(className, 'object-cover')}
src={previewUrl}
alt={alt}
draggable={false}
/>
);
}
return <FileTypeIcon className={iconClassName} type={file.type} />;
}

View File

@@ -0,0 +1,50 @@
import clsx from 'clsx';
import {DefaultFileIcon} from './icons/default-file-icon';
import {AudioFileIcon} from './icons/audio-file-icon';
import {VideoFileIcon} from './icons/video-file-icon';
import {TextFileIcon} from './icons/text-file-icon';
import {PdfFileIcon} from './icons/pdf-file-icon';
import {ArchiveFileIcon} from './icons/archive-file-icon';
import {FolderFileIcon} from './icons/folder-file-icon';
import {ImageFileIcon} from './icons/image-file-icon';
import {PowerPointFileIcon} from './icons/power-point-file-icon';
import {WordFileIcon} from './icons/word-file-icon';
import {SpreadsheetFileIcon} from './icons/spreadsheet-file-icon';
import {SharedFolderFileIcon} from './icons/shared-folder-file-icon';
import {IconSize} from '@common/icons/svg-icon';
interface Props {
type?: string;
mime?: string | null;
className?: string;
size?: IconSize;
}
export function FileTypeIcon({type, mime, className, size}: Props) {
if (!type && mime) {
type = mime.split('/')[0];
}
// @ts-ignore
const Icon = FileTypeIcons[type] || FileTypeIcons.default;
return (
<Icon
size={size}
className={clsx(className, `${type}-file-color`)}
viewBox="0 0 64 64"
/>
);
}
const FileTypeIcons = {
default: DefaultFileIcon,
audio: AudioFileIcon,
video: VideoFileIcon,
text: TextFileIcon,
pdf: PdfFileIcon,
archive: ArchiveFileIcon,
folder: FolderFileIcon,
sharedFolder: SharedFolderFileIcon,
image: ImageFileIcon,
powerPoint: PowerPointFileIcon,
word: WordFileIcon,
spreadsheet: SpreadsheetFileIcon,
};

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const ArchiveFileIcon = createSvgIcon(
<g>
<path d="M 21.65625 4 C 20.320313 4 19.066406 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 36 6 L 36 27.59375 C 35.144531 27.222656 34.210938 27 33.226563 27 L 32.773438 27 C 31.789063 27 30.859375 27.222656 30 27.59375 L 30 9 C 30 8.449219 29.554688 8 29 8 C 28.449219 8 28 8.449219 28 9 L 28 28.902344 C 27.015625 29.824219 26.277344 31.023438 25.953125 32.425781 L 24.875 37.097656 C 24.597656 38.292969 24.878906 39.53125 25.640625 40.488281 C 26.40625 41.449219 27.546875 42 28.769531 42 L 37.230469 42 C 38.457031 42 39.59375 41.449219 40.359375 40.488281 C 41.121094 39.53125 41.402344 38.292969 41.125 37.097656 L 40.046875 32.425781 C 39.726563 31.023438 38.984375 29.824219 38 28.902344 L 38 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.609375 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.738281 19.835938 6.609375 20 6.5 Z M 32 8 C 31.449219 8 31 8.445313 31 9 C 31 9.554688 31.449219 10 32 10 L 34 10 C 34.550781 10 35 9.554688 35 9 C 35 8.445313 34.550781 8 34 8 Z M 32 13 C 31.449219 13 31 13.445313 31 14 C 31 14.554688 31.449219 15 32 15 L 34 15 C 34.550781 15 35 14.554688 35 14 C 35 13.445313 34.550781 13 34 13 Z M 32 18 C 31.449219 18 31 18.445313 31 19 C 31 19.554688 31.449219 20 32 20 L 34 20 C 34.550781 20 35 19.554688 35 19 C 35 18.445313 34.550781 18 34 18 Z M 32 23 C 31.449219 23 31 23.445313 31 24 C 31 24.554688 31.449219 25 32 25 L 34 25 C 34.550781 25 35 24.554688 35 24 C 35 23.445313 34.550781 23 34 23 Z M 32.773438 29 L 33.226563 29 C 35.570313 29 37.574219 30.59375 38.097656 32.875 L 39.175781 37.550781 C 39.316406 38.148438 39.175781 38.765625 38.796875 39.246094 C 38.414063 39.722656 37.839844 40 37.230469 40 L 28.769531 40 C 28.160156 40 27.589844 39.722656 27.207031 39.246094 C 26.824219 38.765625 26.683594 38.148438 26.824219 37.550781 L 27.902344 32.875 C 28.429688 30.59375 30.429688 29 32.773438 29 Z M 31 34 C 30.449219 34 30 34.445313 30 35 C 30 35.554688 30.449219 36 31 36 L 35 36 C 35.550781 36 36 35.554688 36 35 C 36 34.445313 35.550781 34 35 34 Z M 13 52 C 12.449219 52 12 52.445313 12 53 C 12 53.554688 12.449219 54 13 54 L 17 54 C 17.550781 54 18 53.554688 18 53 C 18 52.445313 17.550781 52 17 52 Z M 21 52 C 20.449219 52 20 52.445313 20 53 C 20 53.554688 20.449219 54 21 54 L 49 54 C 49.550781 54 50 53.554688 50 53 C 50 52.445313 49.550781 52 49 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const AudioFileIcon = createSvgIcon(
<g>
<path d="M 21.65625 4 C 20.320313 4 19.0625 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.609375 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.734375 19.835938 6.609375 20 6.5 Z M 42.78125 18.023438 L 24.78125 22.023438 C 24.328125 22.125 24 22.53125 24 23 L 24 37 C 20.691406 37 18 39.242188 18 42 C 18 44.757813 20.691406 47 24 47 C 27.308594 47 30 44.757813 30 42 L 30 29.910156 L 38 28.136719 L 38 33 C 34.691406 33 32 35.242188 32 38 C 32 40.757813 34.691406 43 38 43 C 41.308594 43 44 40.757813 44 38 L 44 19 C 44 18.695313 43.863281 18.410156 43.625 18.21875 C 43.390625 18.03125 43.082031 17.960938 42.78125 18.023438 Z M 42 20.246094 L 42 38 C 42 39.652344 40.207031 41 38 41 C 35.792969 41 34 39.652344 34 38 C 34 36.347656 35.792969 35 38 35 C 38.28125 35 38.5625 35.023438 38.839844 35.066406 C 39.128906 35.117188 39.421875 35.03125 39.648438 34.84375 C 39.871094 34.652344 40 34.375 40 34.078125 L 40 26.890625 C 40 26.585938 39.863281 26.300781 39.625 26.109375 C 39.390625 25.921875 39.078125 25.847656 38.78125 25.910156 L 28.78125 28.136719 C 28.328125 28.238281 28 28.644531 28 29.109375 L 28 42 C 28 43.652344 26.207031 45 24 45 C 21.792969 45 20 43.652344 20 42 C 20 40.347656 21.792969 39 24 39 C 24.28125 39 24.5625 39.023438 24.839844 39.066406 C 25.128906 39.117188 25.425781 39.03125 25.648438 38.84375 C 25.871094 38.652344 26 38.375 26 38.078125 L 26 23.800781 Z M 13 52 C 12.449219 52 12 52.445313 12 53 L 12 55 C 12 55.554688 12.449219 56 13 56 C 13.550781 56 14 55.554688 14 55 L 14 53 C 14 52.445313 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.445313 17 53 L 17 55 C 17 55.554688 17.449219 56 18 56 C 18.550781 56 19 55.554688 19 55 L 19 53 C 19 52.445313 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.445313 22 53 L 22 55 C 22 55.554688 22.449219 56 23 56 C 23.550781 56 24 55.554688 24 55 L 24 53 C 24 52.445313 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.445313 27 53 L 27 55 C 27 55.554688 27.449219 56 28 56 C 28.550781 56 29 55.554688 29 55 L 29 53 C 29 52.445313 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.445313 32 53 L 32 55 C 32 55.554688 32.449219 56 33 56 C 33.550781 56 34 55.554688 34 55 L 34 53 C 34 52.445313 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.445313 37 53 L 37 55 C 37 55.554688 37.449219 56 38 56 C 38.550781 56 39 55.554688 39 55 L 39 53 C 39 52.445313 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.445313 42 53 L 42 55 C 42 55.554688 42.449219 56 43 56 C 43.550781 56 44 55.554688 44 55 L 44 53 C 44 52.445313 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.445313 47 53 L 47 55 C 47 55.554688 47.449219 56 48 56 C 48.550781 56 49 55.554688 49 55 L 49 53 C 49 52.445313 48.550781 52 48 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const DefaultFileIcon = createSvgIcon(
<g>
<path d="M 23.65625 4 C 22.320313 4 21.066406 4.519531 20.121094 5.464844 L 11.464844 14.121094 C 10.519531 15.066406 10 16.320313 10 17.65625 L 10 57 C 10 58.652344 11.347656 60 13 60 L 53 60 C 54.652344 60 56 58.652344 56 57 L 56 7 C 56 5.347656 54.652344 4 53 4 Z M 24 6 L 53 6 C 53.550781 6 54 6.449219 54 7 L 54 57 C 54 57.550781 53.550781 58 53 58 L 13 58 C 12.449219 58 12 57.550781 12 57 L 12 18 L 21 18 C 22.652344 18 24 16.652344 24 15 Z M 22 6.5 L 22 15 C 22 15.550781 21.550781 16 21 16 L 12.5 16 C 12.605469 15.835938 12.734375 15.679688 12.878906 15.535156 L 21.535156 6.878906 C 21.679688 6.738281 21.835938 6.613281 22 6.5 Z M 21 22 C 20.449219 22 20 22.449219 20 23 C 20 23.550781 20.449219 24 21 24 L 37 24 C 37.550781 24 38 23.550781 38 23 C 38 22.449219 37.550781 22 37 22 Z M 41 22 C 40.449219 22 40 22.449219 40 23 C 40 23.550781 40.449219 24 41 24 L 45 24 C 45.550781 24 46 23.550781 46 23 C 46 22.449219 45.550781 22 45 22 Z M 21 26 C 20.449219 26 20 26.449219 20 27 C 20 27.550781 20.449219 28 21 28 L 41 28 C 41.550781 28 42 27.550781 42 27 C 42 26.449219 41.550781 26 41 26 Z M 21 32 C 20.449219 32 20 32.449219 20 33 C 20 33.550781 20.449219 34 21 34 L 43 34 C 43.550781 34 44 33.550781 44 33 C 44 32.449219 43.550781 32 43 32 Z M 21 36 C 20.449219 36 20 36.449219 20 37 C 20 37.550781 20.449219 38 21 38 L 33 38 C 33.550781 38 34 37.550781 34 37 C 34 36.449219 33.550781 36 33 36 Z M 15 50 C 14.449219 50 14 50.449219 14 51 L 14 53 C 14 53.550781 14.449219 54 15 54 C 15.550781 54 16 53.550781 16 53 L 16 51 C 16 50.449219 15.550781 50 15 50 Z M 20 50 C 19.449219 50 19 50.449219 19 51 L 19 53 C 19 53.550781 19.449219 54 20 54 C 20.550781 54 21 53.550781 21 53 L 21 51 C 21 50.449219 20.550781 50 20 50 Z M 25 50 C 24.449219 50 24 50.449219 24 51 L 24 53 C 24 53.550781 24.449219 54 25 54 C 25.550781 54 26 53.550781 26 53 L 26 51 C 26 50.449219 25.550781 50 25 50 Z M 30 50 C 29.449219 50 29 50.449219 29 51 L 29 53 C 29 53.550781 29.449219 54 30 54 C 30.550781 54 31 53.550781 31 53 L 31 51 C 31 50.449219 30.550781 50 30 50 Z M 35 50 C 34.449219 50 34 50.449219 34 51 L 34 53 C 34 53.550781 34.449219 54 35 54 C 35.550781 54 36 53.550781 36 53 L 36 51 C 36 50.449219 35.550781 50 35 50 Z M 40 50 C 39.449219 50 39 50.449219 39 51 L 39 53 C 39 53.550781 39.449219 54 40 54 C 40.550781 54 41 53.550781 41 53 L 41 51 C 41 50.449219 40.550781 50 40 50 Z M 45 50 C 44.449219 50 44 50.449219 44 51 L 44 53 C 44 53.550781 44.449219 54 45 54 C 45.550781 54 46 53.550781 46 53 L 46 51 C 46 50.449219 45.550781 50 45 50 Z M 50 50 C 49.449219 50 49 50.449219 49 51 L 49 53 C 49 53.550781 49.449219 54 50 54 C 50.550781 54 51 53.550781 51 53 L 51 51 C 51 50.449219 50.550781 50 50 50 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const FolderFileIcon = createSvgIcon(
<g>
<path d="M 5 10 C 3.300781 10 2 11.300781 2 13 L 2 52 C 2 54.199219 3.800781 56 6 56 L 60 56 C 62.199219 56 64 54.199219 64 52 L 64 23 C 64 21.300781 62.699219 20 61 20 L 58 20 L 58 19 C 58 17.300781 56.699219 16 55 16 L 29.699219 16 C 28.898438 16 28.199219 15.699219 27.597656 15.097656 L 23.902344 11.402344 C 23 10.5 21.699219 10 20.402344 10 Z M 5 12 L 20.402344 12 C 21.199219 12 21.898438 12.300781 22.5 12.902344 L 26.199219 16.597656 C 27.097656 17.5 28.398438 18 29.699219 18 L 55 18 C 55.601563 18 56 18.398438 56 19 L 56 52 C 56 52.601563 56.199219 53.300781 56.597656 54 L 6 54 C 4.898438 54 4 53.101563 4 52 L 4 46 L 45 46 C 45.601563 46 46 45.601563 46 45 C 46 44.398438 45.601563 44 45 44 L 4 44 L 4 13 C 4 12.398438 4.398438 12 5 12 Z M 58 22 L 61 22 C 61.601563 22 62 22.398438 62 23 L 62 52 C 62 53.101563 61.101563 54 60 54 C 58.800781 54 58 52.601563 58 52 Z M 11 24 C 10.398438 24 10 24.398438 10 25 C 10 25.601563 10.398438 26 11 26 L 21 26 C 21.601563 26 22 25.601563 22 25 C 22 24.398438 21.601563 24 21 24 Z M 25 24 C 24.398438 24 24 24.398438 24 25 C 24 25.601563 24.398438 26 25 26 L 31 26 C 31.601563 26 32 25.601563 32 25 C 32 24.398438 31.601563 24 31 24 Z M 11 28 C 10.398438 28 10 28.398438 10 29 C 10 29.601563 10.398438 30 11 30 L 15 30 C 15.601563 30 16 29.601563 16 29 C 16 28.398438 15.601563 28 15 28 Z M 19 28 C 18.398438 28 18 28.398438 18 29 C 18 29.601563 18.398438 30 19 30 L 26 30 C 26.601563 30 27 29.601563 27 29 C 27 28.398438 26.601563 28 26 28 Z M 49 44 C 48.398438 44 48 44.398438 48 45 C 48 45.601563 48.398438 46 49 46 L 53 46 C 53.601563 46 54 45.601563 54 45 C 54 44.398438 53.601563 44 53 44 Z M 7 48 C 6.398438 48 6 48.398438 6 49 L 6 51 C 6 51.601563 6.398438 52 7 52 C 7.601563 52 8 51.601563 8 51 L 8 49 C 8 48.398438 7.601563 48 7 48 Z M 12 48 C 11.398438 48 11 48.398438 11 49 L 11 51 C 11 51.601563 11.398438 52 12 52 C 12.601563 52 13 51.601563 13 51 L 13 49 C 13 48.398438 12.601563 48 12 48 Z M 17 48 C 16.398438 48 16 48.398438 16 49 L 16 51 C 16 51.601563 16.398438 52 17 52 C 17.601563 52 18 51.601563 18 51 L 18 49 C 18 48.398438 17.601563 48 17 48 Z M 22 48 C 21.398438 48 21 48.398438 21 49 L 21 51 C 21 51.601563 21.398438 52 22 52 C 22.601563 52 23 51.601563 23 51 L 23 49 C 23 48.398438 22.601563 48 22 48 Z M 27 48 C 26.398438 48 26 48.398438 26 49 L 26 51 C 26 51.601563 26.398438 52 27 52 C 27.601563 52 28 51.601563 28 51 L 28 49 C 28 48.398438 27.601563 48 27 48 Z M 32 48 C 31.398438 48 31 48.398438 31 49 L 31 51 C 31 51.601563 31.398438 52 32 52 C 32.601563 52 33 51.601563 33 51 L 33 49 C 33 48.398438 32.601563 48 32 48 Z M 37 48 C 36.398438 48 36 48.398438 36 49 L 36 51 C 36 51.601563 36.398438 52 37 52 C 37.601563 52 38 51.601563 38 51 L 38 49 C 38 48.398438 37.601563 48 37 48 Z M 42 48 C 41.398438 48 41 48.398438 41 49 L 41 51 C 41 51.601563 41.398438 52 42 52 C 42.601563 52 43 51.601563 43 51 L 43 49 C 43 48.398438 42.601563 48 42 48 Z M 47 48 C 46.398438 48 46 48.398438 46 49 L 46 51 C 46 51.601563 46.398438 52 47 52 C 47.601563 52 48 51.601563 48 51 L 48 49 C 48 48.398438 47.601563 48 47 48 Z M 52 48 C 51.398438 48 51 48.398438 51 49 L 51 51 C 51 51.601563 51.398438 52 52 52 C 52.601563 52 53 51.601563 53 51 L 53 49 C 53 48.398438 52.601563 48 52 48 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const ImageFileIcon = createSvgIcon(
<g>
<path d="M 21.65625 4 C 20.320313 4 19.066406 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.605469 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.738281 19.835938 6.613281 20 6.5 Z M 20 24 C 17.792969 24 16 25.792969 16 28 C 16 30.207031 17.792969 32 20 32 C 22.207031 32 24 30.207031 24 28 C 24 25.792969 22.207031 24 20 24 Z M 20 25.75 C 21.242188 25.75 22.25 26.757813 22.25 28 C 22.25 29.242188 21.242188 30.25 20 30.25 C 18.757813 30.25 17.75 29.242188 17.75 28 C 17.75 26.757813 18.757813 25.75 20 25.75 Z M 37 30.414063 C 36.488281 30.414063 35.976563 30.609375 35.585938 31 L 29 37.585938 L 26.414063 35 C 25.632813 34.21875 24.363281 34.21875 23.585938 35 L 14.585938 44 L 13.042969 44 C 12.417969 44 12 44.398438 12 45 C 12 45.601563 12.523438 46 13.042969 46 L 48.980469 46 C 49.5 46 50.023438 45.601563 50.023438 45 C 50.023438 44.398438 49.5 44 48.980469 44 L 25.414063 44 L 37 32.414063 L 45.292969 40.707031 C 45.683594 41.097656 46.316406 41.097656 46.707031 40.707031 C 47.097656 40.316406 47.097656 39.683594 46.707031 39.292969 L 38.414063 31 C 38.023438 30.609375 37.511719 30.414063 37 30.414063 Z M 25 36.414063 L 27.585938 39 L 22.585938 44 L 17.414063 44 Z M 13 52 C 12.449219 52 12 52.449219 12 53 L 12 55 C 12 55.550781 12.449219 56 13 56 C 13.550781 56 14 55.550781 14 55 L 14 53 C 14 52.449219 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.449219 17 53 L 17 55 C 17 55.550781 17.449219 56 18 56 C 18.550781 56 19 55.550781 19 55 L 19 53 C 19 52.449219 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.449219 22 53 L 22 55 C 22 55.550781 22.449219 56 23 56 C 23.550781 56 24 55.550781 24 55 L 24 53 C 24 52.449219 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.449219 27 53 L 27 55 C 27 55.550781 27.449219 56 28 56 C 28.550781 56 29 55.550781 29 55 L 29 53 C 29 52.449219 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.449219 32 53 L 32 55 C 32 55.550781 32.449219 56 33 56 C 33.550781 56 34 55.550781 34 55 L 34 53 C 34 52.449219 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.449219 37 53 L 37 55 C 37 55.550781 37.449219 56 38 56 C 38.550781 56 39 55.550781 39 55 L 39 53 C 39 52.449219 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.449219 42 53 L 42 55 C 42 55.550781 42.449219 56 43 56 C 43.550781 56 44 55.550781 44 55 L 44 53 C 44 52.449219 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.449219 47 53 L 47 55 C 47 55.550781 47.449219 56 48 56 C 48.550781 56 49 55.550781 49 55 L 49 53 C 49 52.449219 48.550781 52 48 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const PdfFileIcon = createSvgIcon(
<g>
<path d="M 17.65625 4 C 16.320313 4 15.066406 4.519531 14.121094 5.464844 L 5.464844 14.121094 C 4.519531 15.066406 4 16.320313 4 17.65625 L 4 57 C 4 58.652344 5.347656 60 7 60 L 47 60 C 48.652344 60 50 58.652344 50 57 L 50 46 L 58 46 C 59.101563 46 60 45.101563 60 44 L 60 24 C 60 22.898438 59.101563 22 58 22 L 50 22 L 50 7 C 50 5.347656 48.652344 4 47 4 Z M 18 6 L 47 6 C 47.550781 6 48 6.449219 48 7 L 48 22 L 16 22 C 14.898438 22 14 22.898438 14 24 L 14 44 C 14 45.101563 14.898438 46 16 46 L 48 46 L 48 57 C 48 57.550781 47.550781 58 47 58 L 7 58 C 6.449219 58 6 57.550781 6 57 L 6 18 L 15 18 C 16.652344 18 18 16.652344 18 15 Z M 16 6.5 L 16 15 C 16 15.550781 15.550781 16 15 16 L 6.5 16 C 6.609375 15.835938 6.734375 15.679688 6.878906 15.535156 L 15.535156 6.878906 C 15.679688 6.734375 15.835938 6.609375 16 6.5 Z M 16 24 L 58 24 L 58 44 L 16 44 Z M 25 28 C 24.445313 28 24 28.449219 24 29 L 24 39 C 24 39.550781 24.445313 40 25 40 C 25.554688 40 26 39.550781 26 39 L 26 36 L 29 36 C 30.652344 36 32 34.652344 32 33 L 32 31 C 32 29.347656 30.652344 28 29 28 Z M 35 28 C 34.445313 28 34 28.449219 34 29 L 34 39 C 34 39.550781 34.445313 40 35 40 L 38 40 C 40.207031 40 42 38.207031 42 36 L 42 32 C 42 29.792969 40.207031 28 38 28 Z M 45 28 C 44.445313 28 44 28.449219 44 29 L 44 39 C 44 39.550781 44.445313 40 45 40 C 45.554688 40 46 39.550781 46 39 L 46 36 L 49 36 C 49.554688 36 50 35.550781 50 35 C 50 34.449219 49.554688 34 49 34 L 46 34 L 46 30 L 50 30 C 50.554688 30 51 29.550781 51 29 C 51 28.449219 50.554688 28 50 28 Z M 26 30 L 29 30 C 29.550781 30 30 30.449219 30 31 L 30 33 C 30 33.550781 29.550781 34 29 34 L 26 34 Z M 36 30 L 38 30 C 39.101563 30 40 30.898438 40 32 L 40 36 C 40 37.101563 39.101563 38 38 38 L 36 38 Z M 9 52 C 8.445313 52 8 52.449219 8 53 L 8 55 C 8 55.550781 8.445313 56 9 56 C 9.554688 56 10 55.550781 10 55 L 10 53 C 10 52.449219 9.554688 52 9 52 Z M 14 52 C 13.445313 52 13 52.449219 13 53 L 13 55 C 13 55.550781 13.445313 56 14 56 C 14.554688 56 15 55.550781 15 55 L 15 53 C 15 52.449219 14.554688 52 14 52 Z M 19 52 C 18.445313 52 18 52.449219 18 53 L 18 55 C 18 55.550781 18.445313 56 19 56 C 19.554688 56 20 55.550781 20 55 L 20 53 C 20 52.449219 19.554688 52 19 52 Z M 24 52 C 23.445313 52 23 52.449219 23 53 L 23 55 C 23 55.550781 23.445313 56 24 56 C 24.554688 56 25 55.550781 25 55 L 25 53 C 25 52.449219 24.554688 52 24 52 Z M 29 52 C 28.445313 52 28 52.449219 28 53 L 28 55 C 28 55.550781 28.445313 56 29 56 C 29.554688 56 30 55.550781 30 55 L 30 53 C 30 52.449219 29.554688 52 29 52 Z M 34 52 C 33.445313 52 33 52.449219 33 53 L 33 55 C 33 55.550781 33.445313 56 34 56 C 34.554688 56 35 55.550781 35 55 L 35 53 C 35 52.449219 34.554688 52 34 52 Z M 39 52 C 38.445313 52 38 52.449219 38 53 L 38 55 C 38 55.550781 38.445313 56 39 56 C 39.554688 56 40 55.550781 40 55 L 40 53 C 40 52.449219 39.554688 52 39 52 Z M 44 52 C 43.445313 52 43 52.449219 43 53 L 43 55 C 43 55.550781 43.445313 56 44 56 C 44.554688 56 45 55.550781 45 55 L 45 53 C 45 52.449219 44.554688 52 44 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const PowerPointFileIcon = createSvgIcon(
<g>
<path d="M 35.136719 2.386719 C 34.917969 2.378906 34.699219 2.390625 34.480469 2.429688 L 5.304688 7.578125 C 3.390625 7.917969 2 9.574219 2 11.515625 L 2 50.484375 C 2 52.429688 3.390625 54.085938 5.304688 54.421875 L 34.480469 59.570313 C 34.652344 59.601563 34.828125 59.613281 35 59.613281 C 35.703125 59.613281 36.382813 59.371094 36.925781 58.914063 C 37.609375 58.34375 38 57.503906 38 56.613281 L 38 52 L 57 52 C 58.652344 52 60 50.652344 60 49 L 60 13 C 60 11.347656 58.652344 10 57 10 L 38 10 L 38 5.382813 C 38 4.496094 37.609375 3.65625 36.925781 3.085938 C 36.417969 2.65625 35.789063 2.414063 35.136719 2.386719 Z M 35.105469 4.390625 C 35.359375 4.414063 35.542969 4.535156 35.640625 4.617188 C 35.777344 4.730469 36 4.980469 36 5.382813 L 36 56.613281 C 36 57.019531 35.777344 57.269531 35.640625 57.382813 C 35.507813 57.496094 35.226563 57.667969 34.828125 57.601563 L 5.652344 52.453125 C 4.695313 52.285156 4 51.457031 4 50.484375 L 4 11.515625 C 4 10.542969 4.695313 9.714844 5.652344 9.546875 L 34.824219 4.398438 C 34.925781 4.382813 35.019531 4.378906 35.105469 4.390625 Z M 38 12 L 57 12 C 57.550781 12 58 12.449219 58 13 L 58 49 C 58 49.550781 57.550781 50 57 50 L 38 50 L 38 45.949219 L 52.949219 45.949219 C 53.5 45.949219 53.949219 45.554688 53.949219 45 C 53.949219 44.445313 53.5 44 52.949219 44 L 50 44 L 50 41 C 50 40.445313 49.550781 40 49 40 L 46 40 L 46 37 C 46 36.445313 45.550781 36 45 36 L 41 36 C 40.449219 36 40 36.445313 40 37 L 40 39 L 38 39 L 38 32.46875 C 39.46875 33.449219 41.203125 34 43 34 C 47.960938 34 52 29.964844 52 25 C 52 20.035156 47.960938 16 43 16 C 41.1875 16 39.464844 16.535156 38 17.519531 Z M 42 18.078125 L 42 24.832031 C 42 25.027344 42.070313 25.203125 42.171875 25.359375 C 42.21875 25.492188 42.289063 25.617188 42.394531 25.726563 L 47.234375 30.5625 C 46.054688 31.460938 44.589844 32 43 32 C 41.113281 32 39.316406 31.230469 38 29.886719 L 38 20.105469 C 39.089844 18.992188 40.484375 18.292969 42 18.078125 Z M 44 18.078125 C 47.386719 18.566406 50 21.480469 50 25 C 50 26.546875 49.488281 27.976563 48.636719 29.136719 L 44 24.5 Z M 15 20 C 14.449219 20 14 20.445313 14 21 L 14 41 C 14 41.554688 14.449219 42 15 42 C 15.550781 42 16 41.554688 16 41 L 16 34 L 21 34 C 23.757813 34 26 31.757813 26 29 L 26 25 C 26 22.242188 23.757813 20 21 20 Z M 16 22 L 21 22 C 22.652344 22 24 23.347656 24 25 L 24 29 C 24 30.652344 22.652344 32 21 32 L 16 32 Z M 42 38 L 44 38 L 44 44 L 42 44 Z M 38 41 L 40 41 L 40 44 L 38 44 Z M 46 42 L 48 42 L 48 44 L 46 44 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const SharedFolderFileIcon = createSvgIcon(
<g>
<path d="M 3 8 C 1.347656 8 0 9.347656 0 11 L 0 52 C 0 54.207031 1.792969 56 4 56 L 58 56 C 60.207031 56 62 54.207031 62 52 L 62 21 C 62 19.347656 60.652344 18 59 18 L 56 18 L 56 17 C 56 15.347656 54.652344 14 53 14 L 27.707031 14 C 26.910156 14 26.164063 13.691406 25.597656 13.132813 L 21.875 9.445313 C 20.929688 8.515625 19.679688 8 18.355469 8 Z M 3 10 L 18.355469 10 C 19.152344 10 19.898438 10.308594 20.464844 10.867188 L 24.1875 14.554688 C 25.132813 15.484375 26.382813 16 27.707031 16 L 53 16 C 53.550781 16 54 16.449219 54 17 L 54 52 C 54 52.617188 54.222656 53.339844 54.632813 54 L 4 54 C 2.898438 54 2 53.101563 2 52 L 2 46 L 43 46 C 43.550781 46 44 45.550781 44 45 C 44 44.449219 43.550781 44 43 44 L 2 44 L 2 11 C 2 10.449219 2.449219 10 3 10 Z M 56 20 L 59 20 C 59.550781 20 60 20.449219 60 21 L 60 52 C 60 53.101563 59.101563 54 58 54 C 56.753906 54 56 52.609375 56 52 Z M 27 22 C 24.242188 22 22 24.242188 22 27 L 22 29 C 22 29.992188 22.300781 30.914063 22.800781 31.691406 C 20.058594 32.886719 17.882813 35.527344 17.28125 38.765625 C 17.179688 39.3125 17.539063 39.832031 18.082031 39.933594 C 18.625 40.035156 19.148438 39.675781 19.25 39.132813 C 19.785156 36.242188 21.863281 33.949219 24.371094 33.234375 C 25.136719 33.710938 26.03125 34 27 34 C 27.96875 34 28.863281 33.710938 29.628906 33.234375 C 32.136719 33.949219 34.214844 36.246094 34.75 39.136719 C 34.839844 39.617188 35.261719 39.953125 35.734375 39.953125 C 35.796875 39.953125 35.855469 39.949219 35.917969 39.9375 C 36.460938 39.835938 36.820313 39.3125 36.71875 38.769531 C 36.117188 35.53125 33.941406 32.886719 31.199219 31.691406 C 31.699219 30.914063 32 29.992188 32 29 L 32 27 C 32 24.242188 29.757813 22 27 22 Z M 27 24 C 28.652344 24 30 25.347656 30 27 L 30 29 C 30 30.652344 28.652344 32 27 32 C 25.347656 32 24 30.652344 24 29 L 24 27 C 24 25.347656 25.347656 24 27 24 Z M 47 44 C 46.449219 44 46 44.449219 46 45 C 46 45.550781 46.449219 46 47 46 L 51 46 C 51.550781 46 52 45.550781 52 45 C 52 44.449219 51.550781 44 51 44 Z M 5 48 C 4.449219 48 4 48.449219 4 49 L 4 51 C 4 51.550781 4.449219 52 5 52 C 5.550781 52 6 51.550781 6 51 L 6 49 C 6 48.449219 5.550781 48 5 48 Z M 10 48 C 9.449219 48 9 48.449219 9 49 L 9 51 C 9 51.550781 9.449219 52 10 52 C 10.550781 52 11 51.550781 11 51 L 11 49 C 11 48.449219 10.550781 48 10 48 Z M 15 48 C 14.449219 48 14 48.449219 14 49 L 14 51 C 14 51.550781 14.449219 52 15 52 C 15.550781 52 16 51.550781 16 51 L 16 49 C 16 48.449219 15.550781 48 15 48 Z M 20 48 C 19.449219 48 19 48.449219 19 49 L 19 51 C 19 51.550781 19.449219 52 20 52 C 20.550781 52 21 51.550781 21 51 L 21 49 C 21 48.449219 20.550781 48 20 48 Z M 25 48 C 24.449219 48 24 48.449219 24 49 L 24 51 C 24 51.550781 24.449219 52 25 52 C 25.550781 52 26 51.550781 26 51 L 26 49 C 26 48.449219 25.550781 48 25 48 Z M 30 48 C 29.449219 48 29 48.449219 29 49 L 29 51 C 29 51.550781 29.449219 52 30 52 C 30.550781 52 31 51.550781 31 51 L 31 49 C 31 48.449219 30.550781 48 30 48 Z M 35 48 C 34.449219 48 34 48.449219 34 49 L 34 51 C 34 51.550781 34.449219 52 35 52 C 35.550781 52 36 51.550781 36 51 L 36 49 C 36 48.449219 35.550781 48 35 48 Z M 40 48 C 39.449219 48 39 48.449219 39 49 L 39 51 C 39 51.550781 39.449219 52 40 52 C 40.550781 52 41 51.550781 41 51 L 41 49 C 41 48.449219 40.550781 48 40 48 Z M 45 48 C 44.449219 48 44 48.449219 44 49 L 44 51 C 44 51.550781 44.449219 52 45 52 C 45.550781 52 46 51.550781 46 51 L 46 49 C 46 48.449219 45.550781 48 45 48 Z M 50 48 C 49.449219 48 49 48.449219 49 49 L 49 51 C 49 51.550781 49.449219 52 50 52 C 50.550781 52 51 51.550781 51 51 L 51 49 C 51 48.449219 50.550781 48 50 48 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const SpreadsheetFileIcon = createSvgIcon(
<g>
<path d="M 35.136719 2.386719 C 34.917969 2.378906 34.699219 2.390625 34.480469 2.429688 L 5.304688 7.578125 C 3.390625 7.917969 2 9.574219 2 11.515625 L 2 50.484375 C 2 52.429688 3.390625 54.085938 5.304688 54.421875 L 34.480469 59.570313 C 34.652344 59.601563 34.828125 59.613281 35 59.613281 C 35.703125 59.613281 36.382813 59.371094 36.925781 58.914063 C 37.609375 58.34375 38 57.503906 38 56.613281 L 38 52 L 57 52 C 58.652344 52 60 50.652344 60 49 L 60 13 C 60 11.347656 58.652344 10 57 10 L 38 10 L 38 5.382813 C 38 4.496094 37.609375 3.65625 36.925781 3.085938 C 36.417969 2.65625 35.789063 2.414063 35.136719 2.386719 Z M 35.105469 4.390625 C 35.359375 4.414063 35.542969 4.535156 35.640625 4.617188 C 35.777344 4.730469 36 4.980469 36 5.382813 L 36 56.613281 C 36 57.019531 35.777344 57.269531 35.640625 57.382813 C 35.507813 57.496094 35.226563 57.671875 34.828125 57.601563 L 5.652344 52.453125 C 4.695313 52.285156 4 51.457031 4 50.484375 L 4 11.515625 C 4 10.542969 4.695313 9.714844 5.652344 9.546875 L 34.824219 4.398438 C 34.925781 4.382813 35.019531 4.378906 35.105469 4.390625 Z M 38 12 L 57 12 C 57.550781 12 58 12.449219 58 13 L 58 49 C 58 49.550781 57.550781 50 57 50 L 38 50 L 38 44 L 41 44 C 41.550781 44 42 43.554688 42 43 C 42 42.445313 41.550781 42 41 42 L 38 42 L 38 38 L 41 38 C 41.550781 38 42 37.554688 42 37 C 42 36.445313 41.550781 36 41 36 L 38 36 L 38 32 L 41 32 C 41.550781 32 42 31.554688 42 31 C 42 30.445313 41.550781 30 41 30 L 38 30 L 38 26 L 41 26 C 41.550781 26 42 25.554688 42 25 C 42 24.445313 41.550781 24 41 24 L 38 24 L 38 20 L 41 20 C 41.550781 20 42 19.554688 42 19 C 42 18.445313 41.550781 18 41 18 L 38 18 Z M 45 18 C 44.449219 18 44 18.445313 44 19 C 44 19.554688 44.449219 20 45 20 L 51 20 C 51.550781 20 52 19.554688 52 19 C 52 18.445313 51.550781 18 51 18 Z M 12.824219 20.015625 C 12.695313 20.039063 12.570313 20.085938 12.453125 20.160156 C 11.992188 20.460938 11.859375 21.082031 12.160156 21.546875 L 18.308594 31 L 12.160156 40.453125 C 11.859375 40.917969 11.992188 41.539063 12.453125 41.839844 C 12.625 41.949219 12.8125 42 13 42 C 13.324219 42 13.648438 41.839844 13.839844 41.546875 L 19.5 32.835938 L 25.160156 41.546875 C 25.351563 41.839844 25.675781 42 26 42 C 26.1875 42 26.375 41.949219 26.546875 41.839844 C 27.007813 41.539063 27.140625 40.917969 26.839844 40.453125 L 20.691406 31 L 26.839844 21.546875 C 27.140625 21.082031 27.007813 20.460938 26.546875 20.160156 C 26.082031 19.859375 25.460938 19.992188 25.160156 20.453125 L 19.5 29.164063 L 13.839844 20.453125 C 13.613281 20.105469 13.207031 19.945313 12.824219 20.015625 Z M 45 24 C 44.449219 24 44 24.445313 44 25 C 44 25.554688 44.449219 26 45 26 L 51 26 C 51.550781 26 52 25.554688 52 25 C 52 24.445313 51.550781 24 51 24 Z M 45 30 C 44.449219 30 44 30.445313 44 31 C 44 31.554688 44.449219 32 45 32 L 51 32 C 51.550781 32 52 31.554688 52 31 C 52 30.445313 51.550781 30 51 30 Z M 45 36 C 44.449219 36 44 36.445313 44 37 C 44 37.554688 44.449219 38 45 38 L 51 38 C 51.550781 38 52 37.554688 52 37 C 52 36.445313 51.550781 36 51 36 Z M 45 42 C 44.449219 42 44 42.445313 44 43 C 44 43.554688 44.449219 44 45 44 L 51 44 C 51.550781 44 52 43.554688 52 43 C 52 42.445313 51.550781 42 51 42 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const TextFileIcon = createSvgIcon(
<g>
<path d="M 17.660156 4 C 16.320313 4 15.058594 4.519531 14.121094 5.460938 L 5.460938 14.121094 C 4.519531 15.070313 4 16.320313 4 17.660156 L 4 57 C 4 58.648438 5.351563 60 7 60 L 47 60 C 48.648438 60 50 58.648438 50 57 L 50 46 L 58 46 C 59.101563 46 60 45.101563 60 44 L 60 24 C 60 22.898438 59.101563 22 58 22 L 50 22 L 50 7 C 50 5.351563 48.648438 4 47 4 Z M 18 6 L 47 6 C 47.550781 6 48 6.449219 48 7 L 48 22 L 16 22 C 14.898438 22 14 22.898438 14 24 L 14 44 C 14 45.101563 14.898438 46 16 46 L 48 46 L 48 57 C 48 57.550781 47.550781 58 47 58 L 7 58 C 6.449219 58 6 57.550781 6 57 L 6 18 L 15 18 C 16.652344 18 18 16.652344 18 15 Z M 16 6.5 L 16 15 C 16 15.550781 15.550781 16 15 16 L 6.5 16 C 6.613281 15.835938 6.738281 15.679688 6.882813 15.539063 L 15.539063 6.882813 C 15.679688 6.738281 15.835938 6.609375 16 6.5 Z M 16 24 L 58 24 L 58 44 L 16 44 Z M 24 28 C 23.449219 28 23 28.445313 23 29 C 23 29.554688 23.449219 30 24 30 L 26 30 L 26 39 C 26 39.554688 26.449219 40 27 40 C 27.550781 40 28 39.554688 28 39 L 28 30 L 30 30 C 30.550781 30 31 29.554688 31 29 C 31 28.445313 30.550781 28 30 28 Z M 44 28 C 43.449219 28 43 28.445313 43 29 C 43 29.554688 43.449219 30 44 30 L 46 30 L 46 39 C 46 39.554688 46.449219 40 47 40 C 47.550781 40 48 39.554688 48 39 L 48 30 L 50 30 C 50.550781 30 51 29.554688 51 29 C 51 28.445313 50.550781 28 50 28 Z M 33.859375 28.011719 C 33.730469 28.027344 33.601563 28.070313 33.484375 28.140625 C 33.011719 28.425781 32.859375 29.039063 33.140625 29.515625 L 35.832031 34 L 33.140625 38.484375 C 32.859375 38.957031 33.011719 39.574219 33.484375 39.859375 C 33.644531 39.953125 33.824219 40 34 40 C 34.339844 40 34.671875 39.828125 34.859375 39.515625 L 37 35.941406 L 39.140625 39.515625 C 39.328125 39.828125 39.660156 40 40 40 C 40.175781 40 40.355469 39.953125 40.515625 39.859375 C 40.988281 39.574219 41.140625 38.957031 40.859375 38.484375 L 38.167969 34 L 40.859375 29.515625 C 41.140625 29.042969 40.988281 28.425781 40.515625 28.140625 C 40.042969 27.859375 39.425781 28.011719 39.140625 28.484375 L 37 32.058594 L 34.859375 28.484375 C 34.644531 28.128906 34.246094 27.957031 33.859375 28.011719 Z M 9 52 C 8.449219 52 8 52.445313 8 53 L 8 55 C 8 55.554688 8.449219 56 9 56 C 9.550781 56 10 55.554688 10 55 L 10 53 C 10 52.445313 9.550781 52 9 52 Z M 14 52 C 13.449219 52 13 52.445313 13 53 L 13 55 C 13 55.554688 13.449219 56 14 56 C 14.550781 56 15 55.554688 15 55 L 15 53 C 15 52.445313 14.550781 52 14 52 Z M 19 52 C 18.449219 52 18 52.445313 18 53 L 18 55 C 18 55.554688 18.449219 56 19 56 C 19.550781 56 20 55.554688 20 55 L 20 53 C 20 52.445313 19.550781 52 19 52 Z M 24 52 C 23.449219 52 23 52.445313 23 53 L 23 55 C 23 55.554688 23.449219 56 24 56 C 24.550781 56 25 55.554688 25 55 L 25 53 C 25 52.445313 24.550781 52 24 52 Z M 29 52 C 28.449219 52 28 52.445313 28 53 L 28 55 C 28 55.554688 28.449219 56 29 56 C 29.550781 56 30 55.554688 30 55 L 30 53 C 30 52.445313 29.550781 52 29 52 Z M 34 52 C 33.449219 52 33 52.445313 33 53 L 33 55 C 33 55.554688 33.449219 56 34 56 C 34.550781 56 35 55.554688 35 55 L 35 53 C 35 52.445313 34.550781 52 34 52 Z M 39 52 C 38.449219 52 38 52.445313 38 53 L 38 55 C 38 55.554688 38.449219 56 39 56 C 39.550781 56 40 55.554688 40 55 L 40 53 C 40 52.445313 39.550781 52 39 52 Z M 44 52 C 43.449219 52 43 52.445313 43 53 L 43 55 C 43 55.554688 43.449219 56 44 56 C 44.550781 56 45 55.554688 45 55 L 45 53 C 45 52.445313 44.550781 52 44 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const VideoFileIcon = createSvgIcon(
<g>
<path d="M 23.65625 4 C 22.320313 4 21.0625 4.519531 20.121094 5.464844 L 11.464844 14.121094 C 10.519531 15.066406 10 16.320313 10 17.65625 L 10 57 C 10 58.652344 11.347656 60 13 60 L 53 60 C 54.652344 60 56 58.652344 56 57 L 56 7 C 56 5.347656 54.652344 4 53 4 Z M 24 6 L 53 6 C 53.550781 6 54 6.449219 54 7 L 54 57 C 54 57.550781 53.550781 58 53 58 L 13 58 C 12.449219 58 12 57.550781 12 57 L 12 18 L 21 18 C 22.652344 18 24 16.652344 24 15 Z M 22 6.5 L 22 15 C 22 15.550781 21.550781 16 21 16 L 12.5 16 C 12.613281 15.835938 12.738281 15.675781 12.878906 15.535156 L 21.535156 6.878906 C 21.679688 6.734375 21.835938 6.609375 22 6.5 Z M 28.023438 21.816406 C 27.671875 21.808594 27.316406 21.890625 26.996094 22.0625 C 26.355469 22.417969 25.964844 23.085938 25.964844 23.816406 L 25.964844 42.183594 C 25.964844 42.910156 26.355469 43.582031 26.996094 43.933594 C 27.296875 44.097656 27.632813 44.183594 27.964844 44.183594 C 28.335938 44.183594 28.707031 44.078125 29.03125 43.871094 L 43.53125 34.6875 C 44.113281 34.320313 44.464844 33.6875 44.464844 33 C 44.464844 32.308594 44.113281 31.679688 43.53125 31.3125 L 29.03125 22.125 C 28.722656 21.933594 28.375 21.828125 28.023438 21.816406 Z M 27.964844 23.816406 L 42.464844 33 L 27.964844 42.1875 Z M 15 52 C 14.449219 52 14 52.449219 14 53 L 14 55 C 14 55.550781 14.449219 56 15 56 C 15.550781 56 16 55.550781 16 55 L 16 53 C 16 52.449219 15.550781 52 15 52 Z M 20 52 C 19.449219 52 19 52.449219 19 53 L 19 55 C 19 55.550781 19.449219 56 20 56 C 20.550781 56 21 55.550781 21 55 L 21 53 C 21 52.449219 20.550781 52 20 52 Z M 25 52 C 24.449219 52 24 52.449219 24 53 L 24 55 C 24 55.550781 24.449219 56 25 56 C 25.550781 56 26 55.550781 26 55 L 26 53 C 26 52.449219 25.550781 52 25 52 Z M 30 52 C 29.449219 52 29 52.449219 29 53 L 29 55 C 29 55.550781 29.449219 56 30 56 C 30.550781 56 31 55.550781 31 55 L 31 53 C 31 52.449219 30.550781 52 30 52 Z M 35 52 C 34.449219 52 34 52.449219 34 53 L 34 55 C 34 55.550781 34.449219 56 35 56 C 35.550781 56 36 55.550781 36 55 L 36 53 C 36 52.449219 35.550781 52 35 52 Z M 40 52 C 39.449219 52 39 52.449219 39 53 L 39 55 C 39 55.550781 39.449219 56 40 56 C 40.550781 56 41 55.550781 41 55 L 41 53 C 41 52.449219 40.550781 52 40 52 Z M 45 52 C 44.449219 52 44 52.449219 44 53 L 44 55 C 44 55.550781 44.449219 56 45 56 C 45.550781 56 46 55.550781 46 55 L 46 53 C 46 52.449219 45.550781 52 45 52 Z M 50 52 C 49.449219 52 49 52.449219 49 53 L 49 55 C 49 55.550781 49.449219 56 50 56 C 50.550781 56 51 55.550781 51 55 L 51 53 C 51 52.449219 50.550781 52 50 52 Z " />
</g>
);

View File

@@ -0,0 +1,7 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const WordFileIcon = createSvgIcon(
<g>
<path d="M 21.65625 4 C 20.320313 4 19.0625 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.613281 15.832031 10.738281 15.675781 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.734375 19.835938 6.609375 20 6.5 Z M 21.140625 23.011719 C 21.015625 22.992188 20.878906 22.996094 20.746094 23.03125 C 20.210938 23.175781 19.894531 23.722656 20.03125 24.253906 L 25.03125 43.253906 C 25.148438 43.691406 25.539063 43.996094 25.984375 44 L 26 44 C 26.441406 44 26.832031 43.710938 26.957031 43.28125 L 31 29.546875 L 35.042969 43.28125 C 35.167969 43.707031 35.558594 44 36 44 L 36.015625 44 C 36.460938 43.992188 36.851563 43.6875 36.96875 43.253906 L 41.96875 24.253906 C 42.105469 23.722656 41.789063 23.175781 41.253906 23.03125 C 40.71875 22.890625 40.171875 23.210938 40.03125 23.746094 L 35.945313 39.273438 L 31.957031 25.71875 C 31.832031 25.292969 31.445313 25 31 25 C 30.554688 25 30.167969 25.292969 30.042969 25.71875 L 26.054688 39.277344 L 21.96875 23.746094 C 21.863281 23.347656 21.527344 23.066406 21.140625 23.011719 Z M 13 52 C 12.449219 52 12 52.445313 12 53 L 12 55 C 12 55.554688 12.449219 56 13 56 C 13.550781 56 14 55.554688 14 55 L 14 53 C 14 52.445313 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.445313 17 53 L 17 55 C 17 55.554688 17.449219 56 18 56 C 18.550781 56 19 55.554688 19 55 L 19 53 C 19 52.445313 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.445313 22 53 L 22 55 C 22 55.554688 22.449219 56 23 56 C 23.550781 56 24 55.554688 24 55 L 24 53 C 24 52.445313 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.445313 27 53 L 27 55 C 27 55.554688 27.449219 56 28 56 C 28.550781 56 29 55.554688 29 55 L 29 53 C 29 52.445313 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.445313 32 53 L 32 55 C 32 55.554688 32.449219 56 33 56 C 33.550781 56 34 55.554688 34 55 L 34 53 C 34 52.445313 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.445313 37 53 L 37 55 C 37 55.554688 37.449219 56 38 56 C 38.550781 56 39 55.554688 39 55 L 39 53 C 39 52.445313 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.445313 42 53 L 42 55 C 42 55.554688 42.449219 56 43 56 C 43.550781 56 44 55.554688 44 55 L 44 53 C 44 52.445313 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.445313 47 53 L 47 55 C 47 55.554688 47.449219 56 48 56 C 48.550781 56 49 55.554688 49 55 L 49 53 C 49 52.445313 48.550781 52 48 52 Z " />
</g>
);

View File

@@ -0,0 +1,9 @@
import {Fragment, memo} from 'react';
import {prettyBytes} from './utils/pretty-bytes';
interface FormattedBytesProps {
bytes?: number;
}
export const FormattedBytes = memo(({bytes}: FormattedBytesProps) => {
return <Fragment>{prettyBytes(bytes)}</Fragment>;
});

View File

@@ -0,0 +1,66 @@
import React, {useContext, useMemo} from 'react';
import {FileEntry} from '../file-entry';
import {useSettings} from '../../core/settings/use-settings';
import {isAbsoluteUrl} from '@common/utils/urls/is-absolute-url';
export const FileEntryUrlsContext = React.createContext<
Record<string, string | number | null | undefined>
>(null!);
export function useFileEntryUrls(
entry?: FileEntry,
options?: {thumbnail?: boolean; downloadHashes?: string[]},
): {previewUrl?: string; downloadUrl?: string} {
const {base_url} = useSettings();
const urlSearchParams = useContext(FileEntryUrlsContext);
return useMemo(() => {
if (!entry) {
return {};
}
let previewUrl: string | undefined;
if (entry.url) {
previewUrl = isAbsoluteUrl(entry.url)
? entry.url
: `${base_url}/${entry.url}`;
}
const urls = {
previewUrl,
downloadUrl: `${base_url}/api/v1/file-entries/download/${
options?.downloadHashes || entry.hash
}`,
};
if (urlSearchParams) {
// preview url
if (urls.previewUrl) {
urls.previewUrl = addParams(
urls.previewUrl,
{...urlSearchParams, thumbnail: options?.thumbnail ? 'true' : ''},
base_url,
);
}
// download url
urls.downloadUrl = addParams(urls.downloadUrl, urlSearchParams, base_url);
}
return urls;
}, [
base_url,
entry,
options?.downloadHashes,
options?.thumbnail,
urlSearchParams,
]);
}
function addParams(urlString: string, params: object, baseUrl: string): string {
const url = new URL(urlString, baseUrl);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value as string);
});
return url.toString();
}

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()
)}`;
}

View File

@@ -0,0 +1,23 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient} from '../../http/query-client';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
interface Payload {
entryIds?: number[];
deleteForever?: boolean;
paths?: string[];
}
function deleteFileEntries(payload: Payload): Promise<Response> {
return apiClient.post('file-entries/delete', payload).then(r => r.data);
}
export function useDeleteFileEntries() {
return useMutation({
mutationFn: (props: Payload) => deleteFileEntries(props),
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,41 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {FileEntry} from '@common/uploads/file-entry';
interface Response extends BackendResponse {
fileEntry: FileEntry;
}
interface Options {
enabled?: boolean;
}
export function useFileEntryModel(
entryIdOrUrl: number | string | undefined,
options: Options = {enabled: true},
) {
const entryId = extractEntryId(entryIdOrUrl);
return useQuery({
queryKey: ['file-entries', `${entryId}`],
queryFn: () => fetchFileEntry(entryId!),
enabled: !!entryId && options.enabled,
});
}
function fetchFileEntry(entryId: number | string) {
return apiClient
.get<Response>(`file-entries/${entryId}/model`)
.then(response => response.data);
}
function extractEntryId(entryIdOrUrl: number | string | undefined) {
if (!entryIdOrUrl) {
return undefined;
}
const parsedId = parseInt(entryIdOrUrl as string);
if (!isNaN(parsedId)) {
return parsedId;
}
return `${entryIdOrUrl}`.split('/').pop();
}

View File

@@ -0,0 +1,11 @@
export interface BackendMetadata {
disk?: Disk;
diskPrefix?: string;
relativePath?: string | null;
[key: string]: number | string | null | undefined;
}
export enum Disk {
public = 'public',
uploads = 'uploads',
}

View File

@@ -0,0 +1,14 @@
export interface UploadInputConfig {
types?: (UploadInputType | string)[];
extensions?: string[];
multiple?: boolean;
directory?: boolean;
}
export enum UploadInputType {
image = 'image/*',
audio = 'audio/*',
text = 'text/*',
json = 'application/json',
video = 'video/mp4,video/mpeg,video/x-m4v,video/*',
}

View File

@@ -0,0 +1,111 @@
import {getFileMime} from './utils/get-file-mime';
import {extensionFromFilename} from './utils/extension-from-filename';
import {nanoid} from 'nanoid';
import {getActiveWorkspaceId} from '../workspace/active-workspace-id';
export class UploadedFile {
id: string;
fingerprint: string;
name: string;
relativePath = '';
size: number;
mime = '';
extension = '';
native: File;
lastModified: number;
private cachedData?: string;
get data(): Promise<string> {
return new Promise(resolve => {
if (this.cachedData) {
resolve(this.cachedData);
}
const reader = new FileReader();
reader.addEventListener('load', () => {
this.cachedData = reader.result as string;
resolve(this.cachedData);
});
if (this.extension === 'json') {
reader.readAsText(this.native);
} else {
reader.readAsDataURL(this.native);
}
});
}
constructor(file: File, relativePath?: string | null) {
this.id = nanoid();
this.name = file.name;
this.size = file.size;
this.mime = getFileMime(file);
this.lastModified = file.lastModified;
this.extension = extensionFromFilename(file.name) || 'bin';
this.native = file;
relativePath = relativePath || file.webkitRelativePath || '';
// remove leading slashes
relativePath = relativePath.replace(/^\/+/g, '');
// only include relative path if file is actually in a folder and not just /file.txt
if (relativePath && relativePath.split('/').length > 1) {
this.relativePath = relativePath;
}
this.fingerprint = generateId({
name: this.name,
size: this.size,
mime: this.mime,
lastModified: this.lastModified,
});
}
}
interface FileMeta {
name?: string;
mime?: string | null;
size?: number | string;
lastModified?: number;
relativePath?: string;
}
function generateId({name, mime, size, relativePath, lastModified}: FileMeta) {
let id = 'be';
if (typeof name === 'string') {
id += `-${encodeFilename(name.toLowerCase())}`;
}
if (mime) {
id += `-${mime}`;
}
if (typeof relativePath === 'string') {
id += `-${encodeFilename(relativePath.toLowerCase())}`;
}
if (size !== undefined) {
id += `-${size}`;
}
if (lastModified !== undefined) {
id += `-${lastModified}`;
}
id += `${getActiveWorkspaceId()}`;
// add version number, so it can be incremented easily to allow uploading same file multiple times
return `${id}-v1`;
}
function encodeCharacter(character: string) {
return character.charCodeAt(0).toString(32);
}
function encodeFilename(name: string) {
let suffix = '';
return (
name.replace(/[^A-Z0-9]/gi, character => {
suffix += `-${encodeCharacter(character)}`;
return '/';
}) + suffix
);
}

View File

@@ -0,0 +1,18 @@
import {UploadedFile} from '../uploaded-file';
import {UploadStrategyConfig} from './strategy/upload-strategy';
import {FileUpload} from './file-upload-store';
export function createUpload(
file: UploadedFile | File,
options?: UploadStrategyConfig
): FileUpload {
const uploadedFile =
file instanceof UploadedFile ? file : new UploadedFile(file);
return {
file: uploadedFile,
percentage: 0,
bytesUploaded: 0,
status: 'pending',
options: options || {},
};
}

View File

@@ -0,0 +1,47 @@
import {StoreApi, useStore} from 'zustand';
import {createContext, ReactNode, useContext, useState} from 'react';
import {createFileUploadStore, FileUploadState} from './file-upload-store';
import {useSettings} from '../../core/settings/use-settings';
const FileUploadContext = createContext<StoreApi<FileUploadState>>(null!);
type ExtractState<S> = S extends {
getState: () => infer T;
}
? T
: never;
type UseFileUploadStore = {
(): ExtractState<StoreApi<FileUploadState>>;
<U>(
selector: (state: ExtractState<StoreApi<FileUploadState>>) => U,
equalityFn?: (a: U, b: U) => boolean
): U;
};
// @ts-ignore
export const useFileUploadStore: UseFileUploadStore = (
selector,
equalityFn
) => {
const store = useContext(FileUploadContext);
return useStore(store, selector, equalityFn);
};
interface FileUploadProviderProps {
children: ReactNode;
}
export function FileUploadProvider({children}: FileUploadProviderProps) {
const settings = useSettings();
//lazily create store object only once
const [store] = useState(() => {
return createFileUploadStore({settings});
});
return (
<FileUploadContext.Provider value={store as StoreApi<FileUploadState>}>
{children}
</FileUploadContext.Provider>
);
}

View File

@@ -0,0 +1,195 @@
import {create} from 'zustand';
import {immer} from 'zustand/middleware/immer';
import {Draft, enableMapSet} from 'immer';
import {UploadedFile} from '../uploaded-file';
import {UploadStrategy, UploadStrategyConfig} from './strategy/upload-strategy';
import {MessageDescriptor} from '../../i18n/message-descriptor';
import {FileEntry} from '../file-entry';
import {S3MultipartUpload} from './strategy/s3-multipart-upload';
import {Settings} from '../../core/settings/settings';
import {TusUpload} from './strategy/tus-upload';
import {ProgressTimeout} from './progress-timeout';
import {startUploading} from './start-uploading';
import {createUpload} from './create-file-upload';
enableMapSet();
export interface FileUpload {
file: UploadedFile;
percentage: number;
bytesUploaded: number;
status: 'pending' | 'inProgress' | 'aborted' | 'failed' | 'completed';
errorMessage?: string | MessageDescriptor | null;
entry?: FileEntry;
request?: UploadStrategy;
timer?: ProgressTimeout;
options: UploadStrategyConfig;
meta?: unknown;
}
interface State {
concurrency: number;
fileUploads: Map<string, FileUpload>;
// uploads with pending and inProgress status
activeUploadsCount: number;
completedUploadsCount: number;
}
const initialState: State = {
concurrency: 3,
fileUploads: new Map(),
activeUploadsCount: 0,
completedUploadsCount: 0,
};
interface Actions {
uploadMultiple: (
files: (File | UploadedFile)[] | FileList,
options?: Omit<
UploadStrategyConfig,
// progress would be called for each upload simultaneously
'onProgress' | 'showToastOnRestrictionFail'
>,
) => string[];
uploadSingle: (
file: File | UploadedFile,
options?: UploadStrategyConfig,
) => string;
clearInactive: () => void;
abortUpload: (id: string) => void;
updateFileUpload: (id: string, state: Partial<FileUpload>) => void;
getUpload: (id: string) => FileUpload | undefined;
runQueue: () => void;
reset: () => void;
}
export type FileUploadState = State & Actions;
interface StoreProps {
settings: Settings;
}
export const createFileUploadStore = ({settings}: StoreProps) =>
create<FileUploadState>()(
immer((set, get) => {
return {
...initialState,
reset: () => {
set(initialState);
},
getUpload: uploadId => {
return get().fileUploads.get(uploadId);
},
clearInactive: () => {
set(state => {
state.fileUploads.forEach((upload, key) => {
if (upload.status !== 'inProgress') {
state.fileUploads.delete(key);
}
});
});
get().runQueue();
},
abortUpload: id => {
const upload = get().fileUploads.get(id);
if (upload) {
upload.request?.abort();
get().updateFileUpload(id, {status: 'aborted', percentage: 0});
get().runQueue();
}
},
updateFileUpload: (id, newUploadState) => {
set(state => {
const fileUpload = state.fileUploads.get(id);
if (fileUpload) {
state.fileUploads.set(id, {
...fileUpload,
...newUploadState,
});
// only need to update inProgress count if status of the uploads in queue change
if ('status' in newUploadState) {
updateTotals(state);
}
}
});
},
uploadSingle: (file, userOptions) => {
const upload = createUpload(file, userOptions);
const fileUploads = new Map(get().fileUploads);
fileUploads.set(upload.file.id, upload);
set(state => {
updateTotals(state);
state.fileUploads = fileUploads;
});
get().runQueue();
return upload.file.id;
},
uploadMultiple: (files, options) => {
// create file upload items from specified files
const uploads = new Map<string, FileUpload>(get().fileUploads);
[...files].forEach(file => {
const upload = createUpload(file, options);
uploads.set(upload.file.id, upload);
});
// set state only once, there might be thousands of files, don't want to trigger a rerender for each one
set(state => {
updateTotals(state);
state.fileUploads = uploads;
});
get().runQueue();
return [...uploads.keys()];
},
runQueue: async () => {
const uploads = [...get().fileUploads.values()];
const activeUploads = uploads.filter(u => u.status === 'inProgress');
let concurrency = get().concurrency;
if (
activeUploads.filter(
activeUpload =>
// only upload one file from folder at a time to avoid creating duplicate folders
activeUpload.file.relativePath ||
// only allow one s3 multipart upload at a time, it will already upload multiple parts in parallel
activeUpload.request instanceof S3MultipartUpload ||
// only allow one tus upload if file is larger than chunk size, tus will have parallel uploads already in that case
(activeUpload.request instanceof TusUpload &&
settings.uploads.chunk_size &&
activeUpload.file.size > settings.uploads.chunk_size),
).length
) {
concurrency = 1;
}
if (activeUploads.length < concurrency) {
//const pendingUploads = uploads.filter(u => u.status === 'pending');
//const next = pendingUploads.find(a => !!a.request);
const next = uploads.find(u => u.status === 'pending');
if (next) {
await startUploading(next, get());
}
}
},
};
}),
);
const updateTotals = (state: Draft<FileUploadState>) => {
state.completedUploadsCount = [...state.fileUploads.values()].filter(
u => u.status === 'completed',
).length;
state.activeUploadsCount = [...state.fileUploads.values()].filter(
u => u.status === 'inProgress' || u.status === 'pending',
).length;
};

View File

@@ -0,0 +1,26 @@
export class ProgressTimeout {
public aliveTimer: any;
public isDone = false;
public timeout = 30000;
public timeoutHandler: (() => void) | null = null;
progress() {
// Some browsers fire another progress event when the upload is
// cancelled, so we have to ignore progress after the timer was
// told to stop.
if (this.isDone || !this.timeoutHandler) return;
if (this.timeout > 0) {
clearTimeout(this.aliveTimer);
this.aliveTimer = setTimeout(this.timeoutHandler, this.timeout);
}
}
done() {
if (!this.isDone) {
clearTimeout(this.aliveTimer);
this.aliveTimer = null;
this.isDone = true;
}
}
}

View File

@@ -0,0 +1,129 @@
import {UploadStrategy, UploadStrategyConfig} from './strategy/upload-strategy';
import {UploadedFile} from '../uploaded-file';
import {Disk} from '../types/backend-metadata';
import {S3MultipartUpload} from './strategy/s3-multipart-upload';
import {S3Upload} from './strategy/s3-upload';
import {TusUpload} from './strategy/tus-upload';
import {AxiosUpload} from './strategy/axios-upload';
import {FileUpload, FileUploadState} from './file-upload-store';
import {validateUpload} from './validate-upload';
import {getBootstrapData} from '../../core/bootstrap-data/use-backend-bootstrap-data';
import {toast} from '../../ui/toast/toast';
import {ProgressTimeout} from './progress-timeout';
import {message} from '../../i18n/message';
export async function startUploading(
upload: FileUpload,
state: FileUploadState
): Promise<UploadStrategy | null> {
const settings = getBootstrapData().settings;
const options = upload.options;
const file = upload.file;
// validate file, if validation fails, error the upload and bail
if (options?.restrictions) {
const errorMessage = validateUpload(file, options.restrictions);
if (errorMessage) {
state.updateFileUpload(file.id, {
errorMessage,
status: 'failed',
request: undefined,
timer: undefined,
});
if (options.showToastOnRestrictionFail) {
toast.danger(errorMessage);
}
state.runQueue();
return null;
}
}
// prepare config for file upload strategy
const timer = new ProgressTimeout();
const config: UploadStrategyConfig = {
metadata: {
...options?.metadata,
relativePath: file.relativePath,
disk: options?.metadata?.disk || Disk.uploads,
parentId: options?.metadata?.parentId || '',
},
chunkSize: settings.uploads.chunk_size,
baseUrl: settings.base_url,
onError: errorMessage => {
state.updateFileUpload(file.id, {
errorMessage,
status: 'failed',
});
state.runQueue();
timer.done();
options?.onError?.(errorMessage, file);
},
onSuccess: entry => {
state.updateFileUpload(file.id, {
status: 'completed',
entry,
});
state.runQueue();
timer.done();
options?.onSuccess?.(entry, file);
},
onProgress: ({bytesUploaded, bytesTotal}) => {
const percentage = (bytesUploaded / bytesTotal) * 100;
state.updateFileUpload(file.id, {
percentage,
bytesUploaded,
});
timer.progress();
options?.onProgress?.({bytesUploaded, bytesTotal});
},
};
// choose and create upload strategy, based on file size and settings
const strategy = chooseUploadStrategy(file, config);
const request = await strategy.create(file, config);
// add handler for when upload times out (no progress for 30+ seconds)
timer.timeoutHandler = () => {
request.abort();
state.updateFileUpload(file.id, {
status: 'failed',
errorMessage: message('Upload timed out'),
});
state.runQueue();
};
state.updateFileUpload(file.id, {
status: 'inProgress',
request,
});
request.start();
return request;
}
const OneMB = 1024 * 1024;
const FourMB = 4 * OneMB;
const HundredMB = 100 * OneMB;
const chooseUploadStrategy = (
file: UploadedFile,
config: UploadStrategyConfig
) => {
const settings = getBootstrapData().settings;
const disk = config.metadata?.disk || Disk.uploads;
const driver =
disk === Disk.uploads
? settings.uploads.uploads_driver
: settings.uploads.public_driver;
if (driver?.endsWith('s3') && settings.uploads.s3_direct_upload) {
return file.size >= HundredMB ? S3MultipartUpload : S3Upload;
} else {
// 4MB = Axios, otherwise Tus
return file.size >= FourMB && !settings.uploads.disable_tus
? TusUpload
: AxiosUpload;
}
};

View File

@@ -0,0 +1,71 @@
import {UploadedFile} from '../../uploaded-file';
import {UploadStrategy, UploadStrategyConfig} from './upload-strategy';
import {apiClient} from '@common/http/query-client';
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
import {AxiosProgressEvent} from 'axios';
export class AxiosUpload implements UploadStrategy {
private abortController: AbortController;
constructor(
private file: UploadedFile,
private config: UploadStrategyConfig,
) {
this.abortController = new AbortController();
}
async start() {
const formData = new FormData();
const {onSuccess, onError, onProgress, metadata} = this.config;
formData.set('file', this.file.native);
formData.set('workspaceId', `12`);
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
formData.set(key, `${value}`);
});
}
const response = await apiClient
.post('file-entries', formData, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.event.lengthComputable) {
onProgress?.({
bytesUploaded: e.loaded,
bytesTotal: e.total || 0,
});
}
},
signal: this.abortController.signal,
headers: {
'Content-Type': 'multipart/form-data',
},
})
.catch(err => {
if (err.code !== 'ERR_CANCELED') {
onError?.(getAxiosErrorMessage(err), this.file);
}
});
// if upload was aborted, it will be handled and set
// as "aborted" already, no need to set it as "failed"
if (this.abortController.signal.aborted) {
return;
}
if (response && response.data.fileEntry) {
onSuccess?.(response.data.fileEntry, this.file);
}
}
abort() {
this.abortController.abort();
return Promise.resolve();
}
static async create(
file: UploadedFile,
config: UploadStrategyConfig,
): Promise<AxiosUpload> {
return new AxiosUpload(file, config);
}
}

View File

@@ -0,0 +1,331 @@
import {UploadStrategy, UploadStrategyConfig} from './upload-strategy';
import {UploadedFile} from '../../uploaded-file';
import axios, {AxiosInstance, AxiosProgressEvent} from 'axios';
import {FileEntry} from '../../file-entry';
import {
getFromLocalStorage,
removeFromLocalStorage,
setInLocalStorage,
} from '@common/utils/hooks/local-storage';
import {apiClient} from '@common/http/query-client';
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
import axiosRetry from 'axios-retry';
const oneMB = 1024 * 1024;
// chunk size that will be uploaded to s3 per request
const desiredChunkSize = 20 * oneMB;
// how many urls should be pre-signed per call to backend
const batchSize = 10;
// number of concurrent requests to s3 api
const concurrency = 5;
interface ChunkState {
blob: Blob | File;
done: boolean;
etag?: string;
partNumber: number;
bytesUploaded: number;
}
interface SignedUrl {
url: string;
partNumber: number;
}
interface StoredUrl {
createdAt: string;
uploadId: string;
fileKey: string;
}
interface UploadedPart {
PartNumber: number;
ETag: string;
Size: string;
LastModified: string;
}
export class S3MultipartUpload implements UploadStrategy {
private abortController: AbortController;
private chunks: ChunkState[] = [];
private uploadId?: string;
private fileKey?: string;
private readonly chunkAxios: AxiosInstance;
private abortedByUser = false;
private uploadedParts?: UploadedPart[];
get storageKey(): string {
return `s3-multipart::${this.file.fingerprint}`;
}
constructor(
private file: UploadedFile,
private config: UploadStrategyConfig
) {
this.abortController = new AbortController();
this.chunkAxios = axios.create();
axiosRetry(this.chunkAxios, {retries: 3});
}
async start() {
const storedUrl = getFromLocalStorage(this.storageKey);
if (storedUrl) {
await this.getUploadedParts(storedUrl);
}
if (!this.uploadedParts?.length) {
await this.createMultipartUpload();
if (!this.uploadId) return;
}
this.prepareChunks();
const result = await this.uploadParts();
if (result === 'done') {
const isCompleted = await this.completeMultipartUpload();
if (!isCompleted) return;
// catch any errors so below "onError" handler gets executed
try {
const response = await this.createFileEntry();
if (response?.fileEntry) {
this.config.onSuccess?.(response?.fileEntry, this.file);
removeFromLocalStorage(this.storageKey);
return;
}
} catch {}
}
// upload failed
if (!this.abortController.signal.aborted) {
this.abortController.abort();
}
if (!this.abortedByUser) {
this.config.onError?.(null, this.file);
}
}
async abort() {
this.abortedByUser = true;
this.abortController.abort();
await this.abortUploadOnS3();
}
private async uploadParts(): Promise<any> {
const pendingChunks = this.chunks.filter(c => !c.done);
if (!pendingChunks.length) {
return Promise.resolve('done');
}
const signedUrls = await this.batchSignUrls(
pendingChunks.slice(0, batchSize)
);
if (!signedUrls) return;
while (signedUrls.length) {
const batch = signedUrls.splice(0, concurrency);
const pendingUploads = batch.map(item => {
return this.uploadPartToS3(item);
});
const result = await Promise.all(pendingUploads);
// if not all uploads in batch completed, bail
if (!result.every(r => r)) return;
}
return await this.uploadParts();
}
private async batchSignUrls(
batch: ChunkState[]
): Promise<SignedUrl[] | undefined> {
const response = await this.chunkAxios
.post(
'api/v1/s3/multipart/batch-sign-part-urls',
{
partNumbers: batch.map(i => i.partNumber),
uploadId: this.uploadId,
key: this.fileKey,
},
{signal: this.abortController.signal}
)
.then(r => r.data as {urls: SignedUrl[]})
.catch(err => {
if (!this.abortController.signal.aborted) {
this.abortController.abort();
}
});
return response?.urls;
}
private async uploadPartToS3({
url,
partNumber,
}: SignedUrl): Promise<boolean | void> {
const chunk = this.chunks.find(c => c.partNumber === partNumber);
if (!chunk) return;
return this.chunkAxios
.put(url, chunk.blob, {
withCredentials: false,
signal: this.abortController.signal,
onUploadProgress: (e: AxiosProgressEvent) => {
if (!e.event.lengthComputable) return;
chunk.bytesUploaded = e.loaded;
const totalUploaded = this.chunks.reduce(
(n, c) => n + c.bytesUploaded,
0
);
this.config.onProgress?.({
bytesUploaded: totalUploaded,
bytesTotal: this.file.size,
});
},
})
.then(r => {
const etag = r.headers.etag;
if (etag) {
chunk.done = true;
chunk.etag = etag;
return true;
}
})
.catch(err => {
if (!this.abortController.signal.aborted && err !== undefined) {
this.abortController.abort();
}
});
}
private async createMultipartUpload(): Promise<void> {
const response = await apiClient
.post('s3/multipart/create', {
filename: this.file.name,
mime: this.file.mime,
size: this.file.size,
extension: this.file.extension,
...this.config.metadata,
})
.then(r => r.data as {uploadId: string; key: string})
.catch(err => {
if (err.code !== 'ERR_CANCELED') {
this.config.onError?.(getAxiosErrorMessage(err), this.file);
}
});
if (response) {
this.uploadId = response.uploadId;
this.fileKey = response.key;
setInLocalStorage(this.storageKey, {
createdAt: new Date().toISOString(),
fileKey: this.fileKey,
uploadId: this.uploadId,
} as StoredUrl);
}
}
private async getUploadedParts({fileKey, uploadId}: StoredUrl) {
const response = await apiClient
.post('s3/multipart/get-uploaded-parts', {
key: fileKey,
uploadId,
})
.then(r => r.data as {parts: UploadedPart[]})
.catch(() => {
removeFromLocalStorage(this.storageKey);
return null;
});
if (response?.parts?.length) {
this.uploadedParts = response.parts;
this.uploadId = uploadId;
this.fileKey = fileKey;
}
}
private async completeMultipartUpload(): Promise<{location: string} | null> {
return apiClient
.post('s3/multipart/complete', {
key: this.fileKey,
uploadId: this.uploadId,
parts: this.chunks.map(c => {
return {
ETag: c.etag,
PartNumber: c.partNumber,
};
}),
})
.then(r => r.data)
.catch(() => {
this.config.onError?.(null, this.file);
this.abortUploadOnS3();
})
.finally(() => {
removeFromLocalStorage(this.storageKey);
});
}
private async createFileEntry(): Promise<{fileEntry: FileEntry}> {
return await apiClient
.post('s3/entries', {
...this.config.metadata,
clientMime: this.file.mime,
clientName: this.file.name,
filename: this.fileKey!.split('/').pop(),
size: this.file.size,
clientExtension: this.file.extension,
})
.then(r => r.data)
.catch();
}
private prepareChunks() {
this.chunks = [];
// at least 5MB per request, at most 10k requests
const minChunkSize = Math.max(5 * oneMB, Math.ceil(this.file.size / 10000));
const chunkSize = Math.max(desiredChunkSize, minChunkSize);
// Upload zero-sized files in one zero-sized chunk
if (this.file.size === 0) {
this.chunks.push({
blob: this.file.native,
done: false,
partNumber: 1,
bytesUploaded: 0,
});
} else {
let partNumber = 1;
for (let i = 0; i < this.file.size; i += chunkSize) {
const end = Math.min(this.file.size, i + chunkSize);
// check if this part was already uploaded previously
const previouslyUploaded = this.uploadedParts?.find(
p => p.PartNumber === partNumber
);
this.chunks.push({
blob: this.file.native.slice(i, end),
done: !!previouslyUploaded,
partNumber,
etag: previouslyUploaded ? previouslyUploaded.ETag : undefined,
bytesUploaded: previouslyUploaded?.Size
? parseInt(previouslyUploaded?.Size)
: 0,
});
partNumber++;
}
}
}
private abortUploadOnS3() {
return apiClient.post('s3/multipart/abort', {
key: this.fileKey,
uploadId: this.uploadId,
});
}
static async create(
file: UploadedFile,
config: UploadStrategyConfig
): Promise<S3MultipartUpload> {
return new S3MultipartUpload(file, config);
}
}

View File

@@ -0,0 +1,120 @@
import {UploadStrategy, UploadStrategyConfig} from './upload-strategy';
import {UploadedFile} from '../../uploaded-file';
import axios, {AxiosProgressEvent} from 'axios';
import {FileEntry} from '../../file-entry';
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
import {apiClient} from '@common/http/query-client';
interface PresignedRequest {
url: string;
key: string;
acl: string;
}
export class S3Upload implements UploadStrategy {
private abortController: AbortController;
private presignedRequest?: PresignedRequest;
constructor(
private file: UploadedFile,
private config: UploadStrategyConfig
) {
this.abortController = new AbortController();
}
async start() {
this.presignedRequest = await this.presignPostUrl();
if (!this.presignedRequest) return;
const result = await this.uploadFileToS3();
if (result !== 'uploaded') return;
const response = await this.createFileEntry();
if (response?.fileEntry) {
this.config.onSuccess?.(response.fileEntry, this.file);
} else if (!this.abortController.signal) {
this.config.onError?.(null, this.file);
}
}
abort() {
this.abortController.abort();
return Promise.resolve();
}
private presignPostUrl(): Promise<PresignedRequest> {
return apiClient
.post(
's3/simple/presign',
{
filename: this.file.name,
mime: this.file.mime,
disk: this.config.metadata?.disk,
size: this.file.size,
extension: this.file.extension,
...this.config.metadata,
},
{signal: this.abortController.signal}
)
.then(r => r.data)
.catch(err => {
if (err.code !== 'ERR_CANCELED') {
this.config.onError?.(getAxiosErrorMessage(err), this.file);
}
});
}
private uploadFileToS3() {
const {url, acl} = this.presignedRequest!;
return axios
.put(url, this.file.native, {
signal: this.abortController.signal,
withCredentials: false,
headers: {
'Content-Type': this.file.mime,
'x-amz-acl': acl,
},
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.event.lengthComputable) {
this.config.onProgress?.({
bytesUploaded: e.loaded,
bytesTotal: e.total || 0,
});
}
},
})
.then(() => 'uploaded')
.catch(err => {
if (err.code !== 'ERR_CANCELED') {
this.config.onError?.(getAxiosErrorMessage(err), this.file);
}
});
}
private async createFileEntry() {
return await apiClient
.post('s3/entries', {
...this.config.metadata,
clientMime: this.file.mime,
clientName: this.file.name,
filename: this.presignedRequest!.key.split('/').pop(),
size: this.file.size,
clientExtension: this.file.extension,
})
.then(r => {
return r.data as {fileEntry: FileEntry};
})
.catch(err => {
if (err.code !== 'ERR_CANCELED') {
this.config.onError?.(getAxiosErrorMessage(err), this.file);
}
});
}
static async create(
file: UploadedFile,
config: UploadStrategyConfig
): Promise<S3Upload> {
return new S3Upload(file, config);
}
}

View File

@@ -0,0 +1,90 @@
import {Upload} from 'tus-js-client';
import {UploadedFile} from '../../uploaded-file';
import {UploadStrategy, UploadStrategyConfig} from './upload-strategy';
import {FileEntry} from '../../file-entry';
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
import {apiClient} from '@common/http/query-client';
import {getCookie} from 'react-use-cookie';
export class TusUpload implements UploadStrategy {
constructor(private upload: Upload) {}
start() {
this.upload.start();
}
abort() {
return this.upload.abort(true);
}
static async create(
file: UploadedFile,
{
onProgress,
onSuccess,
onError,
metadata,
chunkSize,
baseUrl,
}: UploadStrategyConfig
): Promise<TusUpload> {
const tusFingerprint = ['tus', file.fingerprint, 'drive'].join('-');
const upload = new Upload(file.native, {
fingerprint: () => Promise.resolve(tusFingerprint),
removeFingerprintOnSuccess: true,
endpoint: `${baseUrl}/api/v1/tus/upload`,
chunkSize,
retryDelays: [0, 3000, 5000, 10000, 20000],
overridePatchMethod: true,
metadata: {
name: window.btoa(file.id),
clientName: file.name,
clientExtension: file.extension,
clientMime: file.mime || '',
clientSize: `${file.size}`,
...(metadata as Record<string, string>),
},
headers: {
'X-XSRF-TOKEN': getCookie('XSRF-TOKEN'),
},
onError: err => {
if ('originalResponse' in err && err.originalResponse) {
try {
const message = JSON.parse(err.originalResponse.getBody())?.message;
onError?.(message, file);
} catch (e) {
onError?.(null, file);
}
} else {
onError?.(null, file);
}
},
onProgress(bytesUploaded, bytesTotal) {
onProgress?.({bytesUploaded, bytesTotal});
},
onSuccess: async () => {
const uploadKey = upload.url?.split('/').pop();
try {
if (uploadKey) {
const response = await createFileEntry(uploadKey);
onSuccess?.(response.fileEntry, file);
}
} catch (err) {
localStorage.removeItem(tusFingerprint);
onError?.(getAxiosErrorMessage(err), file);
}
},
});
const previousUploads = await upload.findPreviousUploads();
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
return new TusUpload(upload);
}
}
function createFileEntry(uploadKey: string): Promise<{fileEntry: FileEntry}> {
return apiClient.post('tus/entries', {uploadKey}).then(r => r.data);
}

View File

@@ -0,0 +1,20 @@
import {BackendMetadata} from '../../types/backend-metadata';
import {Restrictions} from '../validate-upload';
import {FileEntry} from '../../file-entry';
import {UploadedFile} from '@common/uploads/uploaded-file';
export interface UploadStrategyConfig {
chunkSize?: number;
baseUrl?: string;
restrictions?: Restrictions;
showToastOnRestrictionFail?: boolean;
onProgress?: (progress: {bytesUploaded: number; bytesTotal: number}) => void;
onSuccess?: (entry: FileEntry, file: UploadedFile) => void;
onError?: (message: string | undefined | null, file: UploadedFile) => void;
metadata?: BackendMetadata;
}
export interface UploadStrategy {
start: () => void;
abort: () => Promise<void>;
}

View File

@@ -0,0 +1,93 @@
import {useCallback, useRef} from 'react';
import {useFileUploadStore} from './file-upload-provider';
import {UploadedFile} from '../uploaded-file';
import {UploadStrategyConfig} from './strategy/upload-strategy';
import {openUploadWindow} from '../utils/open-upload-window';
import {useDeleteFileEntries} from '@common/uploads/requests/delete-file-entries';
interface DeleteEntryProps {
onSuccess: () => void;
entryPath?: string;
}
export function useActiveUpload() {
const deleteFileEntries = useDeleteFileEntries();
// use ref for setting ID to avoid extra renders, zustand selector
// will pick up changed selector on first progress event
const uploadIdRef = useRef<string>();
const uploadSingle = useFileUploadStore(s => s.uploadSingle);
const _abortUpload = useFileUploadStore(s => s.abortUpload);
const updateFileUpload = useFileUploadStore(s => s.updateFileUpload);
const activeUpload = useFileUploadStore(s =>
uploadIdRef.current ? s.fileUploads.get(uploadIdRef.current) : null,
);
const uploadFile = useCallback(
(file: File | UploadedFile, config?: UploadStrategyConfig) => {
uploadIdRef.current = uploadSingle(file, config);
},
[uploadSingle],
);
const selectAndUploadFile = useCallback(
async (config?: UploadStrategyConfig) => {
const files = await openUploadWindow({
types: config?.restrictions?.allowedFileTypes,
});
uploadFile(files[0], config);
return files[0];
},
[uploadFile],
);
const deleteEntry = useCallback(
({onSuccess, entryPath}: DeleteEntryProps) => {
const handleSuccess = () => {
if (activeUpload) {
updateFileUpload(activeUpload.file.id, {
...activeUpload,
entry: undefined,
});
}
onSuccess();
};
if (!entryPath && !activeUpload?.entry?.id) {
handleSuccess();
return;
}
deleteFileEntries.mutate(
{
paths: entryPath ? [entryPath] : undefined,
entryIds: activeUpload?.entry?.id
? [activeUpload?.entry?.id]
: undefined,
deleteForever: true,
},
{onSuccess: handleSuccess},
);
},
[deleteFileEntries, activeUpload, updateFileUpload],
);
const abortUpload = useCallback(() => {
if (activeUpload) {
_abortUpload(activeUpload.file.id);
}
}, [activeUpload, _abortUpload]);
return {
uploadFile,
selectAndUploadFile,
percentage: activeUpload?.percentage || 0,
uploadStatus: activeUpload?.status,
entry: activeUpload?.entry,
deleteEntry,
isDeletingEntry: deleteFileEntries.isPending,
activeUpload,
abortUpload,
};
}

View File

@@ -0,0 +1,61 @@
import {UploadedFile} from '../uploaded-file';
import {message} from '../../i18n/message';
import {prettyBytes} from '../utils/pretty-bytes';
import {MessageDescriptor} from '../../i18n/message-descriptor';
import match from 'mime-match';
export interface Restrictions {
maxFileSize?: number;
allowedFileTypes?: string[];
blockedFileTypes?: string[];
}
export function validateUpload(
file: UploadedFile,
restrictions?: Restrictions
): MessageDescriptor | void {
if (!restrictions) return;
const {maxFileSize, allowedFileTypes, blockedFileTypes} = restrictions;
if (maxFileSize && file.size != null && file.size > maxFileSize) {
return message('`:file` exceeds maximum allowed size of :size', {
values: {file: file.name, size: prettyBytes(maxFileSize)},
});
}
if (allowedFileTypes?.length) {
if (!fileMatchesTypes(file, allowedFileTypes)) {
return message('This file type is not allowed');
}
}
if (blockedFileTypes?.length) {
if (fileMatchesTypes(file, blockedFileTypes)) {
return message('This file type is not allowed');
}
}
}
function fileMatchesTypes(file: UploadedFile, types: string[]): boolean {
return (
types
// support multiple file types in a string (video/mp4,audio/mp3,image/png)
.map(type => type.split(','))
.flat()
.some(type => {
// check if this is a mime-type
if (type.includes('/')) {
if (!file.mime) return false;
return match(file.mime.replace(/;.*?$/, ''), type);
}
// otherwise this is likely an extension
const extension = type.replace('.', '').toLowerCase();
if (extension && file.extension) {
return file.extension.toLowerCase() === extension;
}
return false;
})
);
}

View File

@@ -0,0 +1,19 @@
export type SpaceUnit = 'KB' | 'MB' | 'GB' | 'TB' | 'PB';
export function convertToBytes(value: number, unit: SpaceUnit): number {
if (value == null) return 0;
switch (unit) {
case 'KB':
return value * 1024;
case 'MB':
return value * 1024 ** 2;
case 'GB':
return value * 1024 ** 3;
case 'TB':
return value * 1024 ** 4;
case 'PB':
return value * 1024 ** 5;
default:
return value;
}
}

View File

@@ -0,0 +1,49 @@
import {UploadInputConfig} from '../types/upload-input-config';
export function createUploadInput(
config: UploadInputConfig = {}
): HTMLInputElement {
const old = document.querySelector('#hidden-file-upload-input');
if (old) old.remove();
const input = document.createElement('input');
input.type = 'file';
input.multiple = config.multiple ?? false;
input.classList.add('hidden');
input.style.display = 'none';
input.style.visibility = 'hidden';
input.id = 'hidden-file-upload-input';
input.accept = buildUploadInputAccept(config);
if (config.directory) {
input.webkitdirectory = true;
}
document.body.appendChild(input);
return input;
}
export interface UploadAccentProps {
extensions?: string[];
types?: string[];
}
export function buildUploadInputAccept({
extensions = [],
types = [],
}: UploadAccentProps): string {
const accept = [];
if (extensions?.length) {
extensions = extensions.map(e => {
return e.startsWith('.') ? e : `.${e}`;
});
accept.push(extensions.join(','));
}
if (types?.length) {
accept.push(types.join(','));
}
return accept.join(',');
}

View File

@@ -0,0 +1,8 @@
export function downloadFileFromUrl(url: string, name?: string) {
const link = document.createElement('a');
link.href = url;
if (name) link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

View File

@@ -0,0 +1,4 @@
export function extensionFromFilename(fullFileName: string): string {
const re = /(?:\.([^.]+))?$/;
return re.exec(fullFileName)?.[1] || '';
}

View File

@@ -0,0 +1,81 @@
import {UploadedFile} from '../uploaded-file';
export async function getDroppedFiles(
dataTransfer: DataTransfer
): Promise<UploadedFile[]> {
let files: UploadedFile[] = [];
if (dataTransfer.items?.[0] && 'webkitGetAsEntry' in dataTransfer.items[0]) {
// need to make a copy if transfer items and get entry here, it
// might not be available anymore in subsequent loop iterations
const entries = [...dataTransfer.items].map(item => {
return item.webkitGetAsEntry();
});
for (const entry of entries) {
if (entry && !entry.isDirectory) {
files.push(await filesystemEntryToFile(entry as FileSystemFileEntry));
} else if (entry) {
files = [
...files,
...(await readDirRecursive(entry as FileSystemDirectoryEntry)),
];
}
}
} else {
files = [...dataTransfer.files].map(f => new UploadedFile(f));
}
return files;
}
function filesystemEntryToFile(
entry: FileSystemFileEntry
): Promise<UploadedFile> {
return new Promise(resolve => {
entry.file(file => {
resolve(new UploadedFile(file, entry.fullPath));
});
});
}
async function readDirRecursive(
entry: FileSystemDirectoryEntry,
files: UploadedFile[] = []
) {
const entries = await readEntries(entry);
for (const childEntry of entries) {
if (childEntry.isDirectory) {
await readDirRecursive(childEntry as FileSystemDirectoryEntry, files);
} else {
files.push(
await filesystemEntryToFile(childEntry as FileSystemFileEntry)
);
}
}
return files;
}
function readEntries(
dir: FileSystemDirectoryEntry
): Promise<FileSystemEntry[]> {
return new Promise(resolve => {
drainDirReader(dir.createReader(), resolve);
});
}
function drainDirReader(
reader: FileSystemDirectoryReader,
resolve: (value: FileSystemEntry[]) => void,
allEntries: FileSystemEntry[] = []
) {
// directory reader needs to be called repeatedly until it returns an empty array
reader.readEntries(entries => {
if (entries.length) {
allEntries = [...allEntries, ...entries];
drainDirReader(reader, resolve, allEntries);
} else {
resolve(allEntries);
}
});
}

View File

@@ -0,0 +1,30 @@
import {extensionFromFilename} from './extension-from-filename';
export function getFileMime(file: File): string {
const extensionsToMime: Record<string, string> = {
md: 'text/markdown',
markdown: 'text/markdown',
mp4: 'video/mp4',
mp3: 'audio/mp3',
svg: 'image/svg+xml',
jpg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
yaml: 'text/yaml',
yml: 'text/yaml',
};
const fileExtension = file.name ? extensionFromFilename(file.name) : null;
// check if mime type is set in the file object
if (file.type) {
return file.type;
}
// see if we can map extension to a mime type
if (fileExtension && fileExtension in extensionsToMime) {
return extensionsToMime[fileExtension];
}
return 'application/octet-stream';
}

View File

@@ -0,0 +1,31 @@
import {UploadInputConfig} from '../types/upload-input-config';
import {UploadedFile} from '../uploaded-file';
import {createUploadInput} from './create-upload-input';
/**
* Open browser dialog for uploading files and
* resolve promise with uploaded files.
*/
export function openUploadWindow(
config: UploadInputConfig = {}
): Promise<UploadedFile[]> {
return new Promise(resolve => {
const input = createUploadInput(config);
input.onchange = e => {
const fileList = (e.target as HTMLInputElement).files;
if (!fileList) {
return resolve([]);
}
const uploads = Array.from(fileList)
.filter(f => f.name !== '.DS_Store')
.map(file => new UploadedFile(file));
resolve(uploads);
input.remove();
};
document.body.appendChild(input);
input.click();
});
}

View File

@@ -0,0 +1,30 @@
// Adapted from https://github.com/Flet/prettier-bytes/
// Changing 1000 bytes to 1024, so we can keep uppercase KB vs kB
// ISC License (c) Dan Flettre https://github.com/Flet/prettier-bytes/blob/master/LICENSE
export function prettyBytes(num?: number, fractionDigits = 1): string {
if (num == null || Number.isNaN(num)) return '';
const neg = num < 0;
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (neg) {
num = -num;
}
if (num < 1) {
return `${(neg ? '-' : '') + num} B`;
}
const exponent = Math.min(
Math.floor(Math.log(num) / Math.log(1024)),
units.length - 1
);
num = Number(num / Math.pow(1024, exponent));
const unit = units[exponent];
if (num >= 10 || num % 1 === 0) {
// Do not show decimals when the number is two-digit, or if the number has no
// decimal component.
return `${(neg ? '-' : '') + num.toFixed(0)} ${unit}`;
}
return `${(neg ? '-' : '') + num.toFixed(fractionDigits)} ${unit}`;
}

View File

@@ -0,0 +1 @@
export const spaceUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];