diff --git a/scripts/server.cjs b/scripts/server.cjs index 561d311e..84e9e6d5 100644 --- a/scripts/server.cjs +++ b/scripts/server.cjs @@ -21,7 +21,7 @@ const handler = (req, resp) => { resp.end(buf); }); }; - if(req.url.startsWith('/web') || req.url.startsWith('/search') || req.url.startsWith('/export')) { + if (req.url.startsWith('/web') || req.url.startsWith('/search') || req.url.startsWith('/export') || req.url.startsWith('/import')) { proxy.web(req, resp, { changeOrigin: true, target: `${translateURL}`, diff --git a/src/js/actions/identifier.js b/src/js/actions/identifier.js index 3ef1d6d2..1e0f700c 100644 --- a/src/js/actions/identifier.js +++ b/src/js/actions/identifier.js @@ -22,30 +22,42 @@ const getNextLinkFromResponse = response => { return next; } +const importFromFile = fileData => { + return searchIdentifier(fileData.file, { shouldImport: true }); +} -const searchIdentifier = identifier => { +const searchIdentifier = (identifier, { shouldImport = false } = {}) => { return async (dispatch, getState) => { - identifier = identifier.trim(); const { config } = getState(); const { translateUrl } = config; - const matchDOI = decodeURIComponent(identifier) - .match(/^https?:\/\/doi.org\/(10(?:\.[0-9]{4,})?\/[^\s]*[^\s.,])$/); - if(matchDOI) { - identifier = matchDOI[1]; - } - const identifierIsUrl = isLikeURL(identifier); - if(!identifierIsUrl) { - const identifierObjects = extractIdentifiers(identifier); - if(identifierObjects.length === 0) { - // invalid identifier, if we don't return, it will run search for a generic term like zbib - dispatch(reportIdentifierNoResults()); - return; - } else { - return dispatch(currentAddMultipleTranslatedItems(identifierObjects.map(io => Object.values(io)[0]))); + + let identifierIsUrl = false; + let url; + + if(!shouldImport) { + identifier = identifier.trim(); + const matchDOI = decodeURIComponent(identifier) + .match(/^https?:\/\/doi.org\/(10(?:\.[0-9]{4,})?\/[^\s]*[^\s.,])$/); + if(matchDOI) { + identifier = matchDOI[1]; } + const identifierIsUrl = isLikeURL(identifier); + if(!identifierIsUrl) { + const identifierObjects = extractIdentifiers(identifier); + if(identifierObjects.length === 0) { + // invalid identifier, if we don't return, it will run search for a generic term like zbib + dispatch(reportIdentifierNoResults()); + return; + } else { + return dispatch(currentAddMultipleTranslatedItems(identifierObjects.map(io => Object.values(io)[0]))); + } + } + url = `${translateUrl}/${((identifierIsUrl ? 'web' : 'search'))}`; + } else { + url = `${translateUrl}/import`; } - const url = `${translateUrl}/${((identifierIsUrl ? 'web' : 'search'))}`; - dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl }); + + dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, import: shouldImport }); try { const response = await fetch(url, { @@ -58,6 +70,9 @@ const searchIdentifier = identifier => { if (response.status === 501 || response.status === 400) { const message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.'; dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message }); + } else if (response.status === 413) { + const message = 'Selected file is too large.'; + dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message }); } else if (response.status === 300) { const data = await response.json(); const items = 'items' in data && 'session' in data ? data.items : data; @@ -71,20 +86,21 @@ const searchIdentifier = identifier => { identifierIsUrl, identifier, items, + import: shouldImport, response }); return items; } else if(response.status !== 200) { const message = 'Unexpected response from the server.'; - dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message }); + dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport }); } else if (!response.headers.get('content-type').startsWith('application/json')) { const message = 'Unexpected response from the server.'; - dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message }); + dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport }); } else { const json = await response.json(); if (!json.length) { const message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.'; - dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message }); + dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport }); } else { const rootItems = json.filter(item => !item.parentItem); @@ -95,6 +111,7 @@ const searchIdentifier = identifier => { items: rootItems.map(ri => omit(ri, ['key', 'version'])), identifierIsUrl, identifier, + import: shouldImport, response }); return rootItems; @@ -109,6 +126,7 @@ const searchIdentifier = identifier => { item, identifier, identifierIsUrl, + import: shouldImport, response }); return item; @@ -116,7 +134,7 @@ const searchIdentifier = identifier => { } } } catch(error) { - dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier }); + dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier, import: shouldImport }); } } } @@ -214,9 +232,7 @@ const currentAddTranslatedItem = translatedItem => { const searchIdentifierMore = () => { return async (dispatch, getState) => { const state = getState(); - const { config } = state - const { translateUrl } = config; - const { identifier, identifierIsUrl, next, result } = state.identifier; + const { identifier, next, result } = state.identifier; if(!identifier || result !== CHOICE) { return; } @@ -256,10 +272,10 @@ const searchIdentifierMore = () => { } } -const reportIdentifierNoResults = () => ({ +const reportIdentifierNoResults = (message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.') => ({ type: ERROR_IDENTIFIER_NO_RESULT, - error: 'Zotero could not find any identifiers in your input. Please verify your input and try again.', + error: message, errorType: 'info', }); -export { currentAddTranslatedItem, currentAddMultipleTranslatedItems, resetIdentifier, searchIdentifier, searchIdentifierMore, reportIdentifierNoResults }; +export { currentAddTranslatedItem, currentAddMultipleTranslatedItems, importFromFile, resetIdentifier, searchIdentifier, searchIdentifierMore, reportIdentifierNoResults }; diff --git a/src/js/component/item/actions.jsx b/src/js/component/item/actions.jsx index f9857193..0fc5171f 100644 --- a/src/js/component/item/actions.jsx +++ b/src/js/component/item/actions.jsx @@ -4,6 +4,7 @@ import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Icon } fr import { ToolGroup } from '../ui/toolbars'; import NewItemSelector from 'component/item/actions/new-item'; +import ImportAction from 'component/item/actions/import'; import ExportActions from 'component/item/actions/export'; import columnProperties from '../../constants/column-properties'; import AddByIdentifier from 'component/item/actions/add-by-identifier'; @@ -101,6 +102,7 @@ const ItemActionsTouch = memo(() => { Add By Identifier + )} { !isEmbedded && ( @@ -180,6 +182,13 @@ const ItemActionsDesktop = memo(props => { )} + {(itemsSource === 'collection' || itemsSource === 'top') && ( + + )} { !isTrash && ( diff --git a/src/js/component/item/actions/add-by-identifier.jsx b/src/js/component/item/actions/add-by-identifier.jsx index ad0ea6bb..42353207 100644 --- a/src/js/component/item/actions/add-by-identifier.jsx +++ b/src/js/component/item/actions/add-by-identifier.jsx @@ -21,6 +21,7 @@ const AddByIdentifier = props => { const result = useSelector(state => state.identifier.result); const item = useSelector(state => state.identifier.item); const items = useSelector(state => state.identifier.items); + const message = useSelector(state => state.identifier.message); const prevItem = usePrevious(item); const prevItems = usePrevious(items); const wasSearching = usePrevious(isSearching); @@ -96,6 +97,20 @@ const AddByIdentifier = props => { } }, [toggleOpen]); + const handlePaste = useCallback(ev => { + const clipboardData = ev.clipboardData || window.clipboardData; + const pastedData = clipboardData.getData('Text'); + const isMultiLineData = pastedData.split('\n').filter(line => line.trim().length > 0).length > 1; + + if (!isMultiLineData) { + return; + } + + ev.preventDefault(); + setIdentifier(pastedData); + dispatch(searchIdentifier(pastedData, { shouldImport: true })); + }, [dispatch]); + useEffect(() => { if(isOpen && item && prevItem === null) { addItem({ ...item }); @@ -103,7 +118,7 @@ const AddByIdentifier = props => { }, [addItem, isOpen, item, prevItem]); useEffect(() => { - if(isOpen && items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) { + if (isOpen && items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) { setIsOpen(!isOpen); dispatch(toggleModal(IDENTIFIER_PICKER, true)); } @@ -113,13 +128,13 @@ const AddByIdentifier = props => { if(!isSearching && wasSearching) { setIdentifier(''); if(result === EMPTY) { - dispatch(reportIdentifierNoResults()); + dispatch(reportIdentifierNoResults(message)); } else { ref.current.focus(); setIsOpen(false); } } - }, [dispatch, isSearching, wasSearching, result]); + }, [dispatch, isSearching, wasSearching, result, message]); useLayoutEffect(() => { if (isOpen && !wasOpen) { @@ -193,6 +208,7 @@ const AddByIdentifier = props => { onChange={ handleInputChange } onCommit={ handleInputCommit } onKeyDown={ handleInputKeyDown } + onPaste={ handlePaste } ref={ inputEl } tabIndex={ 0 } value={ identifier } diff --git a/src/js/component/item/actions/import.jsx b/src/js/component/item/actions/import.jsx new file mode 100644 index 00000000..1a5ea89c --- /dev/null +++ b/src/js/component/item/actions/import.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import { memo, useCallback, useId, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Button, DropdownItem, Icon } from 'web-common/components'; + +import { importFromFile, toggleModal } from '../../../actions'; +import { getFileData } from '../../../common/event'; +import { IDENTIFIER_PICKER } from '../../../constants/modals'; + + +const ImportAction = ({ disabled, onFocusNext, onFocusPrev, tabIndex }) => { + const dispatch = useDispatch(); + const isTouchOrSmall = useSelector(state => state.device.isTouchOrSmall); + const uploadFileId = useId(); + const fileInputRef = useRef(null); + + const handleKeyDown = useCallback(ev => { + if (ev.target !== ev.currentTarget) { + return; + } + + if (ev.key === 'ArrowRight') { + onFocusNext(ev); + } else if (ev.key === 'ArrowLeft') { + onFocusPrev(ev); + } + }, [onFocusNext, onFocusPrev]); + + const handleImportClick = useCallback(ev => { + if (ev.currentTarget === ev.target) { + fileInputRef.current.click(); + } + ev.stopPropagation(); + }, []); + + const handleFileInputChange = useCallback(async ev => { + const target = ev.currentTarget; // persist, or it will be nullified after await + const fileData = await getFileData(target.files[0]); + target.value = ''; // clear the invisible input so that onChange is triggered even if the same file is selected again + if (fileData) { + dispatch(importFromFile(fileData)); + dispatch(toggleModal(IDENTIFIER_PICKER, true)); + } + }, [dispatch]); + + return isTouchOrSmall ? ( + + + Import From a File + + + + ) : ( +
+ + + +
+ ); +} + +ImportAction.propTypes = { + disabled: PropTypes.bool, + onFocusNext: PropTypes.func, + onFocusPrev: PropTypes.func, + tabIndex: PropTypes.number +}; + +export default memo(ImportAction); diff --git a/src/js/component/item/actions/new-item.jsx b/src/js/component/item/actions/new-item.jsx index 1132b4b1..4511002a 100644 --- a/src/js/component/item/actions/new-item.jsx +++ b/src/js/component/item/actions/new-item.jsx @@ -154,6 +154,7 @@ const NewItemSelector = props => { )} + { isSecondaryVisible ? secondaryItemTypesDesc.map(itemTypeSpec => ( { /> )) : ( - More diff --git a/src/js/component/modal/add-by-identifier.jsx b/src/js/component/modal/add-by-identifier.jsx index fce12eab..1201c63a 100644 --- a/src/js/component/modal/add-by-identifier.jsx +++ b/src/js/component/modal/add-by-identifier.jsx @@ -16,6 +16,7 @@ const AddByIdentifierModal = () => { const result = useSelector(state => state.identifier.result); const item = useSelector(state => state.identifier.item); const items = useSelector(state => state.identifier.items); + const message = useSelector(state => state.identifier.message); const prevItem = usePrevious(item); const prevItems = usePrevious(items); const wasSearching = usePrevious(isSearching); @@ -71,6 +72,20 @@ const AddByIdentifierModal = () => { dispatch(searchIdentifier(identifier)); }, [dispatch, identifier]); + const handlePaste = useCallback(ev => { + const clipboardData = ev.clipboardData || window.clipboardData; + const pastedData = clipboardData.getData('Text'); + const isMultiLineData = pastedData.split('\n').filter(line => line.trim().length > 0).length > 1; + + if (!isMultiLineData) { + return; + } + + ev.preventDefault(); + setIdentifier(pastedData); + dispatch(searchIdentifier(pastedData, { shouldImport: true })); + }, [dispatch]); + useEffect(() => { if(isOpen && item && prevItem === null) { addItem({ ...item }); @@ -81,7 +96,7 @@ const AddByIdentifierModal = () => { if(isOpen && wasSearching && !isSearching) { setIdentifier(''); if(result === EMPTY) { - dispatch(reportIdentifierNoResults()); + dispatch(reportIdentifierNoResults(message)); } else { dispatch(toggleModal(ADD_BY_IDENTIFIER, false)); if(items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) { @@ -90,7 +105,7 @@ const AddByIdentifierModal = () => { } } - }, [dispatch, isOpen, isSearching, items, prevItems, result, wasSearching]); + }, [dispatch, isOpen, isSearching, items, message, prevItems, result, wasSearching]); return ( { onBlur={ handleInputBlur } onChange={ handleInputChange } onCommit={ handleInputCommit } + onPaste={ handlePaste } placeholder="URL, ISBNs, DOIs, PMIDs, or arXiv IDs" ref={ inputEl } tabIndex={ 0 } diff --git a/src/js/component/modal/identifier-picker.jsx b/src/js/component/modal/identifier-picker.jsx index d98e1fa1..76af835e 100644 --- a/src/js/component/modal/identifier-picker.jsx +++ b/src/js/component/modal/identifier-picker.jsx @@ -2,16 +2,17 @@ import { useCallback, useEffect, memo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import { Button, Spinner } from 'web-common/components'; +import { Button, Icon, Spinner } from 'web-common/components'; import { usePrevious } from 'web-common/hooks'; import Modal from '../ui/modal'; import { IDENTIFIER_PICKER } from '../../constants/modals'; -import { currentAddMultipleTranslatedItems, searchIdentifierMore, toggleModal } from '../../actions'; +import { currentAddMultipleTranslatedItems, searchIdentifierMore, reportIdentifierNoResults, toggleModal } from '../../actions'; import { useBufferGate } from '../../hooks'; import { getUniqueId, processIdentifierMultipleItems } from '../../utils'; import { getBaseMappedValue } from '../../common/item'; -import { CHOICE } from '../../constants/identifier-result-types'; +import { CHOICE, EMPTY, MULTIPLE } from '../../constants/identifier-result-types'; +import { pluralize } from '../../common/format'; const Item = memo(({ onChange, identifierIsUrl, isPicked, item, mappings }) => { const { description } = item; @@ -92,16 +93,20 @@ const IdentifierPicker = () => { const isOpen = useSelector(state => state.modal.id === IDENTIFIER_PICKER); const itemTypes = useSelector(state => state.meta.itemTypes); const items = useSelector(state => state.identifier.items); + const isImport = useSelector(state => state.identifier.import); const isSearchingMultiple = useSelector(state => state.identifier.isSearchingMultiple); const identifierIsUrl = useSelector(state => state.identifier.identifierIsUrl); const identifierResult = useSelector(state => state.identifier.result); + const identifierMessage = useSelector(state => state.identifier.message); const mappings = useSelector(state => state.meta.mappings); const isSearching = useSelector(state => state.identifier.isSearching); const isTouchOrSmall = useSelector(state => state.device.isTouchOrSmall); const wasSearchingMultiple = usePrevious(isSearchingMultiple); const processedItems = items && processIdentifierMultipleItems(items, itemTypes, false); //@TODO: isUrl source should be stored in redux const [selectedKeys, setSelectedKeys] = useState([]); - const isBusy = useBufferGate(!wasSearchingMultiple && isSearchingMultiple, 200); + const isBusy = useBufferGate((isImport && isSearching) || (!wasSearchingMultiple && isSearchingMultiple), 200); + const isReady = isOpen && !isBusy; + const wasReady = usePrevious(isReady); const handleCancel = useCallback(() => { dispatch(toggleModal(IDENTIFIER_PICKER, false)); @@ -122,50 +127,93 @@ const IdentifierPicker = () => { dispatch(searchIdentifierMore()); }, [dispatch]); + const handleSelectAll = useCallback(() => { + if (Array.isArray(processedItems)) { + setSelectedKeys(processedItems.map(i => i.key)); + } + }, [processedItems]); + + const handleClearSelection = useCallback(() => { + setSelectedKeys([]); + }, []); + useEffect(() => { if(wasSearchingMultiple && !isSearchingMultiple) { dispatch(toggleModal(IDENTIFIER_PICKER, false)); } }, [dispatch, isSearchingMultiple, wasSearchingMultiple]); - const className = cx({ - 'identifier-picker-modal modal-scrollable modal-lg': true, - 'modal-touch': isTouchOrSmall - }); + useEffect(() => { + if(!wasReady && isReady) { + if (identifierResult === EMPTY) { + dispatch(toggleModal(IDENTIFIER_PICKER, false)); + if (identifierMessage) { + dispatch(reportIdentifierNoResults(identifierMessage)); + } + } else if(isImport || identifierResult === MULTIPLE) { + // Select all if either importing or add by identifier result is multiple (but not choice) + handleSelectAll(); + } + } + }, [dispatch, handleSelectAll, identifierMessage, identifierResult, isImport, isReady, wasReady]); return (
-
- -
-
-

- Select Items -

-
-
- -
+ { + isTouchOrSmall ? ( + <> +
+ +
+
+

+ Select Items +

+
+
+ +
+ + ) : ( + <> +

+ Select Items +

+ + + ) + }
-
+
    { Array.isArray(processedItems) && processedItems .map(item => { onChange={ handleItemChange } />) } +
+
+
+
+ +
- { identifierResult === CHOICE && ( -
- { isSearching ? : ( + { identifierResult === CHOICE && ( +
+ { isSearching ? : ( + + ) } +
+ ) } + { !isTouchOrSmall && ( +
- ) }
- )} + ) }
); diff --git a/src/js/component/ui/modal.jsx b/src/js/component/ui/modal.jsx index 2499c479..2ab27a00 100644 --- a/src/js/component/ui/modal.jsx +++ b/src/js/component/ui/modal.jsx @@ -2,6 +2,8 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import ReactModal from 'react-modal'; +import { createPortal} from 'react-dom'; + import { useSelector } from 'react-redux'; import { usePrevious } from 'web-common/hooks'; import { getScrollbarWidth, pick } from 'web-common/utils'; @@ -51,20 +53,26 @@ const Modal = forwardRef((props, ref) => { } }, [isOpen, wasOpen]); - return ( + return isBusy ? createPortal( +
+
+ +
+
, document.querySelector(`.${containterClassName}`) + ) : ( document.querySelector(`.${containterClassName}`) } - className= { cx('modal modal-content', className) } - overlayClassName={ cx({ 'loading': isBusy }, 'modal-backdrop', overlayClassName) } + className={cx('modal modal-content', className) } + overlayClassName={cx('modal-backdrop', overlayClassName) } isOpen={ isOpen } closeTimeoutMS={ isTouchOrSmall ? 200 : null } { ...pick(rest, ['contentLabel', 'onRequestClose', 'shouldFocusAfterRender', 'shouldCloseOnOverlayClick', 'shouldCloseOnEsc', 'shouldReturnFocusAfterClose']) } > - { isBusy ? : children } + { children } ); }); diff --git a/src/js/reducers/identifier.js b/src/js/reducers/identifier.js index 5969462c..6205ca1c 100644 --- a/src/js/reducers/identifier.js +++ b/src/js/reducers/identifier.js @@ -12,7 +12,9 @@ const defaultState = { items: null, identifier: null, identifierIsUrl: null, + import: false, next: null, + message: null } const identifier = (state = defaultState, action) => { @@ -32,6 +34,7 @@ const identifier = (state = defaultState, action) => { result: null, item: null, items: null, + import: action.import, next: null, }; case RECEIVE_ADD_BY_IDENTIFIER: @@ -44,7 +47,9 @@ const identifier = (state = defaultState, action) => { result: action.result, item: action.item || null, items: action.items || null, + import: action.import, next: action.next, + message: action.message, }; case ERROR_ADD_BY_IDENTIFIER: return { @@ -56,6 +61,7 @@ const identifier = (state = defaultState, action) => { item: null, items: null, identifierIsUrl: null, + import: action.import, next: null, }; case REQUEST_IDENTIFIER_MORE: diff --git a/src/scss/components/_tabs.scss b/src/scss/components/_tabs.scss index 84cc4694..79040fba 100644 --- a/src/scss/components/_tabs.scss +++ b/src/scss/components/_tabs.scss @@ -3,6 +3,7 @@ border-bottom: $border-width solid $tabs-border-color; &.justified { + flex: 1 1 auto; width: 100%; } @@ -16,7 +17,6 @@ color: $tab-inactive-color; cursor: pointer; display: inline; - flex: 1 1 auto; font-family: inherit; font-size: $font-size-base; font-weight: inherit; diff --git a/src/scss/components/modal/_identifier-picker.scss b/src/scss/components/modal/_identifier-picker.scss index aa50f839..f09ce9a1 100644 --- a/src/scss/components/modal/_identifier-picker.scss +++ b/src/scss/components/modal/_identifier-picker.scss @@ -1,8 +1,18 @@ -// -// Multiple choice dialog -// - .identifier-picker-modal { + .modal-body { + overflow: auto; + max-height: calc(100vh - 96px); + + @include mouse-and-bp-up(md) { + max-height: calc(90vh - 96px - 32px); // 90% of the screen, accounting for the modal header and modal margin + } + } + + .modal-footer-left { + .btn-link + .btn-link { + margin-left: $space-sm; + } + } .results { margin: 0 - $modal-inner-padding; diff --git a/src/scss/components/modal/_react-modal.scss b/src/scss/components/modal/_react-modal.scss index 6b205059..a7c2acd2 100644 --- a/src/scss/components/modal/_react-modal.scss +++ b/src/scss/components/modal/_react-modal.scss @@ -27,13 +27,14 @@ align-items: center; .modal { + border: none; display: flex; align-items: center; justify-content: center; } .icon-spin { - color: $modal-icon-spin-color; + color: var(--color-backdrop-spinner); } } } @@ -92,24 +93,24 @@ padding-left: $modal-inner-padding; padding-right: $modal-inner-padding; - @include touch-or-bp-down(sm) { - height: $modal-header-height + $border-width; - background-color: $modal-touch-header-bg; + &-left, + &-right { + flex: 1 0 0; + } - &-left, - &-right { - flex: 1 0 0; - } + &-center { + flex: 2 0 0; + max-width: 50%; + text-align: center; + } - &-center { - flex: 2 0 0; - max-width: 50%; - text-align: center; - } + &-right { + text-align: right; + } - &-right { - text-align: right; - } + @include touch-or-bp-down(sm) { + height: $modal-header-height + $border-width; + background-color: $modal-touch-header-bg; } @include mouse-and-bp-up(md) { diff --git a/src/scss/themes/_common.scss b/src/scss/themes/_common.scss index 80d5169c..fd99d393 100644 --- a/src/scss/themes/_common.scss +++ b/src/scss/themes/_common.scss @@ -205,7 +205,6 @@ $modal-header-bg: $shade-0; $modal-touch-header-bg: $shade-1; $modal-form-header-bg: var(--color-shade-1-darker); $modal-border-color: $shade-2; -$modal-icon-spin-color: $shade-0; // Toolbar $tool-group-separator-color: $shade-2; diff --git a/src/scss/themes/_dark.scss b/src/scss/themes/_dark.scss index e9c19a11..bf45ff52 100644 --- a/src/scss/themes/_dark.scss +++ b/src/scss/themes/_dark.scss @@ -82,6 +82,7 @@ $-colors: ( color-nav-sidebar-border: #252525, color-identifier-badge-hover-border: #b9b8b5, color-backdrop: rgba(0, 0, 0, 0.6), + color-backdrop-spinner: #fff, accent-red-on-shade-1: #c52f37, primary-on-shade-1: #e3e3e3, color-nav-sidebar: #111, diff --git a/src/scss/themes/_light.scss b/src/scss/themes/_light.scss index e07fadd1..a85b049d 100644 --- a/src/scss/themes/_light.scss +++ b/src/scss/themes/_light.scss @@ -84,6 +84,7 @@ $-colors: ( color-nav-sidebar-border: #252525, color-identifier-badge-hover-border: #b9b8b5, color-backdrop: rgba(0, 0, 0, 0.4), + color-backdrop-spinner: #fff, accent-red-on-shade-1: #db2c3a, // only differs from accent-red in dark theme primary-on-shade-1: #252525, color-nav-sidebar: #111, diff --git a/src/static/icons/16/import.svg b/src/static/icons/16/import.svg new file mode 100644 index 00000000..868ceb3c --- /dev/null +++ b/src/static/icons/16/import.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/snapshots/desktop-items-list-macos-safari.png b/test/snapshots/desktop-items-list-macos-safari.png index fbe6df6a..40430b6a 100644 Binary files a/test/snapshots/desktop-items-list-macos-safari.png and b/test/snapshots/desktop-items-list-macos-safari.png differ diff --git a/test/snapshots/desktop-items-list-windows-chrome-small-desktop.png b/test/snapshots/desktop-items-list-windows-chrome-small-desktop.png index 1d1b3ee2..a064e162 100644 Binary files a/test/snapshots/desktop-items-list-windows-chrome-small-desktop.png and b/test/snapshots/desktop-items-list-windows-chrome-small-desktop.png differ diff --git a/test/snapshots/desktop-items-list-windows-chrome.png b/test/snapshots/desktop-items-list-windows-chrome.png index 77790ebb..d0371dfc 100644 Binary files a/test/snapshots/desktop-items-list-windows-chrome.png and b/test/snapshots/desktop-items-list-windows-chrome.png differ