25
common/resources/client/uploads/file-entry.ts
Executable file
25
common/resources/client/uploads/file-entry.ts
Executable 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}[];
|
||||
}
|
||||
47
common/resources/client/uploads/file-type-colors.css
vendored
Executable file
47
common/resources/client/uploads/file-type-colors.css
vendored
Executable 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;
|
||||
}
|
||||
44
common/resources/client/uploads/file-type-icon/file-thumbnail.tsx
Executable file
44
common/resources/client/uploads/file-type-icon/file-thumbnail.tsx
Executable 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} />;
|
||||
}
|
||||
50
common/resources/client/uploads/file-type-icon/file-type-icon.tsx
Executable file
50
common/resources/client/uploads/file-type-icon/file-type-icon.tsx
Executable 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/audio-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/audio-file-icon.tsx
Executable 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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/image-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/image-file-icon.tsx
Executable 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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/pdf-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/pdf-file-icon.tsx
Executable 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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/text-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/text-file-icon.tsx
Executable 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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/video-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/video-file-icon.tsx
Executable 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>
|
||||
);
|
||||
7
common/resources/client/uploads/file-type-icon/icons/word-file-icon.tsx
Executable file
7
common/resources/client/uploads/file-type-icon/icons/word-file-icon.tsx
Executable 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>
|
||||
);
|
||||
9
common/resources/client/uploads/formatted-bytes.tsx
Executable file
9
common/resources/client/uploads/formatted-bytes.tsx
Executable 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>;
|
||||
});
|
||||
66
common/resources/client/uploads/hooks/file-entry-urls.ts
Executable file
66
common/resources/client/uploads/hooks/file-entry-urls.ts
Executable 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();
|
||||
}
|
||||
28
common/resources/client/uploads/preview/available-previews.ts
Executable file
28
common/resources/client/uploads/preview/available-previews.ts
Executable 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
|
||||
);
|
||||
}
|
||||
209
common/resources/client/uploads/preview/file-preview-container.tsx
Executable file
209
common/resources/client/uploads/preview/file-preview-container.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
11
common/resources/client/uploads/preview/file-preview-context.ts
Executable file
11
common/resources/client/uploads/preview/file-preview-context.ts
Executable 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!
|
||||
);
|
||||
24
common/resources/client/uploads/preview/file-preview-dialog.tsx
Executable file
24
common/resources/client/uploads/preview/file-preview-dialog.tsx
Executable 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} />;
|
||||
}
|
||||
37
common/resources/client/uploads/preview/file-preview/audio-file-preview.tsx
Executable file
37
common/resources/client/uploads/preview/file-preview/audio-file-preview.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {FileEntry} from '../../file-entry';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
entry: FileEntry;
|
||||
className?: string;
|
||||
allowDownload?: boolean;
|
||||
}
|
||||
26
common/resources/client/uploads/preview/file-preview/image-file-preview.tsx
Executable file
26
common/resources/client/uploads/preview/file-preview/image-file-preview.tsx
Executable 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},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
common/resources/client/uploads/preview/file-preview/pdf-file-preview.tsx
Executable file
26
common/resources/client/uploads/preview/file-preview/pdf-file-preview.tsx
Executable 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
common/resources/client/uploads/preview/file-preview/text-file-preview.tsx
Executable file
91
common/resources/client/uploads/preview/file-preview/text-file-preview.tsx
Executable 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
38
common/resources/client/uploads/preview/file-preview/video-file-preview.tsx
Executable file
38
common/resources/client/uploads/preview/file-preview/video-file-preview.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
)}`;
|
||||
}
|
||||
23
common/resources/client/uploads/requests/delete-file-entries.ts
Executable file
23
common/resources/client/uploads/requests/delete-file-entries.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
41
common/resources/client/uploads/requests/use-file-entry-model.ts
Executable file
41
common/resources/client/uploads/requests/use-file-entry-model.ts
Executable 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();
|
||||
}
|
||||
11
common/resources/client/uploads/types/backend-metadata.ts
Executable file
11
common/resources/client/uploads/types/backend-metadata.ts
Executable 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',
|
||||
}
|
||||
14
common/resources/client/uploads/types/upload-input-config.ts
Executable file
14
common/resources/client/uploads/types/upload-input-config.ts
Executable 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/*',
|
||||
}
|
||||
111
common/resources/client/uploads/uploaded-file.ts
Executable file
111
common/resources/client/uploads/uploaded-file.ts
Executable 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
|
||||
);
|
||||
}
|
||||
18
common/resources/client/uploads/uploader/create-file-upload.ts
Executable file
18
common/resources/client/uploads/uploader/create-file-upload.ts
Executable 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 || {},
|
||||
};
|
||||
}
|
||||
47
common/resources/client/uploads/uploader/file-upload-provider.tsx
Executable file
47
common/resources/client/uploads/uploader/file-upload-provider.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
195
common/resources/client/uploads/uploader/file-upload-store.ts
Executable file
195
common/resources/client/uploads/uploader/file-upload-store.ts
Executable 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;
|
||||
};
|
||||
26
common/resources/client/uploads/uploader/progress-timeout.ts
Executable file
26
common/resources/client/uploads/uploader/progress-timeout.ts
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
common/resources/client/uploads/uploader/start-uploading.tsx
Executable file
129
common/resources/client/uploads/uploader/start-uploading.tsx
Executable 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;
|
||||
}
|
||||
};
|
||||
71
common/resources/client/uploads/uploader/strategy/axios-upload.ts
Executable file
71
common/resources/client/uploads/uploader/strategy/axios-upload.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
331
common/resources/client/uploads/uploader/strategy/s3-multipart-upload.ts
Executable file
331
common/resources/client/uploads/uploader/strategy/s3-multipart-upload.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
120
common/resources/client/uploads/uploader/strategy/s3-upload.ts
Executable file
120
common/resources/client/uploads/uploader/strategy/s3-upload.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
90
common/resources/client/uploads/uploader/strategy/tus-upload.ts
Executable file
90
common/resources/client/uploads/uploader/strategy/tus-upload.ts
Executable 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);
|
||||
}
|
||||
20
common/resources/client/uploads/uploader/strategy/upload-strategy.ts
Executable file
20
common/resources/client/uploads/uploader/strategy/upload-strategy.ts
Executable 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>;
|
||||
}
|
||||
93
common/resources/client/uploads/uploader/use-active-upload.ts
Executable file
93
common/resources/client/uploads/uploader/use-active-upload.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
61
common/resources/client/uploads/uploader/validate-upload.ts
Executable file
61
common/resources/client/uploads/uploader/validate-upload.ts
Executable 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
19
common/resources/client/uploads/utils/convert-to-bytes.ts
Executable file
19
common/resources/client/uploads/utils/convert-to-bytes.ts
Executable 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;
|
||||
}
|
||||
}
|
||||
49
common/resources/client/uploads/utils/create-upload-input.ts
Executable file
49
common/resources/client/uploads/utils/create-upload-input.ts
Executable 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(',');
|
||||
}
|
||||
8
common/resources/client/uploads/utils/download-file-from-url.ts
Executable file
8
common/resources/client/uploads/utils/download-file-from-url.ts
Executable 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);
|
||||
}
|
||||
4
common/resources/client/uploads/utils/extension-from-filename.ts
Executable file
4
common/resources/client/uploads/utils/extension-from-filename.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export function extensionFromFilename(fullFileName: string): string {
|
||||
const re = /(?:\.([^.]+))?$/;
|
||||
return re.exec(fullFileName)?.[1] || '';
|
||||
}
|
||||
81
common/resources/client/uploads/utils/get-dropped-files.ts
Executable file
81
common/resources/client/uploads/utils/get-dropped-files.ts
Executable 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
30
common/resources/client/uploads/utils/get-file-mime.ts
Executable file
30
common/resources/client/uploads/utils/get-file-mime.ts
Executable 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';
|
||||
}
|
||||
31
common/resources/client/uploads/utils/open-upload-window.ts
Executable file
31
common/resources/client/uploads/utils/open-upload-window.ts
Executable 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();
|
||||
});
|
||||
}
|
||||
30
common/resources/client/uploads/utils/pretty-bytes.ts
Executable file
30
common/resources/client/uploads/utils/pretty-bytes.ts
Executable 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}`;
|
||||
}
|
||||
1
common/resources/client/uploads/utils/space-units.ts
Executable file
1
common/resources/client/uploads/utils/space-units.ts
Executable file
@@ -0,0 +1 @@
|
||||
export const spaceUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
Reference in New Issue
Block a user