From 16903912b3f313fc9103c16e2ad3bdca39fc2967 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Sat, 7 Dec 2024 01:01:37 +0000 Subject: [PATCH] :sparkles: Allow dragging and dropping/uploading file to import into workspace --- components/workspace/ItemCreator.tsx | 24 +++- components/workspace/Panel.tsx | 204 ++++++++++++++++----------- components/workspace/hooks.tsx | 30 +++- lib/csv.ts | 74 ++++++++++ package.json | 1 + yarn.lock | 26 ++++ 6 files changed, 276 insertions(+), 83 deletions(-) diff --git a/components/workspace/ItemCreator.tsx b/components/workspace/ItemCreator.tsx index 65091892..0db70132 100644 --- a/components/workspace/ItemCreator.tsx +++ b/components/workspace/ItemCreator.tsx @@ -6,14 +6,16 @@ import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid' import { toast } from 'react-toastify' import { buildItemsSummary, groupSubjects } from './utils' import { getDidFromHandleInBatch } from '@/lib/identity' -import { ArrowPathIcon } from '@heroicons/react/24/solid' +import { ArrowPathIcon, PaperClipIcon } from '@heroicons/react/24/solid' interface WorkspaceItemCreatorProps { + onFileUploadClick: () => void onCancel?: () => void size?: 'sm' | 'lg' } const WorkspaceItemCreator: React.FC = ({ + onFileUploadClick, onCancel, size = 'lg', }) => { @@ -30,6 +32,12 @@ const WorkspaceItemCreator: React.FC = ({ try { const formData = new FormData(event.currentTarget) const items = formData.get('items') as string + + if (!items) { + setIsAdding(false) + return false + } + const isDid = (item) => item.startsWith('did:') const isAtUri = (item) => item.startsWith('at://') // if it's not did or at-uri but contains .s it's possibly a handle @@ -115,6 +123,20 @@ const WorkspaceItemCreator: React.FC = ({ )} + + {isAdding ? ( + + ) : ( + + )} + {!!onCancel && ( ) { const [showActionForm, setShowActionForm] = useState(false) const removeItemsMutation = useWorkspaceRemoveItemsMutation() const emptyWorkspaceMutation = useWorkspaceEmptyMutation() + const { importFromFiles } = useWorkspaceImport() const [modEventType, setModEventType] = useState( MOD_EVENTS.ACKNOWLEDGE, ) const [showItemCreator, setShowItemCreator] = useState(false) const actionSubjects = useActionSubjects() + const dropzoneRef = createRef() const handleRemoveSelected = () => { const selectedItems = Array.from( @@ -273,86 +277,126 @@ export function WorkspacePanel(props: PropsOf) { onClose={onClose} {...others} > - {!workspaceList?.length ? ( -
- <> - -

- Workspace is empty. -

- - -
- ) : ( - <> - {showItemCreator && ( - setShowItemCreator(false)} - /> - )} -
- {showActionForm && ( - setShowActionForm((current) => !current), - }} - /> - )} - {!showItemCreator && ( -
- -
+ { + toast.error( + rejections + .map((r) => r.errors.map((e) => e.message).join(' | ')) + .flat() + .join(' | '), + ) + }} + noKeyboard + noClick + > + {({ getRootProps, getInputProps }) => ( +
+ + {!workspaceList?.length ? ( + <> + <> + +

+ Workspace is empty. +

+ { + dropzoneRef.current?.open() + }} + /> + + + ) : ( + <> + {showItemCreator && ( + { + dropzoneRef.current?.open() + }} + onCancel={() => setShowItemCreator(false)} + /> + )} + + {showActionForm && ( + + setShowActionForm((current) => !current), + }} + /> + )} + {!showItemCreator && ( +
+ +
+ )} + {/* The inline styling is not ideal but there's no easy way to set calc() values in tailwind */} + {/* We are basically telling the browser to leave 180px at the bottom of the container to make room for navigation arrows and use the remaining vertical space for the main content where scrolling will be allowed if content overflows */} + {/* @ts-ignore */} + +
+ +
+ + )} - {/* The inline styling is not ideal but there's no easy way to set calc() values in tailwind */} - {/* We are basically telling the browser to leave 180px at the bottom of the container to make room for navigation arrows and use the remaining vertical space for the main content where scrolling will be allowed if content overflows */} - {/* @ts-ignore */} - -
- -
- - - )} +
+ )} +
) } diff --git a/components/workspace/hooks.tsx b/components/workspace/hooks.tsx index 5a266595..d845a39a 100644 --- a/components/workspace/hooks.tsx +++ b/components/workspace/hooks.tsx @@ -1,4 +1,9 @@ -import { createCSV, downloadCSV, escapeCSVValue } from '@/lib/csv' +import { + createCSV, + downloadCSV, + escapeCSVValue, + processFileForWorkspaceImport, +} from '@/lib/csv' import { getLocalStorageData, setLocalStorageData } from '@/lib/local-storage' import { buildBlueSkyAppUrl, isNonNullable, pluralize } from '@/lib/util' import { useServerConfig } from '@/shell/ConfigurationContext' @@ -6,7 +11,6 @@ import { AppBskyActorProfile, AtUri, ComAtprotoAdminDefs, - ComAtprotoLabelDefs, ComAtprotoRepoStrongRef, ToolsOzoneModerationDefs, ToolsOzoneTeamDefs, @@ -231,6 +235,28 @@ export const useWorkspaceExport = () => { } } +export const useWorkspaceImport = () => { + const { mutateAsync: addToWorkspace } = useWorkspaceAddItemsMutation() + + const importFromFiles = async (acceptedFiles: File[]) => { + console.log(acceptedFiles) + if (acceptedFiles.length === 0) return + try { + const results = await Promise.all( + acceptedFiles.map((file) => processFileForWorkspaceImport(file)), + ) + const items = results.flat() + addToWorkspace(items) + } catch (error) { + toast.error( + `Failed to import items to workspace. ${(error as Error).message}`, + ) + } + } + + return { importFromFiles } +} + const getList = (): string[] => { const list = getLocalStorageData(WORKSPACE_LIST_KEY) if (!list) return [] diff --git a/lib/csv.ts b/lib/csv.ts index dd2e691c..0345806c 100644 --- a/lib/csv.ts +++ b/lib/csv.ts @@ -47,3 +47,77 @@ export function downloadCSV(csv: CsvContent) { link.click() document.body.removeChild(link) } + +export const processFileForWorkspaceImport = (file: File): Promise => { + return new Promise((resolve, reject) => { + const fileType = file.type + const fileName = file.name.toLowerCase() + + if (fileType === 'application/json' || fileName.endsWith('.json')) { + const reader = new FileReader() + reader.onload = () => { + try { + const jsonData = JSON.parse(reader.result as string) + const values = extractFromJSON(jsonData) + if (values.length === 0) { + reject(new Error(`No 'did' or 'uri' found in ${file.name}`)) + } + resolve(values) + } catch (error) { + reject(new Error(`Invalid JSON file: ${file.name}`)) + } + } + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)) + reader.readAsText(file) + } else if (fileType === 'text/csv' || fileName.endsWith('.csv')) { + const reader = new FileReader() + reader.onload = () => { + try { + const csvData = reader.result as string + const values = extractFromCSV(csvData) + if (values.length === 0) { + reject(new Error(`No 'did' or 'uri' found in ${file.name}`)) + } + resolve(values) + } catch (error) { + reject(new Error(`Invalid CSV file: ${file.name}`)) + } + } + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)) + reader.readAsText(file) + } else { + reject(new Error(`Unsupported file type: ${file.name}`)) + } + }) +} + +export const extractFromCSV = (data: string): string[] => { + const rows = data.split('\n').map((row) => row.trim()) + const [header, ...content] = rows + + if (!header) return [] + + const headers = header.split(',').map((col) => col.trim()) + const didIndex = headers.indexOf('did') + const uriIndex = headers.indexOf('uri') + + if (didIndex === -1 && uriIndex === -1) return [] + + return content + .map((row) => { + const columns = row.split(',').map((col) => col.trim()) + return columns[didIndex] || columns[uriIndex] + }) + .filter(Boolean) +} + +export const extractFromJSON = (data: any): string[] => { + if (Array.isArray(data)) { + return data + .filter((item) => item.did || item.uri) + .map((item) => item.did || item.uri) + } else if (typeof data === 'object') { + return data.did || data.uri ? [data.did || data.uri] : [] + } + return [] +} diff --git a/package.json b/package.json index 477b922d..31a0042b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "next": "14.2.5", "react": "18.2.0", "react-dom": "18.2.0", + "react-dropzone": "^14.3.5", "react-json-view": "1.21.3", "react-tetris": "^0.3.0", "react-toastify": "^9.1.1", diff --git a/yarn.lock b/yarn.lock index d9bb860b..105d29f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -966,6 +966,11 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +attr-accept@^2.2.4: + version "2.2.5" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== + autoprefixer@^10.4.13: version "10.4.13" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz" @@ -2232,6 +2237,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" + integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== + dependencies: + tslib "^2.7.0" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz" @@ -4453,6 +4465,15 @@ react-dom@18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dropzone@^14.3.5: + version "14.3.5" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.5.tgz#1a8bd312c8a353ec78ef402842ccb3589c225add" + integrity sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -5460,6 +5481,11 @@ tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== +tslib@^2.7.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"