Skip to content

Commit 4943f8c

Browse files
Pollepsjoepio
authored andcommitted
#228 Better dropzone styling + Add file drag & drop to folders
1 parent 0c1d9c4 commit 4943f8c

File tree

7 files changed

+282
-73
lines changed

7 files changed

+282
-73
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Resource } from '@tomic/react';
2+
import React, { useCallback, useEffect } from 'react';
3+
import { useDropzone } from 'react-dropzone';
4+
import { FaUpload } from 'react-icons/fa';
5+
import styled, { keyframes } from 'styled-components';
6+
import { ErrMessage } from '../InputStyles';
7+
import { useUpload } from './useUpload';
8+
9+
export interface FileDropZoneProps {
10+
parentResource: Resource;
11+
onFilesUploaded?: (files: string[]) => void;
12+
}
13+
14+
/**
15+
* A dropzone for adding files. Renders its children by default, unless you're
16+
* holding a file, an error occurred, or it's uploading.
17+
*/
18+
export function FileDropZone({
19+
parentResource,
20+
children,
21+
onFilesUploaded,
22+
}: React.PropsWithChildren<FileDropZoneProps>): JSX.Element {
23+
const { upload, isUploading, error } = useUpload(parentResource);
24+
const dropzoneRef = React.useRef<HTMLDivElement>(null);
25+
const onDrop = useCallback(
26+
async (files: File[]) => {
27+
const uploaded = await upload(files);
28+
onFilesUploaded?.(uploaded);
29+
},
30+
[upload],
31+
);
32+
33+
const { getRootProps, isDragActive } = useDropzone({ onDrop });
34+
35+
// Move the dropzone down if the user has scrolled down.
36+
useEffect(() => {
37+
if (isDragActive && dropzoneRef.current) {
38+
const rect = dropzoneRef.current.getBoundingClientRect();
39+
40+
if (rect.top < 0) {
41+
dropzoneRef.current.style.top = `calc(${Math.abs(rect.top)}px + 1rem)`;
42+
}
43+
}
44+
}, [isDragActive]);
45+
46+
return (
47+
<Root
48+
{...getRootProps()}
49+
// For some reason this is tabbable by default, but it does not seem to actually help users.
50+
// Let's disable it.
51+
tabIndex={-1}
52+
>
53+
{isUploading && <p>{'Uploading...'}</p>}
54+
{error && <ErrMessage>{error.message}</ErrMessage>}
55+
{children}
56+
{isDragActive && (
57+
<VisualDropzone ref={dropzoneRef}>
58+
<TextWrapper>
59+
<FaUpload /> Drop files here to upload.
60+
</TextWrapper>
61+
</VisualDropzone>
62+
)}
63+
</Root>
64+
);
65+
}
66+
67+
const Root = styled.div`
68+
height: 100%;
69+
position: relative;
70+
`;
71+
72+
const fadeIn = keyframes`
73+
from {
74+
opacity: 0;
75+
backdrop-filter: blur(0px);
76+
}
77+
to {
78+
opacity: 1;
79+
backdrop-filter: blur(10px);
80+
}
81+
`;
82+
83+
const VisualDropzone = styled.div`
84+
position: absolute;
85+
inset: 0;
86+
height: 90vh;
87+
background-color: ${p =>
88+
p.theme.darkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'};
89+
backdrop-filter: blur(10px);
90+
border: 3px dashed ${p => p.theme.colors.textLight};
91+
border-radius: ${p => p.theme.radius};
92+
display: grid;
93+
place-items: center;
94+
font-size: 1.8rem;
95+
color: ${p => p.theme.colors.textLight};
96+
animation: 0.1s ${fadeIn} ease-in;
97+
`;
98+
99+
const TextWrapper = styled.div`
100+
display: flex;
101+
align-items: center;
102+
gap: 1rem;
103+
padding: ${p => p.theme.margin}rem;
104+
`;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Resource } from '@tomic/react';
2+
import React, { useCallback } from 'react';
3+
import { useDropzone } from 'react-dropzone';
4+
import { FaUpload } from 'react-icons/fa';
5+
import styled from 'styled-components';
6+
import { ErrMessage } from '../InputStyles';
7+
import { useUpload } from './useUpload';
8+
9+
export interface FileDropzoneInputProps {
10+
parentResource: Resource;
11+
onFilesUploaded?: (files: string[]) => void;
12+
}
13+
14+
/**
15+
* A dropzone for adding files. Renders its children by default, unless you're
16+
* holding a file, an error occurred, or it's uploading.
17+
*/
18+
export function FileDropzoneInput({
19+
parentResource,
20+
onFilesUploaded,
21+
}: FileDropzoneInputProps): JSX.Element {
22+
const { upload, isUploading, error } = useUpload(parentResource);
23+
24+
const onFileSelect = useCallback(
25+
async (files: File[]) => {
26+
const uploaded = await upload(files);
27+
onFilesUploaded?.(uploaded);
28+
},
29+
[upload],
30+
);
31+
32+
const { getRootProps, getInputProps } = useDropzone({
33+
onDrop: onFileSelect,
34+
});
35+
36+
return (
37+
<>
38+
<VisualDropZone {...getRootProps()}>
39+
{error && <ErrMessage>{error.message}</ErrMessage>}
40+
<input {...getInputProps()} />
41+
<TextWrapper>
42+
<FaUpload />{' '}
43+
{isUploading ? 'Uploading...' : 'Drop files or click here to upload.'}
44+
</TextWrapper>
45+
</VisualDropZone>
46+
</>
47+
);
48+
}
49+
50+
const VisualDropZone = styled.div`
51+
background-color: ${p =>
52+
p.theme.darkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'};
53+
backdrop-filter: blur(10px);
54+
border: 2px dashed ${p => p.theme.colors.bg2};
55+
border-radius: ${p => p.theme.radius};
56+
display: grid;
57+
place-items: center;
58+
font-size: 1.3rem;
59+
color: ${p => p.theme.colors.textLight};
60+
min-height: 10rem;
61+
cursor: pointer;
62+
63+
&:hover,
64+
&focus {
65+
color: ${p => p.theme.colors.main};
66+
border-color: ${p => p.theme.colors.main};
67+
}
68+
`;
69+
70+
const TextWrapper = styled.div`
71+
display: flex;
72+
align-items: center;
73+
padding: ${p => p.theme.margin}rem;
74+
gap: 1rem;
75+
`;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
properties,
3+
Resource,
4+
uploadFiles,
5+
useArray,
6+
useStore,
7+
} from '@tomic/react';
8+
import { useCallback, useState } from 'react';
9+
10+
export interface UseUploadResult {
11+
/** Uploads files to the upload endpoint and returns the created subjects. */
12+
upload: (acceptedFiles: File[]) => Promise<string[]>;
13+
isUploading: boolean;
14+
error: Error | undefined;
15+
}
16+
17+
export function useUpload(parentResource: Resource): UseUploadResult {
18+
const store = useStore();
19+
const [isUploading, setIsUploading] = useState(false);
20+
const [error, setError] = useState<Error | undefined>(undefined);
21+
const [subResources, setSubResources] = useArray(
22+
parentResource,
23+
properties.subResources,
24+
);
25+
26+
const upload = useCallback(
27+
async (acceptedFiles: File[]) => {
28+
try {
29+
setError(undefined);
30+
setIsUploading(true);
31+
const netUploaded = await uploadFiles(
32+
acceptedFiles,
33+
store,
34+
parentResource.getSubject(),
35+
);
36+
const allUploaded = [...netUploaded];
37+
setIsUploading(false);
38+
setSubResources([...subResources, ...allUploaded]);
39+
40+
return allUploaded;
41+
} catch (e) {
42+
setError(e);
43+
setIsUploading(false);
44+
45+
return [];
46+
}
47+
},
48+
[parentResource],
49+
);
50+
51+
return {
52+
upload,
53+
isUploading,
54+
error,
55+
};
56+
}

data-browser/src/components/forms/UploadForm.tsx

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import React, { useCallback, useState } from 'react';
22
import { Resource, uploadFiles } from '@tomic/react';
33
import { useStore } from '@tomic/react';
44
import { useDropzone } from 'react-dropzone';
5-
import styled from 'styled-components';
6-
75
import { Button } from '../Button';
86
import FilePill from '../FilePill';
97
import { ErrMessage } from './InputStyles';
@@ -77,63 +75,3 @@ export default function UploadForm({
7775
</div>
7876
);
7977
}
80-
81-
interface UploadWrapperProps extends UploadFormProps {
82-
children: React.ReactNode;
83-
onFilesUploaded: (filesSubjects: string[]) => unknown;
84-
}
85-
86-
/**
87-
* A dropzone for adding files. Renders its children by default, unless you're
88-
* holding a file, an error occurred, or it's uploading.
89-
*/
90-
export function UploadWrapper({
91-
parentResource,
92-
children,
93-
onFilesUploaded,
94-
}: UploadWrapperProps) {
95-
const store = useStore();
96-
// const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
97-
const [isUploading, setIsUploading] = useState(false);
98-
const [err, setErr] = useState<Error | undefined>(undefined);
99-
const onDrop = useCallback(
100-
async (acceptedFiles: File[]) => {
101-
try {
102-
setErr(undefined);
103-
setIsUploading(true);
104-
const netUploaded = await uploadFiles(
105-
acceptedFiles,
106-
store,
107-
parentResource.getSubject(),
108-
);
109-
const allUploaded = [...netUploaded];
110-
onFilesUploaded(allUploaded);
111-
setIsUploading(false);
112-
} catch (e) {
113-
setErr(e);
114-
setIsUploading(false);
115-
}
116-
},
117-
[onFilesUploaded],
118-
);
119-
const { getRootProps, isDragActive } = useDropzone({ onDrop });
120-
121-
return (
122-
<div
123-
{...getRootProps()}
124-
// For some reason this is tabbable by default, but it does not seem to actually help users.
125-
// Let's disable it.
126-
tabIndex={-1}
127-
>
128-
{isUploading && <p>{'Uploading...'}</p>}
129-
{err && <ErrMessage>{err.message}</ErrMessage>}
130-
{isDragActive ? <Fill>{'Drop the files here ...'}</Fill> : children}
131-
</div>
132-
);
133-
}
134-
135-
const Fill = styled.div`
136-
height: 100%;
137-
width: 100%;
138-
min-height: 4rem;
139-
`;

data-browser/src/routes/NewRoute.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { useResource, useString } from '@tomic/react';
22
import { urls } from '@tomic/react';
3-
import React, { useState } from 'react';
3+
import React, { useCallback, useState } from 'react';
44
import { useNavigate } from 'react-router';
55

6-
import { newURL, useQueryString } from '../helpers/navigation';
6+
import {
7+
constructOpenURL,
8+
newURL,
9+
useQueryString,
10+
} from '../helpers/navigation';
711
import { ContainerNarrow } from '../components/Containers';
812
import NewIntanceButton from '../components/NewInstanceButton';
913
import { ResourceSelector } from '../components/forms/ResourceSelector';
@@ -13,6 +17,8 @@ import { Row } from '../components/Row';
1317
import { NewFormFullPage } from '../components/forms/NewForm/index';
1418
import { ResourceInline } from '../views/ResourceInline';
1519
import styled from 'styled-components';
20+
import { FileDropzoneInput } from '../components/forms/FileDropzone/FileDropzoneInput';
21+
import toast from 'react-hot-toast';
1622

1723
/** Start page for instantiating a new Resource from some Class */
1824
function New(): JSX.Element {
@@ -27,6 +33,7 @@ function New(): JSX.Element {
2733
const { drive } = useSettings();
2834

2935
const calculatedParent = parentSubject || drive;
36+
const parentResource = useResource(calculatedParent);
3037

3138
function handleClassSet(e) {
3239
if (!classInput) {
@@ -39,6 +46,17 @@ function New(): JSX.Element {
3946
navigate(newURL(classInput, calculatedParent));
4047
}
4148

49+
const onUploadComplete = useCallback(
50+
(files: string[]) => {
51+
toast.success(`Uploaded ${files.length} files.`);
52+
53+
if (parentSubject) {
54+
navigate(constructOpenURL(parentSubject));
55+
}
56+
},
57+
[parentSubject, navigate],
58+
);
59+
4260
return (
4361
<ContainerNarrow>
4462
{classSubject ? (
@@ -107,6 +125,10 @@ function New(): JSX.Element {
107125
</>
108126
)}
109127
</Row>
128+
<FileDropzoneInput
129+
parentResource={parentResource}
130+
onFilesUploaded={onUploadComplete}
131+
/>
110132
</StyledForm>
111133
)}
112134
</ContainerNarrow>

0 commit comments

Comments
 (0)