uppy icon indicating copy to clipboard operation
uppy copied to clipboard

Add useRemoteSource

Open Murderlon opened this issue 7 months ago • 4 comments

  • Shared logic in @uppy/components/src/hooks
  • Framework specific wrappers in @uppy/react, @uppy/vue, and @uppy/svelte
  • Add dequal (only 304B) for state equality

Example

Screenshot 2025-06-10 at 10 35 04
import { type PartialTreeFile, PartialTreeFolderNode } from '@uppy/core'
import { useRemoteSource } from '@uppy/react'

function File({
  item,
  checkbox,
}: {
  item: PartialTreeFile
  checkbox: (item: PartialTreeFile, checked: boolean) => void
}) {
  const dtf = new Intl.DateTimeFormat('en-US', {
    dateStyle: 'short',
    timeStyle: 'short',
  })

  return (
    <li key={item.id} className="flex items-center gap-2 mb-2">
      <input
        type="checkbox"
        onChange={() => checkbox(item, false)}
        checked={item.status === 'checked'}
      />
      {item.data.thumbnail && (
        <img src={item.data.thumbnail} alt="" className="w-5 h-5" />
      )}
      <div className="truncate">{item.data.name}</div>
      <p className="text-gray-500 text-sm ml-auto min-w-28 text-right">
        {dtf.format(new Date(item.data.modifiedDate))}
      </p>
    </li>
  )
}
function Folder({
  item,
  checkbox,
  open,
}: {
  item: PartialTreeFolderNode
  checkbox: (item: PartialTreeFolderNode, checked: boolean) => void
  open: (folderId: string | null) => Promise<void>
}) {
  return (
    <li key={item.id} className="flex items-center gap-2 mb-2">
      <input
        type="checkbox"
        onChange={() => checkbox(item, false)}
        checked={item.status === 'checked'}
      />
      <button
        type="button"
        className="text-blue-500"
        onClick={() => open(item.id)}
      >
        <span aria-hidden className="w-5 h-5">
          📁
        </span>{' '}
        {item.data.name}
      </button>
    </li>
  )
}

export function Dropbox({ close }: { close: () => void }) {
  const { state, login, logout, checkbox, open, done, cancel } =
    useRemoteSource('Dropbox')

  if (!state.authenticated) {
    return (
      <div className="p-4 pt-0 min-w-xl min-h-96">
        <button
          type="button"
          className="block ml-auto text-blue-500"
          onClick={() => login()}
        >
          Login
        </button>
      </div>
    )
  }

  return (
    <div className="w-screen h-screen max-w-3xl max-h-96 relative">
      <div className="flex justify-between items-center gap-2 bg-gray-100 pb-2 px-4 py-2">
        {state.breadcrumbs.map((breadcrumb, index) => (
          <>
            {index > 0 && <span className="text-gray-500">&gt;</span>}{' '}
            {index === state.breadcrumbs.length - 1 ?
              <span>
                {breadcrumb.type === 'root' ? 'Dropbox' : breadcrumb.data.name}
              </span>
            : <button
                type="button"
                className="text-blue-500"
                key={breadcrumb.id}
                onClick={() => open(breadcrumb.id)}
              >
                {breadcrumb.type === 'root' ? 'Dropbox' : breadcrumb.data.name}
              </button>
            }
          </>
        ))}
        <div className="flex items-center gap-2 ml-auto">
          <button
            type="button"
            className="text-blue-500"
            onClick={() => {
              logout()
              close()
            }}
          >
            Logout
          </button>
        </div>
      </div>

      <ul className="p-4">
        {state.partialTree.map((item) => {
          if (item.type === 'file') {
            return <File key={item.id} item={item} checkbox={checkbox} />
          }
          if (item.type === 'folder') {
            return (
              <Folder
                key={item.id}
                item={item}
                checkbox={checkbox}
                open={open}
              />
            )
          }
          return null
        })}
      </ul>

      {state.selectedAmount > 0 && (
        <div className="flex items-center gap-4 bg-gray-100 mt-auto py-2 px-4 absolute bottom-0 left-0 right-0">
          <button
            type="button"
            className="text-blue-500"
            onClick={() => {
              done()
              close()
            }}
          >
            Done
          </button>
          <button
            type="button"
            className="text-blue-500"
            onClick={() => {
              cancel()
            }}
          >
            Cancel
          </button>
          <p className="text-gray-500 text-sm">
            Selected {state.selectedAmount} items
          </p>
        </div>
      )}
    </div>
  )
}

Murderlon avatar Jun 09 '25 13:06 Murderlon

Diff output files
diff --git a/packages/@uppy/components/lib/Thumbnail.js b/packages/@uppy/components/lib/Thumbnail.js
index 07ef352..8998e2b 100644
--- a/packages/@uppy/components/lib/Thumbnail.js
+++ b/packages/@uppy/components/lib/Thumbnail.js
@@ -14,9 +14,9 @@ export default function Thumbnail(props) {
       .includes(fileTypeSpecific);
   const isPDF = fileTypeGeneral === "application" && fileTypeSpecific === "pdf";
   const objectUrl = useMemo(() => {
-    if (!props.images) return "";
+    if (!props.images || props.file.isRemote) return "";
     return URL.createObjectURL(props.file.data);
-  }, [props.file.data, props.images]);
+  }, [props.file.data, props.images, props.file.isRemote]);
   const showThumbnail = props.images && isImage && objectUrl;
   useEffect(() => {
     return () => {
diff --git a/packages/@uppy/components/lib/hooks/webcam.js b/packages/@uppy/components/lib/hooks/webcam.js
index bfc1000..acfbb30 100644
--- a/packages/@uppy/components/lib/hooks/webcam.js
+++ b/packages/@uppy/components/lib/hooks/webcam.js
@@ -1,21 +1,5 @@
+import { Subscribers } from "./utils.js";
 const videoId = "uppy-webcam-video";
-class Subscribers {
-  constructor() {
-    this.subscribers = new Set();
-    this.add = listener => {
-      this.subscribers.add(listener);
-      return () => this.subscribers.delete(listener);
-    };
-    this.emit = () => {
-      for (const listener of this.subscribers) {
-        listener();
-      }
-    };
-    this.clear = () => {
-      this.subscribers.clear();
-    };
-  }
-}
 export function createWebcamController(uppy, onSubmit) {
   const plugin = uppy.getPlugin("Webcam");
   if (!plugin) {
diff --git a/packages/@uppy/components/lib/index.d.ts b/packages/@uppy/components/lib/index.d.ts
index e8ce317..a6e2d76 100644
--- a/packages/@uppy/components/lib/index.d.ts
+++ b/packages/@uppy/components/lib/index.d.ts
@@ -7,6 +7,7 @@ export { default as ProviderIcon, type ProviderIconProps, } from './ProviderIcon
 export { createDropzone, type DropzoneReturn, type DropzoneOptions, } from './hooks/dropzone.js';
 export { createFileInput, type FileInputProps, type FileInputFunctions, } from './hooks/file-input.js';
 export { createWebcamController, type WebcamStore, type WebcamStatus, type WebcamSnapshot, } from './hooks/webcam.js';
+export { createRemoteSourceController, type RemoteSourceStore, type RemoteSourceSnapshot, type RemoteSourceKeys, } from './hooks/remote-source.js';
 export type { UppyContext, UppyState, UploadStatus, NonNullableUppyContext, } from './types.js';
 export { createUppyEventAdapter } from './uppyEventAdapter.js';
 //# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/@uppy/components/lib/index.js b/packages/@uppy/components/lib/index.js
index 237584a..8b5885d 100644
--- a/packages/@uppy/components/lib/index.js
+++ b/packages/@uppy/components/lib/index.js
@@ -3,6 +3,7 @@ export { default as FilesGrid } from "./FilesGrid.js";
 export { default as FilesList } from "./FilesList.js";
 export { createDropzone } from "./hooks/dropzone.js";
 export { createFileInput } from "./hooks/file-input.js";
+export { createRemoteSourceController } from "./hooks/remote-source.js";
 export { createWebcamController } from "./hooks/webcam.js";
 export { default as ProviderIcon } from "./ProviderIcon.js";
 export { default as Thumbnail } from "./Thumbnail.js";
diff --git a/packages/@uppy/core/lib/Uppy.d.ts b/packages/@uppy/core/lib/Uppy.d.ts
index 13c0e79..091a584 100644
--- a/packages/@uppy/core/lib/Uppy.d.ts
+++ b/packages/@uppy/core/lib/Uppy.d.ts
@@ -99,6 +99,7 @@ export type UnknownProviderPlugin<M extends Meta, B extends Body> = UnknownPlugi
     rootFolderId: string | null;
     files: UppyFile<M, B>[];
     provider: CompanionClientProvider;
+    view: any;
 };
 export type UnknownSearchProviderPluginState = {
     isInputMode: boolean;
diff --git a/packages/@uppy/provider-views/lib/ProviderView/ProviderView.d.ts b/packages/@uppy/provider-views/lib/ProviderView/ProviderView.d.ts
index a7b5dca..04ffb9b 100644
--- a/packages/@uppy/provider-views/lib/ProviderView/ProviderView.d.ts
+++ b/packages/@uppy/provider-views/lib/ProviderView/ProviderView.d.ts
@@ -1,5 +1,5 @@
 import { h } from 'preact';
-import type { UnknownProviderPlugin, PartialTreeFolderNode, PartialTreeFile, PartialTree, Body, Meta } from '@uppy/core';
+import type { UnknownProviderPlugin, PartialTreeFolder, PartialTreeFolderNode, PartialTreeFile, PartialTree, Body, Meta } from '@uppy/core';
 import type { CompanionFile } from '@uppy/utils/lib/CompanionFile';
 import type { I18n } from '@uppy/utils/lib/Translator';
 export declare function defaultPickerIcon(): h.JSX.Element;
@@ -48,6 +48,8 @@ export default class ProviderView<M extends Meta, B extends Body> {
     donePicking(): Promise<void>;
     toggleCheckbox(ourItem: PartialTreeFolderNode | PartialTreeFile, isShiftKeyPressed: boolean): void;
     getDisplayedPartialTree: () => (PartialTreeFile | PartialTreeFolderNode)[];
+    getBreadcrumbs: () => PartialTreeFolder[];
+    getSelectedAmount: () => number;
     validateAggregateRestrictions: (partialTree: PartialTree) => string | null;
     render(state: unknown, viewOptions?: RenderOpts<M, B>): h.JSX.Element;
 }
diff --git a/packages/@uppy/provider-views/lib/ProviderView/ProviderView.js b/packages/@uppy/provider-views/lib/ProviderView/ProviderView.js
index 7e1c6b3..7b152c1 100644
--- a/packages/@uppy/provider-views/lib/ProviderView/ProviderView.js
+++ b/packages/@uppy/provider-views/lib/ProviderView/ProviderView.js
@@ -22,6 +22,7 @@ import getClickedRange from "../utils/getClickedRange.js";
 import handleError from "../utils/handleError.js";
 import getBreadcrumbs from "../utils/PartialTreeUtils/getBreadcrumbs.js";
 import getCheckedFilesWithPaths from "../utils/PartialTreeUtils/getCheckedFilesWithPaths.js";
+import getNumberOfSelectedFiles from "../utils/PartialTreeUtils/getNumberOfSelectedFiles.js";
 import PartialTreeUtils from "../utils/PartialTreeUtils/index.js";
 import shouldHandleScroll from "../utils/shouldHandleScroll.js";
 export function defaultPickerIcon() {
@@ -85,6 +86,19 @@ export default class ProviderView {
       });
       return filtered;
     };
+    this.getBreadcrumbs = () => {
+      const {
+        partialTree,
+        currentFolderId,
+      } = this.plugin.getPluginState();
+      return getBreadcrumbs(partialTree, currentFolderId);
+    };
+    this.getSelectedAmount = () => {
+      const {
+        partialTree,
+      } = this.plugin.getPluginState();
+      return getNumberOfSelectedFiles(partialTree);
+    };
     this.validateAggregateRestrictions = partialTree => {
       const checkedFiles = partialTree.filter(item => item.type === "file" && item.status === "checked");
       const uppyFiles = checkedFiles.map(file => file.data);
@@ -338,11 +352,10 @@ export default class ProviderView {
     }
     const {
       partialTree,
-      currentFolderId,
       username,
       searchString,
     } = this.plugin.getPluginState();
-    const breadcrumbs = getBreadcrumbs(partialTree, currentFolderId);
+    const breadcrumbs = this.getBreadcrumbs();
     return h(
       "div",
       {
diff --git a/packages/@uppy/react/lib/headless/UppyContextProvider.js b/packages/@uppy/react/lib/headless/UppyContextProvider.js
index 608b5ab..69b2bac 100644
--- a/packages/@uppy/react/lib/headless/UppyContextProvider.js
+++ b/packages/@uppy/react/lib/headless/UppyContextProvider.js
@@ -39,7 +39,7 @@ export default UppyContextProvider;
 export function useUppyContext() {
   const ctx = useContext(UppyContext);
   if (!ctx.uppy) {
-    throw new Error("useDropzone must be called within a UppyContextProvider");
+    throw new Error("Uppy hooks must be called within a UppyContextProvider");
   }
   return ctx;
 }
diff --git a/packages/@uppy/react/lib/index.d.ts b/packages/@uppy/react/lib/index.d.ts
index c3d07f6..c8d36b9 100644
--- a/packages/@uppy/react/lib/index.d.ts
+++ b/packages/@uppy/react/lib/index.d.ts
@@ -9,6 +9,7 @@ export { default as useUppyEvent } from './useUppyEvent.js';
 export { useDropzone } from './useDropzone.js';
 export { useWebcam } from './useWebcam.js';
 export { useFileInput } from './useFileInput.js';
+export { useRemoteSource } from './useRemoteSource.js';
 export { UppyContext, UppyContextProvider, } from './headless/UppyContextProvider.js';
 export * from './headless/generated/index.js';
 //# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/@uppy/react/lib/index.js b/packages/@uppy/react/lib/index.js
index 54d1942..18e1325 100644
--- a/packages/@uppy/react/lib/index.js
+++ b/packages/@uppy/react/lib/index.js
@@ -8,6 +8,7 @@ export { default as ProgressBar } from "./ProgressBar.js";
 export { default as StatusBar } from "./StatusBar.js";
 export { useDropzone } from "./useDropzone.js";
 export { useFileInput } from "./useFileInput.js";
+export { useRemoteSource } from "./useRemoteSource.js";
 export { default as useUppyEvent } from "./useUppyEvent.js";
 export { default as useUppyState } from "./useUppyState.js";
 export { useWebcam } from "./useWebcam.js";
diff --git a/packages/@uppy/remote-sources/lib/index.d.ts b/packages/@uppy/remote-sources/lib/index.d.ts
index b1b19f3..7cc97ac 100644
--- a/packages/@uppy/remote-sources/lib/index.d.ts
+++ b/packages/@uppy/remote-sources/lib/index.d.ts
@@ -1,7 +1,28 @@
 import { BasePlugin } from '@uppy/core';
 import type { Uppy, DefinePluginOpts, Body, Meta } from '@uppy/core';
+import Dropbox from '@uppy/dropbox';
+import GoogleDrive from '@uppy/google-drive';
+import Instagram from '@uppy/instagram';
+import Facebook from '@uppy/facebook';
+import OneDrive from '@uppy/onedrive';
+import Box from '@uppy/box';
+import Unsplash from '@uppy/unsplash';
+import Url from '@uppy/url';
+import Zoom from '@uppy/zoom';
 import type { CompanionPluginOptions } from '@uppy/companion-client';
-type AvailablePluginsKeys = 'Box' | 'Dropbox' | 'Facebook' | 'GoogleDrive' | 'Instagram' | 'OneDrive' | 'Unsplash' | 'Url' | 'Zoom';
+export declare const availablePlugins: {
+    __proto__: null;
+    Box: typeof Box;
+    Dropbox: typeof Dropbox;
+    Facebook: typeof Facebook;
+    GoogleDrive: typeof GoogleDrive;
+    Instagram: typeof Instagram;
+    OneDrive: typeof OneDrive;
+    Unsplash: typeof Unsplash;
+    Url: typeof Url;
+    Zoom: typeof Zoom;
+};
+export type AvailablePluginsKeys = 'Box' | 'Dropbox' | 'Facebook' | 'GoogleDrive' | 'Instagram' | 'OneDrive' | 'Unsplash' | 'Url' | 'Zoom';
 type NestedCompanionKeysParams = {
     [key in AvailablePluginsKeys]?: CompanionPluginOptions['companionKeysParams'];
 };
diff --git a/packages/@uppy/remote-sources/lib/index.js b/packages/@uppy/remote-sources/lib/index.js
index 70acc91..90d4b68 100644
--- a/packages/@uppy/remote-sources/lib/index.js
+++ b/packages/@uppy/remote-sources/lib/index.js
@@ -19,7 +19,7 @@ import Zoom from "@uppy/zoom";
 const packageJson = {
   "version": "2.3.3",
 };
-const availablePlugins = {
+export const availablePlugins = {
   __proto__: null,
   Box,
   Dropbox,
diff --git a/packages/@uppy/vue/lib/index.d.ts b/packages/@uppy/vue/lib/index.d.ts
index 6118964..bf31efd 100644
--- a/packages/@uppy/vue/lib/index.d.ts
+++ b/packages/@uppy/vue/lib/index.d.ts
@@ -9,4 +9,5 @@ export * from './headless/generated/index.js';
 export * from './useDropzone.js';
 export * from './useFileInput.js';
 export * from './useWebcam.js';
+export * from './useRemoteSource.js';
 //# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/@uppy/vue/lib/index.js b/packages/@uppy/vue/lib/index.js
index a77b225..121c16f 100644
--- a/packages/@uppy/vue/lib/index.js
+++ b/packages/@uppy/vue/lib/index.js
@@ -8,4 +8,5 @@ export { default as ProgressBar } from "./progress-bar.js";
 export { default as StatusBar } from "./status-bar.js";
 export * from "./useDropzone.js";
 export * from "./useFileInput.js";
+export * from "./useRemoteSource.js";
 export * from "./useWebcam.js";

github-actions[bot] avatar Jun 09 '25 13:06 github-actions[bot]

This is a great start! I noticed some bugs/improvements when testing:

Note that some are intended. The goal is to show a bare minimal example, creating a perfect example would massively inflate the lines of code, making it overwhelming. However, some quickfixes which require very little code change should be looked into yes

Murderlon avatar Jun 12 '25 09:06 Murderlon

@mifi from your todo list I implemented the ones that were objectively good and left out the opinionated ones. For instance, cursor pointer is strictly for links and I don't want that here.

Murderlon avatar Jun 12 '25 11:06 Murderlon

i feel like for most of the web, things that are clickable have cursor: pointer even though they are not links (see even here on github)

mifi avatar Jun 19 '25 21:06 mifi

i feel like for most of the web, things that are clickable have cursor: pointer even though they are not links (see even here on github)

Apple’s Human Interface Guidelines states that the hand cursor should be used when “the content is a URL link”.

W3C User Interface guidelines says the same thing again with “The cursor is a pointer that indicates a link”.

From "Buttons shouldn’t have a hand cursor" by Adam Silver


It's just a demo, not a shipped component. So I think it's better to stick to standards and let people decide for themselves if they want to change it. It's definitely not worth blocking this PR over.

Murderlon avatar Jun 23 '25 08:06 Murderlon