diff --git a/gui/package.json b/gui/package.json index 9626c7d6..afebebc5 100644 --- a/gui/package.json +++ b/gui/package.json @@ -25,6 +25,7 @@ "plotly.js-cartesian-dist": "^2.33.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-plotly.js": "^2.6.0", "react-router-dom": "^6.17.0", "react-visibility-sensor": "^5.1.1", @@ -50,6 +51,5 @@ "typescript": "^5.0.2", "vite": "^5.2.12", "vitest": "^1.6.0" - }, - "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" + } } diff --git a/gui/src/app/SPAnalysis/FileMapping.ts b/gui/src/app/SPAnalysis/FileMapping.ts new file mode 100644 index 00000000..0eaf0de8 --- /dev/null +++ b/gui/src/app/SPAnalysis/FileMapping.ts @@ -0,0 +1,105 @@ +import { SPAnalysisDataModel, SPAnalysisPersistentDataModel, stringifyField } from "./SPAnalysisDataModel" + +// This code exists to provide rigorous definitions for the mappings between +// the in-memory representation of a Stan Playground project (i.e. the +// SPAnalysisDataModel) and the on-disk representation of its parts, as (for example) +// when downloading or uploading a zip. +// +// Effectively, we need to map among three things: +// 1. the fields of the in-memory data model +// 2. the names of the on-disk/in-zip files +// 3. the actual contents of those on-disk files +// We need the link between 1-2 to serialize the data model fields to files, and +// between 2-3 for deserialization from files. + +// Mechanically, we'll also want an exhaustive list of the filenames we will use +// (that's the FileNames enum). + +export enum FileNames { + META = 'meta.json', + SAMPLING = 'sampling_opts.json', + STANFILE = 'main.stan', + DATAFILE = 'data.json', +} + +// FileMapType enforces an exhaustive mapping from data-model fields to the +// known file names that store those fields. (This is the 1-2 leg of the +// triangle). +type FileMapType = { + [name in keyof SPAnalysisPersistentDataModel]: FileNames +} + +// This dictionary stores the actual (global) fields-to-file-names map. +// Because it's of type FileMapType, it enforces that every key in the +// data model (except the "ephemera" key, which is not to be preserved) +// maps to some file name +export const SPAnalysisFileMap: FileMapType = { + meta: FileNames.META, + samplingOpts: FileNames.SAMPLING, + stanFileContent: FileNames.STANFILE, + dataFileContent: FileNames.DATAFILE, +} + +// The FileRegistry is the 2-3 leg of the triangle: it maps the known file names +// to their actual contents when read from disk. +// Since we don't *actually* want to mandate that all the known files +// are present, it'll almost always be used in a Partial<>. +// But this way, during deserialization, we can associate the (string) data with +// the file it came from, and the file with the field of the data model, so we +// know how to (re)populate the data model. +export type FileRegistry = { + [name in FileNames]: string +} + +// This is a serialization function that maps a data model to a FileRegistry, +// i.e. a dictionary mapping the intended file names to their intended contents. +export const mapModelToFileManifest = (data: SPAnalysisDataModel): Partial => { + const fileManifest: Partial = {}; + const fields = Object.keys(SPAnalysisFileMap) as (keyof SPAnalysisDataModel)[] + fields.forEach((k) => { + if (k === "ephemera") return; + const key = SPAnalysisFileMap[k] + fileManifest[key] = stringifyField(data, k) + }) + return fileManifest +} + +// This is used during deserialization as an intermediate representation. +// It maps the (named) fields of the data model to the string representation of their +// contents as was written into the file representation. +// During actual deserialization, special case files can be deserialized as needed, +// and the actual file list can just be mapped directly. +export type FieldsContentsMap = { + [name in keyof SPAnalysisPersistentDataModel]: string +} + +// This is the inverse of the SPAnalysisFileMap dictionary; with the bonus that it actually +// populates the fields. +export const mapFileContentsToModel = (files: Partial): Partial => { + const fields = Object.keys(files) + const theMap: Partial = {} + fields.forEach(f => { + switch (f) { + case FileNames.META: { + theMap.meta = files[f] + break; + } + case FileNames.DATAFILE: { + theMap.dataFileContent = files[f] + break; + } + case FileNames.STANFILE: { + theMap.stanFileContent = files[f] + break; + } + case FileNames.SAMPLING: { + theMap.samplingOpts = files[f] + break; + } + default: + // Don't do anything for unrecognized filenames + break; + } + }) + return theMap +} diff --git a/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx index 1ca9b5de..eb5c15e7 100644 --- a/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx +++ b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx @@ -1,6 +1,7 @@ import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react" -import { deserializeAnalysis, initialDataModel, serializeAnalysis, SPAnalysisDataModel } from "./SPAnalysisDataModel" +import { initialDataModel, SPAnalysisDataModel } from "./SPAnalysisDataModel" import { SPAnalysisReducer, SPAnalysisReducerAction, SPAnalysisReducerType } from "./SPAnalysisReducer" +import { deserializeAnalysisFromLocalStorage, serializeAnalysisToLocalStorage } from "./SPAnalysisSerialization" type SPAnalysisContextType = { data: SPAnalysisDataModel @@ -25,7 +26,7 @@ const SPAnalysisContextProvider: FunctionComponent { // as user reloads the page or closes the tab, save state to local storage const handleBeforeUnload = () => { - const state = serializeAnalysis(data) + const state = serializeAnalysisToLocalStorage(data) localStorage.setItem('stan-playground-saved-state', state) }; window.addEventListener('beforeunload', handleBeforeUnload); @@ -39,7 +40,7 @@ const SPAnalysisContextProvider: FunctionComponent + export const initialDataModel: SPAnalysisDataModel = { meta: { title: "Undefined" }, ephemera: { @@ -42,17 +44,26 @@ export const initialDataModel: SPAnalysisDataModel = { samplingOpts: defaultSamplingOpts } -export const serializeAnalysis = (data: SPAnalysisDataModel): string => { - const intermediary = { - ...data, ephemera: undefined } - return JSON.stringify(intermediary) +export const persistStateToEphemera = (data: SPAnalysisDataModel): SPAnalysisDataModel => { + const newEphemera = { ...data.ephemera } + getStringKnownFileKeys().forEach(k => newEphemera[k] = data[k]) + return { + ...data, + ephemera: newEphemera + } } -export const deserializeAnalysis = (serialized: string): SPAnalysisDataModel => { - const intermediary = JSON.parse(serialized) - // Not sure if this is strictly necessary - intermediary.ephemera = {} - const stringFileKeys = Object.values(SPAnalysisKnownFiles).filter((v) => isNaN(Number(v))); - stringFileKeys.forEach((k) => intermediary.ephemera[k] = intermediary[k]); - return intermediary as SPAnalysisDataModel +export const getStringKnownFileKeys = () => Object.values(SPAnalysisKnownFiles); + +export const modelHasUnsavedChanges = (data: SPAnalysisDataModel): boolean => { + const stringFileKeys = getStringKnownFileKeys() + return stringFileKeys.some((k) => data[k] !== data.ephemera[k]) } + +export const stringifyField = (data: SPAnalysisDataModel, field: keyof SPAnalysisDataModel): string => { + if (field === 'ephemera') return '' + const value = data[field] + if (typeof value === 'string') return value + return JSON.stringify(value) +} + diff --git a/gui/src/app/SPAnalysis/SPAnalysisReducer.ts b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts index 804406be..29dec53a 100644 --- a/gui/src/app/SPAnalysis/SPAnalysisReducer.ts +++ b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts @@ -1,7 +1,8 @@ import { Reducer } from "react" import { Stanie } from "../exampleStanies/exampleStanies" import { defaultSamplingOpts, SamplingOpts } from '../StanSampler/StanSampler' -import { initialDataModel, SPAnalysisDataModel, SPAnalysisKnownFiles } from "./SPAnalysisDataModel" +import { FieldsContentsMap } from "./FileMapping" +import { initialDataModel, persistStateToEphemera, SPAnalysisDataModel, SPAnalysisKnownFiles } from "./SPAnalysisDataModel" export type SPAnalysisReducerType = Reducer @@ -9,6 +10,10 @@ export type SPAnalysisReducerType = Reducer, + clearExisting: boolean } | { type: 'retitle', title: string @@ -46,6 +51,9 @@ export const SPAnalysisReducer: SPAnalysisReducerType = (s: SPAnalysisDataModel, } } } + case "loadFiles": { + return loadFromProjectFiles(s, a.files, a.clearExisting) + } case "retitle": { return { ...s, @@ -74,3 +82,38 @@ export const SPAnalysisReducer: SPAnalysisReducerType = (s: SPAnalysisDataModel, } } +const loadMetaFromString = (data: SPAnalysisDataModel, json: string, clearExisting: boolean = false): SPAnalysisDataModel => { + const newMeta = JSON.parse(json) + // TODO: properly check type of deserialized meta + const newMetaMember = clearExisting ? { ...newMeta } : { ...data.meta, ...newMeta } + return { ...data, meta: newMetaMember } +} + +const loadSamplingOptsFromString = (data: SPAnalysisDataModel, json: string, clearExisting: boolean = false): SPAnalysisDataModel => { + const newSampling = JSON.parse(json) + // TODO: properly check type/fields of deserialized sampling opts + const newSamplingOptsMember = clearExisting ? { ...newSampling } : { ...data.samplingOpts, ...newSampling } + return { ...data, samplingOpts: newSamplingOptsMember } +} + +const loadFileFromString = (data: SPAnalysisDataModel, field: SPAnalysisKnownFiles, contents: string, replaceProject: boolean = false): SPAnalysisDataModel => { + const newData = replaceProject ? { ...initialDataModel } : { ...data } + newData[field] = contents + return newData +} + +const loadFromProjectFiles = (data: SPAnalysisDataModel, files: Partial, clearExisting: boolean = false): SPAnalysisDataModel => { + let newData = clearExisting ? initialDataModel : data + if (Object.keys(files).includes('meta')) { + newData = loadMetaFromString(newData, files.meta ?? '') + delete files['meta'] + } + if (Object.keys(files).includes('samplingOpts')) { + newData = loadSamplingOptsFromString(newData, files.samplingOpts ?? '') + delete files['samplingOpts'] + } + const fileKeys = Object.keys(files) as SPAnalysisKnownFiles[] + newData = fileKeys.reduce((currData, currField) => loadFileFromString(currData, currField, files[currField] ?? ''), newData) + newData = persistStateToEphemera(newData) + return newData +} \ No newline at end of file diff --git a/gui/src/app/SPAnalysis/SPAnalysisSerialization.ts b/gui/src/app/SPAnalysis/SPAnalysisSerialization.ts new file mode 100644 index 00000000..7aa11c6a --- /dev/null +++ b/gui/src/app/SPAnalysis/SPAnalysisSerialization.ts @@ -0,0 +1,72 @@ +import JSZip from "jszip" +import { replaceSpacesWithUnderscores } from "../util/replaceSpaces" +import { FileNames, FileRegistry, mapFileContentsToModel, mapModelToFileManifest, SPAnalysisFileMap } from "./FileMapping" +import { getStringKnownFileKeys, SPAnalysisDataModel } from "./SPAnalysisDataModel" + +export const serializeAnalysisToLocalStorage = (data: SPAnalysisDataModel): string => { + const intermediary = { + ...data, ephemera: undefined } + return JSON.stringify(intermediary) +} + +export const deserializeAnalysisFromLocalStorage = (serialized: string): SPAnalysisDataModel => { + const intermediary = JSON.parse(serialized) + // Not sure if this is strictly necessary + intermediary.ephemera = {} + const stringFileKeys = getStringKnownFileKeys() + stringFileKeys.forEach((k) => intermediary.ephemera[k] = intermediary[k]); + return intermediary as SPAnalysisDataModel +} + +export const serializeAsZip = async (data: SPAnalysisDataModel): Promise<[Blob, string]> => { + const fileManifest = mapModelToFileManifest(data) + const folderName = replaceSpacesWithUnderscores(data.meta.title) + const zip = new JSZip() + const folder = zip.folder(folderName) + if (!folder) { + throw new Error('Error creating folder in zip file') + } + Object.entries(fileManifest).forEach(([name, content]) => { + folder.file(name, content) + }) + const zipBlob = await zip.generateAsync({type: 'blob'}) + + return [zipBlob, folderName] +} + +export const parseFile = (fileBuffer: ArrayBuffer) => { + const content = new TextDecoder().decode(fileBuffer) + return content +} + +export const deserializeZipToFiles = async (zipBuffer: ArrayBuffer) => { + const zip = await JSZip.loadAsync(zipBuffer) + const dirNames: string[] = [] + zip.forEach((relpath, file) => file.dir && dirNames.push(relpath)) + const folderName = dirNames[0] ?? '' + if (! dirNames.every(n => n === folderName)) { + throw new Error('Multiple directories in zip file') + } + zip.forEach((_, file) => { + if (!file.name.startsWith(folderName)) { + throw new Error('Files are not all in a single folder') + } + }) + const folderLength = folderName.length + const files: {[name: string]: string} = {} + // we want to use a traditional for loop here, since async doesn't do nicely with higher-order callbacks + for (const name in zip.files) { + const file = zip.files[name] + if (file.dir) continue + const basename = name.substring(folderLength) + if (Object.values(SPAnalysisFileMap).includes(basename as FileNames)) { + const content = await file.async('arraybuffer') + const decoded = new TextDecoder().decode(content) + files[basename] = decoded + } else { + throw new Error(`Unrecognized file in zip: ${file.name} (basename ${basename})`) + } + + } + return mapFileContentsToModel(files as Partial) +} diff --git a/gui/src/app/SamplerOutputView/SamplerOutputView.tsx b/gui/src/app/SamplerOutputView/SamplerOutputView.tsx index 192d9b15..2bc6defe 100644 --- a/gui/src/app/SamplerOutputView/SamplerOutputView.tsx +++ b/gui/src/app/SamplerOutputView/SamplerOutputView.tsx @@ -1,13 +1,14 @@ import { SmallIconButton } from "@fi-sci/misc" import { Download } from "@mui/icons-material" +import JSZip from 'jszip' import { FunctionComponent, useCallback, useMemo, useState } from "react" import StanSampler from "../StanSampler/StanSampler" import { useSamplerOutput } from "../StanSampler/useStanSampler" import TabWidget from "../TabWidget/TabWidget" -import TracePlotsView from "./TracePlotsView" -import SummaryView from "./SummaryView" +import { triggerDownload } from "../util/triggerDownload" import HistsView from "./HistsView" -import JSZip from 'jszip' +import SummaryView from "./SummaryView" +import TracePlotsView from "./TracePlotsView" type SamplerOutputViewProps = { width: number @@ -250,11 +251,7 @@ const createZipBlobForMultipleCsvs = async (csvTexts: string[], uniqueChainIds: const downloadTextFile = (text: string, filename: string) => { const blob = new Blob([text], {type: 'text/plain'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); + triggerDownload(blob, filename, () => {}); } export default SamplerOutputView \ No newline at end of file diff --git a/gui/src/app/pages/HomePage/ExportWindow.tsx b/gui/src/app/pages/HomePage/ExportWindow.tsx new file mode 100644 index 00000000..b3d73a46 --- /dev/null +++ b/gui/src/app/pages/HomePage/ExportWindow.tsx @@ -0,0 +1,69 @@ +import { FunctionComponent, useContext } from "react" + +import { mapModelToFileManifest } from "../../SPAnalysis/FileMapping" +import { SPAnalysisContext } from "../../SPAnalysis/SPAnalysisContextProvider" +import { serializeAsZip } from "../../SPAnalysis/SPAnalysisSerialization" +import { triggerDownload } from "../../util/triggerDownload" + +type ExportWindowProps = { + onClose: () => void +} + +const ExportWindow: FunctionComponent = ({ onClose }) => { + const { data, update } = useContext(SPAnalysisContext) + const fileManifest = mapModelToFileManifest(data) + + return ( +
+

Export this analysis

+ + + + + + + { + Object.entries(fileManifest).map(([name, content], i) => ( + + + + + )) + } + +
Title + update({ type: 'retitle', title: newTitle })} + /> +
{name} + {content.length} bytes +
+
+ +
+
+ ) +} + + +type EditTitleComponentProps = { + value: string + onChange: (value: string) => void +} + +const EditTitleComponent: FunctionComponent = ({ value, onChange }) => { + return ( + onChange(e.target.value)} + /> + ) +} + +export default ExportWindow diff --git a/gui/src/app/pages/HomePage/HomePage.tsx b/gui/src/app/pages/HomePage/HomePage.tsx index 3b659113..4ba0e1c2 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -6,7 +6,7 @@ import RunPanel from "../../RunPanel/RunPanel"; import SamplerOutputView from "../../SamplerOutputView/SamplerOutputView"; import SamplingOptsPanel from "../../SamplingOptsPanel/SamplingOptsPanel"; import SPAnalysisContextProvider, { SPAnalysisContext } from '../../SPAnalysis/SPAnalysisContextProvider'; -import { SPAnalysisKnownFiles } from "../../SPAnalysis/SPAnalysisDataModel"; +import { modelHasUnsavedChanges, SPAnalysisKnownFiles } from "../../SPAnalysis/SPAnalysisDataModel"; import { SamplingOpts } from "../../StanSampler/StanSampler"; import useStanSampler, { useSamplerStatus } from "../../StanSampler/useStanSampler"; import LeftPanel from "./LeftPanel"; @@ -46,6 +46,7 @@ const HomePageChild: FunctionComponent = ({ width, height }) => { document.title = "Stan Playground - " + data.meta.title; }, [data.meta.title]) + return (
@@ -59,6 +60,7 @@ const HomePageChild: FunctionComponent = ({ width, height }) => {
diff --git a/gui/src/app/pages/HomePage/ImportWindow.tsx b/gui/src/app/pages/HomePage/ImportWindow.tsx new file mode 100644 index 00000000..cf342233 --- /dev/null +++ b/gui/src/app/pages/HomePage/ImportWindow.tsx @@ -0,0 +1,112 @@ +import { FunctionComponent, useCallback, useContext, useEffect, useState } from "react" +import { FieldsContentsMap, FileNames, FileRegistry, mapFileContentsToModel } from "../../SPAnalysis/FileMapping" +import { SPAnalysisContext } from "../../SPAnalysis/SPAnalysisContextProvider" +import { deserializeZipToFiles, parseFile } from "../../SPAnalysis/SPAnalysisSerialization" +import UploadFilesArea from "./UploadFilesArea" + +type ImportWindowProps = { + onClose: () => void +} + +const ImportWindow: FunctionComponent = ({ onClose }) => { + const { update } = useContext(SPAnalysisContext) + const [errorText, setErrorText] = useState(null) + const [filesUploaded, setFilesUploaded] = useState<{name: string, content: ArrayBuffer}[] | null>(null) + const [showReplaceProjectOptions, setShowReplaceProjectOptions] = useState(false) + + const importUploadedFiles = useCallback(async (o: {replaceProject: boolean}) => { + const {replaceProject} = o + if (!filesUploaded) return + try { + if ((filesUploaded.length === 1) && (filesUploaded[0].name.endsWith('.zip'))) { + // a single .zip file + const fileManifest = await deserializeZipToFiles(filesUploaded[0].content) + update({ type: 'loadFiles', files: fileManifest, clearExisting: replaceProject }) + } + else if ((filesUploaded.length === 1) && (filesUploaded[0].name.endsWith('.stan'))) { + // a single .stan file + if (replaceProject) { + update({ type: 'retitle', title: filesUploaded[0].name }) + } + const fileManifest: Partial = { 'stanFileContent': parseFile(filesUploaded[0].content) } + update({ type: 'loadFiles', files: fileManifest, clearExisting: replaceProject }) + } + else { + const files: Partial = {} + for (const file of filesUploaded) { + if ( !Object.values(FileNames).includes(file.name as any) ){ + throw Error(`Unrecognized file: ${file.name}`) + } + files[file.name as FileNames] = parseFile(file.content) + } + + const fileManifest = mapFileContentsToModel(files); + update({ type: 'loadFiles', files: fileManifest, clearExisting: replaceProject }) + } + onClose() + } + catch (e: any) { + setErrorText(e.message) + } + }, [filesUploaded, onClose, update]) + + useEffect(() => { + if (!filesUploaded) return + if ((filesUploaded.length === 1) && (!filesUploaded[0].name.endsWith('.zip'))) { + // The user has uploaded a single file and it is not a zip file. In + // this case we want to give the user the option whether or not to + // replace the current project. + setShowReplaceProjectOptions(true) + } + else { + // Otherwise, we just go ahead and import the files, replacing the + // entire project + importUploadedFiles({replaceProject: true}) + } + }, [filesUploaded, importUploadedFiles]) + + return ( +
+

Import analysis

+
+ You can upload: +
    +
  • A .zip file that was previously exported
  • +
  • A directory of files that were extracted from an exported .zip file
  • +
  • An individual *.stan file
  • +
  • An individual data.json file
  • +
+
+
+ {errorText} +
+ {!filesUploaded ? ( +
+ +
+ ) : ( +
+ {filesUploaded.map(file => ( +
+ {file.name} +
+ ))} +
+ )} + { + showReplaceProjectOptions && ( +
+ +   + +
+ ) + } +
+ ) +} + +export default ImportWindow \ No newline at end of file diff --git a/gui/src/app/pages/HomePage/LeftPanel.tsx b/gui/src/app/pages/HomePage/LeftPanel.tsx index dcd0d71e..554758ce 100644 --- a/gui/src/app/pages/HomePage/LeftPanel.tsx +++ b/gui/src/app/pages/HomePage/LeftPanel.tsx @@ -1,20 +1,27 @@ import { Hyperlink } from "@fi-sci/misc" +import ModalWindow, { useModalWindow } from "@fi-sci/modal-window" import { FunctionComponent, useCallback, useContext } from "react" import examplesStanies, { Stanie } from "../../exampleStanies/exampleStanies" import { SPAnalysisContext } from "../../SPAnalysis/SPAnalysisContextProvider" +import ExportWindow from "./ExportWindow" +import ImportWindow from "./ImportWindow" type LeftPanelProps = { width: number height: number + hasUnsavedChanges: boolean } -const LeftPanel: FunctionComponent = ({ width, height }) => { +const LeftPanel: FunctionComponent = ({ width, height, hasUnsavedChanges }) => { // note: this is close enough to pass in directly if we wish const { update } = useContext(SPAnalysisContext) const handleOpenExample = useCallback((stanie: Stanie) => () => { update({ type: 'loadStanie', stanie }) }, [update]) + + const { visible: exportVisible, handleOpen: exportOpen, handleClose: exportClose } = useModalWindow() + const { visible: importVisible, handleOpen: importOpen, handleClose: importClose } = useModalWindow() return (
@@ -28,6 +35,7 @@ const LeftPanel: FunctionComponent = ({ width, height }) => {
)) } +
{/* This will probably be removed or replaced in the future. It's just for convenience during development. */}
+
+ +   + +
+ + + + + +
) } diff --git a/gui/src/app/pages/HomePage/UploadFilesArea.tsx b/gui/src/app/pages/HomePage/UploadFilesArea.tsx new file mode 100644 index 00000000..dfe83124 --- /dev/null +++ b/gui/src/app/pages/HomePage/UploadFilesArea.tsx @@ -0,0 +1,37 @@ +import { FunctionComponent, useCallback } from "react" +import { useDropzone } from "react-dropzone" + +type UploadFilesAreaProps = { + height: number + onUpload: (files: { + name: string, + content: ArrayBuffer + }[]) => void +} + +const UploadFilesArea: FunctionComponent = ({ height, onUpload }) => { + const onDrop = useCallback(async (acceptedFiles: File[]) => { + const fileNames = acceptedFiles.map(file => file.name) + const fileContents = await Promise.all(acceptedFiles.map(file => file.arrayBuffer())) + const files = fileNames.map((name, i) => ({ + name, + content: fileContents[i] + })) + onUpload(files) + }, [onUpload]) + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) + return ( +
+
+ + { + isDragActive ? +

Drop the files here ...

: +

Drag and drop some files here, or click to select files

+ } +
+
+ ) +} + +export default UploadFilesArea \ No newline at end of file diff --git a/gui/src/app/util/replaceSpaces.ts b/gui/src/app/util/replaceSpaces.ts new file mode 100644 index 00000000..affffd23 --- /dev/null +++ b/gui/src/app/util/replaceSpaces.ts @@ -0,0 +1,3 @@ +export const replaceSpacesWithUnderscores = (str: string) => { + return str.replace(/ /g, '_') +} diff --git a/gui/src/app/util/triggerDownload.ts b/gui/src/app/util/triggerDownload.ts new file mode 100644 index 00000000..96d1d054 --- /dev/null +++ b/gui/src/app/util/triggerDownload.ts @@ -0,0 +1,10 @@ + +export const triggerDownload = (blob: Blob, filename: string, onClose: () => void) => { + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + a.download = filename + a.click() + URL.revokeObjectURL(blobUrl) + onClose() +} diff --git a/gui/yarn.lock b/gui/yarn.lock index 01afefe0..21c41cf1 100644 --- a/gui/yarn.lock +++ b/gui/yarn.lock @@ -1279,6 +1279,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +attr-accept@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -1982,6 +1987,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" + integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw== + dependencies: + tslib "^2.4.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -3122,6 +3134,15 @@ react-draggable@^4.4.6: clsx "^1.1.1" prop-types "^15.8.1" +react-dropzone@^14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" + integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.6.0" + prop-types "^15.8.1" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -3627,6 +3648,11 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== +tslib@^2.4.0: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"