From b7559acd7c9142094dd5ab0ca3fe84db48bdf99c Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Fri, 1 Oct 2021 16:31:58 +0200 Subject: [PATCH] Make `@uppy/unsplash` production ready (#3196) Co-authored-by: Alexander Zaytsev Co-authored-by: Antoine du Hamel --- .../src/server/provider/unsplash/adapter.js | 4 + .../src/server/provider/unsplash/index.js | 108 +++++----- .../src/components/FileItem/FileInfo/index.js | 98 +++++---- .../components/FileItem/FileInfo/index.scss | 13 ++ packages/@uppy/provider-views/src/Browser.js | 152 ++++++++++---- .../src/Item/components/GridLi.js | 10 +- .../@uppy/provider-views/src/Item/index.js | 30 ++- packages/@uppy/provider-views/src/ItemList.js | 63 ------ .../src/ProviderView/ProviderView.js | 187 +++++------------- .../SearchProviderView/SearchProviderView.js | 175 +++++----------- packages/@uppy/provider-views/src/View.js | 119 +++++++++++ .../uppy-ProviderBrowser-viewType--grid.scss | 45 ++++- packages/@uppy/unsplash/src/index.js | 1 + website/src/docs/unsplash.md | 117 +++++++++++ website/src/docs/url.md | 2 +- 15 files changed, 664 insertions(+), 460 deletions(-) delete mode 100644 packages/@uppy/provider-views/src/ItemList.js create mode 100644 packages/@uppy/provider-views/src/View.js create mode 100644 website/src/docs/unsplash.md diff --git a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js b/packages/@uppy/companion/src/server/provider/unsplash/adapter.js index 514911cbb2..9735b0c1c0 100644 --- a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js +++ b/packages/@uppy/companion/src/server/provider/unsplash/adapter.js @@ -49,3 +49,7 @@ exports.getNextPageQuery = (currentQuery) => { delete query.q return querystring.stringify(query) } + +exports.getAuthor = (item) => { + return { name: item.user.name, url: item.user.links.html } +} diff --git a/packages/@uppy/companion/src/server/provider/unsplash/index.js b/packages/@uppy/companion/src/server/provider/unsplash/index.js index b0dadd916c..4cf2085df9 100644 --- a/packages/@uppy/companion/src/server/provider/unsplash/index.js +++ b/packages/@uppy/companion/src/server/provider/unsplash/index.js @@ -7,6 +7,33 @@ const { ProviderApiError } = require('../error') const BASE_URL = 'https://api.unsplash.com' +function adaptData (body, currentQuery) { + const pagesCount = body.total_pages + const currentPage = Number(currentQuery.cursor || 1) + const hasNextPage = currentPage < pagesCount + const subList = adapter.getItemSubList(body) || [] + + return { + searchedFor: currentQuery.q, + username: null, + items: subList.map((item) => ({ + isFolder: adapter.isFolder(item), + icon: adapter.getItemIcon(item), + name: adapter.getItemName(item), + mimeType: adapter.getMimeType(item), + id: adapter.getItemId(item), + thumbnail: adapter.getItemThumbnailUrl(item), + requestPath: adapter.getItemRequestPath(item), + modifiedDate: adapter.getItemModifiedDate(item), + author: adapter.getAuthor(item), + size: null, + })), + nextPageQuery: hasNextPage + ? adapter.getNextPageQuery(currentQuery) + : null, + } +} + /** * Adapter for API https://api.unsplash.com */ @@ -31,11 +58,11 @@ class Unsplash extends SearchProvider { request(reqOpts, (err, resp, body) => { if (err || resp.statusCode !== 200) { - err = this._error(err, resp) - logger.error(err, 'provider.unsplash.list.error') - return done(err) + const error = this.error(err, resp) + logger.error(error, 'provider.unsplash.list.error') + return done(error) } - done(null, this.adaptData(body, query)) + return done(null, adaptData(body, query)) }) } @@ -48,28 +75,33 @@ class Unsplash extends SearchProvider { Authorization: `Client-ID ${token}`, }, } - request(reqOpts, (err, resp, body) => { if (err || resp.statusCode !== 200) { - err = this._error(err, resp) - logger.error(err, 'provider.unsplash.download.error') - onData(err) + const error = this.error(err, resp) + logger.error(error, 'provider.unsplash.download.error') + onData(error) return } const url = body.links.download - request.get(url) - .on('response', (resp) => { - if (resp.statusCode !== 200) { - onData(this._error(null, resp)) + + request + .get(url) + .on('response', (response) => { + if (response.statusCode !== 200) { + onData(this.error(null, response)) } else { - resp.on('data', (chunk) => onData(null, chunk)) + response.on('data', (chunk) => onData(null, chunk)) } }) .on('end', () => onData(null, null)) - .on('error', (err) => { - logger.error(err, 'provider.unsplash.download.url.error') - onData(err) + // To attribute the author of the image, we call the `download_location` + // endpoint to increment the download count on Unsplash. + // https://help.unsplash.com/en/articles/2511258-guideline-triggering-a-download + .on('complete', () => request({ ...reqOpts, url: body.links.download_location })) + .on('error', (error) => { + logger.error(error, 'provider.unsplash.download.url.error') + onData(error) }) }) } @@ -86,53 +118,27 @@ class Unsplash extends SearchProvider { request(reqOpts, (err, resp, body) => { if (err || resp.statusCode !== 200) { - err = this._error(err, resp) - logger.error(err, 'provider.unsplash.size.error') - done(err) + const error = this.error(err, resp) + logger.error(error, 'provider.unsplash.size.error') + done(error) return } getURLMeta(body.links.download) .then(({ size }) => done(null, size)) - .catch((err) => { - logger.error(err, 'provider.unsplash.size.error') + .catch((error) => { + logger.error(error, 'provider.unsplash.size.error') done() }) }) } - adaptData (body, currentQuery) { - const data = { - searchedFor: currentQuery.q, - username: null, - items: [], - } - const items = adapter.getItemSubList(body) - items.forEach((item) => { - data.items.push({ - isFolder: adapter.isFolder(item), - icon: adapter.getItemIcon(item), - name: adapter.getItemName(item), - mimeType: adapter.getMimeType(item), - id: adapter.getItemId(item), - thumbnail: adapter.getItemThumbnailUrl(item), - requestPath: adapter.getItemRequestPath(item), - modifiedDate: adapter.getItemModifiedDate(item), - size: null, - }) - }) - - const pagesCount = body.total_pages - const currentPage = parseInt(currentQuery.cursor || 1) - const hasNextPage = currentPage < pagesCount - data.nextPageQuery = hasNextPage ? adapter.getNextPageQuery(currentQuery) : null - return data - } - - _error (err, resp) { + // eslint-disable-next-line class-methods-use-this + error (err, resp) { if (resp) { const fallbackMessage = `request to Unsplash returned ${resp.statusCode}` - const msg = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage + const msg + = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage return new ProviderApiError(msg, resp.statusCode) } diff --git a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js index 4c6a7b8806..d79d799246 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js +++ b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js @@ -1,51 +1,75 @@ -const { h } = require('preact') +const { h, Fragment } = require('preact') const prettierBytes = require('@transloadit/prettier-bytes') const truncateString = require('@uppy/utils/lib/truncateString') const renderFileName = (props) => { - // Take up at most 2 lines on any screen - let maxNameLength - // For very small mobile screens - if (props.containerWidth <= 352) { - maxNameLength = 35 - // For regular mobile screens - } else if (props.containerWidth <= 576) { - maxNameLength = 60 - // For desktops - } else { - maxNameLength = 30 + const { author, name } = props.file.meta + + function getMaxNameLength () { + if (props.containerWidth <= 352) { + return 35 + } + if (props.containerWidth <= 576) { + return 60 + } + // When `author` is present, we want to make sure + // the file name fits on one line so we can place + // the author on the second line. + return author ? 20 : 30 } return ( -
- {truncateString(props.file.meta.name, maxNameLength)} +
+ {truncateString(name, getMaxNameLength())}
) } -const renderFileSize = (props) => ( - props.file.size - && ( -
- {prettierBytes(props.file.size)} +const renderAuthor = (props) => { + const { author } = props.file.meta + const { providerName } = props.file.remote + const dot = `\u00B7` + + if (!author) { + return null + } + + return ( +
+ + {truncateString(author.name, 13)} + + {providerName ? ( + + {` ${dot} `} + {providerName} + + ) : null}
- ) + ) +} + +const renderFileSize = (props) => props.file.size && ( +
+ {prettierBytes(props.file.size)} +
) -const ReSelectButton = (props) => ( - props.file.isGhost - && ( - - {' \u2022 '} - - - ) +const ReSelectButton = (props) => props.file.isGhost && ( + + {' \u2022 '} + + ) const ErrorButton = ({ file, onClick }) => { @@ -68,10 +92,14 @@ const ErrorButton = ({ file, onClick }) => { module.exports = function FileInfo (props) { return ( -
+
{renderFileName(props)}
{renderFileSize(props)} + {renderAuthor(props)} {ReSelectButton(props)} { +const VIRTUAL_SHARED_DIR = 'shared-with-me' + +function Browser (props) { const { currentSelection, folders, files, uppyFiles, - filterItems, + viewType, + headerComponent, + showBreadcrumbs, + isChecked, + toggleCheckbox, + handleScroll, + showTitles, + i18n, + validateRestrictions, + showFilter, + filterQuery, filterInput, + getNextFolder, + cancel, + done, + columns, } = props - let filteredFolders = folders - let filteredFiles = files - - if (filterInput !== '') { - filteredFolders = filterItems(folders) - filteredFiles = filterItems(files) - } - const selected = currentSelection.length return ( -
+
-
- {props.headerComponent} +
+ {headerComponent}
- {props.showFilter && } - - {selected > 0 && } + + {showFilter && ( + + )} + + {(() => { + if (!folders.length && !files.length) { + return ( +
+ {props.i18n('noFilesFound')} +
+ ) + } + + return ( +
+
    not focusable for firefox + tabIndex="-1" + > + {folders.map((folder) => { + return Item({ + columns, + showTitles, + viewType, + i18n, + id: folder.id, + title: folder.name, + getItemIcon: () => folder.icon, + isChecked: isChecked(folder), + toggleCheckbox: (event) => toggleCheckbox(event, folder), + type: 'folder', + isDisabled: isChecked(folder)?.loading, + isCheckboxDisabled: folder.id === VIRTUAL_SHARED_DIR, + handleFolderClick: () => getNextFolder(folder), + }) + })} + + {files.map((file) => { + const validated = validateRestrictions( + remoteFileObjToLocal(file), + [...uppyFiles, ...currentSelection] + ) + + return Item({ + id: file.id, + title: file.name, + author: file.author, + getItemIcon: () => file.icon, + isChecked: isChecked(file), + toggleCheckbox: (event) => toggleCheckbox(event, file), + columns, + showTitles, + viewType, + i18n, + type: 'file', + isDisabled: !validated.result && isChecked(file), + restrictionReason: validated.reason, + }) + })} +
+
+ ) + })()} + + {selected > 0 && ( + + )}
) } diff --git a/packages/@uppy/provider-views/src/Item/components/GridLi.js b/packages/@uppy/provider-views/src/Item/components/GridLi.js index d7d88e3316..62b7e8c87f 100644 --- a/packages/@uppy/provider-views/src/Item/components/GridLi.js +++ b/packages/@uppy/provider-views/src/Item/components/GridLi.js @@ -11,6 +11,7 @@ function GridListItem (props) { showTitles, toggleCheckbox, id, + children, } = props return ( @@ -35,8 +36,13 @@ function GridListItem (props) { aria-label={title} className="uppy-u-reset uppy-ProviderBrowserItem-inner" > - {itemIconEl} - {showTitles && title} + + {itemIconEl} + + {showTitles && title} + + {children} + ) diff --git a/packages/@uppy/provider-views/src/Item/index.js b/packages/@uppy/provider-views/src/Item/index.js index 4d56af5ed7..ab15420a16 100644 --- a/packages/@uppy/provider-views/src/Item/index.js +++ b/packages/@uppy/provider-views/src/Item/index.js @@ -1,10 +1,11 @@ const { h } = require('preact') const classNames = require('classnames') const ItemIcon = require('./components/ItemIcon') -const GridLi = require('./components/GridLi') -const ListLi = require('./components/ListLi') +const GridListItem = require('./components/GridLi') +const ListItem = require('./components/ListLi') module.exports = (props) => { + const { author } = props const itemIconString = props.getItemIcon() const className = classNames( @@ -18,9 +19,30 @@ module.exports = (props) => { switch (props.viewType) { case 'grid': - return + return ( + + ) case 'list': - return + return ( + + ) + case 'unsplash': + return ( + + + {author.name} + + + ) default: throw new Error(`There is no such type ${props.viewType}`) } diff --git a/packages/@uppy/provider-views/src/ItemList.js b/packages/@uppy/provider-views/src/ItemList.js deleted file mode 100644 index a08e9ddca3..0000000000 --- a/packages/@uppy/provider-views/src/ItemList.js +++ /dev/null @@ -1,63 +0,0 @@ -const { h } = require('preact') -const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal') -const Item = require('./Item/index') - -// Hopefully this name will not be used by Google -const VIRTUAL_SHARED_DIR = 'shared-with-me' - -const getSharedProps = (fileOrFolder, props) => ({ - id: fileOrFolder.id, - title: fileOrFolder.name, - getItemIcon: () => fileOrFolder.icon, - isChecked: props.isChecked(fileOrFolder), - toggleCheckbox: (e) => props.toggleCheckbox(e, fileOrFolder), - columns: props.columns, - showTitles: props.showTitles, - viewType: props.viewType, - i18n: props.i18n, -}) - -module.exports = (props) => { - const { folders, files, handleScroll, isChecked } = props - - if (!folders.length && !files.length) { - return
{props.i18n('noFilesFound')}
- } - - return ( -
-
    not focusable for firefox - tabIndex="-1" - > - {folders.map(folder => { - return Item({ - ...getSharedProps(folder, props), - type: 'folder', - isDisabled: isChecked(folder) ? isChecked(folder).loading : false, - isCheckboxDisabled: folder.id === VIRTUAL_SHARED_DIR, - handleFolderClick: () => props.handleFolderClick(folder), - }) - })} - {files.map(file => { - const validateRestrictions = props.validateRestrictions( - remoteFileObjToLocal(file), - [...props.uppyFiles, ...props.currentSelection] - ) - const sharedProps = getSharedProps(file, props) - const restrictionReason = validateRestrictions.reason - - return Item({ - ...sharedProps, - type: 'file', - isDisabled: !validateRestrictions.result && !sharedProps.isChecked, - restrictionReason, - }) - })} -
-
- ) -} diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.js b/packages/@uppy/provider-views/src/ProviderView/ProviderView.js index 4618ae256d..1046feb779 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.js +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.js @@ -1,13 +1,10 @@ const { h } = require('preact') -const generateFileID = require('@uppy/utils/lib/generateFileID') -const getFileType = require('@uppy/utils/lib/getFileType') -const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported') const AuthView = require('./AuthView') const Header = require('./Header') const Browser = require('../Browser') const LoaderView = require('../Loader') -const SharedHandler = require('../SharedHandler') const CloseWrapper = require('../CloseWrapper') +const View = require('../View') function getOrigin () { // eslint-disable-next-line no-restricted-globals @@ -17,22 +14,15 @@ function getOrigin () { /** * Class to easily generate generic views for Provider plugins */ -module.exports = class ProviderView { +module.exports = class ProviderView extends View { static VERSION = require('../../package.json').version - #isHandlingScroll - - #sharedHandler - /** * @param {object} plugin instance of the plugin * @param {object} opts */ constructor (plugin, opts) { - this.plugin = plugin - this.provider = opts.provider - this.#sharedHandler = new SharedHandler(plugin) - + super(plugin, opts) // set default options const defaultOptions = { viewType: 'list', @@ -45,25 +35,18 @@ module.exports = class ProviderView { this.opts = { ...defaultOptions, ...opts } // Logic - this.addFile = this.addFile.bind(this) this.filterQuery = this.filterQuery.bind(this) this.getFolder = this.getFolder.bind(this) this.getNextFolder = this.getNextFolder.bind(this) this.logout = this.logout.bind(this) - this.preFirstRender = this.preFirstRender.bind(this) this.handleAuth = this.handleAuth.bind(this) - this.handleError = this.handleError.bind(this) this.handleScroll = this.handleScroll.bind(this) this.listAllFiles = this.listAllFiles.bind(this) this.donePicking = this.donePicking.bind(this) - this.cancelPicking = this.cancelPicking.bind(this) - this.clearSelection = this.clearSelection.bind(this) // Visual this.render = this.render.bind(this) - this.clearSelection() - // Set default state for the plugin this.plugin.setPluginState({ authenticated: false, @@ -72,6 +55,7 @@ module.exports = class ProviderView { directories: [], filterInput: '', isSearchVisible: false, + currentSelection: [], }) } @@ -92,15 +76,6 @@ module.exports = class ProviderView { this.plugin.setPluginState({ folders, files }) } - /** - * Called only the first time the provider view is rendered. - * Kind of like an init function. - */ - preFirstRender () { - this.plugin.setPluginState({ didFirstRender: true }) - this.plugin.onFirstRender() - } - /** * Based on folder ID, fetch a new folder and update it to state * @@ -108,7 +83,7 @@ module.exports = class ProviderView { * @returns {Promise} Folders/files in folder */ getFolder (id, name) { - return this.#sharedHandler.loaderWrapper( + return this.sharedHandler.loaderWrapper( this.provider.list(id), (res) => { const folders = [] @@ -142,44 +117,6 @@ module.exports = class ProviderView { this.lastCheckbox = undefined } - addFile (file) { - const tagFile = { - id: this.providerFileToId(file), - source: this.plugin.id, - data: file, - name: file.name || file.id, - type: file.mimeType, - isRemote: true, - body: { - fileId: file.id, - }, - remote: { - companionUrl: this.plugin.opts.companionUrl, - url: `${this.provider.fileUrl(file.requestPath)}`, - body: { - fileId: file.id, - }, - providerOptions: this.provider.opts, - }, - } - - const fileType = getFileType(tagFile) - // TODO Should we just always use the thumbnail URL if it exists? - if (fileType && isPreviewSupported(fileType)) { - tagFile.preview = file.thumbnail - } - this.plugin.uppy.log('Adding remote file') - try { - this.plugin.uppy.addFile(tagFile) - return true - } catch (err) { - if (!err.isRestriction) { - this.plugin.uppy.log(err) - } - return false - } - } - /** * Removes session token on client side. */ @@ -281,14 +218,6 @@ module.exports = class ProviderView { }) } - providerFileToId (file) { - return generateFileID({ - data: file, - name: file.name || file.id, - type: file.mimeType, - }) - } - handleAuth () { const authState = btoa(JSON.stringify({ origin: getOrigin() })) const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}` @@ -341,29 +270,22 @@ module.exports = class ProviderView { .some((pattern) => pattern.test(origin) || pattern.test(`${origin}/`)) // allowing for trailing '/' } - handleError (error) { - const { uppy } = this.plugin - uppy.log(error.toString()) - if (error.isAuthError) { - return - } - const message = uppy.i18n('companionError') - uppy.info({ message, details: error.toString() }, 'error', 5000) - } - - handleScroll (e) { - const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight) + async handleScroll (event) { const path = this.nextPagePath || null - if (scrollPos < 50 && path && !this.#isHandlingScroll) { - this.provider.list(path) - .then((res) => { - const { files, folders } = this.plugin.getPluginState() - this.#updateFilesAndFolders(res, files, folders) - }).catch(this.handleError) - .then(() => { this.#isHandlingScroll = false }) // always called + if (this.shouldHandleScroll(event) && path) { + this.isHandlingScroll = true - this.#isHandlingScroll = true + try { + const response = await this.provider.list(path) + const { files, folders } = this.plugin.getPluginState() + + this.#updateFilesAndFolders(response, files, folders) + } catch (error) { + this.handleError(error) + } finally { + this.isHandlingScroll = false + } } } @@ -398,53 +320,22 @@ module.exports = class ProviderView { return this.addFile(file) }) - this.#sharedHandler.loaderWrapper(Promise.all(promises), () => { + this.sharedHandler.loaderWrapper(Promise.all(promises), () => { this.clearSelection() }, () => {}) } - cancelPicking () { - this.clearSelection() - - const dashboard = this.plugin.uppy.getPlugin('Dashboard') - if (dashboard) dashboard.hideAllPanels() - } - - clearSelection () { - this.plugin.setPluginState({ currentSelection: [] }) - } - render (state, viewOptions = {}) { const { authenticated, didFirstRender } = this.plugin.getPluginState() + if (!didFirstRender) { this.preFirstRender() } - // reload pluginState for "loading" attribute because it might - // have changed above. - if (this.plugin.getPluginState().loading) { - return ( - - - - ) - } - - if (!authenticated) { - return ( - - - - ) - } - const targetViewOptions = { ...this.opts, ...viewOptions } + const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState() + const { isChecked, toggleCheckbox, filterItems } = this.sharedHandler + const hasInput = filterInput !== '' const headerProps = { showBreadcrumbs: targetViewOptions.showBreadcrumbs, getFolder: this.getFolder, @@ -457,15 +348,17 @@ module.exports = class ProviderView { } const browserProps = { - ...this.plugin.getPluginState(), + isChecked, + toggleCheckbox, + currentSelection, + files: hasInput ? filterItems(files) : files, + folders: hasInput ? filterItems(folders) : folders, username: this.username, getNextFolder: this.getNextFolder, getFolder: this.getFolder, - filterItems: this.#sharedHandler.filterItems, + filterItems: this.sharedHandler.filterItems, filterQuery: this.filterQuery, logout: this.logout, - isChecked: this.#sharedHandler.isChecked, - toggleCheckbox: this.#sharedHandler.toggleCheckbox, handleScroll: this.handleScroll, listAllFiles: this.listAllFiles, done: this.donePicking, @@ -482,6 +375,28 @@ module.exports = class ProviderView { validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args), } + if (loading) { + return ( + + + + ) + } + + if (!authenticated) { + return ( + + + + ) + } + return ( diff --git a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js index 2ea93ce42d..76a4bcf70b 100644 --- a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js +++ b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js @@ -1,34 +1,25 @@ const { h } = require('preact') -const generateFileID = require('@uppy/utils/lib/generateFileID') -const getFileType = require('@uppy/utils/lib/getFileType') -const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported') const SearchInput = require('./InputView') const Browser = require('../Browser') const LoaderView = require('../Loader') const Header = require('./Header') -const SharedHandler = require('../SharedHandler') const CloseWrapper = require('../CloseWrapper') +const View = require('../View') /** * Class to easily generate generic views for Provider plugins */ -module.exports = class ProviderView { +module.exports = class SearchProviderView extends View { static VERSION = require('../../package.json').version - #isHandlingScroll - #searchTerm - #sharedHandler - /** * @param {object} plugin instance of the plugin * @param {object} opts */ constructor (plugin, opts) { - this.plugin = plugin - this.provider = opts.provider - this.#sharedHandler = new SharedHandler(plugin) + super(plugin, opts) // set default options const defaultOptions = { @@ -45,18 +36,12 @@ module.exports = class ProviderView { this.search = this.search.bind(this) this.triggerSearchInput = this.triggerSearchInput.bind(this) this.addFile = this.addFile.bind(this) - this.preFirstRender = this.preFirstRender.bind(this) - this.handleError = this.handleError.bind(this) this.handleScroll = this.handleScroll.bind(this) this.donePicking = this.donePicking.bind(this) - this.cancelPicking = this.cancelPicking.bind(this) - this.clearSelection = this.clearSelection.bind(this) // Visual this.render = this.render.bind(this) - this.clearSelection() - // Set default state for the plugin this.plugin.setPluginState({ isInputMode: true, @@ -65,6 +50,7 @@ module.exports = class ProviderView { directories: [], filterInput: '', isSearchVisible: false, + currentSelection: [], }) } @@ -79,15 +65,6 @@ module.exports = class ProviderView { this.plugin.setPluginState({ isInputMode: false, files }) } - /** - * Called only the first time the provider view is rendered. - * Kind of like an init function. - */ - preFirstRender () { - this.plugin.setPluginState({ didFirstRender: true }) - this.plugin.onFirstRender() - } - search (query) { if (query && query === this.#searchTerm) { // no need to search again as this is the same as the previous search @@ -95,7 +72,7 @@ module.exports = class ProviderView { return } - return this.#sharedHandler.loaderWrapper( + return this.sharedHandler.loaderWrapper( this.provider.search(query), (res) => { this.#updateFilesAndInputMode(res, []) @@ -108,72 +85,22 @@ module.exports = class ProviderView { this.plugin.setPluginState({ isInputMode: true }) } - // @todo this function should really be a function of the plugin and not the view. - // maybe we should consider creating a base ProviderPlugin class that has this method - addFile (file) { - const tagFile = { - id: this.providerFileToId(file), - source: this.plugin.id, - data: file, - name: file.name || file.id, - type: file.mimeType, - isRemote: true, - body: { - fileId: file.id, - }, - remote: { - companionUrl: this.plugin.opts.companionUrl, - url: `${this.provider.fileUrl(file.requestPath)}`, - body: { - fileId: file.id, - }, - providerOptions: { ...this.provider.opts, provider: null }, - }, - } - - const fileType = getFileType(tagFile) - // TODO Should we just always use the thumbnail URL if it exists? - if (fileType && isPreviewSupported(fileType)) { - tagFile.preview = file.thumbnail - } - this.plugin.uppy.log('Adding remote file') - try { - this.plugin.uppy.addFile(tagFile) - } catch (err) { - if (!err.isRestriction) { - this.plugin.uppy.log(err) - } - } - } - - providerFileToId (file) { - return generateFileID({ - data: file, - name: file.name || file.id, - type: file.mimeType, - }) - } - - handleError (error) { - const { uppy } = this.plugin - uppy.log(error.toString()) - const message = uppy.i18n('companionError') - uppy.info({ message, details: error.toString() }, 'error', 5000) - } - - handleScroll (e) { - const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight) + async handleScroll (event) { const query = this.nextPageQuery || null - if (scrollPos < 50 && query && !this.#isHandlingScroll) { - this.provider.search(this.#searchTerm, query) - .then((res) => { - const { files } = this.plugin.getPluginState() - this.#updateFilesAndInputMode(res, files) - }).catch(this.handleError) - .then(() => { this.#isHandlingScroll = false }) // always called + if (this.shouldHandleScroll(event) && query) { + this.isHandlingScroll = true - this.#isHandlingScroll = true + try { + const response = await this.provider.search(this.#searchTerm, query) + const { files } = this.plugin.getPluginState() + + this.#updateFilesAndInputMode(response, files) + } catch (error) { + this.handleError(error) + } finally { + this.isHandlingScroll = false + } } } @@ -181,54 +108,29 @@ module.exports = class ProviderView { const { currentSelection } = this.plugin.getPluginState() const promises = currentSelection.map((file) => this.addFile(file)) - this.#sharedHandler.loaderWrapper(Promise.all(promises), () => { + this.sharedHandler.loaderWrapper(Promise.all(promises), () => { this.clearSelection() }, () => {}) } - cancelPicking () { - this.clearSelection() - - const dashboard = this.plugin.uppy.getPlugin('Dashboard') - if (dashboard) dashboard.hideAllPanels() - } - - clearSelection () { - this.plugin.setPluginState({ currentSelection: [] }) - } - render (state, viewOptions = {}) { const { didFirstRender, isInputMode } = this.plugin.getPluginState() + if (!didFirstRender) { this.preFirstRender() } - // reload pluginState for "loading" attribute because it might - // have changed above. - if (this.plugin.getPluginState().loading) { - return ( - - - - ) - } - - if (isInputMode) { - return ( - - - - ) - } - const targetViewOptions = { ...this.opts, ...viewOptions } + const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState() + const { isChecked, toggleCheckbox, filterItems } = this.sharedHandler + const hasInput = filterInput !== '' + const browserProps = { - ...this.plugin.getPluginState(), - isChecked: this.#sharedHandler.isChecked, - toggleCheckbox: this.#sharedHandler.toggleCheckbox, + isChecked, + toggleCheckbox, + currentSelection, + files: hasInput ? filterItems(files) : files, + folders: hasInput ? filterItems(folders) : folders, handleScroll: this.handleScroll, done: this.donePicking, cancel: this.cancelPicking, @@ -247,6 +149,25 @@ module.exports = class ProviderView { validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args), } + if (loading) { + return ( + + + + ) + } + + if (isInputMode) { + return ( + + + + ) + } + return ( diff --git a/packages/@uppy/provider-views/src/View.js b/packages/@uppy/provider-views/src/View.js new file mode 100644 index 0000000000..53de453690 --- /dev/null +++ b/packages/@uppy/provider-views/src/View.js @@ -0,0 +1,119 @@ +const getFileType = require('@uppy/utils/lib/getFileType') +const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported') +const generateFileID = require('@uppy/utils/lib/generateFileID') + +// TODO: now that we have a shared `View` class, +// `SharedHandler` could be cleaned up and moved into here +const SharedHandler = require('./SharedHandler') + +module.exports = class View { + constructor (plugin, opts) { + this.plugin = plugin + this.provider = opts.provider + this.sharedHandler = new SharedHandler(plugin) + + this.isHandlingScroll = false + + this.preFirstRender = this.preFirstRender.bind(this) + this.handleError = this.handleError.bind(this) + this.addFile = this.addFile.bind(this) + this.clearSelection = this.clearSelection.bind(this) + this.cancelPicking = this.cancelPicking.bind(this) + } + + // eslint-disable-next-line class-methods-use-this + providerFileToId (file) { + return generateFileID({ + data: file, + name: file.name || file.id, + type: file.mimetype, + }) + } + + preFirstRender () { + this.plugin.setPluginState({ didFirstRender: true }) + this.plugin.onFirstRender() + } + + // eslint-disable-next-line class-methods-use-this + shouldHandleScroll (event) { + const { scrollHeight, scrollTop, offsetHeight } = event.target + const scrollPosition = scrollHeight - (scrollTop + offsetHeight) + + return scrollPosition < 50 && !this.isHandlingScroll + } + + clearSelection () { + this.plugin.setPluginState({ currentSelection: [] }) + } + + cancelPicking () { + this.clearSelection() + + const dashboard = this.plugin.uppy.getPlugin('Dashboard') + + if (dashboard) { + dashboard.hideAllPanels() + } + } + + handleError (error) { + const { uppy } = this.plugin + const message = uppy.i18n('companionError') + + uppy.log(error.toString()) + + if (error.isAuthError) { + return + } + + uppy.info({ message, details: error.toString() }, 'error', 5000) + } + + addFile (file) { + const tagFile = { + id: this.providerFileToId(file), + source: this.plugin.id, + data: file, + name: file.name || file.id, + type: file.mimeType, + isRemote: true, + meta: {}, + body: { + fileId: file.id, + }, + remote: { + companionUrl: this.plugin.opts.companionUrl, + url: `${this.provider.fileUrl(file.requestPath)}`, + body: { + fileId: file.id, + }, + providerOptions: this.provider.opts, + providerName: this.provider.name, + }, + } + + const fileType = getFileType(tagFile) + + // TODO Should we just always use the thumbnail URL if it exists? + if (fileType && isPreviewSupported(fileType)) { + tagFile.preview = file.thumbnail + } + + if (file.author) { + tagFile.meta.author = file.author + } + + this.plugin.uppy.log('Adding remote file') + + try { + this.plugin.uppy.addFile(tagFile) + return true + } catch (err) { + if (!err.isRestriction) { + this.plugin.uppy.log(err) + } + return false + } + } +} diff --git a/packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss b/packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss index dce4db080e..e7ae5170fb 100644 --- a/packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss +++ b/packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss @@ -1,7 +1,8 @@ // *** -// View type: grid +// View type: grid and unsplash // *** -.uppy-ProviderBrowser-viewType--grid { +.uppy-ProviderBrowser-viewType--grid, +.uppy-ProviderBrowser-viewType--unsplash { ul.uppy-ProviderBrowser-list { display: flex; flex-direction: row; @@ -80,9 +81,36 @@ text-align: center; border-radius: 4px; - &:focus { - outline: none; - box-shadow: 0 0 0 3px rgba($blue, 0.9); + .uppy.uppy-ProviderBrowserItem-inner-relative { + position: relative; + } + + .uppy-ProviderBrowserItem-author { + position: absolute; + display: none; + bottom: 0; + left: 0; + width: 100%; + background: rgba(black, 0.3); + color: white; + font-weight: 500; + font-size: 12px; + margin: 0; + padding: 5px; + text-decoration: none; + + &:hover { + background: rgba(black, 0.4); + text-decoration: underline; + } + } + + // Always show the author on touch devices + // https://www.w3.org/TR/mediaqueries-4/#hover + @media (hover: none) { + .uppy-ProviderBrowserItem-author { + display: block; + } } [data-uppy-theme="dark"] & { @@ -122,6 +150,13 @@ opacity: 1; } + .uppy-ProviderBrowserItem-checkbox--grid:hover, + .uppy-ProviderBrowserItem-checkbox--grid:focus { + + label .uppy-ProviderBrowserItem-author { + display: block; + } + } + .uppy-ProviderBrowserItem-checkbox--grid:focus + label { @include clear-focus(); diff --git a/packages/@uppy/unsplash/src/index.js b/packages/@uppy/unsplash/src/index.js index f4e1887732..3a4eb196d3 100644 --- a/packages/@uppy/unsplash/src/index.js +++ b/packages/@uppy/unsplash/src/index.js @@ -43,6 +43,7 @@ module.exports = class Unsplash extends UIPlugin { install () { this.view = new SearchProviderViews(this, { provider: this.provider, + viewType: 'unsplash', }) const { target } = this.opts diff --git a/website/src/docs/unsplash.md b/website/src/docs/unsplash.md new file mode 100644 index 0000000000..018e7d48e3 --- /dev/null +++ b/website/src/docs/unsplash.md @@ -0,0 +1,117 @@ +--- +type: docs +order: 14 +title: "Unsplash" +menu_prefix: "" +module: "@uppy/unsplash" +permalink: docs/unsplash/ +category: "Sources" +tagline: "import images from Unsplash" +--- + +The `@uppy/unsplash` plugin lets users search and select photos from Unsplash. + +A Companion instance is required for the Unsplash plugin to work. Companion handles authentication with Unsplash, downloads the files, and uploads them to the destination. This saves the user bandwidth, especially helpful if they are on a mobile connection. + +```js +import Uppy from '@uppy/core' +import Unsplash from '@uppy/unsplash' + +const uppy = new Uppy() + +uppy.use(Unsplash, { + // Options +}) +``` + +Try it live + +## Installation + +This plugin is published as the `@uppy/unsplash` package. + +Install from NPM: + +```shell +npm install @uppy/unsplash +``` + +In the [CDN package](/docs/#With-a-script-tag), it is available on the `Uppy` global object: + +```js +const { Unsplash } = Uppy +``` + +## Setting Up + +To use the Unsplash provider, you need to configure the Unsplash keys that Companion should use. With the standalone Companion server, specify environment variables: +```shell +export COMPANION_UNSPLASH_KEY="Unsplash API key" +export COMPANION_UNSPLASH_SECRET="Unsplash API secret" +``` + +When using the Companion Node.js API, configure these options: + +```js +companion.app({ + providerOptions: { + unsplash: { + key: 'Unsplash API key', + secret: 'Unsplash API secret', + }, + }, +}) +``` + +You can create a Unsplash App on the [Unsplash Developers site](https://unsplash.com/developers). + +You'll be redirected to the app page. This page lists the app key and app secret, which you should use to configure Companion as shown above. + +## CSS + +Dashboard plugin is recommended as a container to all Provider plugins, including Unsplash. If you are using Dashboard, it [comes with all the nessesary styles](/docs/dashboard/#CSS) for Unsplash as well. + +⚠️ If you are feeling adventurous, and want to use Unsplash plugin separately, without Dashboard, make sure to include `@uppy/provider-views/dist/style.css` (or `style.min.css`) CSS file. This is experimental, not officially supported and not recommended. + +## Options + +The `@uppy/dropbox` plugin has the following configurable options: + +```js +uppy.use(Unsplash, { + target: Dashboard, + companionUrl: 'https://companion.uppy.io/', +}) +``` + +### `id: 'Unsplash'` + +A unique identifier for this plugin. It defaults to `'Unsplash'`. + +### `title: 'Unsplash'` + +Title / name shown in the UI, such as Dashboard tabs. It defaults to `'Unsplash'`. + +### `target: null` + +DOM element, CSS selector, or plugin to mount the Unsplash provider into. This should normally be the [`@uppy/dashboard`](/docs/dashboard) plugin. + +### `companionUrl: null` + +URL to a [Companion](/docs/companion) instance. + +### `companionHeaders: {}` + +Custom headers that should be sent along to [Companion](/docs/companion) on every request. + +### `companionAllowedHosts: companionUrl` + +The valid and authorised URL(s) from which OAuth responses should be accepted. + +This value can be a `String`, a `Regex` pattern, or an `Array` of both. + +This is useful when you have your [Companion](/docs/companion) running on multiple hosts. Otherwise, the default value should do just fine. + +### `companionCookiesRule: 'same-origin'` + +This option correlates to the [RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials), which tells the plugin whether or not to send cookies to [Companion](/docs/companion). diff --git a/website/src/docs/url.md b/website/src/docs/url.md index 4c1cad2ba2..a130887f5b 100644 --- a/website/src/docs/url.md +++ b/website/src/docs/url.md @@ -1,6 +1,6 @@ --- type: docs -order: 14 +order: 15 title: "Import From URL" menu_prefix: "" module: "@uppy/url"