Skip to content

Commit eebc14f

Browse files
committed
feat: Drag and drop or click to upload multiple files and folders
https://harperdb.atlassian.net/browse/STUDIO-545
1 parent c17bfe6 commit eebc14f

File tree

13 files changed

+302
-52
lines changed

13 files changed

+302
-52
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"react": "^19.0.0",
5858
"react-complex-tree": "^2.6.1",
5959
"react-dom": "^19.0.0",
60+
"react-dropzone": "^14.3.8",
6061
"react-hook-form": "^7.54.2",
6162
"react-markdown": "^10.1.0",
6263
"recharts": "^3.2.1",

pnpm-lock.yaml

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Loading.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
interface LoadingProps extends React.ComponentProps<'div'> {
2-
text?: string;
1+
import { ComponentProps, ReactNode } from 'react';
2+
3+
interface LoadingProps extends ComponentProps<'div'> {
4+
text?: ReactNode;
35
centered?: boolean;
46
}
57

src/components/ui/input.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
99
data-slot="input"
1010
className={cn(
1111
`border-input file:text-foreground placeholder:text-muted-foreground selection:bg-purple
12-
selection:text-primary-foreground
13-
dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10
14-
dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60
15-
aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive
12+
selection:text-primary-foreground
13+
dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10
14+
dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60
15+
aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive
1616
flex h-9 w-full min-w-0 rounded-md border bg-grey-700 px-3 py-1 text-base text-white
17-
file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium
18-
focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-purple disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
17+
file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium
18+
focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-purple disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
1919
aria-invalid:focus-visible:ring-[1px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-1`,
2020
className
2121
)}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
2+
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
3+
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
4+
import { setComponentFile } from '@/integrations/api/instance/applications/setComponentFile';
5+
import { humanFileSize } from '@/lib/humanFileSize';
6+
import { pluralize } from '@/lib/pluralize';
7+
import { readAsDataURL } from '@/lib/storage/readAsDataURL';
8+
import { cx } from 'class-variance-authority';
9+
import { useCallback, useState } from 'react';
10+
import { FileRejection, FileWithPath, useDropzone } from 'react-dropzone';
11+
import { toast } from 'sonner';
12+
13+
export function DropTarget() {
14+
const [uploading, setUploading] = useState('');
15+
const { openedEntry, restrictPackageModification, reloadRootEntries, entryExists } = useEditorView();
16+
const cannotUpload = !openedEntry || openedEntry.package || restrictPackageModification;
17+
const instanceParams = useInstanceClientIdParams();
18+
19+
const onUploadDrop = useCallback(async (
20+
rawAcceptedFiles: FileWithPath[],
21+
rawRejectedFiles: FileRejection[],
22+
) => {
23+
if (cannotUpload) {
24+
return;
25+
}
26+
27+
const filesToUpload: FileWithPath[] = [];
28+
const filesRejected: FileRejection[] = rawRejectedFiles.slice();
29+
30+
const splitPath = openedEntry.path.split('/');
31+
const intoPath = (
32+
isDirectory(openedEntry)
33+
? splitPath.slice(1)
34+
: splitPath.slice(1, -1)
35+
).join('/');
36+
37+
for (const file of rawAcceptedFiles) {
38+
const filePath = getFilePath(intoPath, file);
39+
if (file.name.startsWith('.') || file.relativePath?.includes('/.')) {
40+
filesRejected.push({
41+
file,
42+
errors: [
43+
{
44+
message: 'Sensitive files and folders starting with . are skipped.',
45+
code: 'dot-ignored',
46+
},
47+
],
48+
});
49+
} else if (entryExists(openedEntry.project + '/' + filePath)) {
50+
filesRejected.push({
51+
file,
52+
errors: [
53+
{
54+
message: `${filePath} already exists`,
55+
code: 'duplicate',
56+
},
57+
],
58+
});
59+
} else {
60+
filesToUpload.push(file);
61+
}
62+
}
63+
64+
const totalBytes = filesToUpload.reduce((sum, f) => sum + f.size, 0);
65+
let uploadedBytes = 0;
66+
let counter = 0;
67+
for (const file of filesToUpload) {
68+
counter += 1;
69+
setUploading(`${counter} of ${pluralize(filesToUpload.length, 'file', 'files')}
70+
${file.name}
71+
${humanFileSize(uploadedBytes)} of ${humanFileSize(totalBytes)}`);
72+
73+
const filePath = getFilePath(intoPath, file);
74+
const dataURLResponse = await readAsDataURL(file);
75+
const dataURLResult = dataURLResponse.target!.result as string;
76+
const demarcation = 'base64,';
77+
const encodingIndex = dataURLResult.indexOf(demarcation);
78+
await setComponentFile({
79+
...instanceParams,
80+
file: filePath,
81+
project: openedEntry.project,
82+
encoding: 'base64',
83+
payload: dataURLResult.slice(encodingIndex + demarcation.length),
84+
});
85+
uploadedBytes += file.size;
86+
}
87+
88+
if (filesRejected.length === 0) {
89+
if (rawAcceptedFiles.length > 0) {
90+
setUploading('Reloading sidebar...');
91+
await reloadRootEntries();
92+
setUploading('');
93+
toast.success(`Uploaded files!`);
94+
}
95+
} else {
96+
toast.error('Rejected uploads', {
97+
descriptionClassName: 'whitespace-pre',
98+
description: filesRejected.map(r => r.errors.map(e => e.message).join('\n')).join('\n'),
99+
});
100+
}
101+
}, [cannotUpload, entryExists, instanceParams, openedEntry, reloadRootEntries]);
102+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
103+
multiple: true,
104+
maxSize: 512 /* mb */ * 1024 /* kb */ * 1024 /* b */,
105+
onDrop: onUploadDrop,
106+
});
107+
108+
if (cannotUpload) {
109+
return;
110+
}
111+
112+
return (
113+
<div {...getRootProps()}
114+
className={cx(
115+
'border-3 border-dashed bg-purple-950 border-purple-600',
116+
'cursor-copy text-sm text-center whitespace-pre',
117+
'p-2 w-full fixed bottom-0 h-16',
118+
'flex items-center justify-center',
119+
isDragActive && 'animate-glow-pulse'
120+
)}>
121+
<input {...getInputProps()} />
122+
{
123+
uploading
124+
? uploading
125+
: isDragActive
126+
? 'Drop the files right here!'
127+
: 'Drag and drop to upload\nor click to select files.'
128+
}
129+
</div>
130+
);
131+
}
132+
133+
function getFilePath(intoPath: string, file: FileWithPath): string {
134+
const relativePath = file.relativePath ?? file.name;
135+
const firstSlashIndex = relativePath.indexOf('/');
136+
const trimmedRelativePath = firstSlashIndex === 0 || firstSlashIndex === 1 ? relativePath.slice(firstSlashIndex + 1) : relativePath;
137+
return intoPath ? `${intoPath}/${trimmedRelativePath}` : trimmedRelativePath;
138+
}

src/features/instance/applications/components/ApplicationsSidebar/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import './file-explorer-modern.css';
1212
import { DraggingPosition } from 'react-complex-tree/src/types';
1313
import { toast } from 'sonner';
1414
import { buildItems } from './buildItems';
15+
import { DropTarget } from './DropTarget';
1516
import { getItemTitle } from './getItemTitle';
1617
import { ItemTitle } from './ItemTitle';
1718

@@ -75,7 +76,7 @@ export function ApplicationsSidebar() {
7576
}, [renameFiles]);
7677

7778
return (
78-
<div className="h-full overflow-auto pr-1.5">
79+
<div className="h-full overflow-auto pr-1.5 pb-18">
7980
<ControlledTreeEnvironment
8081
canDragAndDrop={true}
8182
canDropOnFolder={true}
@@ -98,6 +99,8 @@ export function ApplicationsSidebar() {
9899
>
99100
<Tree treeId="applicationsTree" rootItem={rootId} treeLabel="Applications file tree" />
100101
</ControlledTreeEnvironment>
102+
103+
<DropTarget />
101104
</div>
102105
);
103106
}

src/features/instance/applications/components/ContentViewer.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
22
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
33
import { useReadMeUrlTransformer } from '@/features/instance/applications/lib/readMeUrlTransform';
4+
import { hasImageFileExtension } from '@/lib/string/hasImageFileExtension';
5+
import { parseFileExtension } from '@/lib/string/parseFileExtension';
46
import Markdown from 'react-markdown';
57
import { newApplication } from './ApplicationsSidebar/specialItems';
68
import { NewApplication } from './NewApplication';
@@ -21,5 +23,15 @@ export function ContentViewer() {
2123
</div>;
2224
}
2325

24-
return <TextEditorView />;
26+
if (hasImageFileExtension(openedEntry?.name)) {
27+
return <div className="mt-9 absolute top-0 right-0 bottom-0 left-0">
28+
<img
29+
className="w-full h-full object-contain p-20"
30+
alt={openedEntry?.name}
31+
src={`data:image/${parseFileExtension(openedEntry?.name)};base64,${openedEntryContents}`}
32+
/>
33+
</div>;
34+
} else {
35+
return <TextEditorView />;
36+
}
2537
}

src/features/instance/applications/index.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { ContentActions } from '@/features/instance/applications/components/ContentActions';
2-
import { ContentViewer } from '@/features/instance/applications/components/ContentViewer';
3-
import { AddDirectoryOrFileModal } from '@/features/instance/applications/modals/AddDirectoryOrFileModal';
4-
import { DeleteDirectoryOrFileModal } from '@/features/instance/applications/modals/DeleteDirectoryOrFileModal';
5-
import { DownloadApplicationModal } from '@/features/instance/applications/modals/DownloadApplicationModal';
6-
import { RedeployApplicationModal } from '@/features/instance/applications/modals/RedeployApplicationModal';
7-
import { RenameFileModal } from '@/features/instance/applications/modals/RenameFileModal';
81
import { useSessionToggler } from '@/hooks/useSessionToggler';
92
import { cx } from 'class-variance-authority';
103
import { ApplicationsSidebar } from './components/ApplicationsSidebar';
4+
import { ContentActions } from './components/ContentActions';
5+
import { ContentViewer } from './components/ContentViewer';
116
import { EditorViewProvider } from './context/EditorViewProvider';
7+
import { AddDirectoryOrFileModal } from './modals/AddDirectoryOrFileModal';
8+
import { DeleteDirectoryOrFileModal } from './modals/DeleteDirectoryOrFileModal';
9+
import { DownloadApplicationModal } from './modals/DownloadApplicationModal';
10+
import { RedeployApplicationModal } from './modals/RedeployApplicationModal';
11+
import { RenameFileModal } from './modals/RenameFileModal';
1212

1313
export function ApplicationsEditor() {
1414
const { toggle, toggled } = useSessionToggler('ApplicationsSidebarOpened', true);

0 commit comments

Comments
 (0)