mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-20 06:32:11 +00:00
Merge branch 'master' of github.com:Stremio/stremio-web into modal-dialog
This commit is contained in:
commit
0f6d6b22f0
27 changed files with 1389 additions and 848 deletions
30
package.json
30
package.json
|
|
@ -12,32 +12,32 @@
|
|||
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060"
|
||||
},
|
||||
"dependencies": {
|
||||
"a-color-picker": "1.1.9",
|
||||
"a-color-picker": "1.2.1",
|
||||
"classnames": "2.2.6",
|
||||
"events": "1.1.1",
|
||||
"hat": "0.0.3",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.9.0",
|
||||
"react-dom": "16.9.0",
|
||||
"react-focus-lock": "2.1.1",
|
||||
"react": "16.11.0",
|
||||
"react-dom": "16.11.0",
|
||||
"react-focus-lock": "2.2.1",
|
||||
"spatial-navigation-polyfill": "git+ssh://git@github.com/NikolaBorislavovHristov/spatial-navigation.git#964d09bf2b0853e27af6c25924b595d6621a019d",
|
||||
"stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.4",
|
||||
"stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#v0.6.0",
|
||||
"stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#da5b37865004d0ae140518c4f276d1ed1a1483d9",
|
||||
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11",
|
||||
"vtt.js": "0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.6.2",
|
||||
"@babel/core": "7.6.4",
|
||||
"@babel/plugin-proposal-class-properties": "7.5.5",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.6.2",
|
||||
"@babel/preset-env": "7.6.2",
|
||||
"@babel/preset-react": "7.0.0",
|
||||
"@babel/runtime": "7.6.2",
|
||||
"@storybook/addon-actions": "5.2.1",
|
||||
"@babel/preset-env": "7.6.3",
|
||||
"@babel/preset-react": "7.6.3",
|
||||
"@babel/runtime": "7.6.3",
|
||||
"@storybook/addon-actions": "5.2.5",
|
||||
"@storybook/addon-console": "1.2.1",
|
||||
"@storybook/addons": "5.2.1",
|
||||
"@storybook/react": "5.2.1",
|
||||
"@storybook/addons": "5.2.5",
|
||||
"@storybook/react": "5.2.5",
|
||||
"babel-loader": "8.0.6",
|
||||
"clean-webpack-plugin": "3.0.0",
|
||||
"copy-webpack-plugin": "5.0.4",
|
||||
|
|
@ -50,9 +50,9 @@
|
|||
"mini-css-extract-plugin": "0.8.0",
|
||||
"postcss-loader": "3.0.0",
|
||||
"storybook-addon-jsx": "7.1.6",
|
||||
"terser-webpack-plugin": "2.1.0",
|
||||
"webpack": "4.41.0",
|
||||
"terser-webpack-plugin": "2.2.1",
|
||||
"webpack": "4.41.2",
|
||||
"webpack-cli": "3.3.9",
|
||||
"webpack-dev-server": "3.8.1"
|
||||
"webpack-dev-server": "3.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const App = () => {
|
|||
setCoreInitialized(services.core.active || services.core.error instanceof Error);
|
||||
if (services.core.active) {
|
||||
services.core.dispatch({ action: 'LoadCtx' });
|
||||
window.core = services.core;
|
||||
}
|
||||
};
|
||||
services.shell.on('stateChanged', onShellStateChanged);
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
|
|||
const [modalOpen, openModal, closeModal] = useBinaryState(false);
|
||||
const [tempValue, setTempValue] = React.useState(value);
|
||||
const pickerLabelOnClick = React.useCallback((event) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.openModalPrevented) {
|
||||
openModal();
|
||||
}
|
||||
}, []);
|
||||
}, [props.onClick]);
|
||||
const modalContainerOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.openModalPrevented = true;
|
||||
}, []);
|
||||
|
|
@ -52,7 +56,7 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
|
|||
setTempValue(value);
|
||||
}, [value, modalOpen]);
|
||||
return (
|
||||
<Button style={{ backgroundColor: value }} className={className} title={value} onClick={pickerLabelOnClick}>
|
||||
<Button title={value} {...props} style={{ ...props.style, backgroundColor: value }} className={className} onClick={pickerLabelOnClick}>
|
||||
{
|
||||
modalOpen ?
|
||||
<Modal className={styles['color-input-modal-container']} onMouseDown={modalContainerOnMouseDown} onClick={modalContainerOnClick}>
|
||||
|
|
@ -77,6 +81,7 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
|
|||
};
|
||||
|
||||
ColorInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const TABS = [
|
|||
{ label: 'Library', icon: 'ic_library', href: '#/library' }
|
||||
];
|
||||
|
||||
const MainNavBar = ({ className }) => {
|
||||
const MainNavBar = React.memo(({ className }) => {
|
||||
return (
|
||||
<NavBar
|
||||
className={className}
|
||||
|
|
@ -21,7 +21,7 @@ const MainNavBar = ({ className }) => {
|
|||
navMenu={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MainNavBar.propTypes = {
|
||||
className: PropTypes.string
|
||||
|
|
|
|||
|
|
@ -10,18 +10,22 @@ const useBinaryState = require('stremio/common/useBinaryState');
|
|||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ICON_FOR_TYPE = Object.assign(Object.create(null), {
|
||||
'movie': 'ic_movies',
|
||||
'series': 'ic_series',
|
||||
'channel': 'ic_channels',
|
||||
'tv': 'ic_tv',
|
||||
'other': 'ic_movies'
|
||||
});
|
||||
const ICON_FOR_TYPE = new Map([
|
||||
['movie', 'ic_movies'],
|
||||
['series', 'ic_series'],
|
||||
['channel', 'ic_channels'],
|
||||
['tv', 'ic_tv'],
|
||||
['other', 'ic_movies']
|
||||
]);
|
||||
|
||||
const MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, menuOptions, onSelect, menuOptionOnSelect, ...props }) => {
|
||||
const dataset = useDataset(props);
|
||||
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
|
||||
const metaItemOnClick = React.useCallback((event) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
|
|
@ -30,7 +34,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [onSelect, dataset]);
|
||||
}, [props.onClick, onSelect, dataset]);
|
||||
const multiselectOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.selectMetaItemPrevented = true;
|
||||
}, []);
|
||||
|
|
@ -38,6 +42,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
if (typeof menuOptionOnSelect === 'function') {
|
||||
menuOptionOnSelect({
|
||||
type: 'select-option',
|
||||
value: event.value,
|
||||
dataset: dataset,
|
||||
reactEvent: event.reactEvent,
|
||||
nativeEvent: event.nativeEvent
|
||||
|
|
@ -45,7 +50,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
}
|
||||
}, [menuOptionOnSelect, dataset]);
|
||||
return (
|
||||
<Button className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} title={name} onClick={metaItemOnClick}>
|
||||
<Button title={name} {...props} className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} onClick={metaItemOnClick}>
|
||||
<div className={styles['poster-container']}>
|
||||
<div className={styles['poster-image-layer']}>
|
||||
<Image
|
||||
|
|
@ -55,7 +60,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
renderFallback={() => (
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
|
||||
icon={ICON_FOR_TYPE.has(type) ? ICON_FOR_TYPE.get(type) : ICON_FOR_TYPE.get('other')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,25 @@
|
|||
}
|
||||
|
||||
.meta-item-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
&:hover, &:focus, &:global(.active) {
|
||||
outline-style: solid;
|
||||
outline-offset: 0;
|
||||
&:hover, &:focus, &:global(.active), &:global(.selected) {
|
||||
outline-style: none;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
outline-style: solid;
|
||||
outline-width: var(--focus-outline-size);
|
||||
outline-color: var(--color-surfacelighter);
|
||||
outline-offset: calc(-1 * var(--focus-outline-size));
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.title-bar-container {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Modal } = require('stremio-router');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Image = require('stremio/common/Image');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const ActionButton = require('./ActionButton');
|
||||
const MetaLinks = require('./MetaLinks');
|
||||
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaPreview = ({ className, compact, id, type, name, logo, background, duration, releaseInfo, released, description, genres, writers, directors, cast, imdbId, imdbRating, trailer, share, inLibrary, toggleInLibrary }) => {
|
||||
const MetaPreview = ({ className, compact, id, type, name, logo, background, runtime, releaseInfo, released, description, genres, writers, directors, cast, imdbId, imdbRating, trailer, share, inLibrary, toggleInLibrary }) => {
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const genresLinks = React.useMemo(() => {
|
||||
return Array.isArray(genres) ?
|
||||
|
|
@ -75,18 +77,26 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
<div className={styles['meta-info-container']}>
|
||||
{
|
||||
typeof logo === 'string' && logo.length > 0 ?
|
||||
<img
|
||||
<Image
|
||||
key={logo}
|
||||
className={styles['logo']}
|
||||
src={logo}
|
||||
alt={' '}
|
||||
renderFallback={
|
||||
compact ?
|
||||
() => (
|
||||
<Icon className={styles['logo-placeholder-icon']} icon={'ic_broken_link'} />
|
||||
)
|
||||
:
|
||||
null
|
||||
}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
(typeof releaseInfo === 'string' && releaseInfo.length > 0) || (released instanceof Date && !isNaN(released.getTime())) || (typeof duration === 'string' && duration.length > 0) ?
|
||||
<div className={styles['duration-release-info-container']}>
|
||||
(typeof releaseInfo === 'string' && releaseInfo.length > 0) || (released instanceof Date && !isNaN(released.getTime())) || (typeof runtime === 'string' && runtime.length > 0) ?
|
||||
<div className={styles['runtime-release-info-container']}>
|
||||
{
|
||||
typeof releaseInfo === 'string' && releaseInfo.length > 0 ?
|
||||
<div className={styles['release-info-label']}>{releaseInfo}</div>
|
||||
|
|
@ -97,8 +107,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
null
|
||||
}
|
||||
{
|
||||
typeof duration === 'string' && duration.length > 0 ?
|
||||
<div className={styles['duration-label']}>{duration}</div>
|
||||
typeof runtime === 'string' && runtime.length > 0 ?
|
||||
<div className={styles['runtime-label']}>{runtime}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -221,7 +231,7 @@ MetaPreview.propTypes = {
|
|||
name: PropTypes.string,
|
||||
logo: PropTypes.string,
|
||||
background: PropTypes.string,
|
||||
duration: PropTypes.string,
|
||||
runtime: PropTypes.string,
|
||||
releaseInfo: PropTypes.string,
|
||||
released: PropTypes.instanceOf(Date),
|
||||
description: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,18 @@
|
|||
|
||||
&.compact {
|
||||
.meta-info-container {
|
||||
.logo {
|
||||
.logo, .logo-placeholder-icon {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.duration-release-info-container {
|
||||
.runtime-release-info-container {
|
||||
justify-content: space-evenly;
|
||||
|
||||
.duration-label, .release-info-label {
|
||||
.runtime-label, .release-info-label {
|
||||
margin: 1rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +43,7 @@
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top left;
|
||||
filter: blur(5px) brightness(80%);
|
||||
filter: blur(5px) brightness(50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,23 +53,31 @@
|
|||
padding: 0 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.logo {
|
||||
.logo, .logo-placeholder-icon {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: 7rem;
|
||||
height: 8rem;
|
||||
margin-top: 1rem;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surfacedarker20);
|
||||
}
|
||||
|
||||
.duration-release-info-container {
|
||||
.logo {
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.logo-placeholder-icon {
|
||||
fill: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.runtime-release-info-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
|
||||
.duration-label, .release-info-label {
|
||||
.runtime-label, .release-info-label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
background-color: var(--color-backgroundlight);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline-offset: 0;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const useBinaryState = require('stremio/common/useBinaryState');
|
|||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, direction, title, renderLabelContent, options, selected, onOpen, onClose, onSelect, ...props }) => {
|
||||
const Multiselect = ({ className, direction, title, renderLabelContent, renderLabelText, options, selected, disabled, onOpen, onClose, onSelect, ...props }) => {
|
||||
options = Array.isArray(options) ?
|
||||
options.filter(option => option && typeof option.value === 'string')
|
||||
:
|
||||
|
|
@ -31,6 +31,7 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
value: event.currentTarget.dataset.value,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent,
|
||||
dataset: dataset
|
||||
|
|
@ -64,7 +65,7 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
direction={direction}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={({ ref, className: popupLabelClassName, children }) => (
|
||||
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['label-container'], { 'active': menuOpen })} title={title} onClick={popupLabelOnClick}>
|
||||
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
|
||||
{
|
||||
typeof renderLabelContent === 'function' ?
|
||||
renderLabelContent()
|
||||
|
|
@ -72,16 +73,19 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
<React.Fragment>
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(typeof label === 'string' ? label : value);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
typeof renderLabelText === 'function' ?
|
||||
renderLabelText()
|
||||
:
|
||||
title
|
||||
selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(typeof label === 'string' ? label : value);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
:
|
||||
title
|
||||
}
|
||||
</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_down'} />
|
||||
|
|
@ -92,12 +96,19 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['menu-container']} onClick={popupMenuOnClick}>
|
||||
{options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))}
|
||||
{
|
||||
options.length > 0 ?
|
||||
options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))
|
||||
:
|
||||
<div className={styles['no-options-container']}>
|
||||
<div className={styles['label']}>No options available</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -109,11 +120,13 @@ Multiselect.propTypes = {
|
|||
direction: PropTypes.any,
|
||||
title: PropTypes.string,
|
||||
renderLabelContent: PropTypes.func,
|
||||
renderLabelText: PropTypes.func,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
disabled: PropTypes.bool,
|
||||
onOpen: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onSelect: PropTypes.func
|
||||
|
|
|
|||
|
|
@ -73,6 +73,24 @@
|
|||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.no-options-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ const styles = require('./styles');
|
|||
const NotificationsMenu = ({ className, onClearButtonClicked }) => {
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
//TODO use useNotifications hook instead of useCatalogs
|
||||
const metaItems = useCatalogs();
|
||||
const metaItems = []; //useCatalogs();
|
||||
|
||||
return (
|
||||
<Popup
|
||||
|
|
|
|||
108
src/common/PaginateInput/PaginateInput.js
Normal file
108
src/common/PaginateInput/PaginateInput.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Multiselect = require('stremio/common/Multiselect');
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
const PaginateInput = ({ className, options, selected, onSelect, ...props }) => {
|
||||
const dataset = useDataset(props);
|
||||
const selectedLabelText = React.useMemo(() => {
|
||||
if (Array.isArray(options)) {
|
||||
const selectedOption = options.find(({ value }) => {
|
||||
return selected === value;
|
||||
});
|
||||
if (selectedOption && typeof selectedOption.label === 'string') {
|
||||
return selectedOption.label;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}, [options, selected]);
|
||||
const prevNextButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
if (Array.isArray(options) && options.length > 0) {
|
||||
const selectedValueIndex = options.findIndex(({ value }) => {
|
||||
return selected === value;
|
||||
});
|
||||
const nextSelectedIndex = event.currentTarget.dataset.button === 'next' ?
|
||||
Math.min(selectedValueIndex + 1, options.length - 1)
|
||||
:
|
||||
Math.max(selectedValueIndex - 1, 0);
|
||||
const nextSelectedValue = options[nextSelectedIndex].value;
|
||||
onSelect({
|
||||
type: 'select',
|
||||
value: nextSelectedValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
} else {
|
||||
const nextSelectedValue = event.currentTarget.dataset.button === 'next' ?
|
||||
selected + 1
|
||||
:
|
||||
Math.max(selected - 1, 1);
|
||||
onSelect({
|
||||
type: 'select',
|
||||
value: nextSelectedValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [dataset, options, selected, onSelect]);
|
||||
const optionOnSelect = React.useCallback((event) => {
|
||||
const page = parseInt(event.value);
|
||||
if (!isNaN(page) && typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
value: page,
|
||||
dataset: dataset,
|
||||
reactEvent: event.reactEvent,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [onSelect]);
|
||||
return (
|
||||
<div className={classnames(className, styles['paginate-input-container'])} title={selected}>
|
||||
<Button className={styles['prev-button-container']} data-button={'prev'} onClick={prevNextButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
|
||||
</Button>
|
||||
<Multiselect
|
||||
className={styles['multiselect-label-container']}
|
||||
renderLabelContent={() => (
|
||||
<div className={styles['multiselect-label']}>{selectedLabelText}</div>
|
||||
)}
|
||||
options={
|
||||
Array.isArray(options) ?
|
||||
options.map(({ value, label }) => ({
|
||||
value: String(value),
|
||||
label
|
||||
}))
|
||||
:
|
||||
null
|
||||
}
|
||||
disabled={!Array.isArray(options) || options.length === 0}
|
||||
onSelect={optionOnSelect}
|
||||
/>
|
||||
<Button className={styles['next-button-container']} data-button={'next'} onClick={prevNextButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PaginateInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.number.isRequired,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
selected: PropTypes.number.isRequired,
|
||||
onSelect: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = PaginateInput;
|
||||
3
src/common/PaginateInput/index.js
Normal file
3
src/common/PaginateInput/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const PaginateInput = require('./PaginateInput');
|
||||
|
||||
module.exports = PaginateInput;
|
||||
32
src/common/PaginateInput/styles.less
Normal file
32
src/common/PaginateInput/styles.less
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.paginate-input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: visible;
|
||||
|
||||
.prev-button-container, .next-button-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-label-container {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.multiselect-label {
|
||||
flex: none;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ const MetaRow = require('./MetaRow');
|
|||
const ModalDialog = require('./ModalDialog');
|
||||
const Multiselect = require('./Multiselect');
|
||||
const NavBar = require('./NavBar');
|
||||
const PaginateInput = require('./PaginateInput');
|
||||
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
|
||||
const Popup = require('./Popup');
|
||||
const SharePrompt = require('./SharePrompt');
|
||||
|
|
@ -23,6 +24,7 @@ const useLiveRef = require('./useLiveRef');
|
|||
const useLocationHash = require('./useLocationHash');
|
||||
const useRouteActive = require('./useRouteActive');
|
||||
const useSpreadState = require('./useSpreadState');
|
||||
const useUser = require('./useUser');
|
||||
|
||||
module.exports = {
|
||||
Button,
|
||||
|
|
@ -36,6 +38,7 @@ module.exports = {
|
|||
ModalDialog,
|
||||
Multiselect,
|
||||
NavBar,
|
||||
PaginateInput,
|
||||
PlayIconCircleCentered,
|
||||
Popup,
|
||||
SharePrompt,
|
||||
|
|
@ -49,5 +52,6 @@ module.exports = {
|
|||
useLiveRef,
|
||||
useLocationHash,
|
||||
useRouteActive,
|
||||
useSpreadState
|
||||
useSpreadState,
|
||||
useUser
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ const routesRegexp = {
|
|||
urlParamsNames: []
|
||||
},
|
||||
discover: {
|
||||
regexp: /^\/discover(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
|
||||
urlParamsNames: ['type', 'catalog']
|
||||
regexp: /^\/discover(?:\/([^\/]+?)\/([^\/]+?)\/([^\/]+?))?\/?$/i,
|
||||
urlParamsNames: ['addonTransportUrl', 'catalogId', 'type']
|
||||
},
|
||||
library: {
|
||||
regexp: /^\/library(?:\/([^\/]*?))?\/?$/i,
|
||||
|
|
|
|||
19
src/common/useUser.js
Normal file
19
src/common/useUser.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const useUser = () => {
|
||||
const { core } = useServices();
|
||||
const [user, setUser] = React.useState(state.ctx.auth ? state.ctx.auth.user : null);
|
||||
React.useEffect(() => {
|
||||
const onNewModel = () => {
|
||||
setUser(state.ctx.auth ? state.ctx.auth.user : null);
|
||||
};
|
||||
core.on('NewModel', onNewModel);
|
||||
return () => {
|
||||
core.off('NewModel', onNewModel);
|
||||
};
|
||||
}, []);
|
||||
return user;
|
||||
};
|
||||
|
||||
module.exports = useUser;
|
||||
|
|
@ -64,7 +64,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
|
|||
const matches = pathname.match(routeConfig.regexp);
|
||||
const urlParams = routeConfig.urlParamsNames.reduce((urlParams, name, index) => {
|
||||
if (Array.isArray(matches) && typeof matches[index + 1] === 'string') {
|
||||
urlParams[name] = matches[index + 1];
|
||||
urlParams[name] = decodeURIComponent(matches[index + 1]);
|
||||
} else {
|
||||
urlParams[name] = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const useCatalogs = () => {
|
|||
React.useEffect(() => {
|
||||
const onNewState = () => {
|
||||
const state = core.getState();
|
||||
setCatalogs(state.catalogs.groups);
|
||||
setCatalogs(state.board.groups);
|
||||
};
|
||||
core.on('NewModel', onNewState);
|
||||
core.dispatch({
|
||||
|
|
|
|||
|
|
@ -1,56 +1,106 @@
|
|||
const React = require('react');
|
||||
const classnames = require('classnames');
|
||||
const { Dropdown, MainNavBar, MetaItem, MetaPreview } = require('stremio/common');
|
||||
const useCatalog = require('./useCatalog');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const { Button, MainNavBar, MetaItem, MetaPreview, Multiselect, PaginateInput, useBinaryState } = require('stremio/common');
|
||||
const useDiscover = require('./useDiscover');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Discover = ({ urlParams, queryParams }) => {
|
||||
const [dropdowns, metaItems] = useCatalog(urlParams, queryParams);
|
||||
const [selectedItem, setSelectedItem] = React.useState(null);
|
||||
const metaItemsOnMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.blurPrevented = true;
|
||||
const [selectInputs, paginateInput, metaItems, error] = useDiscover(urlParams, queryParams);
|
||||
const [selectedMetaItem, setSelectedMetaItem] = React.useState(null);
|
||||
const [filtersModalOpen, openFiltersModal, closeFiltersModal] = useBinaryState(false);
|
||||
const metaItemsOnMouseDownCapture = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonBlurPrevented = true;
|
||||
}, []);
|
||||
const metaItemsOnFocus = React.useCallback((event) => {
|
||||
const metaItemsOnFocusCapture = React.useCallback((event) => {
|
||||
const metaItem = metaItems.find(({ id }) => {
|
||||
return id === event.target.dataset.id;
|
||||
});
|
||||
if (metaItem) {
|
||||
setSelectedItem(metaItem);
|
||||
setSelectedMetaItem(metaItem);
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const metaItem = metaItems.length > 0 ? metaItems[0] : null;
|
||||
setSelectedItem(metaItem);
|
||||
}, [metaItems]);
|
||||
React.useEffect(() => {
|
||||
const metaItem = Array.isArray(metaItems) && metaItems.length > 0 ? metaItems[0] : null;
|
||||
setSelectedMetaItem(metaItem);
|
||||
}, [metaItems]);
|
||||
React.useEffect(() => {
|
||||
closeFiltersModal();
|
||||
}, [urlParams, queryParams]);
|
||||
return (
|
||||
<div className={styles['discover-container']}>
|
||||
<MainNavBar className={styles['nav-bar']} />
|
||||
<div className={styles['discover-content']}>
|
||||
<div className={styles['dropdowns-container']}>
|
||||
{dropdowns.map((dropdown) => (
|
||||
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['meta-items-container']} onFocusCapture={metaItemsOnFocus} onMouseDownCapture={metaItemsOnMouseDown}>
|
||||
{metaItems.map((metaItem) => (
|
||||
<MetaItem
|
||||
{...metaItem}
|
||||
key={metaItem.id}
|
||||
className={classnames(styles['meta-item'], { 'selected': selectedItem !== null && metaItem.id === selectedItem.id })}
|
||||
<div className={styles['controls-container']}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
))}
|
||||
<Button className={styles['filter-container']} onClick={openFiltersModal}>
|
||||
<Icon className={styles['filter-icon']} icon={'ic_filter'} />
|
||||
</Button>
|
||||
<div className={styles['spacing']} />
|
||||
{
|
||||
paginateInput !== null ?
|
||||
<PaginateInput
|
||||
{...paginateInput}
|
||||
className={styles['paginate-input-container']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['catalog-content-container']}>
|
||||
{
|
||||
error !== null ?
|
||||
<div className={styles['message-container']}>
|
||||
{error.type}{error.type === 'Other' ? ` - ${error.content}` : null}
|
||||
</div>
|
||||
:
|
||||
Array.isArray(metaItems) ?
|
||||
<div className={styles['meta-items-container']} onMouseDownCapture={metaItemsOnMouseDownCapture} onFocusCapture={metaItemsOnFocusCapture}>
|
||||
{metaItems.map(({ id, type, name, poster, posterShape }, index) => (
|
||||
<MetaItem
|
||||
key={index}
|
||||
className={classnames(styles['meta-item'], { 'selected': selectedMetaItem !== null && selectedMetaItem.id === id })}
|
||||
type={type}
|
||||
name={name}
|
||||
poster={poster}
|
||||
posterShape={posterShape}
|
||||
data-id={id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
:
|
||||
<div className={styles['message-container']}>
|
||||
Loading
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
selectedItem !== null ?
|
||||
selectedMetaItem !== null ?
|
||||
<MetaPreview
|
||||
{...selectedItem}
|
||||
{...selectedMetaItem}
|
||||
className={styles['meta-preview-container']}
|
||||
compact={true}
|
||||
background={selectedMetaItem.poster}
|
||||
/>
|
||||
:
|
||||
null
|
||||
<div className={styles['meta-preview-container']} />
|
||||
}
|
||||
</div>
|
||||
{
|
||||
filtersModalOpen ?
|
||||
<Modal>
|
||||
{/* TODO */}
|
||||
</Modal>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
:import('~stremio/common/Multiselect/styles.less') {
|
||||
multiselect-menu-container: menu-container;
|
||||
}
|
||||
|
||||
:import('~stremio/common/PaginateInput/styles.less') {
|
||||
paginate-prev-button-container: prev-button-container;
|
||||
paginate-next-button-container: next-button-container;
|
||||
paginate-multiselect-label: multiselect-label;
|
||||
}
|
||||
|
||||
.discover-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -16,41 +26,110 @@
|
|||
flex: 1;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 26rem;
|
||||
grid-template-rows: fit-content(15rem) 1fr;
|
||||
grid-template-columns: 1fr 28rem;
|
||||
grid-template-rows: 7rem 1fr;
|
||||
grid-template-areas:
|
||||
"dropdowns-area meta-preview-area"
|
||||
"meta-items-area meta-preview-area";
|
||||
"controls-area meta-preview-area"
|
||||
"catalog-content-area meta-preview-area";
|
||||
|
||||
.dropdowns-container {
|
||||
grid-area: dropdowns-area;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 15rem);
|
||||
grid-gap: 1rem;
|
||||
.controls-container {
|
||||
grid-area: controls-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 2rem 0;
|
||||
padding: 0 2rem;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
|
||||
.dropdown {
|
||||
.select-input-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
height: 3rem;
|
||||
margin-right: 1rem;
|
||||
|
||||
&:nth-child(n+4) {
|
||||
display: none;
|
||||
|
||||
&~.filter-container {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: none;
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:not(:nth-last-child(2)) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
display: block;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin: 0.9rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.paginate-input-container {
|
||||
flex: none;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.paginate-prev-button-container, .paginate-next-button-container {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.paginate-multiselect-label {
|
||||
max-width: 3rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-items-container {
|
||||
grid-area: meta-items-area;
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
grid-gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0 2rem;
|
||||
overflow-y: auto;
|
||||
.catalog-content-container {
|
||||
grid-area: catalog-content-area;
|
||||
margin-right: 2rem;
|
||||
|
||||
.meta-item {
|
||||
&.selected {
|
||||
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
|
||||
outline-offset: calc(-1.5 * var(--focus-outline-size));
|
||||
.meta-items-container {
|
||||
display: grid;
|
||||
max-height: 100%;
|
||||
grid-auto-rows: max-content;
|
||||
grid-gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0 2rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.meta-item {
|
||||
&:global(.selected) {
|
||||
&::after {
|
||||
outline-width: calc(1.5 * var(--focus-outline-size));
|
||||
outline-offset: calc(-1.5 * var(--focus-outline-size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-container {
|
||||
padding: 0 2rem;
|
||||
font-size: 2rem;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-preview-container {
|
||||
|
|
@ -63,8 +142,10 @@
|
|||
@media only screen and (min-width: @xxlarge) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(8, auto);
|
||||
.catalog-content-container {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,8 +154,10 @@
|
|||
@media only screen and (max-width: @xxlarge) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(7, auto);
|
||||
.catalog-content-container {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,8 +166,10 @@
|
|||
@media only screen and (max-width: @normal) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(6, auto);
|
||||
.catalog-content-container {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,8 +178,10 @@
|
|||
@media only screen and (max-width: @medium) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(5, auto);
|
||||
.catalog-content-container {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,8 +190,10 @@
|
|||
@media only screen and (max-width: @small) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(4, auto);
|
||||
.catalog-content-container {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,15 +205,15 @@
|
|||
grid-template-columns: 1fr;
|
||||
grid-template-rows: fit-content(19rem) 1fr;
|
||||
grid-template-areas:
|
||||
"dropdowns-area"
|
||||
"meta-items-area";
|
||||
"controls-area"
|
||||
"catalog-content-area";
|
||||
|
||||
.dropdowns-container {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.catalog-content-container {
|
||||
margin-right: 0;
|
||||
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(5, auto);
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-preview-container {
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const useCatalog = (urlParams, queryParams) => {
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const [addon, catalog] = React.useMemo(() => {
|
||||
// TODO impl this logic to stremio-core for code-reuse:
|
||||
// TODO use type if it is part of user's addons
|
||||
// TODO fallback to first type from user's addons
|
||||
// TODO find catalog for addonId, catalogId and type
|
||||
// TODO fallback to first catalog for the type from user's catalogs
|
||||
const addon = {
|
||||
id: 'com.linvo.cinemeta',
|
||||
version: '2.11.0',
|
||||
name: 'Cinemeta'
|
||||
};
|
||||
const catalog = {
|
||||
id: 'top',
|
||||
type: 'movie',
|
||||
name: 'Top',
|
||||
extra: [
|
||||
{
|
||||
name: 'genre',
|
||||
isRequired: false,
|
||||
options: ['Action', 'Drama', 'Boring']
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
isRequired: false,
|
||||
options: ['2017', '2016', '2015']
|
||||
}
|
||||
]
|
||||
};
|
||||
return [addon, catalog];
|
||||
}, [urlParams.type, urlParams.catalog]);
|
||||
const dropdowns = React.useMemo(() => {
|
||||
const onTypeChange = (event) => {
|
||||
const { value } = event.currentTarget.dataset;
|
||||
const query = new URLSearchParams(queryParams);
|
||||
window.location = `#/discover/${value}/${addon.id}:${catalog.id}?${query}`;
|
||||
};
|
||||
const onCatalogChange = (event) => {
|
||||
const { value } = event.currentTarget.dataset;
|
||||
const query = new URLSearchParams(queryParams);
|
||||
window.location = `#/discover/${catalog.type}/${value}?${query}`;
|
||||
};
|
||||
const onQueryParamChange = (event) => {
|
||||
const { name, value } = event.currentTarget.dataset;
|
||||
const query = new URLSearchParams({ ...queryParams, [name]: value });
|
||||
window.location = `#/discover/${catalog.type}/${addon.id}:${catalog.id}?${query}`;
|
||||
};
|
||||
const requiredDropdowns = [
|
||||
{
|
||||
name: 'type',
|
||||
selected: [catalog.type],
|
||||
options: [
|
||||
{ value: 'movie', label: 'movie' },
|
||||
{ value: 'series', label: 'series' },
|
||||
{ value: 'channels', label: 'channels' },
|
||||
{ value: 'games', label: 'games' }
|
||||
],
|
||||
onSelect: onTypeChange
|
||||
},
|
||||
{
|
||||
name: 'catalog',
|
||||
selected: [`${addon.id}:${catalog.id}`],
|
||||
options: [
|
||||
{ value: 'com.linvo.cinemeta:top', label: 'Top' },
|
||||
{ value: 'com.linvo.cinemeta:year', label: 'By year' }
|
||||
],
|
||||
onSelect: onCatalogChange
|
||||
}
|
||||
];
|
||||
const extraDropdowns = catalog.extra
|
||||
.filter((extra) => {
|
||||
return extra.name !== 'skip' && extra.name !== 'search';
|
||||
})
|
||||
.map((extra) => ({
|
||||
...extra,
|
||||
onSelect: onQueryParamChange,
|
||||
options: extra.options.map((option) => ({ value: option, label: option })),
|
||||
selected: extra.options.includes(queryParams[extra.name]) ?
|
||||
[queryParams[extra.name]]
|
||||
:
|
||||
extra.isRequired ?
|
||||
[extra.options[0]]
|
||||
:
|
||||
[]
|
||||
}));
|
||||
return requiredDropdowns.concat(extraDropdowns);
|
||||
}, [addon, catalog, queryString]);
|
||||
const items = React.useMemo(() => {
|
||||
return Array(100).fill(null).map((_, index) => ({
|
||||
id: `tt${index}`,
|
||||
type: 'movie',
|
||||
name: `Stremio demo item ${index}`,
|
||||
poster: `https://www.stremio.com/website/technology-hero.jpg`,
|
||||
logo: `https://www.stremio.com/website/stremio-logo-small.png`,
|
||||
posterShape: 'poster'
|
||||
}));
|
||||
}, []);
|
||||
return [dropdowns, items];
|
||||
};
|
||||
|
||||
module.exports = useCatalog;
|
||||
211
src/routes/Discover/useDiscover.js
Normal file
211
src/routes/Discover/useDiscover.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const DEFAULT_ADDON_TRANSPORT_URL = 'https://v3-cinemeta.strem.io/manifest.json';
|
||||
const DEFAULT_CATALOG_ID = 'top';
|
||||
const DEFAULT_TYPE = 'movie';
|
||||
const NONE_EXTRA_VALUE = 'None';
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const useDiscover = (urlParams, queryParams) => {
|
||||
const { core } = useServices();
|
||||
const [discover, setDiscover] = React.useState([[], null, null, null]);
|
||||
React.useEffect(() => {
|
||||
const addonTransportUrl = typeof urlParams.addonTransportUrl === 'string' ? urlParams.addonTransportUrl : DEFAULT_ADDON_TRANSPORT_URL;
|
||||
const catalogId = typeof urlParams.catalogId === 'string' ? urlParams.catalogId : DEFAULT_CATALOG_ID;
|
||||
const type = typeof urlParams.type === 'string' ? urlParams.type : DEFAULT_TYPE;
|
||||
queryParams = new URLSearchParams(queryParams);
|
||||
if (queryParams.has('skip')) {
|
||||
const skip = parseInt(queryParams.get('skip'));
|
||||
if (!isNaN(skip)) {
|
||||
queryParams.set('skip', Math.floor(skip / PAGE_SIZE) * PAGE_SIZE);
|
||||
}
|
||||
}
|
||||
const onNewModel = () => {
|
||||
const state = core.getState();
|
||||
const navigateWithLoad = (load) => {
|
||||
const addonTransportUrl = encodeURIComponent(load.base);
|
||||
const catalogId = encodeURIComponent(load.path.id);
|
||||
const type = encodeURIComponent(load.path.type_name);
|
||||
const extra = new URLSearchParams(load.path.extra).toString();
|
||||
window.location = `#/discover/${addonTransportUrl}/${catalogId}/${type}?${extra}`;
|
||||
};
|
||||
const selectInputs = [
|
||||
{
|
||||
title: 'Select type',
|
||||
options: state.discover.types
|
||||
.map(({ type_name, load }) => ({
|
||||
value: JSON.stringify(load),
|
||||
label: type_name
|
||||
})),
|
||||
selected: state.discover.types
|
||||
.filter(({ is_selected }) => is_selected)
|
||||
.map(({ load }) => JSON.stringify(load)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoad(JSON.parse(event.value));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Select catalog',
|
||||
options: state.discover.catalogs
|
||||
.filter(({ load: { path: { type_name } } }) => {
|
||||
return type_name === type;
|
||||
})
|
||||
.map(({ name, load }) => ({
|
||||
value: JSON.stringify(load),
|
||||
label: name
|
||||
})),
|
||||
selected: state.discover.catalogs
|
||||
.filter(({ is_selected }) => is_selected)
|
||||
.map(({ load }) => JSON.stringify(load)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoad(JSON.parse(event.value));
|
||||
}
|
||||
},
|
||||
...state.discover.selectable_extra
|
||||
.map((extra) => {
|
||||
const title = `Select ${extra.name}`;
|
||||
const options = (extra.isRequired ? [] : [NONE_EXTRA_VALUE])
|
||||
.concat(extra.options)
|
||||
.map((option) => ({
|
||||
value: option,
|
||||
label: option
|
||||
}));
|
||||
const selected = state.discover.selected.path.extra
|
||||
.reduce((selected, [name, value]) => {
|
||||
if (name === extra.name) {
|
||||
selected = selected.filter(value => value !== NONE_EXTRA_VALUE)
|
||||
.concat([value]);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}, extra.isRequired ? [] : [NONE_EXTRA_VALUE]);
|
||||
const renderLabelText = selected.includes(NONE_EXTRA_VALUE) ?
|
||||
() => title
|
||||
:
|
||||
null;
|
||||
const onSelect = (event) => {
|
||||
navigateWithLoad({
|
||||
base: addonTransportUrl,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: type,
|
||||
id: catalogId,
|
||||
extra: Array.from(queryParams.entries())
|
||||
.concat([[extra.name, event.value]])
|
||||
.reduceRight((result, [name, value]) => {
|
||||
if (extra.name === name) {
|
||||
if (event.value !== NONE_EXTRA_VALUE) {
|
||||
const optionsCount = result.reduce((optionsCount, [name]) => {
|
||||
if (extra.name === name) {
|
||||
optionsCount++;
|
||||
}
|
||||
|
||||
return optionsCount;
|
||||
}, 0);
|
||||
if (extra.optionsLimit === 1) {
|
||||
if (optionsCount === 0) {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
} else if (extra.optionsLimit > 1) {
|
||||
const valueIndex = result.findIndex(([_name, _value]) => {
|
||||
return _name === name && _value === value;
|
||||
});
|
||||
if (valueIndex !== -1) {
|
||||
result.splice(valueIndex, 1);
|
||||
} else if (extra.optionsLimit > optionsCount) {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [])
|
||||
}
|
||||
});
|
||||
}
|
||||
return { title, options, selected, renderLabelText, onSelect };
|
||||
})
|
||||
];
|
||||
const paginateInput = state.discover.load_next !== null || state.discover.load_prev !== null || state.discover.selected.path.extra.some(([name]) => name === 'skip') ?
|
||||
{
|
||||
selected: state.discover.selected.path.extra.reduce((page, [name, value]) => {
|
||||
if (name === 'skip') {
|
||||
const parsedValue = parseInt(value);
|
||||
if (!isNaN(parsedValue)) {
|
||||
return Math.floor(parsedValue / 100) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return page;
|
||||
}, 1),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoad({
|
||||
base: addonTransportUrl,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: type,
|
||||
id: catalogId,
|
||||
extra: Array.from(queryParams.entries())
|
||||
.concat([['skip', String((event.value - 1) * PAGE_SIZE)]])
|
||||
.reduceRight((result, [name, value]) => {
|
||||
if (name === 'skip') {
|
||||
const optionsCount = result.reduce((optionsCount, [name]) => {
|
||||
if (name === 'skip') {
|
||||
optionsCount++;
|
||||
}
|
||||
|
||||
return optionsCount;
|
||||
}, 0);
|
||||
if (optionsCount === 0) {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
} else {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [])
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
:
|
||||
null;
|
||||
const items = state.discover.content.type === 'Ready' ?
|
||||
state.discover.content.content.map((metaItem) => ({
|
||||
...metaItem,
|
||||
released: new Date(metaItem.released)
|
||||
}))
|
||||
:
|
||||
null;
|
||||
const error = state.discover.content.type === 'Err' ? state.discover.content.content : null;
|
||||
setDiscover([selectInputs, paginateInput, items, error]);
|
||||
};
|
||||
core.on('NewModel', onNewModel);
|
||||
core.dispatch({
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: {
|
||||
base: addonTransportUrl,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: type,
|
||||
id: catalogId,
|
||||
extra: Array.from(queryParams.entries())
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'Discover');
|
||||
return () => {
|
||||
core.off('NewModel', onNewModel);
|
||||
};
|
||||
}, [urlParams, queryParams]);
|
||||
return discover;
|
||||
};
|
||||
|
||||
module.exports = useDiscover;
|
||||
|
|
@ -8,7 +8,7 @@ const CredentialsTextInput = React.forwardRef((props, ref) => {
|
|||
props.onKeyDown(event);
|
||||
}
|
||||
|
||||
if (!event.navigationPrevented) {
|
||||
if (!event.nativeEvent.navigationPrevented) {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'ArrowDown') {
|
||||
window.navigate('down');
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ function Core() {
|
|||
if (starting) {
|
||||
containerService = new ContainerService(({ name, args } = {}) => {
|
||||
if (active) {
|
||||
events.emit(name, args);
|
||||
try {
|
||||
events.emit(name, args);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
active = true;
|
||||
|
|
@ -52,12 +56,15 @@ function Core() {
|
|||
function off(name, listener) {
|
||||
events.off(name, listener);
|
||||
}
|
||||
function dispatch({ action, args } = {}) {
|
||||
function dispatch(action, model = 'All') {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
containerService.dispatch({ action, args });
|
||||
containerService.dispatch({
|
||||
model,
|
||||
args: action
|
||||
});
|
||||
}
|
||||
function getState() {
|
||||
if (!active) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue