diff --git a/.eslintrc.json b/.eslintrc.json index 746822b3d..90984b76b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,7 @@ { "parser": "babel-eslint", - "extends": ["standard", "standard-jsx", "plugin:flowtype/recommended"], - "plugins": ["flowtype", "import"], + "extends": ["standard", "standard-jsx", "plugin:react/recommended", "plugin:flowtype/recommended"], + "plugins": ["flowtype", "import", "react-hooks"], "settings": { "import/resolver": { "webpack": { @@ -22,20 +22,21 @@ "IS_WEB": true }, "rules": { - "no-multi-spaces": 0, - "new-cap": 0, - "prefer-promise-reject-errors": 0, - "no-unused-vars": 0, - "standard/object-curly-even-spacing": 0, + "comma-dangle": ["error", "always-multiline"], "handle-callback-err": 0, - "one-var": 0, - "object-curly-spacing": 0, + "jsx-quotes": ["error", "prefer-double"], + "new-cap": 0, + "no-multi-spaces": 0, "no-redeclare": 0, "no-return-await": 0, - "standard/no-callback-literal": 0, - "comma-dangle": ["error", "always-multiline"], + "object-curly-spacing": 0, + "one-var": 0, + "prefer-promise-reject-errors": 0, + "react-hooks/exhaustive-deps": "warn", + "react-hooks/rules-of-hooks": "error", "space-before-function-paren": ["error", "never"], - "jsx-quotes": ["error", "prefer-double"], + "standard/object-curly-even-spacing": 0, + "standard/no-callback-literal": 0, "semi": [ "error", "always", diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 2325bdbf7..771aac230 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1,6 @@ { "linters": { "src/**/*.{js,jsx,scss,json}": ["prettier --write", "git add"], - "src/**/*.{js,jsx}": ["eslint --fix", "flow focus-check --color always", "git add"] + "src/**/*.{js,jsx}": ["eslint", "flow focus-check --color always", "git add"] } } diff --git a/.prettierrc.json b/.prettierrc.json index 1d3fce725..c1c72e81b 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,5 @@ { "trailingComma": "es5", - "printWidth": 100, + "printWidth": 120, "singleQuote": true -} \ No newline at end of file +} diff --git a/package.json b/package.json index 5932f48d7..e749c6154 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "postinstall": "electron-builder install-app-deps && node build/downloadDaemon.js" }, "dependencies": { + "@reach/rect": "^0.2.1", + "@reach/tabs": "^0.1.5", "electron-dl": "^1.11.0", "electron-log": "^2.2.12", "electron-updater": "^4.0.6", @@ -104,6 +106,7 @@ "eslint-plugin-prettier": "^2.6.0", "eslint-plugin-promise": "^4.0.1", "eslint-plugin-react": "^7.7.0", + "eslint-plugin-react-hooks": "^1.6.0", "eslint-plugin-standard": "^4.0.0", "flow-bin": "^0.97.0", "flow-typed": "^2.3.0", @@ -114,7 +117,7 @@ "jsmediatags": "^3.8.1", "json-loader": "^0.5.4", "lbry-format": "https://github.com/lbryio/lbry-format.git", - "lbry-redux": "lbryio/lbry-redux#cc42856676541120b088e4228c04246ba8ff3274", + "lbry-redux": "lbryio/lbry-redux#459bea2257d61003e591daf169fefe9624522680", "lbryinc": "lbryio/lbryinc#9665f2d1c818f1a86b2e5daab642f6879746f25f", "lint-staged": "^7.0.2", "localforage": "^1.7.1", @@ -185,7 +188,7 @@ "yarn": "^1.3" }, "lbrySettings": { - "lbrynetDaemonVersion": "0.37.0rc3", + "lbrynetDaemonVersion": "0.37.0rc4", "lbrynetDaemonUrlTemplate": "https://github.com/lbryio/lbry/releases/download/vDAEMONVER/lbrynet-OSNAME.zip", "lbrynetDaemonDir": "static/daemon", "lbrynetDaemonFileName": "lbrynet" diff --git a/postcss.config.js b/postcss.config.js index 70e448a04..38b8ce92d 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -10,7 +10,7 @@ module.exports = ({ file, options, env }) => { parser: file.extname === '.sss' ? 'sugarss' : false, plugins: { 'postcss-import': { root: file.dirname }, - 'cssnano': env === 'production' ? options.cssnano : false - } + cssnano: env === 'production' ? options.cssnano : false, + }, }; }; diff --git a/src/ui/component/channelAbout/index.js b/src/ui/component/channelAbout/index.js new file mode 100644 index 000000000..d4b0ef517 --- /dev/null +++ b/src/ui/component/channelAbout/index.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { makeSelectMetadataItemForUri } from 'lbry-redux'; +import ChannelAbout from './view'; + +const select = (state, props) => ({ + description: makeSelectMetadataItemForUri(props.uri, 'description')(state), + website: makeSelectMetadataItemForUri(props.uri, 'website_url')(state), + email: makeSelectMetadataItemForUri(props.uri, 'email')(state), +}); + +export default connect( + select, + null +)(ChannelAbout); diff --git a/src/ui/component/channelAbout/view.jsx b/src/ui/component/channelAbout/view.jsx new file mode 100644 index 000000000..82e8dab90 --- /dev/null +++ b/src/ui/component/channelAbout/view.jsx @@ -0,0 +1,38 @@ +// @flow +import React, { Fragment } from 'react'; + +type Props = { + description: ?string, + email: ?string, + website: ?string, +}; + +function ChannelContent(props: Props) { + const { description, email, website } = props; + const showAbout = description || email || website; + + return ( +
+ {!showAbout &&

{__('Nothing here yet')}

} + {showAbout && ( + + {description &&
{description}
} + {email && ( + +
{__('Contact')}
+
{email}
+
+ )} + {website && ( + +
{__('Site')}
+
{website}
+
+ )} +
+ )} +
+ ); +} + +export default ChannelContent; diff --git a/src/ui/component/channelContent/index.js b/src/ui/component/channelContent/index.js new file mode 100644 index 000000000..89bf3f118 --- /dev/null +++ b/src/ui/component/channelContent/index.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { doFetchClaimsByChannel } from 'redux/actions/content'; +import { PAGE_SIZE } from 'constants/claim'; +import { + makeSelectClaimsInChannelForCurrentPageState, + makeSelectFetchingChannelClaims, + makeSelectClaimIsMine, + makeSelectTotalPagesForChannel, +} from 'lbry-redux'; +import ChannelPage from './view'; + +const select = (state, props) => ({ + claimsInChannel: makeSelectClaimsInChannelForCurrentPageState(props.uri)(state), + fetching: makeSelectFetchingChannelClaims(props.uri)(state), + totalPages: makeSelectTotalPagesForChannel(props.uri, PAGE_SIZE)(state), + channelIsMine: makeSelectClaimIsMine(props.uri)(state), +}); + +const perform = dispatch => ({ + fetchClaims: (uri, page) => dispatch(doFetchClaimsByChannel(uri, page)), +}); + +export default connect( + select, + perform +)(ChannelPage); diff --git a/src/ui/component/channelContent/view.jsx b/src/ui/component/channelContent/view.jsx new file mode 100644 index 000000000..83cc3378a --- /dev/null +++ b/src/ui/component/channelContent/view.jsx @@ -0,0 +1,47 @@ +// @flow +import React, { Fragment } from 'react'; +import FileList from 'component/fileList'; +import HiddenNsfwClaims from 'component/hiddenNsfwClaims'; +import { withRouter } from 'react-router-dom'; +import Paginate from 'component/common/paginate'; +import Spinner from 'component/spinner'; + +type Props = { + uri: string, + totalPages: number, + fetching: boolean, + params: { page: number }, + claimsInChannel: Array, + channelIsMine: boolean, + fetchClaims: (string, number) => void, + location: UrlLocation, +}; + +function ChannelContent(props: Props) { + const { uri, fetching, claimsInChannel, totalPages, channelIsMine, fetchClaims } = props; + const hasContent = Boolean(claimsInChannel && claimsInChannel.length); + + return ( + + {fetching && !hasContent && ( +
+ +
+ )} + + {!fetching && !hasContent &&

{__("This channel hasn't uploaded anything.")}

} + + {!channelIsMine && } + + {hasContent && } + + fetchClaims(uri, page)} + totalPages={totalPages} + loading={fetching && !hasContent} + /> +
+ ); +} + +export default withRouter(ChannelContent); diff --git a/src/ui/component/channelThumbnail/index.js b/src/ui/component/channelThumbnail/index.js new file mode 100644 index 000000000..01b1d3c73 --- /dev/null +++ b/src/ui/component/channelThumbnail/index.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import { makeSelectThumbnailForUri } from 'lbry-redux'; +import ChannelThumbnail from './view'; + +const select = (state, props) => ({ + thumbnail: makeSelectThumbnailForUri(props.uri)(state), +}); + +export default connect( + select, + null +)(ChannelThumbnail); diff --git a/src/ui/component/channelThumbnail/view.jsx b/src/ui/component/channelThumbnail/view.jsx new file mode 100644 index 000000000..054f1021e --- /dev/null +++ b/src/ui/component/channelThumbnail/view.jsx @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; + +type Props = { + thumbnail: ?string, +}; + +function ChannelThumbnail(props: Props) { + const { thumbnail } = props; + return ( +
+ {thumbnail && } +
+ ); +} + +export default ChannelThumbnail; diff --git a/src/ui/component/common/paginate.jsx b/src/ui/component/common/paginate.jsx new file mode 100644 index 000000000..7c4635a03 --- /dev/null +++ b/src/ui/component/common/paginate.jsx @@ -0,0 +1,81 @@ +// @flow +import React from 'react'; +import { withRouter } from 'react-router'; +import { Form, FormField } from 'component/common/form'; +import ReactPaginate from 'react-paginate'; + +const PAGINATE_PARAM = 'page'; +const ENTER_KEY_CODE = 13; + +type Props = { + loading: boolean, + totalPages: number, + location: { search: string }, + history: { push: string => void }, + onPageChange?: number => void, +}; + +function Paginate(props: Props) { + const { totalPages, loading, location, history, onPageChange } = props; + const { search } = location; + const urlParams = new URLSearchParams(search); + const currentPage = Number(urlParams.get(PAGINATE_PARAM)) || 1; + + function handleChangePage(newPageNumber: number) { + if (onPageChange) { + onPageChange(newPageNumber); + } + + if (currentPage !== newPageNumber) { + history.push(`?${PAGINATE_PARAM}=${newPageNumber}`); + } + } + + function handlePaginateKeyUp(e: SyntheticKeyboardEvent<*>) { + const newPage = Number(e.currentTarget.value); + const isEnterKey = e.keyCode === ENTER_KEY_CODE; + + if (newPage && isEnterKey && newPage > 0 && newPage <= totalPages) { + handleChangePage(newPage); + } + } + + if (totalPages <= 1 || loading) { + return null; + } + + return ( +
+ + + handleChangePage(e.selected + 1)} + forcePage={currentPage - 1} + initialPage={currentPage - 1} + containerClassName="pagination" + /> + + + + +
+ ); +} + +export default withRouter(Paginate); diff --git a/src/ui/component/common/tabs.jsx b/src/ui/component/common/tabs.jsx new file mode 100644 index 000000000..9965b8cdf --- /dev/null +++ b/src/ui/component/common/tabs.jsx @@ -0,0 +1,130 @@ +// @flow +import React, { Fragment, useState, useRef, useContext, useLayoutEffect, createContext } from 'react'; +import { + Tabs as ReachTabs, + Tab as ReachTab, + TabList as ReachTabList, + TabPanels as ReachTabPanels, + TabPanel as ReachTabPanel, +} from '@reach/tabs'; +import classnames from 'classnames'; +import { useRect } from '@reach/rect'; + +// Tabs are a compound component +// The components are used individually, but they will still interact and share state +// When using, at a minimum you must arrange the components in this pattern +// When the at index 0 is active, the TabPanel at index 0 will be displayed +// +// +// +// Tab label 1 +// Tab label 2 +// ... +// +// +// Content for Tab 1 +// Content for Tab 2 +// ... +// +// +// +// the base @reach/tabs components handle all the focus/accessibilty labels +// We're just adding some styling + +type TabsProps = { + index?: number, + onChange?: number => void, + children: Array, +}; + +// Use context so child TabPanels can set the active tab, which is kept in Tabs' state +const AnimatedContext = createContext(); + +function Tabs(props: TabsProps) { + // Store the position of the selected Tab so we can animate the "active" bar to its position + const [selectedRect, setSelectedRect] = useState(null); + + // Create a ref of the parent element so we can measure the relative "left" for the bar for the child Tab's + const tabsRef = useRef(); + const tabsRect = useRect(tabsRef); + + const tabLabels = props.children[0]; + const tabContent = props.children[1]; + + return ( + + + {tabLabels} + +
+ + {tabContent} + + + ); +} + +// +// The wrapper for the list of tab labels that users can click +type TabListProps = { + className?: string, +}; +function TabList(props: TabListProps) { + const { className, ...rest } = props; + return ; +} + +// +// The links that users click +// Accesses `setSelectedRect` from context to set itself as active if needed +// Flow doesn't understand we don't have to pass it in ourselves +type TabProps = { + isSelected?: Boolean, +}; +function Tab(props: TabProps) { + // @reach/tabs provides an `isSelected` prop + // We could also useContext to read it manually + const { isSelected } = props; + + // Each tab measures itself + const ref = useRef(); + const rect = useRect(ref, isSelected); + + // and calls up to the parent when it becomes selected + // we useLayoutEffect to avoid flicker + const setSelectedRect = useContext(AnimatedContext); + useLayoutEffect(() => { + if (isSelected) setSelectedRect(rect); + }, [isSelected, rect, setSelectedRect]); + + return ; +} + +// +// The wrapper for TabPanel +type TabPanelsProps = { + header?: React$Node, +}; +function TabPanels(props: TabPanelsProps) { + const { header, ...rest } = props; + return ( + + {header} + + + ); +} + +// +// The wrapper for content when it's associated Tab is selected +function TabPanel(props: any) { + return ; +} + +export { Tabs, TabList, Tab, TabPanels, TabPanel }; diff --git a/src/ui/component/hiddenNsfwClaims/view.jsx b/src/ui/component/hiddenNsfwClaims/view.jsx index 4408c800d..1d8b17d4b 100644 --- a/src/ui/component/hiddenNsfwClaims/view.jsx +++ b/src/ui/component/hiddenNsfwClaims/view.jsx @@ -10,12 +10,12 @@ type Props = { export default (props: Props) => { const { numberOfNsfwClaims, obscureNsfw, className } = props; + return ( obscureNsfw && Boolean(numberOfNsfwClaims) && (
- {numberOfNsfwClaims} {numberOfNsfwClaims > 1 ? __('files') : __('file')}{' '} - {__('hidden due to your')}{' '} + {numberOfNsfwClaims} {numberOfNsfwClaims > 1 ? __('files') : __('file')} {__('hidden due to your')}{' '}
) diff --git a/src/ui/component/navigationHistory/view.jsx b/src/ui/component/navigationHistory/view.jsx index 3e1cd4756..41861a20d 100644 --- a/src/ui/component/navigationHistory/view.jsx +++ b/src/ui/component/navigationHistory/view.jsx @@ -1,10 +1,8 @@ // @flow import * as React from 'react'; import Button from 'component/button'; -import { Form, FormField } from 'component/common/form'; -import ReactPaginate from 'react-paginate'; import NavigationHistoryItem from 'component/navigationHistoryItem'; -import { withRouter } from 'react-router-dom'; +import Paginate from 'component/common/paginate'; type HistoryItem = { uri: string, @@ -13,11 +11,8 @@ type HistoryItem = { type Props = { historyItems: Array, - page: number, pageCount: number, clearHistoryUri: string => void, - params: { page: number }, - history: { push: string => void }, }; type State = { @@ -52,24 +47,6 @@ class UserHistoryPage extends React.PureComponent { }); } - changePage(pageNumber: number) { - const { history } = this.props; - history.push(`?page=${pageNumber}`); - } - - paginate(e: SyntheticKeyboardEvent<*>) { - const pageFromInput = Number(e.currentTarget.value); - if ( - pageFromInput && - e.keyCode === 13 && - !Number.isNaN(pageFromInput) && - pageFromInput > 0 && - pageFromInput <= this.props.pageCount - ) { - this.changePage(pageFromInput); - } - } - selectAll() { const { historyItems } = this.props; const newSelectedState = {}; @@ -94,26 +71,20 @@ class UserHistoryPage extends React.PureComponent { } render() { - const { historyItems = [], page, pageCount } = this.props; + const { historyItems = [], pageCount } = this.props; const { itemsSelected } = this.state; - const allSelected = Object.keys(itemsSelected).length === history.length; + const allSelected = Object.keys(itemsSelected).length === historyItems.length; const selectHandler = allSelected ? this.unselectAll : this.selectAll; - return history.length ? ( + return historyItems.length ? (
{Object.keys(itemsSelected).length ? (
{!!historyItems.length && (
@@ -130,47 +101,14 @@ class UserHistoryPage extends React.PureComponent { ))}
)} - {pageCount > 1 && ( -
- - - this.changePage(e.selected)} - forcePage={page} - initialPage={page} - disableInitialCallback - containerClassName="pagination" - /> - - this.paginate(e)} - /> - -
- )} +
) : (

- {__( - "You don't have anything saved in history yet, go check out some content on LBRY!" - )} + {__("You don't have anything saved in history yet, go check out some content on LBRY!")}

@@ -184,4 +122,4 @@ class UserHistoryPage extends React.PureComponent { ); } } -export default withRouter(UserHistoryPage); +export default UserHistoryPage; diff --git a/src/ui/component/navigationHistoryRecent/view.jsx b/src/ui/component/navigationHistoryRecent/view.jsx index 87da77c33..109f9457e 100644 --- a/src/ui/component/navigationHistoryRecent/view.jsx +++ b/src/ui/component/navigationHistoryRecent/view.jsx @@ -1,8 +1,6 @@ // @flow -import React, { Fragment } from 'react'; +import React from 'react'; import Button from 'component/button'; -import { Form, FormField } from 'component/common/form'; -import ReactPaginate from 'react-paginate'; import NavigationHistoryItem from 'component/navigationHistoryItem'; type HistoryItem = { @@ -12,14 +10,10 @@ type HistoryItem = { type Props = { history: Array, - page: number, - pageCount: number, - clearHistoryUri: string => void, - params: { page: number }, }; -export default function UserHistoryRecent(props: Props) { - const { history = [], page, pageCount } = props; +export default function NavigationHistoryRecent(props: Props) { + const { history = [] } = props; return history.length ? (
diff --git a/src/ui/component/page/view.jsx b/src/ui/component/page/view.jsx index 07ddb9f3b..d26a858c5 100644 --- a/src/ui/component/page/view.jsx +++ b/src/ui/component/page/view.jsx @@ -9,8 +9,6 @@ const LOADER_TIMEOUT = 1000; type Props = { children: React.Node | Array, pageTitle: ?string, - noPadding: ?boolean, - extraPadding: ?boolean, notContained: ?boolean, // No max-width, but keep the padding loading: ?boolean, className: ?string, @@ -46,7 +44,6 @@ class Page extends React.PureComponent { this.beginLoadingTimeout(); } else if (!loading && this.loaderTimeout) { clearTimeout(this.loaderTimeout); - if (showLoader) { this.removeLoader(); } @@ -72,23 +69,14 @@ class Page extends React.PureComponent { loaderTimeout: ?TimeoutID; render() { - const { - pageTitle, - children, - noPadding, - extraPadding, - notContained, - loading, - className, - } = this.props; + const { children, notContained, loading, className } = this.props; const { showLoader } = this.state; return (
{!loading && children} diff --git a/src/ui/component/router/view.jsx b/src/ui/component/router/view.jsx index 996f42bcd..47e2fbf86 100644 --- a/src/ui/component/router/view.jsx +++ b/src/ui/component/router/view.jsx @@ -1,6 +1,6 @@ import * as PAGES from 'constants/pages'; import React, { useEffect } from 'react'; -import { Route, Switch, withRouter } from 'react-router-dom'; +import { Route, Redirect, Switch, withRouter } from 'react-router-dom'; import SettingsPage from 'page/settings'; import HelpPage from 'page/help'; import ReportPage from 'page/report'; @@ -58,8 +58,11 @@ export default function AppRouter() { {/* Below need to go at the end to make sure we don't match any of our pages first */} - - + + + + {/* Route not found. Mostly for people typing crazy urls into the url */} + } /> ); diff --git a/src/ui/component/shareButton/index.js b/src/ui/component/shareButton/index.js new file mode 100644 index 000000000..81ed8bf94 --- /dev/null +++ b/src/ui/component/shareButton/index.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions'; +import { doOpenModal } from 'redux/actions/app'; +import { doToast } from 'lbry-redux'; +import ShareButton from './view'; + +const select = (state, props) => ({}); + +export default connect( + select, + { + doChannelSubscribe, + doChannelUnsubscribe, + doOpenModal, + doToast, + } +)(ShareButton); diff --git a/src/ui/component/shareButton/view.jsx b/src/ui/component/shareButton/view.jsx new file mode 100644 index 000000000..3ec0e15e3 --- /dev/null +++ b/src/ui/component/shareButton/view.jsx @@ -0,0 +1,23 @@ +// @flow +import * as MODALS from 'constants/modal_types'; +import * as ICONS from 'constants/icons'; +import React from 'react'; +import Button from 'component/button'; + +type Props = { + uri: string, + doOpenModal: (id: string, {}) => void, +}; + +export default function ShareButton(props: Props) { + const { uri, doOpenModal } = props; + + return ( +
diff --git a/src/ui/page/search/view.jsx b/src/ui/page/search/view.jsx index ddf7264e5..40aa9c91b 100644 --- a/src/ui/page/search/view.jsx +++ b/src/ui/page/search/view.jsx @@ -1,13 +1,10 @@ // @flow -import * as ICONS from 'constants/icons'; import React, { useEffect, Fragment } from 'react'; import { isURIValid, normalizeURI, parseURI } from 'lbry-redux'; import FileTile from 'component/fileTile'; import ChannelTile from 'component/channelTile'; import FileListSearch from 'component/fileListSearch'; import Page from 'component/page'; -import ToolTip from 'component/common/tooltip'; -import Icon from 'component/common/icon'; import SearchOptions from 'component/searchOptions'; import Button from 'component/button'; @@ -24,7 +21,6 @@ export default function SearchPage(props: Props) { let uri; let isChannel; - let label; if (isValid) { uri = normalizeURI(urlQuery); ({ isChannel } = parseURI(uri)); @@ -34,10 +30,10 @@ export default function SearchPage(props: Props) { if (urlQuery) { doSearch(urlQuery); } - }, [urlQuery]); + }, [doSearch, urlQuery]); return ( - +
{urlQuery && ( @@ -58,9 +54,7 @@ export default function SearchPage(props: Props) { -
- {__('These search results are provided by LBRY, Inc.')} -
+
{__('These search results are provided by LBRY, Inc.')}
)} diff --git a/src/ui/page/subscriptions/internal/first-run.jsx b/src/ui/page/subscriptions/internal/first-run.jsx index 7c4806dbd..130e11050 100644 --- a/src/ui/page/subscriptions/internal/first-run.jsx +++ b/src/ui/page/subscriptions/internal/first-run.jsx @@ -1,5 +1,5 @@ // @flow -import React, { Fragment } from 'react'; +import React from 'react'; import Button from 'component/button'; import SuggestedSubscriptions from 'component/subscribeSuggested'; import Yrbl from 'component/yrbl'; @@ -12,17 +12,11 @@ type Props = { doShowSuggestedSubs: () => void, }; -export default (props: Props) => { - const { - showSuggested, - loadingSuggested, - numberOfSubscriptions, - doShowSuggestedSubs, - onFinish, - } = props; +export default function SubscriptionsFirstRun(props: Props) { + const { showSuggested, loadingSuggested, numberOfSubscriptions, doShowSuggestedSubs, onFinish } = props; return ( - +
0 ? __('Woohoo!') : __('No subscriptions... yet.')} subtitle={ @@ -52,6 +46,6 @@ export default (props: Props) => { } /> {showSuggested && !loadingSuggested && } - +
); -}; +} diff --git a/src/ui/page/subscriptions/internal/user-subscriptions.jsx b/src/ui/page/subscriptions/internal/user-subscriptions.jsx index d96cda951..77c78b904 100644 --- a/src/ui/page/subscriptions/internal/user-subscriptions.jsx +++ b/src/ui/page/subscriptions/internal/user-subscriptions.jsx @@ -11,7 +11,7 @@ import SuggestedSubscriptions from 'component/subscribeSuggested'; import MarkAsRead from 'component/subscribeMarkAsRead'; import Tooltip from 'component/common/tooltip'; import Yrbl from 'component/yrbl'; -import { formatLbryUriForWeb } from 'util/uri'; +import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs'; type Props = { viewMode: ViewMode, @@ -33,27 +33,18 @@ export default (props: Props) => { onChangeAutoDownload, unreadSubscriptions, } = props; + + const index = viewMode === VIEW_ALL ? 0 : 1; + const onTabChange = index => (index === 0 ? doSetViewMode(VIEW_ALL) : doSetViewMode(VIEW_LATEST_FIRST)); + return ( {hasSubscriptions && ( -
-
-
-
+ + + {__('All Subscriptions')} + {__('Latest Only')} + { labelOnLeft /> -
-
- )} + - { - if (name && claimId) { - arr.push(`lbry://${name}#${claimId}`); - } - return arr; - }, [])} - /> - - {!hasSubscriptions && ( - - - - - )} - - {hasSubscriptions && ( -
- {viewMode === VIEW_ALL && ( - + { + if (name && claimId) { + arr.push(`lbry://${name}#${claimId}`); + } + return arr; + }, [])} + /> + } + > +
{__('Your subscriptions')} {unreadSubscriptions.length > 0 && }
-
- )} + - {viewMode === VIEW_LATEST_FIRST && ( - + {unreadSubscriptions.length ? ( unreadSubscriptions.map(({ channel, uris }) => { const { claimName } = parseURI(channel); @@ -124,16 +100,24 @@ export default (props: Props) => { }) ) : ( - + )} - - )} -
+ + + + )} + + {!hasSubscriptions && ( + + + + )}
); diff --git a/src/ui/page/subscriptions/view.jsx b/src/ui/page/subscriptions/view.jsx index b359f8c17..a33475d24 100644 --- a/src/ui/page/subscriptions/view.jsx +++ b/src/ui/page/subscriptions/view.jsx @@ -26,7 +26,7 @@ type Props = { showSuggestedSubs: boolean, }; -export default class extends PureComponent { +export default class SubscriptionsPage extends PureComponent { constructor() { super(); @@ -78,7 +78,7 @@ export default class extends PureComponent { // Only pass in the loading prop if there are no subscriptions // If there are any, let the page update in the background // The loading prop removes children and shows a loading spinner - + {firstRunCompleted ? ( *:not(:last-child) { - margin-right: var(--spacing-vertical-large); + margin-right: var(--spacing-vertical-medium); } } @@ -118,6 +121,37 @@ .card__list { display: grid; grid-gap: var(--spacing-vertical-medium); + + // Depending on screen width, the amount of items in + // each row change and are auto-sized + + @media (min-width: 2001px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 10), 1fr)); + } + + @media (min-width: 1801px) and (max-width: 2000px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 8), 1fr)); + } + + @media (min-width: 1551px) and (max-width: 1800px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 7), 1fr)); + } + + @media (min-width: 1051px) and (max-width: 1550px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 6), 1fr)); + } + + @media (min-width: 901px) and (max-width: 1050px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 5), 1fr)); + } + + @media (min-width: 751px) and (max-width: 900px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 4), 1fr)); + } + + @media (max-width: 750px) { + grid-template-columns: repeat(auto-fill, minmax(calc(100% / 3), 1fr)); + } } .card__list--rewards { @@ -137,8 +171,8 @@ .card__message { border-left: 0.5rem solid; - padding: var(--spacing-vertical-medium) var(--spacing-vertical-medium) - var(--spacing-vertical-medium) var(--spacing-vertical-large); + padding: var(--spacing-vertical-medium) var(--spacing-vertical-medium) var(--spacing-vertical-medium) + var(--spacing-vertical-large); &:not(&--error):not(&--failure):not(&--success) { background-color: rgba($lbry-teal-1, 0.1); @@ -184,6 +218,7 @@ .card__title { font-size: 2rem; font-weight: 600; + padding-bottom: var(--spacing-vertical-medium); + .card__content { margin-top: var(--spacing-vertical-medium); diff --git a/src/ui/scss/component/_channel.scss b/src/ui/scss/component/_channel.scss index 829bc1ace..3552b21c1 100644 --- a/src/ui/scss/component/_channel.scss +++ b/src/ui/scss/component/_channel.scss @@ -1,27 +1,71 @@ -.channel-info { - .media__title { - display: block; - user-select: text; - margin-bottom: var(--spacing-vertical-medium); - } +$cover-z-index: 0; +$metadata-z-index: 1; - .channel-info__actions__group { - margin-bottom: var(--spacing-vertical-large); - } -} - -.channel-info__actions { +.channel__cover { + background-image: linear-gradient(to right, $lbry-indigo-4, $lbry-cyan-5 80%); display: flex; + align-items: flex-end; + box-sizing: content-box; + color: $lbry-white; } -.channel-info__actions__group { - @extend .media__action-group; - @extend .media__action-group--large; +.channel__cover--custom { + z-index: $cover-z-index; + align-self: flex-start; + position: absolute; + object-fit: cover; + filter: brightness(40%); } -.channel-name { - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; +.channel__cover, +.channel__cover--custom { + height: var(--cover-photo-height); + width: 100%; } + +.channel__thumbnail { + position: absolute; + left: var(--spacing-main-padding); + height: var(--channel-thumbnail-size); + width: var(--channel-thumbnail-size); + background-color: $lbry-gray-3; + background-image: linear-gradient(to right, $lbry-white, $lbry-gray-3 80%); + background-size: cover; + box-shadow: 0px 8px 40px -3px $lbry-black; +} + +.channel__thumbnail--custom { + width: 100%; + object-fit: cover; +} + +.channel__thumbnail, +.channel__thumbnail--custom { + border-radius: var(--card-radius); +} + +.channel__primary-info { + // Ensure the profile pic/title sit ontop of the default cover background + z-index: $metadata-z-index; + // Jump over the thumbnail photo because it is absolutely positioned + // Then add normal page spacing, _then_ add the actual padding + margin-left: calc(var(--channel-thumbnail-size) + var(--spacing-main-padding)); + padding-left: var(--spacing-vertical-large); + padding-bottom: var(--spacing-vertical-medium); +} + +.channel__title { + font-size: 3rem; + font-weight: 800; +} + +.channel__url { + font-size: 1.2rem; + user-select: all; + margin-top: -0.25rem; +} + +// .channel__description { +// font-size: 1.3rem; +// margin: var(--spacing-vertical-large) 0; +// } diff --git a/src/ui/scss/component/_form-field.scss b/src/ui/scss/component/_form-field.scss index f2ad6dea8..b2ae23630 100644 --- a/src/ui/scss/component/_form-field.scss +++ b/src/ui/scss/component/_form-field.scss @@ -87,7 +87,7 @@ fieldset-group { } &.fieldgroup--paginate { - margin-top: var(--spacing-vertical-medium); + margin: var(--spacing-vertical-large) 0; align-items: center; justify-content: center; diff --git a/src/ui/scss/component/_main.scss b/src/ui/scss/component/_main.scss index 93fdaf86d..9f38ce6e8 100644 --- a/src/ui/scss/component/_main.scss +++ b/src/ui/scss/component/_main.scss @@ -1,14 +1,18 @@ .main-wrapper { position: absolute; top: var(--header-height); - left: var(--side-nav-width); min-height: calc(100% - var(--header-height)); - width: calc(100% - var(--side-nav-width)); + width: 100%; background-color: mix($lbry-white, $lbry-gray-1, 70%); html[data-mode='dark'] & { background-color: $lbry-black; } + + @media (min-width: 600px) { + left: var(--side-nav-width); + width: calc(100% - var(--side-nav-width)); + } } .main { @@ -19,13 +23,23 @@ .main--contained { max-width: 900px; + padding: var(--spacing-main-padding); } -.main:not(.main--no-padding) { - padding: var(--spacing-vertical-large); +.main--not-contained { + padding: var(--spacing-main-padding); +} + +.main--no-padding { + padding: 0; +} + +.main--no-padding-top { + padding-top: 0; } .main--file-page { + padding: var(--spacing-main-padding); max-width: var(--file-page-max-width); display: grid; grid-gap: var(--spacing-vertical-large); @@ -35,7 +49,7 @@ 'content content' 'info related'; - @media (min-width: 1470px) { + @media (min-width: 1300px) { grid-template-areas: 'content related' 'info related'; @@ -60,3 +74,16 @@ margin-bottom: 100px; text-align: center; } + +// On pages that are not contained, they still might want to have items inside the page +// that extend the full width ex: cover photo +// But the components inside of those pages should be still have "page" padding +.main__item--extend-outside { + $main-width: calc(100vw - var(--side-nav-width)); + width: $main-width; + position: relative; + left: 50%; + right: 50%; + margin-left: calc(-50vw + (var(--side-nav-width) * 0.5)); + margin-right: calc(-50vw + (var(--side-nav-width) * 0.5)); +} diff --git a/src/ui/scss/component/_media.scss b/src/ui/scss/component/_media.scss index e26ca8f4a..3d75cd3a9 100644 --- a/src/ui/scss/component/_media.scss +++ b/src/ui/scss/component/_media.scss @@ -345,42 +345,6 @@ // G R O U P .media-group--list { - .card__list { - padding-top: var(--spacing-vertical-large); - padding-bottom: var(--spacing-vertical-large); - - // Depending on screen width, the amount of items in - // each row change and are auto-sized - - @media (min-width: 2001px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 10), 1fr)); - } - - @media (min-width: 1801px) and (max-width: 2000px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 8), 1fr)); - } - - @media (min-width: 1551px) and (max-width: 1800px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 7), 1fr)); - } - - @media (min-width: 1051px) and (max-width: 1550px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 6), 1fr)); - } - - @media (min-width: 901px) and (max-width: 1050px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 5), 1fr)); - } - - @media (min-width: 751px) and (max-width: 900px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 4), 1fr)); - } - - @media (max-width: 750px) { - grid-template-columns: repeat(auto-fill, minmax(calc(100% / 3), 1fr)); - } - } - .media-card { display: inline-block; margin-bottom: var(--spacing-vertical-large); @@ -473,11 +437,7 @@ background-image: linear-gradient(to right, $lbry-white 80%, transparent 100%); html[data-mode='dark'] & { - background-image: linear-gradient( - to right, - mix($lbry-white, $lbry-gray-3, 50%) 80%, - transparent 100% - ); + background-image: linear-gradient(to right, mix($lbry-white, $lbry-gray-3, 50%) 80%, transparent 100%); } } diff --git a/src/ui/scss/component/_navigation.scss b/src/ui/scss/component/_navigation.scss index 5ef13c6af..1440578c9 100644 --- a/src/ui/scss/component/_navigation.scss +++ b/src/ui/scss/component/_navigation.scss @@ -35,11 +35,7 @@ right: 0; // TODO: Make this gradient "to bottom" on mobile view - background-image: linear-gradient( - to right, - transparent, - rgba(mix($lbry-blue-3, $lbry-gray-4, 15%), 0.2) 100% - ); + background-image: linear-gradient(to right, transparent, rgba(mix($lbry-blue-3, $lbry-gray-4, 15%), 0.2) 100%); content: ''; position: absolute; @@ -93,7 +89,7 @@ } &::before { - width: 0.5rem; + width: var(--tab-indicator-size); } } diff --git a/src/ui/scss/component/_search.scss b/src/ui/scss/component/_search.scss index 160ecf35c..0e43769da 100644 --- a/src/ui/scss/component/_search.scss +++ b/src/ui/scss/component/_search.scss @@ -1,25 +1,13 @@ .search__header { - background-color: $lbry-black; - color: $lbry-white; padding: var(--spacing-vertical-large); .placeholder { background-color: rgba($lbry-white, 0.1); } - .media__subtext { - color: rgba($lbry-white, 0.6); - } - .media__subtitle { - color: rgba($lbry-white, 0.9); font-size: 0.7em; } - - html[data-mode='dark'] & { - background-color: transparent; - border-bottom: 1px solid rgba($lbry-white, 0.1); - } } .search__title { @@ -37,7 +25,7 @@ } .search__results-wrapper { - padding: var(--spacing-vertical-large); + margin: var(--spacing-vertical-large); } .search__results-section { @@ -53,7 +41,7 @@ .search__options-wrapper { font-size: 1.25em; - padding-bottom: var(--spacing-vertical-large); + margin-bottom: var(--spacing-vertical-large); } .search__options { diff --git a/src/ui/scss/component/_subscriptions.scss b/src/ui/scss/component/_subscriptions.scss index 8ce693adc..5c5b23e74 100644 --- a/src/ui/scss/component/_subscriptions.scss +++ b/src/ui/scss/component/_subscriptions.scss @@ -1,8 +1,4 @@ -// The gerbil is tied to subscriptions currently, but this style should move to it's own file once -// the gerbil is added in more places with different layouts .subscriptions__suggested { animation: expand 0.2s; - left: -2rem; position: relative; - width: calc(100% + 4rem); } diff --git a/src/ui/scss/component/tabs.scss b/src/ui/scss/component/tabs.scss new file mode 100644 index 000000000..5f83fbb34 --- /dev/null +++ b/src/ui/scss/component/tabs.scss @@ -0,0 +1,62 @@ +.tabs { + position: relative; +} + +.tabs__list { + display: flex; + align-items: center; + background-color: $lbry-black; + color: $lbry-white; + padding: var(--spacing-vertical-medium) var(--spacing-main-padding); + + & > *:not(.tab) { + // If there is anything after the tabs, render it on the opposite side of the page + margin-left: auto; + } + + [data-mode='dark'] & { + background-color: darken($lbry-black, 5%); + } +} + +.tabs__list--channel-page { + padding-left: calc(var(--channel-thumbnail-size) + var(--spacing-main-padding) + var(--spacing-vertical-large)); +} + +.tab { + margin-right: var(--spacing-vertical-large); + padding: 5px 0; + font-weight: 700; + font-size: var(--tab-header-size); + color: $lbry-white; + position: relative; + + &::after { + position: absolute; + bottom: calc(var(--tab-indicator-size) * -2); + height: 0; + width: 100%; + content: ''; + } +} + +.tab__divider { + position: absolute; + margin-top: calc(var(--tab-indicator-size) * -1); +} + +.tab::after, +.tab__divider { + display: block; + transition: all var(--animation-duration) var(--animation-style); +} + +.tab:hover::after, +.tab__divider { + height: var(--tab-indicator-size); + background-color: $lbry-teal-3; +} + +.tab__panel { + margin-top: var(--spacing-vertical-large); +} diff --git a/src/ui/scss/init/_gui.scss b/src/ui/scss/init/_gui.scss index 22e0f845e..2f61ea48b 100644 --- a/src/ui/scss/init/_gui.scss +++ b/src/ui/scss/init/_gui.scss @@ -134,6 +134,7 @@ code { padding: 1rem; margin-top: var(--spacing-vertical-large); margin-bottom: var(--spacing-vertical-large); + border-radius: 5px; html[data-mode='dark'] & { color: inherit; diff --git a/src/ui/scss/init/_reset.scss b/src/ui/scss/init/_reset.scss index 015835566..0a969e1a9 100644 --- a/src/ui/scss/init/_reset.scss +++ b/src/ui/scss/init/_reset.scss @@ -79,16 +79,6 @@ dl { width: 100%; } -dt { - float: left; - width: 20%; -} - -dd { - float: left; - width: 80%; -} - textarea { border: 1px solid $lbry-gray-2; padding: $spacing-vertical * 1/3; diff --git a/src/ui/scss/init/_vars.scss b/src/ui/scss/init/_vars.scss index 9e1392c6c..54e18fb9e 100644 --- a/src/ui/scss/init/_vars.scss +++ b/src/ui/scss/init/_vars.scss @@ -16,6 +16,7 @@ $large-breakpoint: 1921px; --spacing-vertical-medium: calc(2rem / 2); --spacing-vertical-large: 2rem; --spacing-vertical-xlarge: 3rem; + --spacing-main-padding: var(--spacing-vertical-xlarge); --file-page-max-width: 1787px; --file-max-height: 788px; @@ -23,10 +24,16 @@ $large-breakpoint: 1921px; --video-aspect-ratio: 56.25%; // 9 x 16 + --channel-thumbnail-size: 10rem; + // Text --text-max-width: 660px; --text-link-padding: 4px; + // Tabs + --tab-indicator-size: 0.5rem; + --tab-header-size: 1.5rem; // Needs to be static so the animated styling always works + // Input --input-border-size: 1px; @@ -44,8 +51,9 @@ $large-breakpoint: 1921px; --search-modal-input-height: 70px; // Card - --card-radius: 2px; + --card-radius: 5px; --card-max-width: 1000px; + --card-box-shadow: 0px 8px 20px; // File --file-tile-media-height: 125px; @@ -62,10 +70,11 @@ $large-breakpoint: 1921px; --modal-width: 440px; // Animation :) - --animation-duration: 0.3s; - --animation-style: cubic-bezier(0.55, 0, 0.1, 1); + --animation-duration: 0.2s; + --animation-style: ease-in-out; // Image --thumbnail-preview-height: 100px; --thumbnail-preview-width: 177px; + --cover-photo-height: 250px; } diff --git a/webpack.web.config.js b/webpack.web.config.js index b0e89c0a7..731c4bed8 100644 --- a/webpack.web.config.js +++ b/webpack.web.config.js @@ -18,6 +18,9 @@ const webConfig = { path: __dirname + '/dist/web', publicPath: '/', }, + devServer: { + historyApiFallback: true, + }, module: { rules: [ { diff --git a/yarn.lock b/yarn.lock index de1413017..dc237e15d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,6 +885,43 @@ resolved "https://registry.yarnpkg.com/@posthtml/esm/-/esm-1.0.0.tgz#09bcb28a02438dcee22ad1970ca1d85a000ae0cf" integrity sha512-dEVG+ITnvqKGa4v040tP+n8LOKOqr94qjLva7bE5pnfm2KHJwsKz69J4KMxgWLznbpBJzy8vQfCayEk3vLZnZQ== +"@reach/auto-id@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550" + integrity sha512-lVK/svL2HuQdp7jgvlrLkFsUx50Az9chAhxpiPwBqcS83I2pVWvXp98FOcSCCJCV++l115QmzHhFd+ycw1zLBg== + +"@reach/component-component@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@reach/component-component/-/component-component-0.1.3.tgz#5d156319572dc38995b246f81878bc2577c517e5" + integrity sha512-a1USH7L3bEfDdPN4iNZGvMEFuBfkdG+QNybeyDv8RloVFgZYRoM+KGXyy2KOfEnTUM8QWDRSROwaL3+ts5Angg== + +"@reach/observe-rect@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.0.3.tgz#2ea3dcc369ab22bd9f050a92ea319321356a61e8" + integrity sha1-LqPcw2mrIr2fBQqS6jGTITVqYeg= + +"@reach/rect@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.2.1.tgz#7343020174c90e2290b844d17c03fd9c78e6b601" + integrity sha512-aZ9RsNHDMQ3zETonikqu9/85iXxj+LPqZ9Gr9UAncj3AufYmGeWG3XG6b37B+7ORH+mkhVpLU2ZlIWxmOe9Cqg== + dependencies: + "@reach/component-component" "^0.1.3" + "@reach/observe-rect" "^1.0.3" + +"@reach/tabs@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@reach/tabs/-/tabs-0.1.5.tgz#0a3a8c863cc50ac661b3a66afea0f9315c8d8b2b" + integrity sha512-thQKlbN7kN/YoFfBjTVxAlRlYor0dFg7QnZwUN9v1OYFLHMoPpmwaQkae8mAEibRb4BPGgjnoSpdfco2lzP37A== + dependencies: + "@reach/auto-id" "^0.2.0" + "@reach/utils" "^0.2.2" + warning "^4.0.2" + +"@reach/utils@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.2.2.tgz#c3a05ae9fd1f921988ae8a89b5a0d28d1a2b92df" + integrity sha512-jYeIi46AA5jh2gfdXD/nInUYfeLp3girRafiajP7AVHF6B4hpYAzUSx/ZH4xmPyf5alut5rml2DHxrv+X+Xu+A== + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -4092,6 +4129,11 @@ eslint-plugin-promise@^4.0.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.1.1.tgz#1e08cb68b5b2cd8839f8d5864c796f56d82746db" integrity sha512-faAHw7uzlNPy7b45J1guyjazw28M+7gJokKUjC5JSFoYfUEyy6Gw/i7YQvmv2Yk00sUjWcmzXQLpU1Ki/C2IZQ== +eslint-plugin-react-hooks@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.6.0.tgz#348efcda8fb426399ac7b8609607c7b4025a6f5f" + integrity sha512-lHBVRIaz5ibnIgNG07JNiAuBUeKhEf8l4etNx5vfAEwqQ5tcuK3jV9yjmopPgQDagQb7HwIuQVsE3IVcGrRnag== + eslint-plugin-react@^7.7.0: version "7.12.4" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.12.4.tgz#b1ecf26479d61aee650da612e425c53a99f48c8c" @@ -6456,9 +6498,9 @@ lazy-val@^1.0.3, lazy-val@^1.0.4: yargs "^13.2.2" zstd-codec "^0.1.1" -lbry-redux@lbryio/lbry-redux#cc42856676541120b088e4228c04246ba8ff3274: +lbry-redux@lbryio/lbry-redux#459bea2257d61003e591daf169fefe9624522680: version "0.0.1" - resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/cc42856676541120b088e4228c04246ba8ff3274" + resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/459bea2257d61003e591daf169fefe9624522680" dependencies: proxy-polyfill "0.1.6" reselect "^3.0.0" @@ -11671,6 +11713,13 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"