Skip to content

Commit

Permalink
Add drag & drop file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
jelly authored and tomasmatus committed Dec 16, 2024
1 parent 846a40c commit a06532e
Show file tree
Hide file tree
Showing 6 changed files with 324 additions and 50 deletions.
28 changes: 25 additions & 3 deletions src/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@

// Passthrough for layout and styling purposes, to enable main page parts to participate in the grid
.pf-v5-c-page__main-section,
.files-view-stack {
.files-view-stack,
.upload-drop-zone {
display: contents;
}

.files-card {
position: relative;
}

.drag-drop-upload,
.drag-drop-upload-blocked {
position: absolute;
z-index: 10;
border: 3px dashed var(--pf-v5-global--link--Color);
background: rgb(from var(--pf-v5-global--BackgroundColor--100) r g b / 80%);
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
inline-size: 100%;

.pf-v5-c-empty-state__icon {
color: var(--pf-v5-global--Color--100);
}
}

.pf-v5-c-page__main {
gap: var(--pf-v5-global--spacer--md);
display: grid;
Expand All @@ -30,12 +52,12 @@
}

.files-empty-state,
.files-view-stack > .pf-v5-c-card,
.files-view-stack > .upload-drop-zone > .pf-v5-c-card,
.files-view-stack > .files-footer-info {
grid-column: content;
}

.files-view-stack > .pf-v5-c-card {
.files-view-stack > .upload-drop-zone > .pf-v5-c-card {
overflow: auto;
}

Expand Down
6 changes: 6 additions & 0 deletions src/files-card-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,9 @@
column-gap: var(--pf-v5-global--spacer--sm);
text-align: start;
}

// use all available space when there is not enough files to fill the whole view
// so drag&drop works in empty space
.fileview-wrapper {
block-size: 100%;
}
7 changes: 5 additions & 2 deletions src/files-card-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const FilesCardBody = ({
setClipboard,
showHidden,
setShowHidden,
setDragDropActive,
} : {
currentFilter: string,
setCurrentFilter: React.Dispatch<React.SetStateAction<string>>,
Expand All @@ -155,6 +156,7 @@ export const FilesCardBody = ({
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
showHidden: boolean,
setShowHidden: React.Dispatch<React.SetStateAction<boolean>>,
setDragDropActive: React.Dispatch<React.SetStateAction<boolean>>,
}) => {
const [boxPerRow, setBoxPerRow] = useState(0);
const dialogs = useDialogs();
Expand All @@ -170,7 +172,7 @@ export const FilesCardBody = ({
return files.filter(file => file.name.startsWith(".")).length;
}, [files]);
const isMounted = useRef<boolean>();
const folderViewRef = React.useRef<HTMLDivElement>(null);
const folderViewRef = useRef<HTMLDivElement>(null);

function calculateBoxPerRow () {
const boxes = document.querySelectorAll(".fileview tbody > tr") as NodeListOf<HTMLElement>;
Expand Down Expand Up @@ -206,7 +208,7 @@ export const FilesCardBody = ({
});

useEffect(() => {
let folderViewElem = null;
let folderViewElem: HTMLDivElement | null = null;

const resetSelected = (e: MouseEvent) => {
if ((e.target instanceof HTMLElement)) {
Expand Down Expand Up @@ -424,6 +426,7 @@ export const FilesCardBody = ({
cwdInfo,
clipboard,
setClipboard,
setDragDropActive,
]);

// Generic event handler to look up the corresponding `data-item` for a click event when
Expand Down
184 changes: 150 additions & 34 deletions src/files-folder-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,35 @@
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useEffect, useState } from "react";
import React, { createContext, useContext, useEffect, useState, useRef } from "react";

import { Card } from '@patternfly/react-core/dist/esm/components/Card';
import { BanIcon, UploadIcon } from '@patternfly/react-icons';
import { debounce } from "throttle-debounce";

import cockpit from "cockpit";
import { EmptyStatePanel } from "cockpit-components-empty-state";

import type { FolderFileInfo } from "./app.tsx";
import { FilesCardBody } from "./files-card-body.tsx";
import { as_sort, FilesCardHeader } from "./header.tsx";

const _ = cockpit.gettext;

export interface UploadedFilesType {[name: string]:{file: File, progress: number, cancel:() => void}}

interface UploadContextType {
uploadedFiles: UploadedFilesType,
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFilesType>>,
}

export const UploadContext = createContext({
uploadedFiles: {},
setUploadedFiles: () => console.warn("UploadContext not initialized!"),
} as UploadContextType);

export const useUploadContext = () => useContext(UploadContext);

export const FilesFolderView = ({
path,
files,
Expand All @@ -45,9 +65,13 @@ export const FilesFolderView = ({
selected: FolderFileInfo[], setSelected: React.Dispatch<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
}) => {
const dropzoneRef = useRef<HTMLDivElement>(null);
const [currentFilter, setCurrentFilter] = useState("");
const [isGrid, setIsGrid] = useState(localStorage.getItem("files:isGrid") !== "false");
const [sortBy, setSortBy] = useState(as_sort(localStorage.getItem("files:sort")));
const [dragDropActive, setDragDropActive] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<{[name: string]:
{file: File, progress: number, cancel:() => void}}>({});
const onFilterChange = debounce(300,
(_event: React.FormEvent<HTMLInputElement>, value: string) =>
setCurrentFilter(value));
Expand All @@ -57,39 +81,131 @@ export const FilesFolderView = ({
setCurrentFilter("");
}, [path]);

// counter to manage current status of drag&drop
// dragging items over file entries causes a bunch of drag-enter and drag-leave
// events, this counter helps checking when final drag-leave event is fired
const dragDropCnt = useRef(0);

useEffect(() => {
let dropzoneElem: HTMLDivElement | null = null;
const isUploading = Object.keys(uploadedFiles).length !== 0;

const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

if (dragDropCnt.current === 0) {
setDragDropActive(true);
}
dragDropCnt.current++;
};

const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

dragDropCnt.current--;
if (dragDropCnt.current === 0) {
setDragDropActive(false);
}
};

const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

setDragDropActive(false);
dragDropCnt.current = 0;

// disable drag & drop when upload is in progress
if (isUploading) {
return;
}

cockpit.assert(event.dataTransfer !== null, "dataTransfer cannot be null");
dispatchEvent(new CustomEvent('files-drop', { detail: event.dataTransfer.files }));
};

const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};

if (dropzoneRef.current) {
dropzoneElem = dropzoneRef.current;
dropzoneElem.addEventListener("dragenter", handleDragEnter);
dropzoneElem.addEventListener("dragleave", handleDragLeave);
dropzoneElem.addEventListener("dragover", handleDragOver, false);
dropzoneElem.addEventListener("drop", handleDrop, false);
}

return () => {
if (dropzoneElem) {
dropzoneElem.removeEventListener("dragenter", handleDragEnter);
dropzoneElem.removeEventListener("dragover", handleDragOver);
dropzoneElem.removeEventListener("dragleave", handleDragLeave);
dropzoneElem.removeEventListener("drop", handleDrop);
}
};
}, [uploadedFiles, dragDropCnt]);

const dropzoneComponent = (Object.keys(uploadedFiles).length === 0)
? (
<div className="drag-drop-upload">
<EmptyStatePanel
icon={UploadIcon}
title={_("Drop files to upload")}
/>
</div>
)
: (
<div className="drag-drop-upload-blocked">
<EmptyStatePanel
icon={BanIcon}
title={_("Cannot drop files, another upload is already in progress")}
/>
</div>
);

return (
<Card>
<FilesCardHeader
currentFilter={currentFilter}
onFilterChange={onFilterChange}
isGrid={isGrid}
setIsGrid={setIsGrid}
sortBy={sortBy}
setSortBy={setSortBy}
path={path}
showHidden={showHidden}
setShowHidden={setShowHidden}
selected={selected}
setSelected={setSelected}
clipboard={clipboard}
setClipboard={setClipboard}
/>
<FilesCardBody
files={files}
currentFilter={currentFilter}
path={path}
isGrid={isGrid}
sortBy={sortBy}
setSortBy={setSortBy}
selected={selected}
setSelected={setSelected}
loadingFiles={loadingFiles}
clipboard={clipboard}
setClipboard={setClipboard}
showHidden={showHidden}
setShowHidden={setShowHidden}
setCurrentFilter={setCurrentFilter}
/>
</Card>
<UploadContext.Provider value={{ uploadedFiles, setUploadedFiles }}>
<div className="upload-drop-zone" ref={dropzoneRef}>
<Card className="files-card">
<FilesCardHeader
currentFilter={currentFilter}
onFilterChange={onFilterChange}
isGrid={isGrid}
setIsGrid={setIsGrid}
sortBy={sortBy}
setSortBy={setSortBy}
path={path}
showHidden={showHidden}
setShowHidden={setShowHidden}
selected={selected}
setSelected={setSelected}
clipboard={clipboard}
setClipboard={setClipboard}
/>
<FilesCardBody
files={files}
currentFilter={currentFilter}
path={path}
isGrid={isGrid}
sortBy={sortBy}
setSortBy={setSortBy}
selected={selected}
setSelected={setSelected}
loadingFiles={loadingFiles}
clipboard={clipboard}
setClipboard={setClipboard}
showHidden={showHidden}
setShowHidden={setShowHidden}
setCurrentFilter={setCurrentFilter}
setDragDropActive={setDragDropActive}
/>
{dragDropActive && dropzoneComponent}
</Card>
</div>
</UploadContext.Provider>
);
};
Loading

0 comments on commit a06532e

Please sign in to comment.