diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx index 07c4562b..a9c943d4 100644 --- a/components/common/Dropdown.tsx +++ b/components/common/Dropdown.tsx @@ -43,11 +43,11 @@ export const Dropdown = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - {/* z-30 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-30 is the highest z-index we use in this codebase */} + {/* z-50 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-40 is the highest z-index we use in this codebase */} {items.map((item) => ( diff --git a/components/shell/CommandPalette/Root.tsx b/components/shell/CommandPalette/Root.tsx index 8b194e45..c7d0898c 100644 --- a/components/shell/CommandPalette/Root.tsx +++ b/components/shell/CommandPalette/Root.tsx @@ -52,8 +52,8 @@ export const CommandPaletteRoot = ({ return ( - {/* z-40 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-40 */} - + {/* z-50 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-50 */} + { + const { + mutateAsync, + isLoading, + headers, + filename, + setFilename, + selectedColumns, + setSelectedColumns, + } = useWorkspaceExport() + + const canDownload = selectedColumns.length > 0 && !isLoading && !!filename + + return ( + + {({ open, close }) => ( + <> + + + + {isLoading ? 'Exporting...' : 'Export'} + + + + + {/* Use the `Transition` component. */} + + +
+
+ + setFilename(e.target.value)} + /> + + + + + {headers.map((fieldName) => { + return ( + + e.target.checked + ? setSelectedColumns([ + ...selectedColumns, + fieldName, + ]) + : setSelectedColumns( + selectedColumns.filter( + (col) => col !== fieldName, + ), + ) + } + /> + ) + })} +
+

+ Note:{' '} + + The exported file will only contain the selected columns. +
+ When exporting from records (posts, profiles etc.) the + exported file will only contain the author account details + of the records. +
+

+
+ { + mutateAsync(listData).then(() => close()) + }} + > + + {isLoading ? 'Downloading...' : 'Download'} + + +
+
+
+
+ + )} +
+ ) +} diff --git a/components/workspace/FilterSelector.tsx b/components/workspace/FilterSelector.tsx index 44526794..cfccd621 100644 --- a/components/workspace/FilterSelector.tsx +++ b/components/workspace/FilterSelector.tsx @@ -1,10 +1,14 @@ import { Popover, Transition } from '@headlessui/react' import { ActionButton } from '@/common/buttons' import { CheckIcon } from '@heroicons/react/24/outline' -import { Checkbox } from '@/common/forms' +import { Checkbox, FormLabel, Input } from '@/common/forms' import { useState } from 'react' import { WorkspaceListData } from './useWorkspaceListData' -import { ToolsOzoneModerationDefs } from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyActorProfile, + ToolsOzoneModerationDefs, +} from '@atproto/api' import { getSubjectStatusFromItemData } from './utils' const toggleItemCheck = (item: string, select: boolean = true) => { @@ -47,6 +51,14 @@ const ContentFilterOptions = { contentWithVideoEmbed: { label: 'Content with video embed' }, } +const matchKeyword = (keyword?: string, subject?: string) => { + if (!keyword || !subject) return false + const keywords = keyword.split('||') + return keywords.some((k) => + subject.toLowerCase().includes(k.trim().toLowerCase()), + ) +} + export const WorkspaceFilterSelector = ({ listData, }: { @@ -66,6 +78,7 @@ export const WorkspaceFilterSelector = ({ contentAuthorDeactivated: false, contentWithImageEmbed: false, contentWithVideoEmbed: false, + keyword: '', }) const handleFilterChange = (e: React.ChangeEvent) => { @@ -86,6 +99,13 @@ export const WorkspaceFilterSelector = ({ }) } + const unselectAll = () => { + if (!listData) return + Object.keys(listData).forEach((uri) => { + toggleItemCheck(uri, false) + }) + } + const toggleFilteredItems = (select: boolean = true) => { if (!listData) return Object.entries(listData).forEach(([uri, item]) => { @@ -104,7 +124,14 @@ export const WorkspaceFilterSelector = ({ (filters.accountEmailUnConfirmed && isRepo && !item.emailConfirmedAt) || - (filters.accountDeactivated && isRepo && item.deactivatedAt) + (filters.accountDeactivated && isRepo && item.deactivatedAt) || + (filters.keyword && + isRepo && + matchKeyword( + filters.keyword, + item.relatedRecords?.find(AppBskyActorProfile.isRecord) + ?.description, + )) ) { toggleItemCheck(uri, select) } @@ -125,7 +152,10 @@ export const WorkspaceFilterSelector = ({ (filters.contentWithImageEmbed && subjectStatus?.tags?.includes('embed:image')) || (filters.contentWithVideoEmbed && - subjectStatus?.tags?.includes('embed:video')) + subjectStatus?.tags?.includes('embed:video')) || + (filters.keyword && + isRecord && + matchKeyword(filters.keyword, item.value?.['text'])) ) { toggleItemCheck(uri, select) } @@ -134,7 +164,7 @@ export const WorkspaceFilterSelector = ({ } return ( - + {({ open }) => ( <> @@ -204,16 +234,39 @@ export const WorkspaceFilterSelector = ({ )} +
+ + + setFilters({ ...filters, keyword: ev.target.value }) + } + autoComplete="off" + /> + +

- Note:{' '} - - You can select or unselect all items that matches the above - configured filters. The configured filters work with OR - operator.
- So, if you select {'"Deactivated accounts"'} and - {'"Accounts in appealed state"'}, all accounts that are - either deactivated OR in appealed state will be selected -
+ You can select or unselect all items that matches the above + configured filters. The configured filters work with OR + operator.
+ So, if you select {'"Deactivated accounts"'} and + {'"Accounts in appealed state"'}, all accounts that are either + deactivated OR in appealed state will be selected.
+
+ You can use {'||'} separator in the keyword filter to look for + multiple keywords in either {"user's"} profile bio or record + content

{ toggleFilteredItems() - // close() }} > Select Filtered @@ -235,6 +287,15 @@ export const WorkspaceFilterSelector = ({ > Unselect Filtered + { + unselectAll() + }} + > + Unselect All + 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 @@ -77,7 +85,6 @@ const WorkspaceItemCreator: React.FC = ({ setIsAdding(false) return false } catch (error) { - console.error(error) setIsAdding(false) toast.error( `Failed to add items to workspace. ${(error as Error).message}`, @@ -115,6 +122,20 @@ const WorkspaceItemCreator: React.FC = ({ )} + + {isAdding ? ( + + ) : ( + + )} + {!!onCancel && ( void } +const GroupTitles = { + dids: 'Accounts', +} + const getLangTagFromRecordValue = ( record: ToolsOzoneModerationDefs.RecordViewDetail, ): string[] => { @@ -45,9 +51,11 @@ const getLangTagFromRecordValue = ( const WorkspaceList: React.FC = ({ list, listData, + canExport, onRemoveItem, }) => { const groupedItems = groupSubjects(list) + return (
@@ -59,7 +67,11 @@ const WorkspaceList: React.FC = ({ items={items} listData={listData} onRemoveItem={onRemoveItem} - title={`${key.charAt(0).toUpperCase()}${key.slice(1)}`} + canExport={canExport} + title={ + GroupTitles[key] || + `${key.charAt(0).toUpperCase()}${key.slice(1)}` + } /> ) })} @@ -73,9 +85,11 @@ const ListGroup = ({ title, listData, onRemoveItem, + canExport, }: { items: string[] title: string + canExport?: boolean } & Omit) => { const [lastCheckedIndex, setLastCheckedIndex] = useState(null) const checkboxesRef = useRef<(HTMLInputElement | null)[]>([]) @@ -102,12 +116,15 @@ const ListGroup = ({ } return ( -
+
{title}({items.length})
-
+
+ {canExport && ( + + )} (v: V): v is NonNullable { - return v != null -} +import { isNonNullable } from '@/lib/util' export function WorkspacePanel(props: PropsOf) { const { onClose, ...others } = props @@ -42,11 +42,13 @@ export function WorkspacePanel(props: PropsOf) { 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( @@ -71,100 +73,103 @@ export function WorkspacePanel(props: PropsOf) { }>({ isSubmitting: false, error: '' }) const labelerAgent = useLabelerAgent() - const supportsCorrelation = useServerConfig().pds != null - const handleFindCorrelation = supportsCorrelation - ? async () => { - const selectedItems = new FormData(formRef.current!) - .getAll('workspaceItem') - .filter((item): item is string => typeof item === 'string') - - // For every selected item, find out which DID it corresponds - const dids = selectedItems - .map((item) => { - if (item.startsWith('did:')) return item - - const status = workspaceListStatuses?.[item] - - if (ToolsOzoneModerationDefs.isRepoViewDetail(status)) { - return status.did - } - - if (ToolsOzoneModerationDefs.isRecordViewDetail(status)) { - return status.repo.did - } + const { pds, role } = useServerConfig() + const handleFindCorrelation = + pds != null + ? async () => { + const selectedItems = new FormData(formRef.current!) + .getAll('workspaceItem') + .filter((item): item is string => typeof item === 'string') + + // For every selected item, find out which DID it corresponds + const dids = selectedItems + .map((item) => { + if (item.startsWith('did:')) return item + + const status = workspaceListStatuses?.[item] + + if (ToolsOzoneModerationDefs.isRepoViewDetail(status)) { + return status.did + } - if (ToolsOzoneModerationDefs.isSubjectStatusView(status)) { - const { subject } = status - if (ComAtprotoAdminDefs.isRepoRef(subject)) { - return subject.did + if (ToolsOzoneModerationDefs.isRecordViewDetail(status)) { + return status.repo.did } - if (ComAtprotoRepoStrongRef.isMain(subject)) { - return new AtUri(subject.uri).host + if (ToolsOzoneModerationDefs.isSubjectStatusView(status)) { + const { subject } = status + if (ComAtprotoAdminDefs.isRepoRef(subject)) { + return subject.did + } + + if (ComAtprotoRepoStrongRef.isMain(subject)) { + return new AtUri(subject.uri).host + } } - } - // Should never happen (future proofing against new item types in workspace) - return undefined - }) - .filter(isNonNullable) + // Should never happen (future proofing against new item types in workspace) + return undefined + }) + .filter(isNonNullable) - if (dids.length <= 1) { - toast.warning('Please select at least two accounts to correlate.') - return - } + if (dids.length <= 1) { + toast.warning('Please select at least two accounts to correlate.') + return + } - if (dids.length !== selectedItems.length) { - toast.info('Only accounts can be correlated (ignoring non-accounts).') - } + if (dids.length !== selectedItems.length) { + toast.info( + 'Only accounts can be correlated (ignoring non-accounts).', + ) + } - const res = await labelerAgent.tools.ozone.signature.findCorrelation({ - dids, - }) - - const { details } = res.data - - if (!details.length) { - toast.info('No correlation found between the selected accounts.') - } else { - toast.success( -
- The following correlation were found between the selected - accounts: -
- {details.map(({ property, value }) => ( - - - - {property} - - - ))} - {details.length > 1 && ( - <> -
+ const res = await labelerAgent.tools.ozone.signature.findCorrelation({ + dids, + }) + + const { details } = res.data + + if (!details.length) { + toast.info('No correlation found between the selected accounts.') + } else { + toast.success( +
+ The following correlation were found between the selected + accounts: +
+ {details.map(({ property, value }) => ( s.value)), - )}`} - className="text-blue-500 underline" + key={property} + href={`/repositories?term=sig:${encodeURIComponent(value)}`} > - Click here to show all accounts with the same details. + + + {property} + - - )} -
, - { - autoClose: 10_000, - }, - ) + ))} + {details.length > 1 && ( + <> +
+ s.value)), + )}`} + className="text-blue-500 underline" + > + Click here to show all accounts with the same details. + + + )} +
, + { + autoClose: 10_000, + }, + ) + } } - } - : undefined + : undefined // on form submit const onFormSubmit = async ( @@ -250,7 +255,6 @@ export function WorkspacePanel(props: PropsOf) { }) } } catch (err) { - console.error(err) setSubmission({ error: (err as Error).message, isSubmitting: false }) } } @@ -272,79 +276,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 cb1a51af..bcd2e1af 100644 --- a/components/workspace/hooks.tsx +++ b/components/workspace/hooks.tsx @@ -1,8 +1,27 @@ +import { + createCSV, + downloadCSV, + escapeCSVValue, + processFileForWorkspaceImport, +} from '@/lib/csv' import { getLocalStorageData, setLocalStorageData } from '@/lib/local-storage' -import { pluralize } from '@/lib/util' +import { buildBlueSkyAppUrl, isNonNullable, pluralize } from '@/lib/util' +import { useServerConfig } from '@/shell/ConfigurationContext' +import { + AppBskyActorProfile, + AtUri, + ComAtprotoAdminDefs, + ComAtprotoRepoStrongRef, + ToolsOzoneModerationDefs, + ToolsOzoneTeamDefs, +} from '@atproto/api' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useRef } from 'react' +import { useRef, useState } from 'react' import { toast } from 'react-toastify' +import { + WorkspaceListData, + WorkspaceListItemData, +} from './useWorkspaceListData' const WORKSPACE_LIST_KEY = 'workspace_list' const WORKSPACE_LIST_DELIMITER = ',' @@ -95,6 +114,148 @@ export const useWorkspaceEmptyMutation = () => { return mutation } +export const WORKSPACE_EXPORT_FIELDS = [ + 'did', + 'handle', + 'email', + 'ip', + 'name', + 'labels', + 'tags', + 'bskyUrl', +] +export const ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS = ['email', 'ip'] +const filterExportFields = (fields: string[], isAdmin: boolean) => { + return isAdmin + ? fields + : fields.filter( + (field) => !ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS.includes(field), + ) +} + +const getExportFieldsFromWorkspaceListItem = (item: WorkspaceListItemData) => { + const isRecord = ToolsOzoneModerationDefs.isRecordViewDetail(item) + + if (ToolsOzoneModerationDefs.isRepoViewDetail(item) || isRecord) { + const repo = isRecord ? item.repo : item + const profile = repo.relatedRecords.find(AppBskyActorProfile.isRecord) + const baseFields = { + did: repo.did, + handle: repo.handle, + email: repo.email, + ip: 'Unknown', + labels: 'Unknown', + name: profile?.displayName, + tags: repo.moderation.subjectStatus?.tags?.join('|'), + bskyUrl: buildBlueSkyAppUrl({ did: repo.did }), + } + + // For record entries, the repo does not include labels + if (!isRecord) { + return { + ...baseFields, + ip: item.ip as string, + labels: item.labels?.map(({ val }) => val).join('|'), + } + } + } + + if (ToolsOzoneModerationDefs.isSubjectStatusView(item)) { + const did = ComAtprotoRepoStrongRef.isMain(item.subject) + ? new AtUri(item.subject.uri).host + : ComAtprotoAdminDefs.isRepoRef(item.subject) + ? item.subject.did + : '' + return { + did, + handle: item.subjectRepoHandle, + relatedRecords: [] as {}[], + email: 'Unknown', + ip: 'Unknown', + labels: 'None', + name: 'Unknown', + tags: item.tags?.join('|'), + bskyUrl: buildBlueSkyAppUrl({ did }), + } + } + return null +} + +export const useWorkspaceExport = () => { + const { role } = useServerConfig() + const isAdmin = role === ToolsOzoneTeamDefs.ROLEADMIN + const headers = isAdmin + ? WORKSPACE_EXPORT_FIELDS + : WORKSPACE_EXPORT_FIELDS.filter( + (field) => !ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS.includes(field), + ) + + const [selectedColumns, setSelectedColumns] = useState(headers) + const [filename, setFilename] = useState(`workspace-export`) + + const mutation = useMutation({ + mutationFn: async (items: WorkspaceListData) => { + const exportHeaders = filterExportFields(selectedColumns, isAdmin) + downloadCSV( + createCSV({ + filename, + headers: exportHeaders, + lines: Object.values(items) + .map((item) => { + if (!item) return '' + + const exportFields = getExportFieldsFromWorkspaceListItem(item) + if (!exportFields) return '' + + const line: string[] = [ + exportFields.did, + exportFields.handle, + exportHeaders.includes('email') ? exportFields.email : '', + exportHeaders.includes('ip') ? exportFields.ip : '', + exportFields.name, + exportFields.labels, + exportFields.tags, + exportFields.bskyUrl, + ].filter(isNonNullable) + return line.map(escapeCSVValue).join(',') + }) + .filter(Boolean), + }), + ) + }, + }) + + return { + headers, + selectedColumns, + setSelectedColumns, + filename, + setFilename, + ...mutation, + } +} + +export const useWorkspaceImport = () => { + const { mutateAsync: addToWorkspace } = useWorkspaceAddItemsMutation() + + const importFromFiles = async (acceptedFiles: File[]) => { + 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 new file mode 100644 index 00000000..c8a2ac28 --- /dev/null +++ b/lib/csv.ts @@ -0,0 +1,123 @@ +export type CsvContent = { + filename: string + headerRow: string + body: string +} +export const escapeCSVValue = (value: string) => { + if ( + value.includes(',') || + value.includes('"') || + value.includes('\r') || + value.includes('\n') + ) { + return `"${value.replaceAll('"', '""')}"` + } + return value +} +export function createCSV({ + headers, + lines, + filename, + lineDelimiter = '\n', +}: { + lines: string[] + headers: string[] + filename?: string + lineDelimiter?: string +}): CsvContent { + return { + filename: (filename || Date.now().toString()) + '.csv', + headerRow: headers.join(',') + lineDelimiter, // make your own csv head + body: lines.join(lineDelimiter), + } +} + +export function downloadCSV(csv: CsvContent) { + var csvContent = csv.headerRow + csv.body + if (!csvContent.match(/^data:text\/csv/i)) { + csvContent = 'data:text/csv;charset=utf-8,' + csvContent // use 'data:text/csv;charset=utf-8,\ufeff', if you consider using the excel + } + var data = encodeURI(csvContent) + + var link = document.createElement('a') + link.href = data + link.download = csv.filename + + document.body.appendChild(link) + 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() + const reader = new FileReader() + reader.onload = () => { + try { + const content = reader.result as string + let values: string[] = [] + + if (fileType === 'application/json' || fileName.endsWith('.json')) { + const jsonData = JSON.parse(content) + values = extractFromJSON(jsonData) + } else if (fileType === 'text/csv' || fileName.endsWith('.csv')) { + values = extractFromCSV(content) + } else { + return reject(new Error(`Unsupported file type: ${file.name}`)) + } + + if (values.length === 0) { + return reject(new Error(`No 'did' or 'uri' found in ${file.name}`)) + } + + resolve(values) + } catch (error) { + reject(new Error(`Error parsing file content: ${file.name}`)) + } + } + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)) + reader.readAsText(file) + }) +} + +const cleanCSVColumn = (col: string) => { + const trimmed = col.trim() + return trimmed.startsWith('"') && trimmed.endsWith('"') + ? trimmed.slice(1, -1) + : trimmed +} + +export const extractFromCSV = (data: string): string[] => { + const rows = data.split('\n').map((row) => row.trim()) + const [header, ...content] = rows + + if (!header) return [] + + // In case header names are quoted, we want to exclude those quotes before check + const headers = header.split(',').map(cleanCSVColumn) + 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(cleanCSVColumn) + 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/lib/util.ts b/lib/util.ts index d5a5d841..bd94d01d 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -180,4 +180,7 @@ export function simpleHash(str: string) { hash = ((hash << 5) - hash + chr) | 0 } return hash -} \ No newline at end of file +} +export function isNonNullable(v: V): v is NonNullable { + return v != null +} 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"