mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
conflict resolved
This commit is contained in:
commit
8c39721c3c
202 changed files with 9041 additions and 3865 deletions
85
.eslintrc
Normal file
85
.eslintrc
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"YT": "readonly",
|
||||
"FB": "readonly"
|
||||
},
|
||||
"env": {
|
||||
"commonjs": true,
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"/*",
|
||||
"!/src",
|
||||
"src/routes/Settings",
|
||||
"src/routes/Player",
|
||||
"src/video"
|
||||
],
|
||||
"rules": {
|
||||
"arrow-parens": "error",
|
||||
"arrow-spacing": "error",
|
||||
"block-spacing": "error",
|
||||
"comma-spacing": "error",
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"indent": [
|
||||
"error",
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"no-console": "error",
|
||||
"no-extra-semi": "error",
|
||||
"no-eq-null": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multiple-empty-lines": [
|
||||
"error",
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-vars": "error",
|
||||
"prefer-const": "error",
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"quote-props": [
|
||||
"error",
|
||||
"as-needed",
|
||||
{
|
||||
"unnecessary": false
|
||||
}
|
||||
],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"space-before-blocks": "error",
|
||||
"valid-typeof": [
|
||||
"error",
|
||||
{
|
||||
"requireStringLiterals": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
53
package.json
53
package.json
|
|
@ -9,50 +9,57 @@
|
|||
"scripts": {
|
||||
"start": "webpack-dev-server --mode development",
|
||||
"build": "webpack --mode production",
|
||||
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060"
|
||||
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060",
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"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",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.9.0",
|
||||
"react-dom": "16.9.0",
|
||||
"react-focus-lock": "2.1.1",
|
||||
"spatial-navigation-polyfill": "git+ssh://git@github.com/NikolaBorislavovHristov/spatial-navigation.git#964d09bf2b0853e27af6c25924b595d6621a019d",
|
||||
"react": "16.12.0",
|
||||
"react-dom": "16.12.0",
|
||||
"react-focus-lock": "2.2.1",
|
||||
"spatial-navigation-polyfill": "git+ssh://git@github.com/Stremio/spatial-navigation.git#381b4f37d138e66ae8ea025e240af3704245e5dc",
|
||||
"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/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/core": "7.7.5",
|
||||
"@babel/plugin-proposal-class-properties": "7.7.4",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.7.4",
|
||||
"@babel/preset-env": "7.7.6",
|
||||
"@babel/preset-react": "7.7.4",
|
||||
"@babel/runtime": "7.7.6",
|
||||
"@storybook/addon-actions": "5.2.8",
|
||||
"@storybook/addon-console": "1.2.1",
|
||||
"@storybook/addons": "5.2.1",
|
||||
"@storybook/react": "5.2.1",
|
||||
"@storybook/addons": "5.2.8",
|
||||
"@storybook/react": "5.2.8",
|
||||
"babel-loader": "8.0.6",
|
||||
"clean-webpack-plugin": "3.0.0",
|
||||
"copy-webpack-plugin": "5.0.4",
|
||||
"css-loader": "3.2.0",
|
||||
"copy-webpack-plugin": "5.1.1",
|
||||
"css-loader": "3.3.2",
|
||||
"cssnano": "4.1.10",
|
||||
"cssnano-preset-advanced": "4.0.7",
|
||||
"eslint": "6.7.2",
|
||||
"eslint-plugin-react": "7.17.0",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"jest": "24.9.0",
|
||||
"less": "3.10.3",
|
||||
"less-loader": "5.0.0",
|
||||
"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",
|
||||
"webpack-cli": "3.3.9",
|
||||
"webpack-dev-server": "3.8.1"
|
||||
"storybook-addon-jsx": "7.1.13",
|
||||
"terser-webpack-plugin": "2.3.0",
|
||||
"webpack": "4.41.3",
|
||||
"webpack-cli": "3.3.10",
|
||||
"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);
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ const { routesRegexp } = require('stremio/common');
|
|||
|
||||
const routerViewsConfig = [
|
||||
[
|
||||
{
|
||||
...routesRegexp.intro,
|
||||
component: routes.Intro
|
||||
},
|
||||
{
|
||||
...routesRegexp.board,
|
||||
component: routes.Board
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
...routesRegexp.intro,
|
||||
component: routes.Intro
|
||||
},
|
||||
{
|
||||
...routesRegexp.discover,
|
||||
|
|
@ -26,9 +28,11 @@ const routerViewsConfig = [
|
|||
],
|
||||
[
|
||||
{
|
||||
...routesRegexp.detail,
|
||||
component: routes.Detail
|
||||
},
|
||||
...routesRegexp.metadetails,
|
||||
component: routes.MetaDetails
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
...routesRegexp.addons,
|
||||
component: routes.Addons
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Button = React.forwardRef(({ children, ...props }, ref) => {
|
||||
const onKeyUp = React.useCallback((event) => {
|
||||
if (typeof props.onKeyUp === 'function') {
|
||||
props.onKeyUp(event);
|
||||
const Button = React.forwardRef(({ className, href, disabled, children, ...props }, ref) => {
|
||||
const onKeyDown = React.useCallback((event) => {
|
||||
if (typeof props.onKeyDown === 'function') {
|
||||
props.onKeyDown(event);
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.nativeEvent.buttonClickPrevented) {
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}, [props.onKeyUp]);
|
||||
}, [props.onKeyDown]);
|
||||
const onMouseDown = React.useCallback((event) => {
|
||||
if (typeof props.onMouseDown === 'function') {
|
||||
props.onMouseDown(event);
|
||||
|
|
@ -25,13 +26,14 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
|
|||
}
|
||||
}, [props.onMouseDown]);
|
||||
return React.createElement(
|
||||
typeof props.href === 'string' && props.href.length > 0 ? 'a' : 'div',
|
||||
typeof href === 'string' && href.length > 0 ? 'a' : 'div',
|
||||
{
|
||||
tabIndex: 0,
|
||||
...props,
|
||||
ref,
|
||||
className: classnames(props.className, styles['button-container'], { 'disabled': props.disabled }),
|
||||
onKeyUp,
|
||||
className: classnames(className, styles['button-container'], { 'disabled': disabled }),
|
||||
href,
|
||||
onKeyDown,
|
||||
onMouseDown
|
||||
},
|
||||
children
|
||||
|
|
@ -40,4 +42,13 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
|
|||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
Button.propTypes = {
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
onKeyDown: PropTypes.func,
|
||||
onMouseDown: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Button;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
.button-container {
|
||||
--spatial-navigation-action: focus;
|
||||
outline-width: var(--focus-outline-size);
|
||||
outline-color: var(--color-surfacelighter);
|
||||
outline-offset: calc(-1 * var(--focus-outline-size));
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
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 styles = require('./styles');
|
||||
|
||||
const Checkbox = React.forwardRef((props, ref) => {
|
||||
const Checkbox = React.forwardRef(({ className, checked, children, ...props }, ref) => {
|
||||
return (
|
||||
<Button {...props} ref={ref} className={classnames(props.className, styles['checkbox-container'], { 'checked': props.checked })}>
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['checkbox-container'], { 'checked': checked })}>
|
||||
{
|
||||
props.checked ?
|
||||
checked ?
|
||||
<svg className={styles['icon']} viewBox={'0 0 100 100'}>
|
||||
<Icon x={'10'} y={'10'} width={'80'} height={'80'} icon={'ic_check'} />
|
||||
</svg>
|
||||
:
|
||||
<Icon className={styles['icon']} icon={'ic_box_empty'} />
|
||||
}
|
||||
{props.children}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
Checkbox.propTypes = {
|
||||
className: PropTypes.string,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
module.exports = Checkbox;
|
||||
|
|
|
|||
|
|
@ -1,74 +1,72 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const AColorPicker = require('a-color-picker');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const Button = require('stremio/common/Button');
|
||||
const ModalDialog = require('stremio/common/ModalDialog');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const ColorPicker = require('./ColorPicker');
|
||||
const styles = require('./styles');
|
||||
|
||||
const COLOR_FORMAT = 'hexcss4';
|
||||
const parseColor = (value) => {
|
||||
return AColorPicker.parseColor(value, 'hexcss4');
|
||||
};
|
||||
|
||||
const ColorInput = ({ className, value, onChange, ...props }) => {
|
||||
value = AColorPicker.parseColor(value, COLOR_FORMAT);
|
||||
const dataset = useDataset(props);
|
||||
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
||||
const [modalOpen, openModal, closeModal] = useBinaryState(false);
|
||||
const [tempValue, setTempValue] = React.useState(value);
|
||||
const pickerLabelOnClick = React.useCallback((event) => {
|
||||
const [tempValue, setTempValue] = React.useState(() => {
|
||||
return parseColor(value);
|
||||
});
|
||||
const labelButtonStyle = React.useMemo(() => ({
|
||||
backgroundColor: value
|
||||
}), [value]);
|
||||
const labelButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.openModalPrevented) {
|
||||
openModal();
|
||||
}
|
||||
}, []);
|
||||
const modalContainerOnClick = React.useCallback((event) => {
|
||||
}, [props.onClick]);
|
||||
const modalDialogOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.openModalPrevented = true;
|
||||
}, []);
|
||||
const modalContainerOnMouseDown = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.closeModalPrevented) {
|
||||
closeModal();
|
||||
}
|
||||
}, []);
|
||||
const modalContentOnMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closeModalPrevented = true;
|
||||
}, []);
|
||||
const colorPickerOnInput = React.useCallback((event) => {
|
||||
setTempValue(event.value);
|
||||
}, []);
|
||||
const submitButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: tempValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
const modalButtons = React.useMemo(() => {
|
||||
const selectButtonOnClick = (event) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: tempValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}, [onChange, tempValue, dataset]);
|
||||
React.useEffect(() => {
|
||||
setTempValue(value);
|
||||
closeModal();
|
||||
};
|
||||
return [
|
||||
{
|
||||
label: 'Select',
|
||||
props: {
|
||||
'data-autofocus': true,
|
||||
onClick: selectButtonOnClick
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [tempValue, dataset, onChange]);
|
||||
const colorPickerOnInput = React.useCallback((event) => {
|
||||
setTempValue(parseColor(event.value));
|
||||
}, []);
|
||||
React.useLayoutEffect(() => {
|
||||
setTempValue(parseColor(value));
|
||||
}, [value, modalOpen]);
|
||||
return (
|
||||
<Button style={{ backgroundColor: value }} className={className} title={value} onClick={pickerLabelOnClick}>
|
||||
<Button title={value} {...props} style={labelButtonStyle} className={className} onClick={labelButtonOnClick}>
|
||||
{
|
||||
modalOpen ?
|
||||
<Modal className={styles['color-input-modal-container']} onMouseDown={modalContainerOnMouseDown} onClick={modalContainerOnClick}>
|
||||
<div className={styles['color-input-container']} onMouseDown={modalContentOnMouseDown}>
|
||||
<div className={styles['header-container']}>
|
||||
<div className={styles['title']}>Choose a color:</div>
|
||||
<Button className={styles['close-button-container']} title={'Close'} onClick={closeModal}>
|
||||
<Icon className={styles['icon']} icon={'ic_x'} />
|
||||
</Button>
|
||||
</div>
|
||||
<ColorPicker className={styles['color-picker']} value={tempValue} onInput={colorPickerOnInput} />
|
||||
<Button className={styles['submit-button-container']} title={'Submit'} onClick={submitButtonOnClick}>
|
||||
<div className={styles['label']}>Select</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<ModalDialog title={'Choose a color:'} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
|
||||
<ColorPicker value={tempValue} onInput={colorPickerOnInput} />
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -77,8 +75,11 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
|
|||
};
|
||||
|
||||
ColorInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
onChange: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ColorInput;
|
||||
|
|
|
|||
|
|
@ -4,42 +4,43 @@ const classnames = require('classnames');
|
|||
const AColorPicker = require('a-color-picker');
|
||||
const styles = require('./styles');
|
||||
|
||||
const COLOR_FORMAT = 'hexcss4';
|
||||
const parseColor = (value) => {
|
||||
return AColorPicker.parseColor(value, 'hexcss4');
|
||||
};
|
||||
|
||||
// TODO implement custom picker which is keyboard accessible
|
||||
const ColorPicker = ({ className, value, onInput }) => {
|
||||
value = AColorPicker.parseColor(value, COLOR_FORMAT);
|
||||
const pickerRef = React.useRef(null);
|
||||
const pickerElementRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
pickerRef.current = AColorPicker.createPicker(pickerElementRef.current, {
|
||||
color: value,
|
||||
color: parseColor(value),
|
||||
showHSL: false,
|
||||
showHEX: false,
|
||||
showRGB: false,
|
||||
showAlpha: true
|
||||
});
|
||||
const clipboardPicker = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
||||
if (clipboardPicker instanceof HTMLElement) {
|
||||
clipboardPicker.tabIndex = -1;
|
||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
||||
if (pickerClipboard instanceof HTMLElement) {
|
||||
pickerClipboard.tabIndex = -1;
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
pickerRef.current.on('change', (picker, color) => {
|
||||
if (typeof onInput === 'function') {
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof onInput === 'function') {
|
||||
pickerRef.current.on('change', (picker, value) => {
|
||||
onInput({
|
||||
type: 'input',
|
||||
value: AColorPicker.parseColor(color, COLOR_FORMAT)
|
||||
value: parseColor(value)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
pickerRef.current.off('change');
|
||||
};
|
||||
}, [onInput]);
|
||||
React.useEffect(() => {
|
||||
if (AColorPicker.parseColor(pickerRef.current.color, COLOR_FORMAT) !== value) {
|
||||
pickerRef.current.color = value;
|
||||
React.useLayoutEffect(() => {
|
||||
const nextValue = parseColor(value);
|
||||
if (nextValue !== parseColor(pickerRef.current.color)) {
|
||||
pickerRef.current.color = nextValue;
|
||||
}
|
||||
}, [value]);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
.color-input-modal-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
background-color: var(--color-backgrounddarker40);
|
||||
|
||||
.color-input-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 25rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
.header-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.2rem;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.close-button-container {
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0.25rem;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark20);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacedarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
flex: none;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.submit-button-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-signal5);
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.label {
|
||||
max-height: 2.4em;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => {
|
||||
const Image = ({ className, src, alt, fallbackSrc, renderFallback, ...props }) => {
|
||||
const [broken, setBroken] = React.useState(false);
|
||||
const onError = React.useCallback(() => {
|
||||
const onError = React.useCallback((event) => {
|
||||
if (typeof props.onError === 'function') {
|
||||
props.onError(event);
|
||||
}
|
||||
|
||||
setBroken(true);
|
||||
}, []);
|
||||
}, [props.onError]);
|
||||
React.useLayoutEffect(() => {
|
||||
setBroken(false);
|
||||
}, [src]);
|
||||
|
|
@ -13,9 +17,9 @@ const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => {
|
|||
typeof renderFallback === 'function' ?
|
||||
renderFallback()
|
||||
:
|
||||
<img className={className} src={fallbackSrc} alt={alt} />
|
||||
<img {...props} className={className} src={fallbackSrc} alt={alt} />
|
||||
:
|
||||
<img className={className} src={src} alt={alt} onError={onError} />;
|
||||
<img {...props} className={className} src={src} alt={alt} onError={onError} />;
|
||||
};
|
||||
|
||||
Image.propTypes = {
|
||||
|
|
@ -23,7 +27,8 @@ Image.propTypes = {
|
|||
src: PropTypes.string,
|
||||
alt: PropTypes.string,
|
||||
fallbackSrc: PropTypes.string,
|
||||
renderFallback: PropTypes.func
|
||||
renderFallback: PropTypes.func,
|
||||
onError: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Image;
|
||||
|
|
|
|||
|
|
@ -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,9 @@ const MainNavBar = ({ className }) => {
|
|||
navMenu={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MainNavBar.displayName = 'MainNavBar';
|
||||
|
||||
MainNavBar.propTypes = {
|
||||
className: PropTypes.string
|
||||
|
|
|
|||
|
|
@ -7,57 +7,59 @@ const Image = require('stremio/common/Image');
|
|||
const Multiselect = require('stremio/common/Multiselect');
|
||||
const PlayIconCircleCentered = require('stremio/common/PlayIconCircleCentered');
|
||||
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 MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, options, dataset, optionOnSelect, ...props }) => {
|
||||
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
|
||||
const metaItemOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
}, [onSelect, dataset]);
|
||||
const multiselectOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.selectMetaItemPrevented = true;
|
||||
|
||||
if (event.nativeEvent.selectPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, [props.onClick]);
|
||||
const menuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.selectPrevented = true;
|
||||
}, []);
|
||||
const multiselectOnSelect = React.useCallback((event) => {
|
||||
if (typeof menuOptionOnSelect === 'function') {
|
||||
menuOptionOnSelect({
|
||||
const menuOnSelect = React.useCallback((event) => {
|
||||
if (typeof optionOnSelect === 'function') {
|
||||
optionOnSelect({
|
||||
type: 'select-option',
|
||||
value: event.value,
|
||||
dataset: dataset,
|
||||
reactEvent: event.reactEvent,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [menuOptionOnSelect, dataset]);
|
||||
}, [dataset, optionOnSelect]);
|
||||
const renderPosterFallback = React.useMemo(() => () => (
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={ICON_FOR_TYPE.has(type) ? ICON_FOR_TYPE.get(type) : ICON_FOR_TYPE.get('other')}
|
||||
/>
|
||||
), [type]);
|
||||
const renderMenuLabelContent = React.useMemo(() => () => (
|
||||
<Icon className={styles['icon']} icon={'ic_more'} />
|
||||
), []);
|
||||
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
|
||||
className={styles['poster-image']}
|
||||
src={poster}
|
||||
alt={' '}
|
||||
renderFallback={() => (
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
|
||||
/>
|
||||
)}
|
||||
renderFallback={renderPosterFallback}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
|
@ -78,7 +80,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
}
|
||||
</div>
|
||||
{
|
||||
(typeof name === 'string' && name.length > 0) || (Array.isArray(menuOptions) && menuOptions.length > 0) ?
|
||||
(typeof name === 'string' && name.length > 0) || (Array.isArray(options) && options.length > 0) ?
|
||||
<div className={styles['title-bar-container']}>
|
||||
{
|
||||
typeof name === 'string' && name.length > 0 ?
|
||||
|
|
@ -87,19 +89,16 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
|
|||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(menuOptions) && menuOptions.length > 0 ?
|
||||
<div className={styles['multiselect-container']} onClick={multiselectOnClick}>
|
||||
<Multiselect
|
||||
className={styles['multiselect-label-container']}
|
||||
renderLabelContent={() => (
|
||||
<Icon className={styles['icon']} icon={'ic_more'} />
|
||||
)}
|
||||
options={menuOptions}
|
||||
onOpen={onMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
onSelect={multiselectOnSelect}
|
||||
/>
|
||||
</div>
|
||||
Array.isArray(options) && options.length > 0 ?
|
||||
<Multiselect
|
||||
className={styles['menu-label-container']}
|
||||
renderLabelContent={renderMenuLabelContent}
|
||||
options={options}
|
||||
onOpen={onMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
onSelect={menuOnSelect}
|
||||
onClick={menuOnClick}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -121,9 +120,10 @@ MetaItem.propTypes = {
|
|||
posterShape: PropTypes.oneOf(['poster', 'landscape', 'square']),
|
||||
playIcon: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
menuOptions: PropTypes.array,
|
||||
onSelect: PropTypes.func,
|
||||
menuOptionOnSelect: PropTypes.func
|
||||
options: PropTypes.array,
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
optionOnSelect: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = MetaItem;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -27,11 +41,9 @@
|
|||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.multiselect-container {
|
||||
.multiselect-label-container {
|
||||
.icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
.menu-label-container {
|
||||
.icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -148,50 +160,43 @@
|
|||
}
|
||||
}
|
||||
|
||||
.multiselect-container {
|
||||
|
||||
.menu-label-container {
|
||||
flex: none;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
overflow: visible;
|
||||
padding: 0.75rem;
|
||||
background-color: transparent;
|
||||
|
||||
.multiselect-label-container {
|
||||
&:hover, &:global(.active) {
|
||||
background-color: var(--color-surface80);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: transparent;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
background-color: var(--color-surface80);
|
||||
}
|
||||
.popup-menu-container {
|
||||
width: auto;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
.multiselect-menu-container {
|
||||
min-width: 8rem;
|
||||
max-width: 12rem;
|
||||
|
||||
.popup-menu-container {
|
||||
width: auto;
|
||||
right: initial;
|
||||
left: 0;
|
||||
.multiselect-option-container {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
.multiselect-menu-container {
|
||||
min-width: 8rem;
|
||||
max-width: 12rem;
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.multiselect-option-container {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.multiselect-option-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
.multiselect-option-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const MetaLinks = ({ className, label, links }) => {
|
|||
Array.isArray(links) && links.length > 0 ?
|
||||
<div className={styles['links-container']}>
|
||||
{links.map(({ label, href }, index) => (
|
||||
<Button key={`${label}-${href}-${index}`} className={styles['link-container']} title={label} tabIndex={-1} href={href}>
|
||||
<Button key={index} className={styles['link-container']} title={label} tabIndex={-1} href={href}>
|
||||
{label}
|
||||
{index < links.length - 1 ? ',' : null}
|
||||
</Button>
|
||||
|
|
@ -34,8 +34,8 @@ MetaLinks.propTypes = {
|
|||
className: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
links: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
href: PropTypes.string.isRequired
|
||||
label: PropTypes.string,
|
||||
href: PropTypes.string
|
||||
}))
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,80 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Modal } = require('stremio-router');
|
||||
const UrlUtils = require('url');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Image = require('stremio/common/Image');
|
||||
const ModalDialog = require('stremio/common/ModalDialog');
|
||||
const SharePrompt = require('stremio/common/SharePrompt');
|
||||
const routesRegexp = require('stremio/common/routesRegexp');
|
||||
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 IMDB_LINK_CATEGORY = 'imdb';
|
||||
const SHARE_LINK_CATEGORY = 'share';
|
||||
const ALLOWED_LINK_REDIRECTS = [
|
||||
routesRegexp.search.regexp,
|
||||
routesRegexp.discover.regexp,
|
||||
routesRegexp.metadetails.regexp
|
||||
];
|
||||
|
||||
const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, links, trailer, inLibrary, toggleInLibrary }) => {
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const genresLinks = React.useMemo(() => {
|
||||
return Array.isArray(genres) ?
|
||||
genres.filter(genre => typeof genre === 'string')
|
||||
.map((genre) => ({
|
||||
label: genre,
|
||||
href: `#/discover/${type}//?genre=${genre}`
|
||||
}))
|
||||
const linksGroups = React.useMemo(() => {
|
||||
return Array.isArray(links) ?
|
||||
links
|
||||
.filter((link) => {
|
||||
return link &&
|
||||
typeof link.category === 'string' &&
|
||||
typeof link.url === 'string';
|
||||
})
|
||||
.reduce((linksGroups, { category, name, url }) => {
|
||||
if (category === IMDB_LINK_CATEGORY) {
|
||||
linksGroups[category] = {
|
||||
label: name,
|
||||
href: `https://www.stremio.com/warning#${encodeURIComponent(`https://www.imdb.com/title/${url}`)}`
|
||||
};
|
||||
} else if (category === SHARE_LINK_CATEGORY) {
|
||||
linksGroups[category] = {
|
||||
label: name,
|
||||
href: url
|
||||
};
|
||||
} else {
|
||||
const { protocol, host, path, pathname } = UrlUtils.parse(url);
|
||||
if (protocol === 'stremio:') {
|
||||
if (ALLOWED_LINK_REDIRECTS.some((regexp) => pathname.match(regexp))) {
|
||||
linksGroups[category] = linksGroups[category] || [];
|
||||
linksGroups[category].push({
|
||||
label: name,
|
||||
href: `#${path}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof host === 'string' && host.length > 0) {
|
||||
linksGroups[category] = linksGroups[category] || [];
|
||||
linksGroups[category].push({
|
||||
label: name,
|
||||
href: `https://www.stremio.com/warning#${encodeURIComponent(url)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return linksGroups;
|
||||
}, {})
|
||||
:
|
||||
[];
|
||||
}, [type, genres]);
|
||||
const writersLinks = React.useMemo(() => {
|
||||
return Array.isArray(writers) ?
|
||||
writers.filter(writer => typeof writer === 'string')
|
||||
.map((writer) => ({
|
||||
label: writer,
|
||||
href: `#/search?q=${writer}`
|
||||
}))
|
||||
:
|
||||
[];
|
||||
}, [writers]);
|
||||
const directorsLinks = React.useMemo(() => {
|
||||
return Array.isArray(directors) ?
|
||||
directors.filter(director => typeof director === 'string')
|
||||
.map((director) => ({
|
||||
label: director,
|
||||
href: `#/search?q=${director}`
|
||||
}))
|
||||
:
|
||||
[];
|
||||
}, [directors]);
|
||||
const castLinks = React.useMemo(() => {
|
||||
return Array.isArray(cast) ?
|
||||
cast.filter(name => typeof name === 'string')
|
||||
.map((name) => ({
|
||||
label: name,
|
||||
href: `#/search?q=${name}`
|
||||
}))
|
||||
:
|
||||
[];
|
||||
}, [cast]);
|
||||
const shareModalBackgroundOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.closeShareModalPrevented) {
|
||||
closeShareModal();
|
||||
}
|
||||
}, []);
|
||||
const shareModalContentOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.closeShareModalPrevented = true;
|
||||
}, []);
|
||||
}, [links]);
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })}>
|
||||
{
|
||||
typeof background === 'string' && background.length > 0 ?
|
||||
<div className={styles['background-image-layer']}>
|
||||
<img
|
||||
key={background}
|
||||
className={styles['background-image']}
|
||||
src={background}
|
||||
alt={' '}
|
||||
|
|
@ -75,18 +86,24 @@ 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={() => (
|
||||
<Icon
|
||||
className={styles['logo-placeholder-icon']}
|
||||
icon={'ic_broken_link'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
:
|
||||
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 +114,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
|
||||
}
|
||||
|
|
@ -106,9 +123,14 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['name-container']}>
|
||||
{typeof name === 'string' && name.length > 0 ? name : id}
|
||||
</div>
|
||||
{
|
||||
typeof name === 'string' && name.length > 0 ?
|
||||
<div className={styles['name-container']}>
|
||||
{name}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof description === 'string' && description.length > 0 ?
|
||||
<div className={styles['description-container']}>{description}</div>
|
||||
|
|
@ -116,28 +138,19 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
null
|
||||
}
|
||||
{
|
||||
genresLinks.length > 0 ?
|
||||
<MetaLinks className={styles['meta-links']} label={'Genres'} links={genresLinks} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
writersLinks.length > 0 ?
|
||||
<MetaLinks className={styles['meta-links']} label={'Writers'} links={writersLinks} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
directorsLinks.length > 0 ?
|
||||
<MetaLinks className={styles['meta-links']} label={'Directors'} links={directorsLinks} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
castLinks.length > 0 ?
|
||||
<MetaLinks className={styles['meta-links']} label={'Cast'} links={castLinks} />
|
||||
:
|
||||
null
|
||||
Object.keys(linksGroups)
|
||||
.filter((category) => {
|
||||
return category !== IMDB_LINK_CATEGORY &&
|
||||
category !== SHARE_LINK_CATEGORY;
|
||||
})
|
||||
.map((category, index) => (
|
||||
<MetaLinks
|
||||
key={index}
|
||||
className={styles['meta-links']}
|
||||
label={category}
|
||||
links={linksGroups[category]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className={styles['action-buttons-container']}>
|
||||
|
|
@ -147,32 +160,30 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
className={styles['action-button']}
|
||||
icon={inLibrary ? 'ic_removelib' : 'ic_addlib'}
|
||||
label={inLibrary ? 'Remove from Library' : 'Add to library'}
|
||||
data-id={id}
|
||||
onClick={toggleInLibrary}
|
||||
{...(!compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof trailer === 'string' && trailer.length > 0 ?
|
||||
<ActionButton
|
||||
className={styles['action-button']}
|
||||
icon={'ic_movies'}
|
||||
label={'Trailer'}
|
||||
href={`#/player?stream=${trailer}`}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof imdbId === 'string' && imdbId.length > 0 ?
|
||||
typeof trailer === 'object' && trailer !== null ?
|
||||
<ActionButton
|
||||
className={styles['action-button']}
|
||||
icon={'ic_movies'}
|
||||
label={'Trailer'}
|
||||
href={`#/player?stream=${JSON.stringify(trailer)}`}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof linksGroups[IMDB_LINK_CATEGORY] === 'object' ?
|
||||
<ActionButton
|
||||
{...linksGroups[IMDB_LINK_CATEGORY]}
|
||||
className={styles['action-button']}
|
||||
icon={'ic_imdb'}
|
||||
label={typeof imdbRating === 'string' && imdbRating.length > 0 ? `${imdbRating} / 10` : null}
|
||||
href={`https://imdb.com/title/${imdbId}`}
|
||||
target={'_blank'}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
|
|
@ -180,7 +191,7 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
null
|
||||
}
|
||||
{
|
||||
!compact && typeof share === 'string' && share.length > 0 ?
|
||||
!compact && typeof linksGroups[SHARE_LINK_CATEGORY] === 'object' ?
|
||||
<React.Fragment>
|
||||
<ActionButton
|
||||
className={styles['action-button']}
|
||||
|
|
@ -191,14 +202,12 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
/>
|
||||
{
|
||||
shareModalOpen ?
|
||||
<Modal>
|
||||
<div style={{ width: '100%', height: '100%' }} onClick={shareModalBackgroundOnClick}>
|
||||
<div
|
||||
style={{ width: '50%', height: '50%', backgroundColor: 'red' }}
|
||||
onClick={shareModalContentOnClick}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<ModalDialog title={'Share'} onCloseRequest={closeShareModal}>
|
||||
<SharePrompt
|
||||
className={styles['share-prompt']}
|
||||
url={linksGroups[SHARE_LINK_CATEGORY].href}
|
||||
/>
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -216,23 +225,19 @@ MetaPreview.Placeholder = MetaPreviewPlaceholder;
|
|||
MetaPreview.propTypes = {
|
||||
className: PropTypes.string,
|
||||
compact: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
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,
|
||||
genres: PropTypes.arrayOf(PropTypes.string),
|
||||
writers: PropTypes.arrayOf(PropTypes.string),
|
||||
directors: PropTypes.arrayOf(PropTypes.string),
|
||||
cast: PropTypes.arrayOf(PropTypes.string),
|
||||
imdbId: PropTypes.string,
|
||||
imdbRating: PropTypes.string,
|
||||
trailer: PropTypes.string,
|
||||
share: PropTypes.string,
|
||||
links: PropTypes.arrayOf(PropTypes.shape({
|
||||
category: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
})),
|
||||
trailer: PropTypes.object,
|
||||
inLibrary: PropTypes.bool,
|
||||
toggleInLibrary: PropTypes.func
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -108,4 +119,8 @@
|
|||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.share-prompt {
|
||||
width: 24rem;
|
||||
}
|
||||
|
|
@ -7,9 +7,8 @@ const MetaItem = require('stremio/common/MetaItem');
|
|||
const MetaRowPlaceholder = require('./MetaRowPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaRow = ({ className, title, message, items, maximumItemsCount, itemMenuOptions }) => {
|
||||
maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20;
|
||||
items = Array.isArray(items) ? items.slice(0, maximumItemsCount) : [];
|
||||
const MetaRow = ({ className, title, message, items, limit, href }) => {
|
||||
items = Array.isArray(items) ? items.slice(0, limit) : [];
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-row-container'])}>
|
||||
{
|
||||
|
|
@ -22,27 +21,29 @@ const MetaRow = ({ className, title, message, items, maximumItemsCount, itemMenu
|
|||
typeof message === 'string' && message.length > 0 ?
|
||||
<div className={styles['message-container']} title={message}>{message}</div>
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={styles['content-container']}>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{items.map((item, index) => (
|
||||
<MetaItem
|
||||
{...item}
|
||||
key={index}
|
||||
data-id={item.id}
|
||||
data-type={item.type}
|
||||
className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${item.posterShape}`])}
|
||||
menuOptions={itemMenuOptions}
|
||||
/>
|
||||
))}
|
||||
{Array(Math.max(maximumItemsCount - items.length, 0)).fill(null).map((_, index) => (
|
||||
{Array(limit - items.length).fill(null).map((_, index) => (
|
||||
<div key={index} className={classnames(styles['meta-item'], styles['poster-shape-poster'])} />
|
||||
))}
|
||||
</div>
|
||||
<Button className={styles['see-all-container']} title={'SEE ALL'}>
|
||||
<div className={styles['label']}>SEE ALL</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_thin_right'} />
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
{
|
||||
typeof href === 'string' && href.length > 0 ?
|
||||
<Button className={styles['see-all-container']} title={'SEE ALL'} href={href}>
|
||||
<div className={styles['label']}>SEE ALL</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_thin_right'} />
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -57,8 +58,8 @@ MetaRow.propTypes = {
|
|||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
posterShape: PropTypes.string
|
||||
})),
|
||||
maximumItemsCount: PropTypes.number,
|
||||
itemMenuOptions: PropTypes.any
|
||||
limit: PropTypes.number.isRequired,
|
||||
href: PropTypes.string
|
||||
};
|
||||
|
||||
module.exports = MetaRow;
|
||||
|
|
|
|||
|
|
@ -3,20 +3,21 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaRowPlaceholder = ({ className, title, maximumItemsCount }) => {
|
||||
maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20;
|
||||
const MetaRowPlaceholder = ({ className, title, limit }) => {
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-row-placeholder-container'])}>
|
||||
<div className={styles['title-container']} title={title}>{title}</div>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{Array(maximumItemsCount).fill(null).map((_, index) => (
|
||||
<div key={index} className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
<div className={styles['title-bar-container']} />
|
||||
</div>
|
||||
))}
|
||||
<div className={styles['content-container']}>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{Array(limit).fill(null).map((_, index) => (
|
||||
<div key={index} className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
<div className={styles['title-bar-container']} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['see-all-container']} />
|
||||
</div>
|
||||
<div className={styles['see-all-container']} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -24,7 +25,7 @@ const MetaRowPlaceholder = ({ className, title, maximumItemsCount }) => {
|
|||
MetaRowPlaceholder.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
maximumItemsCount: PropTypes.number
|
||||
limit: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
module.exports = MetaRowPlaceholder;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
.meta-row-placeholder-container {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr minmax(12rem, 1fr);
|
||||
grid-template-areas:
|
||||
"title-area title-area"
|
||||
"meta-items-area see-all-area";
|
||||
|
||||
.title-container {
|
||||
grid-area: title-area;
|
||||
max-height: 2.4em;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.5rem;
|
||||
|
|
@ -18,29 +11,36 @@
|
|||
}
|
||||
}
|
||||
|
||||
.meta-items-container {
|
||||
grid-area: meta-items-area;
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
|
||||
.meta-item {
|
||||
.meta-items-container {
|
||||
flex: 1;
|
||||
margin-right: 2rem;
|
||||
background-color: var(--color-placeholder);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
|
||||
.poster-container {
|
||||
padding-bottom: calc(100% * var(--poster-shape-ratio));
|
||||
}
|
||||
|
||||
.title-bar-container {
|
||||
height: 2.8rem;
|
||||
.meta-item {
|
||||
flex: 1;
|
||||
margin-right: 2rem;
|
||||
background-color: var(--color-placeholder);
|
||||
|
||||
.poster-container {
|
||||
padding-bottom: calc(100% * var(--poster-shape-ratio));
|
||||
}
|
||||
|
||||
.title-bar-container {
|
||||
height: 2.8rem;
|
||||
background-color: var(--color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.see-all-container {
|
||||
grid-area: see-all-area;
|
||||
background-color: var(--color-placeholder);
|
||||
.see-all-container {
|
||||
flex: none;
|
||||
background-color: var(--color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
.meta-row-container {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr minmax(12rem, 1fr);
|
||||
grid-template-areas:
|
||||
"title-area title-area"
|
||||
"message-area message-area"
|
||||
"meta-items-area see-all-area";
|
||||
overflow: visible;
|
||||
|
||||
.title-container {
|
||||
grid-area: title-area;
|
||||
max-height: 2.4em;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.5rem;
|
||||
|
|
@ -16,66 +9,71 @@
|
|||
}
|
||||
|
||||
.message-container {
|
||||
grid-area: message-area;
|
||||
max-height: 3.6em;
|
||||
font-size: 1.3rem;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.meta-items-container {
|
||||
grid-area: meta-items-area;
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: visible;
|
||||
|
||||
.meta-item {
|
||||
margin-right: 2rem;
|
||||
.meta-items-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: visible;
|
||||
|
||||
&.poster-shape-poster {
|
||||
flex: calc(1 / var(--poster-shape-ratio));
|
||||
}
|
||||
.meta-item {
|
||||
margin-right: 2rem;
|
||||
|
||||
&.poster-shape-square {
|
||||
flex: 1;
|
||||
}
|
||||
&.poster-shape-poster {
|
||||
flex: calc(1 / var(--poster-shape-ratio));
|
||||
}
|
||||
|
||||
&.poster-shape-landscape {
|
||||
flex: calc(1 / var(--landscape-shape-ratio));
|
||||
&.poster-shape-square {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.poster-shape-landscape {
|
||||
flex: calc(1 / var(--landscape-shape-ratio));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.see-all-container {
|
||||
grid-area: see-all-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlight);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline-offset: 0;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 2.4em;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
.see-all-container {
|
||||
flex: none;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
margin-left: 0.3rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlight);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-backgroundlighter);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 2.4em;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
margin-left: 0.3rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/common/ModalDialog/ModalDialog.js
Normal file
119
src/common/ModalDialog/ModalDialog.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, ...props }) => {
|
||||
const onModalDialogContainerMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closeModalDialogPrevented = true;
|
||||
}, []);
|
||||
const closeButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
onCloseRequest({
|
||||
type: 'close',
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [dataset, onCloseRequest]);
|
||||
React.useEffect(() => {
|
||||
const onCloseEvent = (event) => {
|
||||
if (!event.closeModalDialogPrevented && typeof onCloseRequest === 'function') {
|
||||
const closeEvent = {
|
||||
type: 'close',
|
||||
dataset: dataset,
|
||||
nativeEvent: event
|
||||
};
|
||||
switch (event.type) {
|
||||
case 'resize':
|
||||
onCloseRequest(closeEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
case 'mousedown':
|
||||
if (event.target !== document.documentElement) {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onCloseEvent);
|
||||
window.addEventListener('keydown', onCloseEvent);
|
||||
window.addEventListener('mousedown', onCloseEvent);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onCloseEvent);
|
||||
window.removeEventListener('keydown', onCloseEvent);
|
||||
window.removeEventListener('mousedown', onCloseEvent);
|
||||
};
|
||||
}, [dataset, onCloseRequest]);
|
||||
return (
|
||||
<Modal {...props} className={classnames(className, styles['modal-container'])}>
|
||||
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
|
||||
<div className={styles['header-container']}>
|
||||
{
|
||||
typeof title === 'string' && title.length > 0 ?
|
||||
<div className={styles['title-container']} title={title}>{title}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<Button className={styles['close-button-container']} title={'Close'} onClick={closeButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_x'} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['modal-dialog-content']}>
|
||||
{children}
|
||||
{
|
||||
Array.isArray(buttons) && buttons.length > 0 ?
|
||||
<div className={styles['buttons-container']}>
|
||||
{buttons.map(({ className, label, icon, props }, index) => (
|
||||
<Button title={label} {...props} key={index} className={classnames(className, styles['action-button'])}>
|
||||
{
|
||||
typeof icon === 'string' && icon.length > 0 ?
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof label === 'string' && label.length > 0 ?
|
||||
<div className={styles['label']}>{label}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
ModalDialog.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
buttons: PropTypes.arrayOf(PropTypes.shape({
|
||||
className: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
props: PropTypes.object
|
||||
})),
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]),
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
onCloseRequest: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ModalDialog;
|
||||
3
src/common/ModalDialog/index.js
Normal file
3
src/common/ModalDialog/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const ModalDialog = require('./ModalDialog');
|
||||
|
||||
module.exports = ModalDialog;
|
||||
117
src/common/ModalDialog/styles.less
Normal file
117
src/common/ModalDialog/styles.less
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
.modal-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-backgrounddark40);
|
||||
|
||||
.modal-dialog-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
.header-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding: 0.2rem 0.2rem 0;
|
||||
|
||||
.title-container {
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
max-height: 2.4em;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.close-button-container {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
||||
.icon {
|
||||
fill: var(--color-surfacedarker);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-dialog-content {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
margin: 2rem 1rem;
|
||||
padding: 0 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.buttons-container {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-signal5);
|
||||
|
||||
&:focus {
|
||||
outline-width: calc(1.5 * var(--focus-outline-size));
|
||||
outline-color: var(--color-surfacelighter);
|
||||
outline-offset: calc(-2 * var(--focus-outline-size));
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin-right: .5rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 3.6em;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,32 +5,46 @@ const Icon = require('stremio-icons/dom');
|
|||
const Button = require('stremio/common/Button');
|
||||
const Popup = require('stremio/common/Popup');
|
||||
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 }) => {
|
||||
options = Array.isArray(options) ?
|
||||
options.filter(option => option && typeof option.value === 'string')
|
||||
:
|
||||
[];
|
||||
selected = Array.isArray(selected) ?
|
||||
selected.filter(value => typeof value === 'string')
|
||||
:
|
||||
[];
|
||||
const dataset = useDataset(props);
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const Multiselect = ({ className, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const options = React.useMemo(() => {
|
||||
return Array.isArray(props.options) ?
|
||||
props.options.filter((option) => {
|
||||
return option && typeof option.value === 'string';
|
||||
})
|
||||
:
|
||||
[];
|
||||
}, [props.options]);
|
||||
const selected = React.useMemo(() => {
|
||||
return Array.isArray(props.selected) ?
|
||||
props.selected.filter((value) => {
|
||||
return typeof value === 'string';
|
||||
})
|
||||
:
|
||||
[];
|
||||
}, [props.selected]);
|
||||
const popupLabelOnClick = React.useCallback((event) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
toggleMenu();
|
||||
}
|
||||
}, [toggleMenu]);
|
||||
}, [props.onClick, toggleMenu]);
|
||||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const popupMenuOnKeyDown = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonClickPrevented = true;
|
||||
}, []);
|
||||
const optionOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
value: event.currentTarget.dataset.value,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent,
|
||||
dataset: dataset
|
||||
|
|
@ -40,31 +54,36 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
if (!event.nativeEvent.closeMenuPrevented) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [onSelect, dataset]);
|
||||
}, [dataset, onSelect]);
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useLayoutEffect(() => {
|
||||
if (menuOpen) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen({
|
||||
type: 'open',
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof onClose === 'function') {
|
||||
onClose({
|
||||
type: 'close',
|
||||
dataset: dataset
|
||||
});
|
||||
if (mountedRef.current) {
|
||||
if (menuOpen) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen({
|
||||
type: 'open',
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof onClose === 'function') {
|
||||
onClose({
|
||||
type: 'close',
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mountedRef.current = true;
|
||||
}, [menuOpen]);
|
||||
return (
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
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}>
|
||||
renderLabel={({ children, ...labelProps }) => (
|
||||
<Button {...labelProps} {...props} className={classnames(className, labelProps.className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
|
||||
{
|
||||
typeof renderLabelContent === 'function' ?
|
||||
renderLabelContent()
|
||||
|
|
@ -72,16 +91,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'} />
|
||||
|
|
@ -91,13 +113,20 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
|
|||
</Button>
|
||||
)}
|
||||
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>
|
||||
))}
|
||||
<div className={styles['menu-container']} onKeyDown={popupMenuOnKeyDown} onClick={popupMenuOnClick}>
|
||||
{
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -108,15 +137,19 @@ Multiselect.propTypes = {
|
|||
className: PropTypes.string,
|
||||
direction: PropTypes.any,
|
||||
title: PropTypes.string,
|
||||
renderLabelContent: PropTypes.func,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
disabled: PropTypes.bool,
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
renderLabelContent: PropTypes.func,
|
||||
renderLabelText: PropTypes.func,
|
||||
onOpen: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onSelect: PropTypes.func
|
||||
onSelect: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Multiselect;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ const Button = require('stremio/common/Button');
|
|||
const Popup = require('stremio/common/Popup');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const useFullscreen = require('stremio/common/useFullscreen');
|
||||
const useUser = require('./useUser');
|
||||
const useUser = require('stremio/common/useUser');
|
||||
const styles = require('./styles');
|
||||
|
||||
const NavMenu = ({ className }) => {
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
|
||||
const user = useUser();
|
||||
const [user, logout] = useUser();
|
||||
const popupLabelOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
toggleMenu();
|
||||
|
|
@ -21,10 +21,12 @@ const NavMenu = ({ className }) => {
|
|||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const logoutButtonOnClick = React.useCallback(() => {
|
||||
logout();
|
||||
}, []);
|
||||
return (
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
direction={'bottom'}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={({ ref, className: popupLabelClassName, children }) => (
|
||||
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['nav-menu-label-container'], { 'active': menuOpen })} tabIndex={-1} onClick={popupLabelOnClick}>
|
||||
|
|
@ -38,17 +40,17 @@ const NavMenu = ({ className }) => {
|
|||
<div
|
||||
className={styles['avatar-container']}
|
||||
style={{
|
||||
backgroundImage: user.anonymous ?
|
||||
`url('/images/anonymous.png')`
|
||||
backgroundImage: user === null ?
|
||||
'url(\'/images/anonymous.png\')'
|
||||
:
|
||||
`url('${user.avatar}'), url('/images/default_avatar.png')`
|
||||
}}
|
||||
/>
|
||||
<div className={styles['email-container']}>
|
||||
<div className={styles['email-label']}>{user.anonymous ? 'Anonymous user' : user.email}</div>
|
||||
<div className={styles['email-label']}>{user === null ? 'Anonymous user' : user.email}</div>
|
||||
</div>
|
||||
<Button className={styles['logout-button-container']} title={user.anonymous ? 'Log in / Sign up' : 'Log out'} href={'#/intro'} onClick={user.logout}>
|
||||
<div className={styles['logout-label']}>{user.anonymous ? 'Log in / Sign up' : 'Log out'}</div>
|
||||
<Button className={styles['logout-button-container']} title={user === null ? 'Log in / Sign up' : 'Log out'} href={'#/intro'} onClick={logoutButtonOnClick}>
|
||||
<div className={styles['logout-label']}>{user === null ? 'Log in / Sign up' : 'Log out'}</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['nav-menu-section']}>
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const useUser = () => {
|
||||
const [user] = React.useState({
|
||||
email: '',
|
||||
avatar: '',
|
||||
anonymous: true,
|
||||
logout: () => { }
|
||||
});
|
||||
return user;
|
||||
};
|
||||
|
||||
module.exports = useUser;
|
||||
|
|
@ -80,7 +80,7 @@ const Notification = ({ className, id, type, name, poster, thumbnail, season, ep
|
|||
}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Notification.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const styles = require('./styles');
|
||||
|
||||
const NotificationPlaceholder = ({ className }) => {
|
||||
|
|
@ -20,6 +19,6 @@ const NotificationPlaceholder = ({ className }) => {
|
|||
|
||||
NotificationPlaceholder.propTypes = {
|
||||
className: PropTypes.string
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = NotificationPlaceholder;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const NotificationsList = ({ className, metaItems }) => {
|
|||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
NotificationsList.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ const Icon = require('stremio-icons/dom');
|
|||
const Button = require('stremio/common/Button');
|
||||
const Popup = require('stremio/common/Popup');
|
||||
const NotificationsList = require('./NotificationsList');
|
||||
const useNotifications = require('./useNotifications');
|
||||
const useCatalogs = require('stremio/routes/Board/useCatalogs');
|
||||
// const useNotifications = require('./useNotifications');
|
||||
// const useCatalogs = require('stremio/routes/Board/useCatalogs');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const styles = require('./styles');
|
||||
|
||||
const NotificationsMenu = ({ className, onClearButtonClicked }) => {
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
//TODO use useNotifications hook instead of useCatalogs
|
||||
const metaItems = useCatalogs();
|
||||
const metaItems = []; //useCatalogs();
|
||||
|
||||
return (
|
||||
<Popup
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const SearchBar = ({ className }) => {
|
|||
if (routeActive) {
|
||||
const { search: locationSearch } = UrlUtils.parse(locationHash.slice(1));
|
||||
const queryParams = new URLSearchParams(locationSearch);
|
||||
return queryParams.has('q') ? queryParams.get('q') : '';
|
||||
return queryParams.has('search') ? queryParams.get('search') : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -32,7 +32,7 @@ const SearchBar = ({ className }) => {
|
|||
}, [routeActive]);
|
||||
const queryInputOnSubmit = React.useCallback(() => {
|
||||
if (routeActive) {
|
||||
window.location.replace(`#/search?q=${searchInputRef.current.value}`);
|
||||
window.location.replace(`#/search?search=${searchInputRef.current.value}`);
|
||||
}
|
||||
}, [routeActive]);
|
||||
React.useEffect(() => {
|
||||
|
|
|
|||
42
src/common/PaginationInput/PaginationInput.js
Normal file
42
src/common/PaginationInput/PaginationInput.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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 styles = require('./styles');
|
||||
|
||||
const PaginationInput = ({ className, label, dataset, onSelect, ...props }) => {
|
||||
const prevNextButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'change-page',
|
||||
value: event.currentTarget.dataset.value,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [dataset, onSelect]);
|
||||
return (
|
||||
<div {...props} className={classnames(className, styles['pagination-input-container'])} >
|
||||
<Button className={styles['prev-button-container']} title={'Previous page'} data-value={'prev'} onClick={prevNextButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
|
||||
</Button>
|
||||
<div className={styles['label-container']} title={label}>
|
||||
<div className={styles['label']}>{label}</div>
|
||||
</div>
|
||||
<Button className={styles['next-button-container']} title={'Next page'} data-value={'next'} onClick={prevNextButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PaginationInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
onSelect: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = PaginationInput;
|
||||
3
src/common/PaginationInput/index.js
Normal file
3
src/common/PaginationInput/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const PaginationInput = require('./PaginationInput');
|
||||
|
||||
module.exports = PaginationInput;
|
||||
34
src/common/PaginationInput/styles.less
Normal file
34
src/common/PaginationInput/styles.less
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
.pagination-input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.prev-button-container, .next-button-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.label-container {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
min-width: 1.2rem;
|
||||
max-width: 3rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,20 +2,15 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const FocusLock = require('react-focus-lock').default;
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Popup = ({ open, direction, renderLabel, renderMenu, onCloseRequest, ...props }) => {
|
||||
direction = ['top', 'bottom'].includes(direction) ? direction : null;
|
||||
const dataset = useDataset(props);
|
||||
const Popup = ({ open, direction, renderLabel, renderMenu, dataset, onCloseRequest, ...props }) => {
|
||||
const labelRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [autoDirection, setAutoDirection] = React.useState(null);
|
||||
const menuOnMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closePopupPrevented = true;
|
||||
}, []);
|
||||
const menuOnKeyUp = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonClickPrevented = true;
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const onCloseEvent = (event) => {
|
||||
if (!event.closePopupPrevented && typeof onCloseRequest === 'function') {
|
||||
|
|
@ -54,33 +49,61 @@ const Popup = ({ open, direction, renderLabel, renderMenu, onCloseRequest, ...pr
|
|||
}, [open, onCloseRequest, dataset]);
|
||||
React.useLayoutEffect(() => {
|
||||
if (open) {
|
||||
const autoDirection = [];
|
||||
const documentRect = document.documentElement.getBoundingClientRect();
|
||||
const labelRect = labelRef.current.getBoundingClientRect();
|
||||
const labelOffsetTop = labelRect.top - documentRect.top;
|
||||
const labelOffsetBottom = (documentRect.height + documentRect.top) - (labelRect.top + labelRect.height);
|
||||
const autoDirection = labelOffsetBottom >= labelOffsetTop ? 'bottom' : 'top';
|
||||
setAutoDirection(autoDirection);
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const labelPosition = {
|
||||
left: labelRect.left - documentRect.left,
|
||||
top: labelRect.top - documentRect.top,
|
||||
right: (documentRect.width + documentRect.left) - (labelRect.left + labelRect.width),
|
||||
bottom: (documentRect.height + documentRect.top) - (labelRect.top + labelRect.height)
|
||||
};
|
||||
|
||||
if (menuRect.height <= labelPosition.bottom) {
|
||||
autoDirection.push('bottom');
|
||||
} else if (menuRect.height <= labelPosition.top) {
|
||||
autoDirection.push('top');
|
||||
} else if (labelPosition.bottom >= labelPosition.top) {
|
||||
autoDirection.push('bottom');
|
||||
} else {
|
||||
autoDirection.push('top');
|
||||
}
|
||||
|
||||
if (menuRect.width <= (labelPosition.right + labelRect.width)) {
|
||||
autoDirection.push('right');
|
||||
} else if (menuRect.width <= (labelPosition.left + labelRect.width)) {
|
||||
autoDirection.push('left');
|
||||
} else if (labelPosition.right > labelPosition.left) {
|
||||
autoDirection.push('right');
|
||||
} else {
|
||||
autoDirection.push('left');
|
||||
}
|
||||
|
||||
setAutoDirection(autoDirection.join('-'));
|
||||
} else {
|
||||
setAutoDirection(null);
|
||||
}
|
||||
}, [open]);
|
||||
return renderLabel({
|
||||
...props,
|
||||
ref: labelRef,
|
||||
className: styles['label-container'],
|
||||
children: open ?
|
||||
<FocusLock className={classnames(styles['menu-container'], styles[`menu-direction-${typeof direction === 'string' ? direction : autoDirection}`])} autoFocus={false} lockProps={{ onMouseDown: menuOnMouseDown, onKeyUp: menuOnKeyUp }}>
|
||||
<FocusLock ref={menuRef} className={classnames(styles['menu-container'], styles[`menu-direction-${autoDirection}`], styles[`menu-direction-${direction}`])} autoFocus={false} lockProps={{ onMouseDown: menuOnMouseDown }}>
|
||||
{renderMenu()}
|
||||
</FocusLock>
|
||||
:
|
||||
null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Popup.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
direction: PropTypes.oneOf(['top', 'bottom']),
|
||||
direction: PropTypes.oneOf(['top-left', 'bottom-left', 'top-right', 'bottom-right']),
|
||||
renderLabel: PropTypes.func.isRequired,
|
||||
renderMenu: PropTypes.func.isRequired,
|
||||
dataset: PropTypes.objectOf(PropTypes.string),
|
||||
onCloseRequest: PropTypes.func
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
.menu-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
visibility: hidden;
|
||||
|
|
@ -12,13 +11,35 @@
|
|||
0 1.1rem 0.85rem var(--color-backgrounddarker20);
|
||||
cursor: auto;
|
||||
|
||||
&.menu-direction-bottom {
|
||||
top: 100%;
|
||||
&.menu-direction-top-left {
|
||||
top: initial;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
left: initial;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.menu-direction-top {
|
||||
&.menu-direction-bottom-left {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
bottom: initial;
|
||||
left: initial;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.menu-direction-top-right {
|
||||
top: initial;
|
||||
right: initial;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.menu-direction-bottom-right {
|
||||
top: 100%;
|
||||
right: initial;
|
||||
bottom: initial;
|
||||
left: 0;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,55 +2,56 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { useFocusable } = require('stremio-router');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const Button = require('stremio/common/Button');
|
||||
const TextInput = require('stremio/common/TextInput');
|
||||
const styles = require('./styles');
|
||||
|
||||
const SharePrompt = ({ className, label, url, close }) => {
|
||||
const SharePrompt = ({ className, url }) => {
|
||||
const inputRef = React.useRef(null);
|
||||
const focusable = useFocusable();
|
||||
const routeFocused = useRouteFocused();
|
||||
const selectInputContent = React.useCallback(() => {
|
||||
if (inputRef.current !== null) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
const copyToClipboard = React.useCallback(() => {
|
||||
inputRef.current.select();
|
||||
document.execCommand('copy');
|
||||
if (inputRef.current !== null) {
|
||||
inputRef.current.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const onKeyUp = (event) => {
|
||||
if (event.key === 'Escape' && typeof close === 'function') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
if (focusable) {
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
if (routeFocused && inputRef.current !== null) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [close, focusable]);
|
||||
}, []);
|
||||
return (
|
||||
<div className={classnames(className, styles['share-prompt-container'])}>
|
||||
<Button className={styles['close-button-container']}>
|
||||
<Icon className={styles['icon']} icon={'ic_x'} onClick={close} />
|
||||
</Button>
|
||||
<div className={styles['share-prompt-content']}>
|
||||
<div className={styles['share-prompt-label']}>{label}</div>
|
||||
<div className={styles['buttons-container']}>
|
||||
<Button className={classnames(styles['button-container'], styles['facebook-button'])} href={`https://www.facebook.com/sharer/sharer.php?u=${url}`} target={'_blank'}>
|
||||
<Icon className={styles['icon']} icon={'ic_facebook'} />
|
||||
<div className={styles['label']}>FACEBOOK</div>
|
||||
</Button>
|
||||
<Button className={classnames(styles['button-container'], styles['twitter-button'])} href={`https://twitter.com/home?status=${url}`} target={'_blank'}>
|
||||
<Icon className={styles['icon']} icon={'ic_twitter'} />
|
||||
<div className={styles['label']}>TWITTER</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['url-container']}>
|
||||
<TextInput ref={inputRef} className={styles['url-content']} type={'text'} tabIndex={'-1'} defaultValue={url} readOnly />
|
||||
<Button className={styles['copy-button']} onClick={copyToClipboard}>
|
||||
<Icon className={styles['icon']} icon={'ic_link'} />
|
||||
<div className={styles['label']}>Copy</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['buttons-container']}>
|
||||
<Button className={classnames(styles['button-container'], styles['facebook-button'])} title={'Facebook'} href={`https://www.facebook.com/sharer/sharer.php?u=${url}`} target={'_blank'}>
|
||||
<Icon className={styles['icon']} icon={'ic_facebook'} />
|
||||
<div className={styles['label']}>Facebook</div>
|
||||
</Button>
|
||||
<Button className={classnames(styles['button-container'], styles['twitter-button'])} title={'Twitter'} href={`https://twitter.com/home?status=${url}`} target={'_blank'}>
|
||||
<Icon className={styles['icon']} icon={'ic_twitter'} />
|
||||
<div className={styles['label']}>Twitter</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['url-container']}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={styles['url-text-input']}
|
||||
type={'text'}
|
||||
readOnly={true}
|
||||
defaultValue={url}
|
||||
onClick={selectInputContent}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<Button className={styles['copy-button']} title={'Copy to clipboard'} onClick={copyToClipboard}>
|
||||
<Icon className={styles['icon']} icon={'ic_link'} />
|
||||
<div className={styles['label']}>Copy</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -58,9 +59,7 @@ const SharePrompt = ({ className, label, url, close }) => {
|
|||
|
||||
SharePrompt.propTypes = {
|
||||
className: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
close: PropTypes.func
|
||||
url: PropTypes.string
|
||||
};
|
||||
|
||||
module.exports = SharePrompt;
|
||||
|
|
|
|||
|
|
@ -1,133 +1,110 @@
|
|||
.share-prompt-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2.4rem 0;
|
||||
background-color: var(--color-surfacelighter);
|
||||
min-width: 18rem;
|
||||
background: var(--color-surfacelighter);
|
||||
|
||||
.close-button-container {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
z-index: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.4rem;
|
||||
.buttons-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surfacelight);
|
||||
.button-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 14rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
|
||||
outline-offset: calc(-2 * var(--focus-outline-size));
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
height: 1.2rem;
|
||||
margin-right: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 2.4em;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-backgrounddarker);
|
||||
.facebook-button {
|
||||
background-color: var(--color-facebook);
|
||||
}
|
||||
|
||||
.twitter-button {
|
||||
background-color: var(--color-twitter);
|
||||
}
|
||||
}
|
||||
|
||||
.share-prompt-content {
|
||||
padding: 0 2.4rem;
|
||||
.url-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 1rem;
|
||||
border: thin solid var(--color-surface);
|
||||
|
||||
.share-prompt-label {
|
||||
font-size: 1.3rem;
|
||||
color: var(--color-backgrounddarker);
|
||||
.url-text-input {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
padding: 1rem;
|
||||
color: var(--color-backgroundlighter);
|
||||
text-align: center;
|
||||
border-right: thin solid var(--color-surface);
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
.copy-button {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 1.4rem 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 8rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-surface);
|
||||
|
||||
.button-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 14rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 1rem;
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin-right: 0.6rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-surfacelighter);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.facebook-button {
|
||||
background-color: var(--color-facebook);
|
||||
&:focus {
|
||||
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
|
||||
outline-offset: calc(-1.5 * var(--focus-outline-size));
|
||||
}
|
||||
|
||||
.twitter-button {
|
||||
background-color: var(--color-twitter);
|
||||
}
|
||||
}
|
||||
|
||||
.url-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border: thin solid var(--color-surface);
|
||||
|
||||
.url-content {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-surfacedark);
|
||||
text-align: center;
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin-right: 0.5rem;
|
||||
fill: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--color-surface);
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin-right: 0.6rem;
|
||||
fill: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacedarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const styles = require('./styles');
|
||||
|
||||
const TextInput = React.forwardRef((props, ref) => {
|
||||
const onKeyUp = React.useCallback((event) => {
|
||||
if (typeof props.onKeyUp === 'function') {
|
||||
props.onKeyUp(event);
|
||||
const onKeyDown = React.useCallback((event) => {
|
||||
if (typeof props.onKeyDown === 'function') {
|
||||
props.onKeyDown(event);
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.nativeEvent.submitPrevented && typeof props.onSubmit === 'function') {
|
||||
props.onSubmit(event);
|
||||
}
|
||||
}, [props.onKeyUp, props.onSubmit]);
|
||||
}, [props.onKeyDown, props.onSubmit]);
|
||||
return (
|
||||
<input
|
||||
size={1}
|
||||
|
|
@ -23,11 +24,18 @@ const TextInput = React.forwardRef((props, ref) => {
|
|||
{...props}
|
||||
ref={ref}
|
||||
className={classnames(props.className, styles['text-input'], { 'disabled': props.disabled })}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
TextInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
onKeyDown: PropTypes.func,
|
||||
onSubmit: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = TextInput;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ const MainNavBar = require('./MainNavBar');
|
|||
const MetaItem = require('./MetaItem');
|
||||
const MetaPreview = require('./MetaPreview');
|
||||
const MetaRow = require('./MetaRow');
|
||||
const ModalDialog = require('./ModalDialog');
|
||||
const Multiselect = require('./Multiselect');
|
||||
const NavBar = require('./NavBar');
|
||||
const PaginationInput = require('./PaginationInput');
|
||||
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
|
||||
const Popup = require('./Popup');
|
||||
const SharePrompt = require('./SharePrompt');
|
||||
|
|
@ -17,12 +19,15 @@ const Toast = require('./Toast');
|
|||
const routesRegexp = require('./routesRegexp');
|
||||
const useAnimationFrame = require('./useAnimationFrame');
|
||||
const useBinaryState = require('./useBinaryState');
|
||||
const useDataset = require('./useDataset');
|
||||
const useDeepEqualState = require('./useDeepEqualState');
|
||||
const useFullscreen = require('./useFullscreen');
|
||||
const useInLibrary = require('./useInLibrary');
|
||||
const useLiveRef = require('./useLiveRef');
|
||||
const useLocationHash = require('./useLocationHash');
|
||||
const useModelState = require('./useModelState');
|
||||
const useRouteActive = require('./useRouteActive');
|
||||
const useSpreadState = require('./useSpreadState');
|
||||
const useUser = require('./useUser');
|
||||
|
||||
module.exports = {
|
||||
Button,
|
||||
|
|
@ -33,8 +38,10 @@ module.exports = {
|
|||
MetaItem,
|
||||
MetaPreview,
|
||||
MetaRow,
|
||||
ModalDialog,
|
||||
Multiselect,
|
||||
NavBar,
|
||||
PaginationInput,
|
||||
PlayIconCircleCentered,
|
||||
Popup,
|
||||
SharePrompt,
|
||||
|
|
@ -44,11 +51,13 @@ module.exports = {
|
|||
routesRegexp,
|
||||
useAnimationFrame,
|
||||
useBinaryState,
|
||||
useDataset,
|
||||
useDeepEqualState,
|
||||
useFullscreen,
|
||||
useInLibrary,
|
||||
useLiveRef,
|
||||
useLocationHash,
|
||||
useModelState,
|
||||
useRouteActive,
|
||||
useLiveRef,
|
||||
useSpreadState
|
||||
useSpreadState,
|
||||
useUser
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
const routesRegexp = {
|
||||
intro: {
|
||||
regexp: /^\/intro\/?$/i,
|
||||
regexp: /^\/intro$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
board: {
|
||||
regexp: /^\/?$/i,
|
||||
regexp: /^\/$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
discover: {
|
||||
regexp: /^\/discover(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
|
||||
urlParamsNames: ['type', 'catalog']
|
||||
regexp: /^\/discover(?:\/([^/]*)\/([^/]*)\/([^/]*))?$/,
|
||||
urlParamsNames: ['addonTransportUrl', 'type', 'catalogId']
|
||||
},
|
||||
library: {
|
||||
regexp: /^\/library(?:\/([^\/]*?))?\/?$/i,
|
||||
regexp: /^\/library(?:\/([^/]*))?$/,
|
||||
urlParamsNames: ['type']
|
||||
},
|
||||
search: {
|
||||
regexp: /^\/search\/?$/i,
|
||||
regexp: /^\/search$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
detail: {
|
||||
regexp: /^\/detail\/(?:([^\/]+?))\/(?:([^\/]+?))(?:\/([^\/]*?))?\/?$/i,
|
||||
metadetails: {
|
||||
regexp: /^\/metadetails\/([^/]*)\/([^/]*)(?:\/([^/]*))?$/,
|
||||
urlParamsNames: ['type', 'id', 'videoId']
|
||||
},
|
||||
addons: {
|
||||
regexp: /^\/addons(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
|
||||
urlParamsNames: ['category', 'type']
|
||||
regexp: /^\/addons(?:\/([^/]*)\/([^/]*)\/([^/]*))?$/,
|
||||
urlParamsNames: ['addonTransportUrl', 'catalogId', 'type']
|
||||
},
|
||||
settings: {
|
||||
regexp: /^\/settings\/?$/i,
|
||||
regexp: /^\/settings$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
player: {
|
||||
regexp: /^\/player\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/?$/i,
|
||||
regexp: /^\/player\/([^/]*)\/([^/]*)\/([^/]*)\/([^/]*)$/,
|
||||
urlParamsNames: ['type', 'id', 'videoId', 'stream']
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const toCamelCase = (value) => {
|
||||
return value.replace(/-([a-z])/gi, (_, letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const useDataset = (props) => {
|
||||
props = typeof props === 'object' && props !== null ? props : {};
|
||||
const dataPropNames = Object.keys(props).filter(propsName => propsName.startsWith('data-'));
|
||||
const dataset = React.useMemo(() => {
|
||||
return dataPropNames.reduce((dataset, dataPropName) => {
|
||||
const datasetPropName = toCamelCase(dataPropName.slice(5));
|
||||
dataset[datasetPropName] = String(props[dataPropName]);
|
||||
return dataset;
|
||||
}, {});
|
||||
}, [dataPropNames.join('')]);
|
||||
return dataset;
|
||||
};
|
||||
|
||||
module.exports = useDataset;
|
||||
22
src/common/useDeepEqualState.js
Normal file
22
src/common/useDeepEqualState.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const React = require('react');
|
||||
const isEqual = require('lodash.isequal');
|
||||
|
||||
const useDeepEqualState = (initialState) => {
|
||||
return React.useReducer(
|
||||
(prevState, nextState) => {
|
||||
return isEqual(prevState, nextState) ?
|
||||
prevState
|
||||
:
|
||||
nextState;
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
return typeof initialState === 'function' ?
|
||||
initialState()
|
||||
:
|
||||
initialState;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = useDeepEqualState;
|
||||
60
src/common/useInLibrary.js
Normal file
60
src/common/useInLibrary.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
|
||||
const useInLibrary = (metaItem) => {
|
||||
const { core } = useServices();
|
||||
const initLibraryItemsState = React.useCallback(() => {
|
||||
return core.getState('library_items');
|
||||
}, []);
|
||||
const libraryItems = useModelState({
|
||||
model: 'library_items',
|
||||
init: initLibraryItemsState
|
||||
});
|
||||
const addToLibrary = React.useCallback((metaItem) => {
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'AddToLibrary',
|
||||
args: {
|
||||
meta_item: metaItem,
|
||||
now: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const removeFromLibrary = React.useCallback((id) => {
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'RemoveFromLibrary',
|
||||
args: {
|
||||
id,
|
||||
now: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const inLibrary = React.useMemo(() => {
|
||||
return typeof metaItem === 'object' && metaItem !== null ?
|
||||
libraryItems.ids.includes(metaItem.id)
|
||||
:
|
||||
false;
|
||||
}, [metaItem, libraryItems]);
|
||||
const toggleInLibrary = React.useMemo(() => {
|
||||
if (typeof metaItem !== 'object' || metaItem === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (inLibrary) {
|
||||
removeFromLibrary(metaItem.id);
|
||||
} else {
|
||||
addToLibrary(metaItem);
|
||||
}
|
||||
};
|
||||
}, [metaItem, inLibrary]);
|
||||
return [inLibrary, toggleInLibrary];
|
||||
};
|
||||
|
||||
module.exports = useInLibrary;
|
||||
|
|
@ -2,7 +2,7 @@ const React = require('react');
|
|||
|
||||
const useLiveRef = (value, dependencies) => {
|
||||
const ref = React.useRef(value);
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
}, dependencies);
|
||||
return ref;
|
||||
|
|
|
|||
62
src/common/useModelState.js
Normal file
62
src/common/useModelState.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
const React = require('react');
|
||||
const throttle = require('lodash.throttle');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
const useDeepEqualState = require('stremio/common/useDeepEqualState');
|
||||
|
||||
const UNLOAD_ACTION = {
|
||||
action: 'Unload',
|
||||
};
|
||||
|
||||
const useModelState = ({ model, action, timeout, onNewState, map, mapWithCtx, init }) => {
|
||||
const modelRef = React.useRef(model);
|
||||
const mountedRef = React.useRef(false);
|
||||
const { core } = useServices();
|
||||
const routeFocused = useRouteFocused();
|
||||
const [state, setState] = useDeepEqualState(init);
|
||||
React.useLayoutEffect(() => {
|
||||
core.dispatch(action, modelRef.current);
|
||||
}, [action]);
|
||||
React.useLayoutEffect(() => {
|
||||
return () => {
|
||||
core.dispatch(UNLOAD_ACTION, modelRef.current);
|
||||
};
|
||||
}, []);
|
||||
React.useLayoutEffect(() => {
|
||||
const onNewStateThrottled = throttle(() => {
|
||||
const state = core.getState(modelRef.current);
|
||||
if (typeof onNewState === 'function') {
|
||||
const action = onNewState(state);
|
||||
const handled = core.dispatch(action, modelRef.current);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof mapWithCtx === 'function') {
|
||||
const ctx = core.getState('ctx');
|
||||
setState(mapWithCtx(state, ctx));
|
||||
} else if (typeof map === 'function') {
|
||||
setState(map(state));
|
||||
} else {
|
||||
setState(state);
|
||||
}
|
||||
}, timeout);
|
||||
if (routeFocused) {
|
||||
core.on('NewState', onNewStateThrottled);
|
||||
if (mountedRef.current) {
|
||||
onNewStateThrottled.call();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
onNewStateThrottled.cancel();
|
||||
core.off('NewState', onNewStateThrottled);
|
||||
};
|
||||
}, [routeFocused]);
|
||||
React.useLayoutEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
return state;
|
||||
};
|
||||
|
||||
module.exports = useModelState;
|
||||
|
|
@ -5,8 +5,8 @@ const useLocationHash = require('stremio/common/useLocationHash');
|
|||
const useRouteActive = (routeRegexp) => {
|
||||
const locationHash = useLocationHash();
|
||||
const active = React.useMemo(() => {
|
||||
const { pathname: locationPathname } = UrlUtils.parse(locationHash.slice(1));
|
||||
return routeRegexp instanceof RegExp && !!locationPathname.match(routeRegexp);
|
||||
const { pathname } = UrlUtils.parse(locationHash.slice(1));
|
||||
return typeof pathname === 'string' && routeRegexp instanceof RegExp && !!pathname.match(routeRegexp);
|
||||
}, [locationHash, routeRegexp]);
|
||||
return active;
|
||||
};
|
||||
|
|
|
|||
31
src/common/useUser.js
Normal file
31
src/common/useUser.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
|
||||
const mapUserState = (ctx) => {
|
||||
return ctx.content.auth ? ctx.content.auth.user : null;
|
||||
};
|
||||
|
||||
const useUser = () => {
|
||||
const { core } = useServices();
|
||||
const logout = React.useCallback(() => {
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'Logout'
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const initUserState = React.useCallback(() => {
|
||||
const ctx = core.getState('ctx');
|
||||
return mapUserState(ctx);
|
||||
}, []);
|
||||
const user = useModelState({
|
||||
model: 'ctx',
|
||||
map: mapUserState,
|
||||
init: initUserState
|
||||
});
|
||||
return [user, logout];
|
||||
};
|
||||
|
||||
module.exports = useUser;
|
||||
|
|
@ -12,6 +12,17 @@
|
|||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.fbAsyncInit = function() {
|
||||
FB.init({
|
||||
appId : '1537119779906825',
|
||||
autoLogAppEvents : false,
|
||||
xfbml : false,
|
||||
version : 'v2.5'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<script async defer src="https://connect.facebook.net/en_US/sdk.js"></script>
|
||||
<script type="text/javascript">
|
||||
<%= compilation.assets['main.js'].source() %>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ const classnames = require('classnames');
|
|||
const FocusLock = require('react-focus-lock').default;
|
||||
const { useModalsContainer } = require('../ModalsContainerContext');
|
||||
|
||||
const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
|
||||
const Modal = ({ className, autoFocus, children, ...props }) => {
|
||||
const modalsContainer = useModalsContainer();
|
||||
return ReactDOM.createPortal(
|
||||
<FocusLock className={classnames(className, 'modal-container')} autoFocus={typeof autoFocus === 'boolean' ? autoFocus : false} disabled={disabled} lockProps={props}>
|
||||
<FocusLock className={classnames(className, 'modal-container')} autoFocus={!!autoFocus} lockProps={props}>
|
||||
{children}
|
||||
</FocusLock>,
|
||||
modalsContainer
|
||||
|
|
@ -18,7 +18,6 @@ const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
|
|||
Modal.propTypes = {
|
||||
className: PropTypes.string,
|
||||
autoFocus: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
|
|
|
|||
|
|
@ -3,45 +3,43 @@ const ReactIs = require('react-is');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const UrlUtils = require('url');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { RouteFocusedProvider } = require('../RouteFocusedContext');
|
||||
const Route = require('../Route');
|
||||
const routeConfigForPath = require('./routeConfigForPath');
|
||||
const urlParamsForPath = require('./urlParamsForPath');
|
||||
|
||||
const Router = ({ className, onPathNotMatch, ...props }) => {
|
||||
const [{ homePath, viewsConfig }] = React.useState(() => ({
|
||||
const { homePath, viewsConfig } = React.useMemo(() => ({
|
||||
homePath: props.homePath,
|
||||
viewsConfig: props.viewsConfig
|
||||
}));
|
||||
const routeConfigForPath = React.useCallback((path) => {
|
||||
for (const viewConfig of viewsConfig) {
|
||||
for (const routeConfig of viewConfig) {
|
||||
if (typeof path === 'string' && path.match(routeConfig.regexp)) {
|
||||
return routeConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
}), []);
|
||||
const [views, setViews] = React.useState(() => {
|
||||
return Array(viewsConfig.length).fill(null);
|
||||
});
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof homePath === 'string') {
|
||||
const { pathname, path } = UrlUtils.parse(window.location.hash.slice(1));
|
||||
if (homePath !== path) {
|
||||
window.location.replace(`#${homePath}`);
|
||||
const routeConfig = routeConfigForPath(pathname);
|
||||
const routeConfig = typeof pathname === 'string' ?
|
||||
routeConfigForPath(viewsConfig, pathname)
|
||||
:
|
||||
null;
|
||||
if (routeConfig) {
|
||||
window.location = `#${path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const onLocationHashChange = () => {
|
||||
const { pathname, query } = UrlUtils.parse(window.location.hash.slice(1));
|
||||
const queryParams = new URLSearchParams(typeof query === 'string' ? query : '');
|
||||
const routeConfig = routeConfigForPath(pathname);
|
||||
const routeConfig = typeof pathname === 'string' ?
|
||||
routeConfigForPath(viewsConfig, pathname)
|
||||
:
|
||||
null;
|
||||
if (!routeConfig) {
|
||||
if (typeof onPathNotMatch === 'function') {
|
||||
const component = onPathNotMatch();
|
||||
|
|
@ -61,16 +59,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
} else {
|
||||
urlParams[name] = null;
|
||||
}
|
||||
|
||||
return urlParams;
|
||||
}, {});
|
||||
const urlParams = urlParamsForPath(routeConfig, pathname);
|
||||
const routeViewIndex = viewsConfig.findIndex((vc) => vc.includes(routeConfig));
|
||||
const routeIndex = viewsConfig[routeViewIndex].findIndex((rc) => rc === routeConfig);
|
||||
setViews((views) => {
|
||||
|
|
@ -81,8 +70,14 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
|
|||
return {
|
||||
key: `${routeViewIndex}${routeIndex}`,
|
||||
component: routeConfig.component,
|
||||
urlParams,
|
||||
queryParams
|
||||
urlParams: view !== null && isEqual(view.urlParams, urlParams) ?
|
||||
view.urlParams
|
||||
:
|
||||
urlParams,
|
||||
queryParams: view !== null && isEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
|
||||
view.queryParams
|
||||
:
|
||||
queryParams
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
|
|
@ -100,7 +95,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
|
|||
<div className={classnames(className, 'routes-container')}>
|
||||
{
|
||||
views
|
||||
.filter(view => view !== null)
|
||||
.filter((view) => view !== null)
|
||||
.map(({ key, component, urlParams, queryParams }, index, views) => (
|
||||
<RouteFocusedProvider key={key} value={index === views.length - 1}>
|
||||
<Route>
|
||||
|
|
|
|||
13
src/router/Router/routeConfigForPath.js
Normal file
13
src/router/Router/routeConfigForPath.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const routeConfigForPath = (viewsConfig, path) => {
|
||||
for (const viewConfig of viewsConfig) {
|
||||
for (const routeConfig of viewConfig) {
|
||||
if (path.match(routeConfig.regexp)) {
|
||||
return routeConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = routeConfigForPath;
|
||||
14
src/router/Router/urlParamsForPath.js
Normal file
14
src/router/Router/urlParamsForPath.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const urlParamsForPath = (routeConfig, path) => {
|
||||
const matches = path.match(routeConfig.regexp);
|
||||
return routeConfig.urlParamsNames.reduce((urlParams, name, index) => {
|
||||
if (Array.isArray(matches) && typeof matches[index + 1] === 'string') {
|
||||
urlParams[name] = decodeURIComponent(matches[index + 1]);
|
||||
} else {
|
||||
urlParams[name] = null;
|
||||
}
|
||||
|
||||
return urlParams;
|
||||
}, {});
|
||||
};
|
||||
|
||||
module.exports = urlParamsForPath;
|
||||
|
|
@ -2,24 +2,54 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button } = require('stremio/common');
|
||||
const { Button, Image } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Addon = ({ className, id, name, logo, description, types, version, transportUrl, installed, toggle }) => {
|
||||
const onKeyUp = React.useCallback((event) => {
|
||||
if (event.key === 'Enter' && typeof toggle === 'function') {
|
||||
toggle(event);
|
||||
const Addon = ({ className, id, name, version, logo, description, types, installed, onToggle, onShare, dataset }) => {
|
||||
const toggleButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onToggle === 'function') {
|
||||
onToggle({
|
||||
type: 'toggle',
|
||||
nativeEvent: event.nativeEvent,
|
||||
reactEvent: event,
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
}, [toggle]);
|
||||
}, [onToggle, dataset]);
|
||||
const shareButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onShare === 'function') {
|
||||
onShare({
|
||||
type: 'share',
|
||||
nativeEvent: event.nativeEvent,
|
||||
reactEvent: event,
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
}, [onShare, dataset]);
|
||||
const onKeyDown = React.useCallback((event) => {
|
||||
if (event.key === 'Enter' && typeof onToggle === 'function') {
|
||||
onToggle({
|
||||
type: 'toggle',
|
||||
nativeEvent: event.nativeEvent,
|
||||
reactEvent: event,
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
}, [onToggle, dataset]);
|
||||
const renderLogoFallback = React.useMemo(() => () => {
|
||||
return (
|
||||
<Icon className={styles['icon']} icon={'ic_addons'} />
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<Button className={classnames(styles['addon-container'], className)} data-id={id} onKeyUp={onKeyUp}>
|
||||
<Button className={classnames(className, styles['addon-container'])} onKeyDown={onKeyDown}>
|
||||
<div className={styles['logo-container']}>
|
||||
{
|
||||
typeof logo === 'string' && logo.length > 0 ?
|
||||
<img className={styles['logo']} src={logo} alt={' '} />
|
||||
:
|
||||
<Icon className={styles['icon']} icon={'ic_addons'} />
|
||||
}
|
||||
<Image
|
||||
className={styles['logo']}
|
||||
src={logo}
|
||||
alt={' '}
|
||||
renderFallback={renderLogoFallback}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['info-container']}>
|
||||
<div className={styles['name-container']} title={typeof name === 'string' && name.length > 0 ? name : id}>
|
||||
|
|
@ -32,10 +62,10 @@ const Addon = ({ className, id, name, logo, description, types, version, transpo
|
|||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(types) ?
|
||||
Array.isArray(types) && types.length > 0 ?
|
||||
<div className={styles['types-container']}>
|
||||
{
|
||||
types.length <= 1 ?
|
||||
types.length === 1 ?
|
||||
types.join('')
|
||||
:
|
||||
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
|
||||
|
|
@ -52,10 +82,10 @@ const Addon = ({ className, id, name, logo, description, types, version, transpo
|
|||
}
|
||||
</div>
|
||||
<div className={styles['buttons-container']}>
|
||||
<Button className={installed ? styles['uninstall-button-container'] : styles['install-button-container']} title={installed ? 'Uninstall' : 'Install'} tabIndex={-1} data-id={id} onClick={toggle}>
|
||||
<Button className={installed ? styles['uninstall-button-container'] : styles['install-button-container']} title={installed ? 'Uninstall' : 'Install'} tabIndex={-1} onClick={toggleButtonOnClick}>
|
||||
<div className={styles['label']}>{installed ? 'Uninstall' : 'Install'}</div>
|
||||
</Button>
|
||||
<Button className={styles['share-button-container']} title={'Share addon'} tabIndex={-1}>
|
||||
<Button className={styles['share-button-container']} title={'Share addon'} tabIndex={-1} onClick={shareButtonOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_share'} />
|
||||
<div className={styles['label']}>Share addon</div>
|
||||
</Button>
|
||||
|
|
@ -68,13 +98,14 @@ Addon.propTypes = {
|
|||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
version: PropTypes.string,
|
||||
logo: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
types: PropTypes.arrayOf(PropTypes.string),
|
||||
version: PropTypes.string,
|
||||
transportUrl: PropTypes.string,
|
||||
installed: PropTypes.bool,
|
||||
toggle: PropTypes.func
|
||||
onToggle: PropTypes.func,
|
||||
onShare: PropTypes.func,
|
||||
dataset: PropTypes.objectOf(PropTypes.string)
|
||||
};
|
||||
|
||||
module.exports = Addon;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
.addon-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
|
@ -17,6 +16,7 @@
|
|||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
|
@ -31,14 +31,13 @@
|
|||
}
|
||||
|
||||
.info-container {
|
||||
flex-grow: 1000;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
min-width: 40rem;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
.name-container {
|
||||
|
|
@ -47,7 +46,7 @@
|
|||
flex-basis: auto;
|
||||
padding: 0 0.5rem;
|
||||
max-height: 3.6em;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +54,7 @@
|
|||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacelight);
|
||||
|
|
@ -83,22 +83,14 @@
|
|||
}
|
||||
|
||||
.buttons-container {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
min-width: 17rem;
|
||||
flex: none;
|
||||
width: 17rem;
|
||||
|
||||
.install-button-container, .uninstall-button-container, .share-button-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17rem;
|
||||
height: 3.5rem;
|
||||
padding: 0 1rem;
|
||||
|
||||
|
|
@ -106,13 +98,8 @@
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
|
|
|
|||
|
|
@ -1,116 +1,86 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { useFocusable } = require('stremio-router');
|
||||
const { Button } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const AddonPrompt = ({ className, id, name, logo, description, types, catalogs, version, transportUrl, installed, official, cancel }) => {
|
||||
const focusable = useFocusable();
|
||||
React.useEffect(() => {
|
||||
const onKeyUp = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
if (focusable) {
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [cancel, focusable]);
|
||||
const AddonPrompt = ({ className, id, name, logo, description, types, catalogs, version, transportUrl, official }) => {
|
||||
return (
|
||||
<div className={classnames(className, styles['addon-prompt-container'])}>
|
||||
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={cancel}>
|
||||
<Icon className={styles['icon']} icon={'ic_x'} />
|
||||
</Button>
|
||||
<div className={styles['addon-prompt-content']}>
|
||||
<div className={classnames(styles['title-container'], { [styles['title-with-logo-container']]: typeof logo === 'string' && logo.length > 0 })}>
|
||||
{
|
||||
typeof logo === 'string' && logo.length > 0 ?
|
||||
<div className={styles['logo-container']}>
|
||||
<img className={styles['logo']} src={logo} alt={' '} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{typeof name === 'string' && name.length > 0 ? name : id}
|
||||
{' '}
|
||||
{
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<span className={styles['version-container']}>v.{version}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={classnames(styles['title-container'], { [styles['title-with-logo-container']]: typeof logo === 'string' && logo.length > 0 })}>
|
||||
{
|
||||
typeof description === 'string' && description.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>{description}</span>
|
||||
typeof logo === 'string' && logo.length > 0 ?
|
||||
<div className={styles['logo-container']}>
|
||||
<img className={styles['logo']} src={logo} alt={' '} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{typeof name === 'string' && name.length > 0 ? name : id}
|
||||
{' '}
|
||||
{
|
||||
typeof transportUrl === 'string' && transportUrl.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>URL: </span>
|
||||
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(types) && types.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported types: </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
types.length === 1 ?
|
||||
types[0]
|
||||
:
|
||||
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(catalogs) && catalogs.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported catalogs: </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
catalogs.length === 1 ?
|
||||
catalogs[0].name
|
||||
:
|
||||
catalogs.slice(0, -1).map(({ name }) => name).join(', ') + ' & ' + catalogs[catalogs.length - 1].name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
!official ?
|
||||
<div className={styles['section-container']}>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
|
||||
</div>
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<span className={styles['version-container']}>v.{version}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['buttons-container']}>
|
||||
<Button className={classnames(styles['button-container'], styles['cancel-button'])} title={'cancel'} onClick={cancel}>
|
||||
<div className={styles['label']}>Cancel</div>
|
||||
</Button>
|
||||
<Button className={classnames(styles['button-container'], installed ? styles['uninstall-button'] : styles['install-button'])} title={installed ? 'Uninstall' : 'Install'}>
|
||||
<div className={styles['label']}>{installed ? 'Uninstall' : 'Install'}</div>
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
typeof description === 'string' && description.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>{description}</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof transportUrl === 'string' && transportUrl.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>URL: </span>
|
||||
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(types) && types.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported types: </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
types.length === 1 ?
|
||||
types[0]
|
||||
:
|
||||
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(catalogs) && catalogs.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported catalogs: </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
catalogs.length === 1 ?
|
||||
catalogs[0].name
|
||||
:
|
||||
catalogs.slice(0, -1).map(({ name }) => name).join(', ') + ' & ' + catalogs[catalogs.length - 1].name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
!official ?
|
||||
<div className={styles['section-container']}>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -127,9 +97,7 @@ AddonPrompt.propTypes = {
|
|||
})),
|
||||
version: PropTypes.string,
|
||||
transportUrl: PropTypes.string,
|
||||
installed: PropTypes.bool,
|
||||
official: PropTypes.bool,
|
||||
cancel: PropTypes.func
|
||||
official: PropTypes.bool
|
||||
};
|
||||
|
||||
module.exports = AddonPrompt;
|
||||
|
|
|
|||
|
|
@ -1,153 +1,54 @@
|
|||
.addon-prompt-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 3rem 0;
|
||||
background-color: var(--color-surfacelighter);
|
||||
.title-container {
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
word-break: break-all;
|
||||
|
||||
.close-button-container {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surfacelight);
|
||||
&.title-with-logo-container {
|
||||
&::first-line {
|
||||
line-height: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-backgrounddarker);
|
||||
.logo-container {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin-right: 0.5rem;
|
||||
background-color: var(--color-surfacelight20);
|
||||
float: left;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.version-container {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.addon-prompt-content {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
align-self: stretch;
|
||||
padding: 0 3rem;
|
||||
overflow-y: auto;
|
||||
.section-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
.title-container {
|
||||
font-size: 3rem;
|
||||
.section-header {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 300;
|
||||
word-break: break-all;
|
||||
|
||||
&.title-with-logo-container {
|
||||
&::first-line {
|
||||
line-height: 5rem;
|
||||
}
|
||||
&.transport-url-label {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin-right: 0.5rem;
|
||||
background-color: var(--color-surfacelight20);
|
||||
float: left;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.version-container {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.section-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
.section-header {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 300;
|
||||
|
||||
&.transport-url-label {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
&.disclaimer-label {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 2rem;
|
||||
padding: 0 3rem;
|
||||
|
||||
.button-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 4rem;
|
||||
padding: 0 1rem;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 2.4em;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-button, .uninstall-button {
|
||||
outline-color: var(--color-surfacedark);
|
||||
outline-style: solid;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
|
||||
.install-button {
|
||||
background-color: var(--color-signal5);
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-surfacelighter);
|
||||
&.disclaimer-label {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,93 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const { Button, Dropdown, NavBar, TextInput } = require('stremio/common');
|
||||
const { Button, Multiselect, NavBar, TextInput, SharePrompt, ModalDialog, useBinaryState } = require('stremio/common');
|
||||
const Addon = require('./Addon');
|
||||
const AddonPrompt = require('./AddonPrompt');
|
||||
const useAddons = require('./useAddons');
|
||||
const useSelectedAddon = require('./useSelectedAddon');
|
||||
const useSelectableInputs = require('./useSelectableInputs');
|
||||
const styles = require('./styles');
|
||||
|
||||
const navigateToAddonDetails = (addonsCatalogRequest, transportUrl) => {
|
||||
const queryParams = new URLSearchParams([['addon', transportUrl]]);
|
||||
if (addonsCatalogRequest !== null) {
|
||||
const addonTransportUrl = encodeURIComponent(addonsCatalogRequest.base);
|
||||
const catalogId = encodeURIComponent(addonsCatalogRequest.path.id);
|
||||
const type = encodeURIComponent(addonsCatalogRequest.path.type_name);
|
||||
window.location.replace(`#/addons/${addonTransportUrl}/${catalogId}/${type}?${queryParams}`);
|
||||
} else {
|
||||
window.location.replace(`#/addons?${queryParams}`);
|
||||
}
|
||||
};
|
||||
|
||||
const Addons = ({ urlParams, queryParams }) => {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const queryOnChange = React.useCallback((event) => {
|
||||
setQuery(event.currentTarget.value);
|
||||
}, []);
|
||||
const [addons, dropdowns] = useAddons(urlParams.category, urlParams.type);
|
||||
const [selectedAddon, clearSelectedAddon] = useSelectedAddon(queryParams.get('addon'));
|
||||
const addonPromptModalBackgroundOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.clearSelectedAddonPrevented) {
|
||||
clearSelectedAddon();
|
||||
const addons = useAddons(urlParams);
|
||||
const selectInputs = useSelectableInputs(addons);
|
||||
const [addAddonModalOpen, openAddAddonModal, closeAddAddonModal] = useBinaryState(false);
|
||||
const addAddonUrlInputRef = React.useRef(null);
|
||||
const addAddonOnSubmit = React.useCallback(() => {
|
||||
if (addAddonUrlInputRef.current !== null) {
|
||||
const addonsCatalogRequest = addons.catalog_resource !== null ?
|
||||
addons.catalog_resource.request
|
||||
:
|
||||
null;
|
||||
navigateToAddonDetails(addonsCatalogRequest, addAddonUrlInputRef.current.value);
|
||||
}
|
||||
}, [addons]);
|
||||
const addAddonModalButtons = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
className: styles['cancel-button'],
|
||||
label: 'Cancel',
|
||||
props: {
|
||||
onClick: closeAddAddonModal
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Add',
|
||||
props: {
|
||||
onClick: addAddonOnSubmit
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [addAddonOnSubmit]);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const searchInputOnChange = React.useCallback((event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
}, []);
|
||||
const addonPromptOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.clearSelectedAddonPrevented = true;
|
||||
const [sharedTransportUrl, setSharedTransportUrl] = React.useState(null);
|
||||
const clearSharedTransportUrl = React.useCallback(() => {
|
||||
setSharedTransportUrl(null);
|
||||
}, []);
|
||||
const onAddonShare = React.useCallback((event) => {
|
||||
setSharedTransportUrl(event.dataset.transportUrl);
|
||||
}, []);
|
||||
const onAddonToggle = React.useCallback((event) => {
|
||||
const addonsCatalogRequest = addons.catalog_resource !== null ?
|
||||
addons.catalog_resource.request
|
||||
:
|
||||
null;
|
||||
navigateToAddonDetails(addonsCatalogRequest, event.dataset.transportUrl);
|
||||
}, [addons]);
|
||||
React.useLayoutEffect(() => {
|
||||
closeAddAddonModal();
|
||||
setSearch('');
|
||||
clearSharedTransportUrl();
|
||||
}, [urlParams, queryParams]);
|
||||
return (
|
||||
<div className={styles['addons-container']}>
|
||||
<NavBar className={styles['nav-bar']} backButton={true} title={'Addons'} />
|
||||
<div className={styles['addons-content']}>
|
||||
<div className={styles['top-bar-container']}>
|
||||
<Button className={styles['add-button-container']} title={'Add addon'}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
<Button className={styles['add-button-container']} title={'Add addon'} onClick={openAddAddonModal}>
|
||||
<Icon className={styles['icon']} icon={'ic_plus'} />
|
||||
<div className={styles['add-button-label']}>Add addon</div>
|
||||
</Button>
|
||||
{dropdowns.map((dropdown) => (
|
||||
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
))}
|
||||
<label className={styles['search-bar-container']}>
|
||||
<Icon className={styles['icon']} icon={'ic_search'} />
|
||||
|
|
@ -41,32 +95,105 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
className={styles['search-input']}
|
||||
type={'text'}
|
||||
placeholder={'Search addons...'}
|
||||
value={query}
|
||||
onChange={queryOnChange}
|
||||
value={search}
|
||||
onChange={searchInputOnChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles['addons-list-container']} >
|
||||
{
|
||||
addons.filter(({ name }) => query.length === 0 || (typeof name === 'string' && name.includes(query)))
|
||||
.map((addon) => (
|
||||
<Addon {...addon} key={addon.id} className={styles['addon']} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
selectedAddon !== null ?
|
||||
<Modal className={styles['addon-prompt-modal-container']} onClick={addonPromptModalBackgroundOnClick}>
|
||||
<div className={styles['addon-prompt-container']} onClick={addonPromptOnClick}>
|
||||
<AddonPrompt {...selectedAddon} className={styles['addon-prompt']} cancel={clearSelectedAddon} />
|
||||
</div>
|
||||
</Modal>
|
||||
addons.selectable.catalogs.length === 0 && addons.catalog_resource === null ?
|
||||
<div className={styles['message-container']}>
|
||||
No addons
|
||||
</div>
|
||||
:
|
||||
null
|
||||
addons.catalog_resource === null ?
|
||||
<div className={styles['message-container']}>
|
||||
No select
|
||||
</div>
|
||||
:
|
||||
addons.catalog_resource.content.type === 'Err' ?
|
||||
<div className={styles['message-container']}>
|
||||
Addons could not be loaded
|
||||
</div>
|
||||
:
|
||||
addons.catalog_resource.content.type === 'Loading' ?
|
||||
<div className={styles['message-container']}>
|
||||
Loading
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addons-list-container']}>
|
||||
{
|
||||
addons.catalog_resource.content.content
|
||||
.filter((addon) => {
|
||||
return search.length === 0 ||
|
||||
(
|
||||
(typeof addon.manifest.name === 'string' && addon.manifest.name.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(typeof addon.manifest.description === 'string' && addon.manifest.description.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
})
|
||||
.map((addon, index) => (
|
||||
<Addon
|
||||
key={index}
|
||||
className={styles['addon']}
|
||||
id={addon.manifest.id}
|
||||
name={addon.manifest.name}
|
||||
version={addon.manifest.version}
|
||||
logo={addon.manifest.logo}
|
||||
description={addon.manifest.description}
|
||||
types={addon.manifest.types}
|
||||
installed={addon.installed}
|
||||
onToggle={onAddonToggle}
|
||||
onShare={onAddonShare}
|
||||
dataset={{ transportUrl: addon.transportUrl }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
addAddonModalOpen ?
|
||||
<ModalDialog
|
||||
className={styles['add-addon-modal-container']}
|
||||
title={'Add addon'}
|
||||
buttons={addAddonModalButtons}
|
||||
onCloseRequest={closeAddAddonModal}>
|
||||
<TextInput
|
||||
ref={addAddonUrlInputRef}
|
||||
className={styles['addon-url-input']}
|
||||
type={'text'}
|
||||
placeholder={'Paste url...'}
|
||||
onSubmit={addAddonOnSubmit}
|
||||
/>
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof sharedTransportUrl === 'string' ?
|
||||
<ModalDialog
|
||||
className={styles['share-modal-container']}
|
||||
title={'Share addon'}
|
||||
onCloseRequest={clearSharedTransportUrl}>
|
||||
<SharePrompt
|
||||
className={styles['share-prompt-container']}
|
||||
url={sharedTransportUrl}
|
||||
/>
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Addons.propTypes = {
|
||||
urlParams: PropTypes.exact({
|
||||
addonTransportUrl: PropTypes.string,
|
||||
catalogId: PropTypes.string,
|
||||
type: PropTypes.string
|
||||
}),
|
||||
queryParams: PropTypes.instanceOf(URLSearchParams)
|
||||
};
|
||||
|
||||
module.exports = Addons;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
:import('~stremio/common/Multiselect/styles.less') {
|
||||
multiselect-menu-container: menu-container;
|
||||
}
|
||||
|
||||
.addons-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -16,11 +20,13 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.top-bar-container {
|
||||
.selectable-inputs-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 2rem;
|
||||
padding: 1.5rem;
|
||||
overflow: visible;
|
||||
|
||||
.add-button-container {
|
||||
flex: none;
|
||||
|
|
@ -29,7 +35,7 @@
|
|||
align-items: center;
|
||||
height: 3rem;
|
||||
max-width: 15rem;
|
||||
margin-right: 1rem;
|
||||
margin-right: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-signal5);
|
||||
|
||||
|
|
@ -39,8 +45,8 @@
|
|||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin-right: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
|
@ -55,12 +61,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
.select-input-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
height: 3rem;
|
||||
margin-right: 1rem;
|
||||
margin-right: 1.5rem;
|
||||
|
||||
.multiselect-menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar-container {
|
||||
|
|
@ -71,7 +82,6 @@
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
margin-right: 1rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
cursor: text;
|
||||
|
|
@ -81,7 +91,7 @@
|
|||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin-right: 1rem;
|
||||
|
|
@ -90,7 +100,6 @@
|
|||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
color: var(--color-surfacelighter);
|
||||
|
||||
&::placeholder {
|
||||
|
|
@ -102,39 +111,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
padding: 0 1.5rem;
|
||||
font-size: 2rem;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.addons-list-container {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
padding: 0 2rem;
|
||||
padding: 0 1.5rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.addon {
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addon-prompt-modal-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background60);
|
||||
.add-addon-modal-container {
|
||||
.addon-url-input {
|
||||
width: 25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--color-surfacedark);
|
||||
border: thin solid var(--color-surface);
|
||||
}
|
||||
|
||||
.addon-prompt-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 50rem;
|
||||
height: 80%;
|
||||
.cancel-button {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
|
||||
.addon-prompt {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
.share-modal-container {
|
||||
.share-prompt-container {
|
||||
width: 25rem;
|
||||
}
|
||||
}
|
||||
48
src/routes/Addons/useAddonDetails.js
Normal file
48
src/routes/Addons/useAddonDetails.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
const React = require('react');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const initAddonDetailsState = () => ({
|
||||
descriptor: null
|
||||
});
|
||||
|
||||
const mapAddonDetailsStateWithCtx = (addonDetails, ctx) => {
|
||||
const descriptor = addonDetails.descriptor !== null && addonDetails.descriptor.content.type === 'Ready' ?
|
||||
{
|
||||
...addonDetails.descriptor,
|
||||
content: {
|
||||
...addonDetails.descriptor.content,
|
||||
installed: ctx.content.addons.some((addon) => addon.transportUrl === addonDetails.descriptor.transport_url),
|
||||
}
|
||||
}
|
||||
:
|
||||
addonDetails.descriptor;
|
||||
return { descriptor };
|
||||
};
|
||||
|
||||
const useAddonDetails = (queryParams) => {
|
||||
const loadAddonDetailsAction = React.useMemo(() => {
|
||||
if (queryParams.has('addon')) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'AddonDetails',
|
||||
args: {
|
||||
transport_url: queryParams.get('addon')
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: 'Unload'
|
||||
};
|
||||
}
|
||||
}, [queryParams]);
|
||||
return useModelState({
|
||||
model: 'addon_details',
|
||||
action: loadAddonDetailsAction,
|
||||
mapWithCtx: mapAddonDetailsStateWithCtx,
|
||||
init: initAddonDetailsState,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useAddonDetails;
|
||||
|
|
@ -1,72 +1,100 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const CATEGORIES = ['official', 'community', 'my'];
|
||||
const DEFAULT_CATEGORY = 'community';
|
||||
const DEFAULT_TYPE = 'all';
|
||||
const initAddonsState = () => ({
|
||||
selectable: {
|
||||
types: [],
|
||||
catalogs: [],
|
||||
extra: [],
|
||||
has_next_page: false,
|
||||
has_prev_page: false
|
||||
},
|
||||
catalog_resource: null
|
||||
});
|
||||
|
||||
const useAddons = (category, type) => {
|
||||
category = CATEGORIES.includes(category) ? category : DEFAULT_CATEGORY;
|
||||
type = typeof type === 'string' && type.length > 0 ? type : DEFAULT_TYPE;
|
||||
const addons = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'com.linvo.cinemeta',
|
||||
name: 'Cinemeta',
|
||||
description: 'The official add-on for movie and series catalogs',
|
||||
types: ['movie', 'series'],
|
||||
version: '2.12.1',
|
||||
transportUrl: 'https://v3-cinemeta.strem.io/manifest.json',
|
||||
installed: true,
|
||||
official: true
|
||||
},
|
||||
{
|
||||
id: 'com.linvo.cinemeta2',
|
||||
name: 'Cinemeta2',
|
||||
logo: '/images/intro_background.jpg',
|
||||
description: 'The official add-on for movie and series catalogs',
|
||||
types: ['movie', 'series'],
|
||||
version: '2.12.2',
|
||||
transportUrl: 'https://v2-cinemeta.strem.io/manifest.json',
|
||||
installed: false,
|
||||
official: false
|
||||
const mapAddonsStateWithCtx = (addons, ctx) => {
|
||||
const selectable = addons.selectable;
|
||||
// TODO replace catalog content if resource catalog id is MY
|
||||
const catalog_resource = addons.catalog_resource !== null && addons.catalog_resource.content.type === 'Ready' ?
|
||||
{
|
||||
...addons.catalog_resource,
|
||||
content: {
|
||||
...addons.catalog_resource.content,
|
||||
content: addons.catalog_resource.content.content.map((descriptor) => ({
|
||||
transportUrl: descriptor.transportUrl,
|
||||
installed: ctx.content.addons.some((addon) => addon.transportUrl === descriptor.transportUrl),
|
||||
manifest: {
|
||||
id: descriptor.manifest.id,
|
||||
name: descriptor.manifest.name,
|
||||
version: descriptor.manifest.version,
|
||||
logo: descriptor.manifest.logo,
|
||||
description: descriptor.manifest.description,
|
||||
types: descriptor.manifest.types
|
||||
}
|
||||
}))
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
const onSelect = React.useCallback((event) => {
|
||||
const { name, value } = event.currentTarget.dataset;
|
||||
if (name === 'category') {
|
||||
const nextCategory = CATEGORIES.includes(value) ? value : '';
|
||||
window.location.replace(`#/addons/${nextCategory}/${type}`);
|
||||
} else if (name === 'type') {
|
||||
const nextType = typeof value === 'string' ? value : '';
|
||||
window.location.replace(`#/addons/${category}/${nextType}`);
|
||||
}
|
||||
}, [category, type]);
|
||||
const categoryDropdown = React.useMemo(() => {
|
||||
const selected = CATEGORIES.includes(category) ? [category] : [];
|
||||
const options = CATEGORIES
|
||||
.map((category) => ({ label: category, value: category }));
|
||||
:
|
||||
addons.catalog_resource;
|
||||
return { selectable, catalog_resource };
|
||||
};
|
||||
|
||||
const onNewAddonsState = (addons) => {
|
||||
if (addons.catalog_resource === null && addons.selectable.catalogs.length > 0) {
|
||||
return {
|
||||
name: 'category',
|
||||
selected,
|
||||
options,
|
||||
onSelect
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: addons.selectable.catalogs[0].load_request
|
||||
}
|
||||
};
|
||||
}, [category, onSelect]);
|
||||
const typeDropdown = React.useMemo(() => {
|
||||
const selected = typeof type === 'string' && type.length > 0 ? [type] : [];
|
||||
const options = ['all', 'movie', 'series', 'channel']
|
||||
.concat(selected)
|
||||
.filter((type, index, types) => types.indexOf(type) === index)
|
||||
.map((type) => ({ label: type, value: type }));
|
||||
return {
|
||||
name: 'type',
|
||||
selected,
|
||||
options,
|
||||
onSelect
|
||||
};
|
||||
}, [type, onSelect]);
|
||||
return [addons, [categoryDropdown, typeDropdown]];
|
||||
}
|
||||
};
|
||||
|
||||
const useAddons = (urlParams) => {
|
||||
const { core } = useServices();
|
||||
const loadAddonsAction = React.useMemo(() => {
|
||||
if (typeof urlParams.addonTransportUrl === 'string' && typeof urlParams.catalogId === 'string' && typeof urlParams.type === 'string') {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: {
|
||||
base: urlParams.addonTransportUrl,
|
||||
path: {
|
||||
resource: 'addon_catalog',
|
||||
type_name: urlParams.type,
|
||||
id: urlParams.catalogId,
|
||||
extra: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const addons = core.getState('addons');
|
||||
if (addons.selectable.catalogs.length > 0) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: addons.selectable.catalogs[0].load_request
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: 'Unload'
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [urlParams]);
|
||||
return useModelState({
|
||||
model: 'addons',
|
||||
action: loadAddonsAction,
|
||||
mapWithCtx: mapAddonsStateWithCtx,
|
||||
init: initAddonsState,
|
||||
onNewState: onNewAddonsState
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useAddons;
|
||||
|
|
|
|||
62
src/routes/Addons/useSelectableInputs.js
Normal file
62
src/routes/Addons/useSelectableInputs.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
const React = require('react');
|
||||
|
||||
const navigateWithLoadRequest = (load_request) => {
|
||||
const addonTransportUrl = encodeURIComponent(load_request.base);
|
||||
const catalogId = encodeURIComponent(load_request.path.id);
|
||||
const type = encodeURIComponent(load_request.path.type_name);
|
||||
window.location.replace(`#/addons/${addonTransportUrl}/${catalogId}/${type}`);
|
||||
};
|
||||
|
||||
const equalWithouExtra = (request1, request2) => {
|
||||
return request1.base === request2.base &&
|
||||
request1.path.resource === request2.path.resource &&
|
||||
request1.path.type_name === request2.path.type_name &&
|
||||
request1.path.id === request2.path.id;
|
||||
};
|
||||
|
||||
const mapSelectableInputs = (addons) => {
|
||||
const catalogSelect = {
|
||||
title: 'Select catalog',
|
||||
options: addons.selectable.catalogs
|
||||
.map(({ name, load_request }) => ({
|
||||
value: JSON.stringify(load_request),
|
||||
label: name
|
||||
})),
|
||||
selected: addons.selectable.catalogs
|
||||
.filter(({ load_request: { path: { id } } }) => {
|
||||
return addons.catalog_resource !== null &&
|
||||
addons.catalog_resource.request.path.id === id;
|
||||
})
|
||||
.map(({ load_request }) => JSON.stringify(load_request)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoadRequest(JSON.parse(event.value));
|
||||
}
|
||||
};
|
||||
const typeSelect = {
|
||||
title: 'Select type',
|
||||
options: addons.selectable.types
|
||||
.map(({ name, load_request }) => ({
|
||||
value: JSON.stringify(load_request),
|
||||
label: name
|
||||
})),
|
||||
selected: addons.selectable.types
|
||||
.filter(({ load_request }) => {
|
||||
return addons.catalog_resource !== null &&
|
||||
equalWithouExtra(addons.catalog_resource.request, load_request);
|
||||
})
|
||||
.map(({ load_request }) => JSON.stringify(load_request)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoadRequest(JSON.parse(event.value));
|
||||
}
|
||||
};
|
||||
return [catalogSelect, typeSelect];
|
||||
};
|
||||
|
||||
const useSelectableInputs = (addons) => {
|
||||
const selectableInputs = React.useMemo(() => {
|
||||
return mapSelectableInputs(addons);
|
||||
}, [addons]);
|
||||
return selectableInputs;
|
||||
};
|
||||
|
||||
module.exports = useSelectableInputs;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
const React = require('react');
|
||||
const UrlUtils = require('url');
|
||||
const { routesRegexp, useLocationHash, useRouteActive } = require('stremio/common');
|
||||
|
||||
const useSelectedAddon = (transportUrl) => {
|
||||
const [addon, setAddon] = React.useState(null);
|
||||
const locationHash = useLocationHash();
|
||||
const active = useRouteActive(routesRegexp.addons.regexp);
|
||||
React.useEffect(() => {
|
||||
if (typeof transportUrl !== 'string') {
|
||||
setAddon(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(transportUrl)
|
||||
.then((resp) => resp.json())
|
||||
.then((manifest) => setAddon({ ...manifest, transportUrl }));
|
||||
}, [transportUrl]);
|
||||
const clear = React.useCallback(() => {
|
||||
if (active) {
|
||||
const { pathname, search } = UrlUtils.parse(locationHash.slice(1));
|
||||
const queryParams = new URLSearchParams(search);
|
||||
queryParams.delete('addon');
|
||||
window.location.replace(`#${pathname}?${queryParams.toString()}`);
|
||||
}
|
||||
}, [active]);
|
||||
return [addon, clear];
|
||||
};
|
||||
|
||||
module.exports = useSelectedAddon;
|
||||
|
|
@ -1,53 +1,71 @@
|
|||
const React = require('react');
|
||||
const { MainNavBar, MetaRow } = require('stremio/common');
|
||||
const useCatalogs = require('./useCatalogs');
|
||||
const useBoard = require('./useBoard');
|
||||
const useContinueWatching = require('./useContinueWatching');
|
||||
const useItemOptions = require('./useItemOptions');
|
||||
const styles = require('./styles');
|
||||
|
||||
const CONTINUE_WATCHING_MENU = [
|
||||
{
|
||||
label: 'Play',
|
||||
value: 'play'
|
||||
},
|
||||
{
|
||||
label: 'Dismiss',
|
||||
value: 'dismiss'
|
||||
}
|
||||
];
|
||||
|
||||
const Board = () => {
|
||||
const catalogs = useCatalogs();
|
||||
const board = useBoard();
|
||||
const continueWatching = useContinueWatching();
|
||||
const [options, optionOnSelect] = useItemOptions();
|
||||
return (
|
||||
<div className={styles['board-container']}>
|
||||
<MainNavBar className={styles['nav-bar']} />
|
||||
<div className={styles['board-content']}>
|
||||
{catalogs.map(({ req, content }, index) => {
|
||||
switch (content.type) {
|
||||
case 'Ready':
|
||||
{
|
||||
continueWatching.lib_items.length > 0 ?
|
||||
<MetaRow
|
||||
className={styles['board-row']}
|
||||
title={'Continue Watching'}
|
||||
items={continueWatching.lib_items.map(({ id, videoId, ...libItem }) => ({
|
||||
...libItem,
|
||||
dataset: { id, videoId, type: libItem.type },
|
||||
options,
|
||||
optionOnSelect
|
||||
}))}
|
||||
limit={10}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{board.catalog_resources.map((catalog_resource, index) => {
|
||||
const title = `${catalog_resource.addon_name} - ${catalog_resource.request.path.id} ${catalog_resource.request.path.type_name}`;
|
||||
switch (catalog_resource.content.type) {
|
||||
case 'Ready': {
|
||||
return (
|
||||
<MetaRow
|
||||
key={`${index}${req.base}${content.type}`}
|
||||
key={index}
|
||||
className={styles['board-row']}
|
||||
title={`${req.path.id} - ${req.path.type_name}`}
|
||||
items={content.content}
|
||||
title={title}
|
||||
items={catalog_resource.content.content}
|
||||
href={catalog_resource.href}
|
||||
limit={10}
|
||||
/>
|
||||
);
|
||||
case 'Message':
|
||||
}
|
||||
case 'Err': {
|
||||
const message = `Error(${catalog_resource.content.content.type})${typeof catalog_resource.content.content.content === 'string' ? ` - ${catalog_resource.content.content.content}` : ''}`;
|
||||
return (
|
||||
<MetaRow
|
||||
key={`${index}${req.base}${content.type}`}
|
||||
key={index}
|
||||
className={styles['board-row']}
|
||||
title={`${req.path.id} - ${req.path.type_name}`}
|
||||
message={content.content}
|
||||
title={title}
|
||||
message={message}
|
||||
limit={10}
|
||||
/>
|
||||
);
|
||||
case 'Loading':
|
||||
}
|
||||
case 'Loading': {
|
||||
return (
|
||||
<MetaRow.Placeholder
|
||||
key={`${index}${req.base}${content.type}`}
|
||||
key={index}
|
||||
className={styles['board-row-placeholder']}
|
||||
title={`${req.path.id} - ${req.path.type_name}`}
|
||||
title={title}
|
||||
limit={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
:import('~stremio/common/MetaRow/styles.less') {
|
||||
meta-item: meta-item;
|
||||
see-all-container: see-all-container;
|
||||
}
|
||||
|
||||
:import('~stremio/common/MetaRow/MetaRowPlaceholder/styles.less') {
|
||||
meta-item-placeholder: meta-item;
|
||||
see-all-container-placeholder: see-all-container;
|
||||
}
|
||||
|
||||
.board-container {
|
||||
|
|
@ -35,6 +37,10 @@
|
|||
&:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.see-all-container, .see-all-container-placeholder {
|
||||
width: 12rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/routes/Board/useBoard.js
Normal file
51
src/routes/Board/useBoard.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
const React = require('react');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const initBoardState = () => ({
|
||||
selected: null,
|
||||
catalog_resources: []
|
||||
});
|
||||
|
||||
const mapBoardStateWithCtx = (board, ctx) => {
|
||||
const selected = board.selected;
|
||||
const catalog_resources = board.catalog_resources.map((catalog_resource) => {
|
||||
catalog_resource.addon_name = ctx.content.addons.reduce((addon_name, addon) => {
|
||||
if (addon.transportUrl === catalog_resource.request.base) {
|
||||
return addon.manifest.name;
|
||||
}
|
||||
|
||||
return addon_name;
|
||||
}, catalog_resource.request.base);
|
||||
if (catalog_resource.content.type === 'Ready') {
|
||||
catalog_resource.content.content = catalog_resource.content.content.map((metaItem) => ({
|
||||
type: metaItem.type,
|
||||
name: metaItem.name,
|
||||
poster: metaItem.poster,
|
||||
posterShape: metaItem.posterShape,
|
||||
href: `#/metadetails/${encodeURIComponent(metaItem.type)}/${encodeURIComponent(metaItem.id)}` // TODO this should redirect with videoId at some cases
|
||||
}));
|
||||
}
|
||||
catalog_resource.href = `#/discover/${encodeURIComponent(catalog_resource.request.base)}/${encodeURIComponent(catalog_resource.request.path.type_name)}/${encodeURIComponent(catalog_resource.request.path.id)}`;
|
||||
return catalog_resource;
|
||||
});
|
||||
return { selected, catalog_resources };
|
||||
};
|
||||
|
||||
const useBoard = () => {
|
||||
const loadBoardAction = React.useMemo(() => ({
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogsWithExtra',
|
||||
args: { extra: [] }
|
||||
}
|
||||
}), []);
|
||||
return useModelState({
|
||||
model: 'board',
|
||||
action: loadBoardAction,
|
||||
mapWithCtx: mapBoardStateWithCtx,
|
||||
init: initBoardState,
|
||||
timeout: 1000
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useBoard;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const useCatalogs = () => {
|
||||
const [catalogs, setCatalogs] = React.useState([]);
|
||||
const { core } = useServices();
|
||||
React.useEffect(() => {
|
||||
const onNewState = () => {
|
||||
const state = core.getState();
|
||||
setCatalogs(state.catalogs.groups);
|
||||
};
|
||||
core.on('NewModel', onNewState);
|
||||
core.dispatch({
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogGrouped',
|
||||
args: { extra: [] }
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
core.off('NewModel', onNewState);
|
||||
};
|
||||
}, []);
|
||||
return catalogs;
|
||||
};
|
||||
|
||||
module.exports = useCatalogs;
|
||||
33
src/routes/Board/useContinueWatching.js
Normal file
33
src/routes/Board/useContinueWatching.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const initContinueWatchingState = () => ({
|
||||
lib_items: []
|
||||
});
|
||||
|
||||
const mapContinueWatchingState = (continue_watching) => {
|
||||
const lib_items = continue_watching.lib_items.map((lib_item) => ({
|
||||
id: lib_item._id,
|
||||
type: lib_item.type,
|
||||
name: lib_item.name,
|
||||
poster: lib_item.poster,
|
||||
posterShape: lib_item.posterShape,
|
||||
progress: lib_item.state.timeOffset > 0 && lib_item.state.duration > 0 ?
|
||||
lib_item.state.timeOffset / lib_item.state.duration
|
||||
:
|
||||
null,
|
||||
videoId: lib_item.state.video_id,
|
||||
href: `#/metadetails/${encodeURIComponent(lib_item.type)}/${encodeURIComponent(lib_item._id)}${lib_item.state.video_id !== null ? `/${encodeURIComponent(lib_item.state.video_id)}` : ''}`
|
||||
}));
|
||||
return { lib_items };
|
||||
};
|
||||
|
||||
const useContinueWatching = () => {
|
||||
return useModelState({
|
||||
model: 'continue_watching',
|
||||
map: mapContinueWatchingState,
|
||||
init: initContinueWatchingState,
|
||||
timeout: 5000
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useContinueWatching;
|
||||
25
src/routes/Board/useItemOptions.js
Normal file
25
src/routes/Board/useItemOptions.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const React = require('react');
|
||||
|
||||
const PLAY_OPTION = {
|
||||
label: 'Play',
|
||||
value: 'play'
|
||||
};
|
||||
|
||||
const DISMISS_OPTION = {
|
||||
label: 'Dismiss',
|
||||
value: 'dismiss'
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
// TODO {{event.value}} {{event.dataset}}
|
||||
};
|
||||
|
||||
const useItemOptions = () => {
|
||||
const options = React.useMemo(() => ([
|
||||
PLAY_OPTION,
|
||||
DISMISS_OPTION
|
||||
]), []);
|
||||
return [options, onSelect];
|
||||
};
|
||||
|
||||
module.exports = useItemOptions;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
const React = require('react');
|
||||
const { NavBar, MetaPreview } = require('stremio/common');
|
||||
const VideosList = require('./VideosList');
|
||||
const StreamsList = require('./StreamsList');
|
||||
const useMetaItem = require('./useMetaItem');
|
||||
const useInLibrary = require('./useInLibrary');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Detail = ({ urlParams }) => {
|
||||
const metaItem = useMetaItem(urlParams.type, urlParams.id, urlParams.videoId);
|
||||
const [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary] = useInLibrary(urlParams.id);
|
||||
return (
|
||||
<div className={styles['detail-container']}>
|
||||
<NavBar
|
||||
className={styles['nav-bar']}
|
||||
backButton={true}
|
||||
title={metaItem !== null ? metaItem.name : null}
|
||||
/>
|
||||
<div className={styles['detail-content']}>
|
||||
{
|
||||
metaItem !== null ?
|
||||
<React.Fragment>
|
||||
<div className={styles['background-image-layer']}>
|
||||
<img className={styles['background-image']} src={metaItem.background} alt={' '} />
|
||||
</div>
|
||||
<MetaPreview
|
||||
{...metaItem}
|
||||
className={styles['meta-preview']}
|
||||
background={null}
|
||||
inLibrary={inLibrary}
|
||||
toggleInLibrary={toggleInLibrary}
|
||||
/>
|
||||
</React.Fragment>
|
||||
:
|
||||
<MetaPreview.Placeholder className={styles['meta-preview']} />
|
||||
}
|
||||
{
|
||||
typeof urlParams.videoId === 'string' && urlParams.videoId.length > 0 ?
|
||||
<StreamsList className={styles['streams-list']} metaItem={metaItem} />
|
||||
:
|
||||
<VideosList className={styles['videos-list']} metaItem={metaItem} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Detail;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Stream = ({ className, id, addon, description, progress, onClick }) => {
|
||||
return (
|
||||
<Button className={classnames(className, styles['stream-container'])} title={typeof description === 'string' && description.length > 0 ? description : id} data-id={id} onClick={onClick}>
|
||||
{
|
||||
typeof addon === 'string' && addon.length > 0 ?
|
||||
<div className={styles['addon-container']}>
|
||||
<div className={styles['addon-name']}>{addon}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['info-container']}>
|
||||
<div className={styles['description-label']}>
|
||||
{typeof description === 'string' && description.length > 0 ? description : id}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['play-icon-container']}>
|
||||
<Icon className={styles['play-icon']} icon={'ic_play'} />
|
||||
</div>
|
||||
{
|
||||
progress !== null && !isNaN(progress) && progress > 0 ?
|
||||
<div className={styles['progress-bar-container']}>
|
||||
<div className={styles['progress-bar']} style={{ width: `${Math.min(progress, 1) * 100}%` }} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
Stream.propTypes = {
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
addon: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
progress: PropTypes.number,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Stream;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button } = require('stremio/common');
|
||||
const Stream = require('./Stream');
|
||||
const StreamPlaceholder = require('./StreamPlaceholder');
|
||||
const useStreams = require('./useStreams');
|
||||
const styles = require('./styles');
|
||||
|
||||
const StreamsList = ({ className, metaItem }) => {
|
||||
const streams = useStreams(metaItem);
|
||||
return (
|
||||
<div className={classnames(className, styles['streams-list-container'])}>
|
||||
<div className={styles['streams-scroll-container']}>
|
||||
{
|
||||
streams.length > 0 ?
|
||||
streams.map((stream) => (
|
||||
<Stream {...stream} key={stream.id} className={styles['stream']} />
|
||||
))
|
||||
:
|
||||
<React.Fragment>
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
<StreamPlaceholder className={styles['stream']} />
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
<Button className={styles['install-addons-container']} title={'Install addons'} href={'#/addons'}>
|
||||
<Icon className={styles['icon']} icon={'ic_addons'} />
|
||||
<div className={styles['label']}>Install addons</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
StreamsList.propTypes = {
|
||||
className: PropTypes.string,
|
||||
metaItem: PropTypes.object
|
||||
};
|
||||
|
||||
module.exports = StreamsList;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const useStreams = (metaItem) => {
|
||||
const streams = React.useMemo(() => {
|
||||
return metaItem !== null ?
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
addon: 'Google',
|
||||
description: 'Google sample videos'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
addon: 'Stremio',
|
||||
description: 'Stremio demo videos',
|
||||
progress: 0.3
|
||||
}
|
||||
]
|
||||
:
|
||||
[];
|
||||
}, [metaItem]);
|
||||
return streams;
|
||||
};
|
||||
|
||||
module.exports = useStreams;
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button, Popup, useBinaryState } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const SeasonsBar = ({ className, season, seasons, onSeasonChange }) => {
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const setPrevSeason = React.useCallback(() => {
|
||||
if (Array.isArray(seasons) && typeof onSeasonChange === 'function') {
|
||||
const seasonIndex = seasons.indexOf(season);
|
||||
if (seasonIndex > 0) {
|
||||
onSeasonChange(seasons[seasonIndex - 1]);
|
||||
}
|
||||
}
|
||||
}, [season, seasons, onSeasonChange]);
|
||||
const setNextSeason = React.useCallback(() => {
|
||||
if (Array.isArray(seasons) && typeof onSeasonChange === 'function') {
|
||||
const seasonIndex = seasons.indexOf(season);
|
||||
if (seasonIndex < seasons.length - 1) {
|
||||
onSeasonChange(seasons[seasonIndex + 1]);
|
||||
}
|
||||
}
|
||||
}, [season, seasons, onSeasonChange]);
|
||||
const seasonOnClick = React.useCallback((event) => {
|
||||
closeMenu();
|
||||
const season = parseInt(event.currentTarget.dataset.season);
|
||||
if (!isNaN(season) && typeof onSeasonChange === 'function') {
|
||||
onSeasonChange(season);
|
||||
}
|
||||
}, [onSeasonChange]);
|
||||
return (
|
||||
<div className={classnames(className, styles['seasons-bar-container'])}>
|
||||
<Button className={styles['prev-season-button']} onClick={setPrevSeason}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
|
||||
</Button>
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
menuMatchLabelWidth={true}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={(ref) => (
|
||||
<Button ref={ref} className={classnames(styles['seasons-popup-label-container'], { 'active': menuOpen })} title={season !== null && !isNaN(season) ? `Season ${season}` : 'Season'} onClick={toggleMenu}>
|
||||
<div className={styles['season-label']}>Season</div>
|
||||
{
|
||||
season !== null && !isNaN(season) ?
|
||||
<div className={styles['number-label']}>{season}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['seasons-menu-container']}>
|
||||
{
|
||||
Array.isArray(seasons) ?
|
||||
seasons.map((season) => (
|
||||
<Button key={season} className={styles['season-option-container']} data-season={season} title={season !== null && !isNaN(season) ? `Season ${season}` : 'Season'} onClick={seasonOnClick}>
|
||||
<div className={styles['season-label']}>Season</div>
|
||||
{
|
||||
season !== null && !isNaN(season) ?
|
||||
<div className={styles['number-label']}>{season}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
))
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button className={styles['next-season-button']} onClick={setNextSeason}>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SeasonsBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
season: PropTypes.number,
|
||||
seasons: PropTypes.arrayOf(PropTypes.number),
|
||||
onSeasonChange: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = SeasonsBar;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const SeasonsBar = require('./SeasonsBar');
|
||||
const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder');
|
||||
const Video = require('./Video');
|
||||
const VideoPlaceholder = require('./VideoPlaceholder');
|
||||
const useSeasons = require('./useSeasons');
|
||||
const styles = require('./styles');
|
||||
|
||||
const VideosList = ({ className, metaItem }) => {
|
||||
const [season, seasons, setSeason] = useSeasons(metaItem);
|
||||
return (
|
||||
<div className={classnames(className, styles['videos-list-container'])}>
|
||||
{
|
||||
metaItem !== null ?
|
||||
<React.Fragment>
|
||||
{
|
||||
seasons.length > 1 ?
|
||||
<SeasonsBar
|
||||
className={styles['seasons-bar']}
|
||||
season={season}
|
||||
seasons={seasons}
|
||||
onSeasonChange={setSeason}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['videos-scroll-container']}>
|
||||
{
|
||||
metaItem.videos
|
||||
.filter((video) => isNaN(season) || video.season === season)
|
||||
.map((video) => (
|
||||
<Video
|
||||
{...video}
|
||||
key={video.id}
|
||||
className={styles['video']}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
:
|
||||
<React.Fragment>
|
||||
<SeasonsBarPlaceholder className={styles['seasons-bar']} />
|
||||
<div className={styles['videos-scroll-container']}>
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
<VideoPlaceholder className={styles['video']} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VideosList.propTypes = {
|
||||
className: PropTypes.string,
|
||||
metaItem: PropTypes.object
|
||||
};
|
||||
|
||||
module.exports = VideosList;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const useSeasons = (metaItem) => {
|
||||
const seasons = React.useMemo(() => {
|
||||
return metaItem !== null ?
|
||||
metaItem.videos
|
||||
.map(({ season }) => season)
|
||||
.filter((season, index, seasons) => {
|
||||
return season !== null &&
|
||||
!isNaN(season) &&
|
||||
seasons.indexOf(season) === index;
|
||||
})
|
||||
:
|
||||
[];
|
||||
}, [metaItem]);
|
||||
const [season, setSeason] = React.useState(seasons[0]);
|
||||
React.useEffect(() => {
|
||||
setSeason(seasons[0]);
|
||||
}, [seasons]);
|
||||
return [season, seasons, setSeason];
|
||||
};
|
||||
|
||||
module.exports = useSeasons;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
const Detail = require('./Detail');
|
||||
|
||||
module.exports = Detail;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
const { useBinaryState } = require('stremio/common');
|
||||
|
||||
const useInLibrary = (id = '') => {
|
||||
const [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary] = useBinaryState(false);
|
||||
return [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary];
|
||||
};
|
||||
|
||||
module.exports = useInLibrary;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const useMetaItem = (type = '', id = '', videoId = '') => {
|
||||
const [metaItem] = React.useState(() => ({
|
||||
id,
|
||||
type,
|
||||
name: 'Underworld',
|
||||
logo: 'https://images.metahub.space/logo/medium/tt0320691/img',
|
||||
background: 'https://images.metahub.space/background/medium/tt0320691/img',
|
||||
duration: '121 min',
|
||||
releaseInfo: '2003',
|
||||
released: new Date('2003-09-19T00:00:00.000Z'),
|
||||
description: 'Selene, a vampire warrior, is entrenched in a conflict between vampires and werewolves, while falling in love with Michael, a human who is sought by werewolves for unknown reasons.',
|
||||
genres: ['Action', 'Fantasy', 'Thriller'],
|
||||
writers: ['Kevin Grevioux', 'Len Wiseman', 'Danny McBride', 'Danny McBride'],
|
||||
directors: ['Len Wiseman'],
|
||||
cast: ['Kate Beckinsale', 'Scott Speedman', 'Michael Sheen', 'Shane Brolly'],
|
||||
imdbRating: '7.0',
|
||||
trailer: 'mn4O3iQ8B_s',
|
||||
imdbId: 'tt0320691',
|
||||
share: 'movie/underworld-0320691',
|
||||
videos: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'How to create a Stremio add-on with Node.js',
|
||||
description: 'This is a step-by-step tutorial on how to create your own add-on using Node.js.',
|
||||
released: new Date('Mon Jul 01 2019 00:00:00 GMT+0300 (Eastern European Summer Time)'),
|
||||
poster: 'https://theme.zdassets.com/theme_assets/2160011/77a6ad5aee11a07eb9b87281070f1aadf946f2b3.png',
|
||||
season: 1,
|
||||
episode: 1
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'How to create a Stremio add-on with Node.js',
|
||||
description: 'This is a step-by-step tutorial on how to create your own add-on using Node.js.',
|
||||
released: new Date('Mon Jul 02 2019 00:00:00 GMT+0300 (Eastern European Summer Time)'),
|
||||
poster: 'https://theme.zdassets.com/theme_assets/2160011/77a6ad5aee11a07eb9b87281070f1aadf946f2b3.png',
|
||||
season: 2,
|
||||
episode: 1
|
||||
}
|
||||
]
|
||||
}));
|
||||
return metaItem;
|
||||
};
|
||||
|
||||
module.exports = useMetaItem;
|
||||
|
|
@ -1,58 +1,148 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Dropdown, MainNavBar, MetaItem, MetaPreview } = require('stremio/common');
|
||||
const useCatalog = require('./useCatalog');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button, MainNavBar, MetaItem, MetaPreview, Multiselect, ModalDialog, PaginationInput, useBinaryState } = require('stremio/common');
|
||||
const useDiscover = require('./useDiscover');
|
||||
const useSelectableInputs = require('./useSelectableInputs');
|
||||
const styles = require('./styles');
|
||||
|
||||
const getMetaItemAtIndex = (catalog_resource, index) => {
|
||||
return index !== null &&
|
||||
isFinite(index) &&
|
||||
catalog_resource !== null &&
|
||||
catalog_resource.content.type === 'Ready' &&
|
||||
catalog_resource.content.content[index] ?
|
||||
catalog_resource.content.content[index]
|
||||
:
|
||||
null;
|
||||
};
|
||||
|
||||
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 metaItemsOnFocus = React.useCallback((event) => {
|
||||
const metaItem = metaItems.find(({ id }) => {
|
||||
return id === event.target.dataset.id;
|
||||
});
|
||||
if (metaItem) {
|
||||
setSelectedItem(metaItem);
|
||||
const discover = useDiscover(urlParams, queryParams);
|
||||
const [selectInputs, paginationInput] = useSelectableInputs(discover);
|
||||
const [inputsModalOpen, openInputsModal, closeInputsModal] = useBinaryState(false);
|
||||
const [selectedMetaItem, setSelectedMetaItem] = React.useState(() => {
|
||||
return getMetaItemAtIndex(discover.catalog_resource, 0);
|
||||
});
|
||||
const metaItemsOnFocusCapture = React.useCallback((event) => {
|
||||
const metaItem = getMetaItemAtIndex(discover.catalog_resource, event.target.dataset.index);
|
||||
setSelectedMetaItem(metaItem);
|
||||
}, [discover.catalog_resource]);
|
||||
const metaItemOnClick = React.useCallback((event) => {
|
||||
const metaItem = getMetaItemAtIndex(discover.catalog_resource, event.currentTarget.dataset.index);
|
||||
if (metaItem !== selectedMetaItem) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const metaItem = metaItems.length > 0 ? metaItems[0] : null;
|
||||
setSelectedItem(metaItem);
|
||||
}, [metaItems]);
|
||||
}, [discover.catalog_resource, selectedMetaItem]);
|
||||
React.useLayoutEffect(() => {
|
||||
const metaItem = getMetaItemAtIndex(discover.catalog_resource, 0);
|
||||
setSelectedMetaItem(metaItem);
|
||||
}, [discover.catalog_resource]);
|
||||
React.useLayoutEffect(() => {
|
||||
closeInputsModal();
|
||||
}, [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['selectable-inputs-container']}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
))}
|
||||
<Button className={styles['filter-container']} title={'More filters'} onClick={openInputsModal}>
|
||||
<Icon className={styles['filter-icon']} icon={'ic_filter'} />
|
||||
</Button>
|
||||
<div className={styles['spacing']} />
|
||||
{
|
||||
paginationInput !== null ?
|
||||
<PaginationInput
|
||||
{...paginationInput}
|
||||
className={styles['pagination-input-container']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['catalog-content-container']}>
|
||||
{
|
||||
discover.selectable.types.length === 0 && discover.catalog_resource === null ?
|
||||
<div className={styles['message-container']}>
|
||||
No catalogs
|
||||
</div>
|
||||
:
|
||||
discover.catalog_resource === null ?
|
||||
<div className={styles['message-container']}>
|
||||
No select
|
||||
</div>
|
||||
:
|
||||
discover.catalog_resource.content.type === 'Err' ?
|
||||
<div className={styles['message-container']}>
|
||||
Catalog Error
|
||||
</div>
|
||||
:
|
||||
discover.catalog_resource.content.type === 'Loading' ?
|
||||
<div className={styles['message-container']}>
|
||||
Loading
|
||||
</div>
|
||||
:
|
||||
<div className={styles['meta-items-container']} onFocusCapture={metaItemsOnFocusCapture}>
|
||||
{discover.catalog_resource.content.content.map((metaItem, index) => (
|
||||
<MetaItem
|
||||
key={index}
|
||||
className={classnames(styles['meta-item'], { 'selected': selectedMetaItem === metaItem })}
|
||||
type={metaItem.type}
|
||||
name={metaItem.name}
|
||||
poster={metaItem.poster}
|
||||
posterShape={metaItem.posterShape}
|
||||
href={metaItem.href}
|
||||
data-index={index}
|
||||
onClick={metaItemOnClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
selectedItem !== null ?
|
||||
selectedMetaItem !== null ?
|
||||
<MetaPreview
|
||||
{...selectedItem}
|
||||
className={styles['meta-preview-container']}
|
||||
compact={true}
|
||||
name={selectedMetaItem.name}
|
||||
logo={selectedMetaItem.logo}
|
||||
background={selectedMetaItem.poster}
|
||||
runtime={selectedMetaItem.runtime}
|
||||
releaseInfo={selectedMetaItem.releaseInfo}
|
||||
released={selectedMetaItem.released}
|
||||
description={selectedMetaItem.description}
|
||||
trailer={selectedMetaItem.trailer}
|
||||
/>
|
||||
:
|
||||
null
|
||||
<div className={styles['meta-preview-container']} />
|
||||
}
|
||||
</div>
|
||||
{
|
||||
inputsModalOpen ?
|
||||
<ModalDialog onCloseRequest={closeInputsModal} />
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Discover.propTypes = {
|
||||
urlParams: PropTypes.exact({
|
||||
addonTransportUrl: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
catalogId: PropTypes.string
|
||||
}),
|
||||
queryParams: PropTypes.instanceOf(URLSearchParams)
|
||||
};
|
||||
|
||||
module.exports = Discover;
|
||||
|
|
|
|||
|
|
@ -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/PaginationInput/styles.less') {
|
||||
pagination-prev-button-container: prev-button-container;
|
||||
pagination-next-button-container: next-button-container;
|
||||
pagination-button-icon: icon;
|
||||
}
|
||||
|
||||
.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: auto 1fr;
|
||||
grid-template-areas:
|
||||
"dropdowns-area meta-preview-area"
|
||||
"meta-items-area meta-preview-area";
|
||||
"selectable-inputs-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;
|
||||
margin: 2rem 0;
|
||||
padding: 0 2rem;
|
||||
overflow-y: auto;
|
||||
.selectable-inputs-container {
|
||||
grid-area: selectable-inputs-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 1.5rem;
|
||||
overflow: visible;
|
||||
|
||||
.dropdown {
|
||||
.select-input-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
height: 3rem;
|
||||
margin-right: 1.5rem;
|
||||
|
||||
&:nth-child(n+4) {
|
||||
display: none;
|
||||
|
||||
&~.filter-container {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:not(:nth-last-child(2)) {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
flex: none;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination-input-container {
|
||||
flex: none;
|
||||
height: 3rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.pagination-prev-button-container, .pagination-next-button-container {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
|
||||
.pagination-button-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
grid-gap: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
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 1.5rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -114,17 +203,17 @@
|
|||
.discover-container {
|
||||
.discover-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: fit-content(19rem) 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"dropdowns-area"
|
||||
"meta-items-area";
|
||||
"selectable-inputs-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;
|
||||
102
src/routes/Discover/useDiscover.js
Normal file
102
src/routes/Discover/useDiscover.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const initDiscoverState = () => ({
|
||||
selectable: {
|
||||
types: [],
|
||||
catalogs: [],
|
||||
extra: [],
|
||||
has_next_page: false,
|
||||
has_prev_page: false
|
||||
},
|
||||
catalog_resource: null
|
||||
});
|
||||
|
||||
const mapDiscoverState = (discover) => {
|
||||
const selectable = discover.selectable;
|
||||
const catalog_resource = discover.catalog_resource !== null && discover.catalog_resource.content.type === 'Ready' ?
|
||||
{
|
||||
...discover.catalog_resource,
|
||||
content: {
|
||||
...discover.catalog_resource.content,
|
||||
content: discover.catalog_resource.content.content.map((metaItem) => ({
|
||||
type: metaItem.type,
|
||||
name: metaItem.name,
|
||||
logo: metaItem.logo,
|
||||
background: metaItem.background,
|
||||
poster: metaItem.poster,
|
||||
posterShape: metaItem.posterShape,
|
||||
runtime: metaItem.runtime,
|
||||
releaseInfo: metaItem.releaseInfo,
|
||||
released: new Date(metaItem.released),
|
||||
description: metaItem.description,
|
||||
links: metaItem.links,
|
||||
trailer: metaItem.trailer,
|
||||
href: `#/metadetails/${encodeURIComponent(metaItem.type)}/${encodeURIComponent(metaItem.id)}` // TODO this should redirect with videoId at some cases
|
||||
}))
|
||||
}
|
||||
}
|
||||
:
|
||||
discover.catalog_resource;
|
||||
return { selectable, catalog_resource };
|
||||
};
|
||||
|
||||
const onNewDiscoverState = (discover) => {
|
||||
if (discover.catalog_resource === null && discover.selectable.types.length > 0) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: discover.selectable.types[0].load_request
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const useDiscover = (urlParams, queryParams) => {
|
||||
const { core } = useServices();
|
||||
const loadDiscoverAction = React.useMemo(() => {
|
||||
if (typeof urlParams.addonTransportUrl === 'string' && typeof urlParams.type === 'string' && typeof urlParams.catalogId === 'string') {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: {
|
||||
base: urlParams.addonTransportUrl,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: urlParams.type,
|
||||
id: urlParams.catalogId,
|
||||
extra: Array.from(queryParams.entries())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const discover = core.getState('discover');
|
||||
if (discover.selectable.types.length > 0) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'CatalogFiltered',
|
||||
args: discover.selectable.types[0].load_request
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: 'Unload'
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [urlParams, queryParams]);
|
||||
return useModelState({
|
||||
model: 'discover',
|
||||
action: loadDiscoverAction,
|
||||
map: mapDiscoverState,
|
||||
init: initDiscoverState,
|
||||
onNewState: onNewDiscoverState
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useDiscover;
|
||||
188
src/routes/Discover/useSelectableInputs.js
Normal file
188
src/routes/Discover/useSelectableInputs.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
const React = require('react');
|
||||
|
||||
const NONE_EXTRA_VALUE = 'None';
|
||||
const CATALOG_PAGE_SIZE = 100;
|
||||
const SKIP_EXTRA = {
|
||||
name: 'skip',
|
||||
optionsLimit: 1,
|
||||
isRequired: false
|
||||
};
|
||||
|
||||
const navigateWithLoadRequest = (load_request) => {
|
||||
const addonTransportUrl = encodeURIComponent(load_request.base);
|
||||
const type = encodeURIComponent(load_request.path.type_name);
|
||||
const catalogId = encodeURIComponent(load_request.path.id);
|
||||
const extra = new URLSearchParams(load_request.path.extra).toString();
|
||||
window.location.replace(`#/discover/${addonTransportUrl}/${type}/${catalogId}?${extra}`);
|
||||
};
|
||||
|
||||
const getNextExtra = (prevExtra, extraProp, extraValue) => {
|
||||
return prevExtra
|
||||
.concat([[extraProp.name, extraValue]])
|
||||
.reduceRight((result, [name, value]) => {
|
||||
if (extraProp.name === name) {
|
||||
if (extraValue !== NONE_EXTRA_VALUE) {
|
||||
const optionsCount = result.reduce((optionsCount, [name]) => {
|
||||
if (extraProp.name === name) {
|
||||
optionsCount++;
|
||||
}
|
||||
|
||||
return optionsCount;
|
||||
}, 0);
|
||||
if (extraProp.optionsLimit === 1) {
|
||||
if (optionsCount === 0) {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
} else if (extraProp.optionsLimit > 1) {
|
||||
const valueIndex = result.findIndex(([_name, _value]) => {
|
||||
return _name === name && _value === value;
|
||||
});
|
||||
if (valueIndex !== -1) {
|
||||
result.splice(valueIndex, 1);
|
||||
} else if (extraProp.optionsLimit > optionsCount) {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.unshift([name, value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const equalWithouExtra = (request1, request2) => {
|
||||
return request1.base === request2.base &&
|
||||
request1.path.resource === request2.path.resource &&
|
||||
request1.path.type_name === request2.path.type_name &&
|
||||
request1.path.id === request2.path.id;
|
||||
};
|
||||
|
||||
const mapSelectableInputs = (discover) => {
|
||||
const selectedCatalogRequest = discover.catalog_resource !== null ?
|
||||
discover.catalog_resource.request
|
||||
:
|
||||
{
|
||||
base: null,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
id: null,
|
||||
type_name: null,
|
||||
extra: []
|
||||
}
|
||||
};
|
||||
const requestedPage = selectedCatalogRequest.path.extra.reduce((requestedPage, [name, value]) => {
|
||||
if (name === SKIP_EXTRA.name) {
|
||||
const skip = parseInt(value);
|
||||
if (isFinite(skip)) {
|
||||
return Math.floor(skip / CATALOG_PAGE_SIZE) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return requestedPage;
|
||||
}, 1);
|
||||
const typeSelect = {
|
||||
title: 'Select type',
|
||||
options: discover.selectable.types
|
||||
.map(({ name, load_request }) => ({
|
||||
value: JSON.stringify(load_request),
|
||||
label: name
|
||||
})),
|
||||
selected: discover.selectable.types
|
||||
.filter(({ load_request: { path: { type_name } } }) => {
|
||||
return type_name === selectedCatalogRequest.path.type_name;
|
||||
})
|
||||
.map(({ load_request }) => JSON.stringify(load_request)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoadRequest(JSON.parse(event.value));
|
||||
}
|
||||
};
|
||||
const catalogSelect = {
|
||||
title: 'Select catalog',
|
||||
options: discover.selectable.catalogs
|
||||
.map(({ name, load_request }) => ({
|
||||
value: JSON.stringify(load_request),
|
||||
label: name
|
||||
})),
|
||||
selected: discover.selectable.catalogs
|
||||
.filter(({ load_request }) => {
|
||||
return equalWithouExtra(load_request, selectedCatalogRequest);
|
||||
})
|
||||
.map(({ load_request }) => JSON.stringify(load_request)),
|
||||
onSelect: (event) => {
|
||||
navigateWithLoadRequest(JSON.parse(event.value));
|
||||
}
|
||||
};
|
||||
const extraSelects = 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 = selectedCatalogRequest.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) => {
|
||||
navigateWithLoadRequest({
|
||||
base: selectedCatalogRequest.base,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: selectedCatalogRequest.path.type_name,
|
||||
id: selectedCatalogRequest.path.id,
|
||||
extra: getNextExtra(selectedCatalogRequest.path.extra, extra, event.value)
|
||||
}
|
||||
});
|
||||
};
|
||||
return { title, options, selected, renderLabelText, onSelect };
|
||||
});
|
||||
const paginationInput = discover.selectable.has_prev_page || discover.selectable.has_next_page ?
|
||||
{
|
||||
label: String(requestedPage),
|
||||
onSelect: (event) => {
|
||||
if (event.value === 'prev' && !discover.selectable.has_prev_page ||
|
||||
event.value === 'next' && !discover.selectable.has_next_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = event.value === 'next' ?
|
||||
String(requestedPage * CATALOG_PAGE_SIZE)
|
||||
:
|
||||
String((requestedPage - 2) * CATALOG_PAGE_SIZE);
|
||||
navigateWithLoadRequest({
|
||||
base: selectedCatalogRequest.base,
|
||||
path: {
|
||||
resource: 'catalog',
|
||||
type_name: selectedCatalogRequest.path.type_name,
|
||||
id: selectedCatalogRequest.path.id,
|
||||
extra: getNextExtra(selectedCatalogRequest.path.extra, SKIP_EXTRA, nextValue)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
:
|
||||
null;
|
||||
return [[typeSelect, catalogSelect, ...extraSelects], paginationInput];
|
||||
};
|
||||
|
||||
const useSelectableInputs = (discover) => {
|
||||
const selectableInputs = React.useMemo(() => {
|
||||
return mapSelectableInputs(discover);
|
||||
}, [discover]);
|
||||
return selectableInputs;
|
||||
};
|
||||
|
||||
module.exports = useSelectableInputs;
|
||||
|
|
@ -4,12 +4,20 @@ const classnames = require('classnames');
|
|||
const { Button, Checkbox } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ConsentCheckbox = React.forwardRef(({ className, checked, label, link, href, toggle, ...props }, ref) => {
|
||||
const ConsentCheckbox = React.forwardRef(({ className, checked, label, link, href, onToggle, ...props }, ref) => {
|
||||
const checkboxOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePrevented && typeof toggle === 'function') {
|
||||
toggle(event);
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
}, [toggle]);
|
||||
|
||||
if (!event.nativeEvent.togglePrevented && typeof onToggle === 'function') {
|
||||
onToggle({
|
||||
type: 'toggle',
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [onToggle]);
|
||||
const linkOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePrevented = true;
|
||||
}, []);
|
||||
|
|
@ -38,7 +46,8 @@ ConsentCheckbox.propTypes = {
|
|||
label: PropTypes.string,
|
||||
link: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
toggle: PropTypes.func
|
||||
onToggle: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ConsentCheckbox;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -24,7 +24,7 @@ const CredentialsTextInput = React.forwardRef((props, ref) => {
|
|||
|
||||
CredentialsTextInput.displayName = 'CredentialsTextInput';
|
||||
|
||||
CredentialsTextInput.propTYpes = {
|
||||
CredentialsTextInput.propTypes = {
|
||||
onKeyDown: PropTypes.func
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { Button } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||
const ConsentCheckbox = require('./ConsentCheckbox');
|
||||
const styles = require('./styles');
|
||||
|
||||
const LOGIN_FORM = 'LOGIN_FORM';
|
||||
const SIGNUP_FORM = 'SIGNUP_FORM';
|
||||
const SIGNUP_FORM = 'signup';
|
||||
const LOGIN_FORM = 'login';
|
||||
|
||||
const Intro = () => {
|
||||
const Intro = ({ queryParams }) => {
|
||||
const { core } = useServices();
|
||||
const routeFocused = useRouteFocused();
|
||||
const emailRef = React.useRef();
|
||||
const passwordRef = React.useRef();
|
||||
|
|
@ -22,17 +25,20 @@ const Intro = () => {
|
|||
const [state, dispatch] = React.useReducer(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case 'switch-form':
|
||||
return {
|
||||
form: state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAccepted: false,
|
||||
privacyPolicyAccepted: false,
|
||||
marketingAccepted: false,
|
||||
error: ''
|
||||
};
|
||||
case 'set-form':
|
||||
if (state.form !== action.form) {
|
||||
return {
|
||||
form: action.form,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAccepted: false,
|
||||
privacyPolicyAccepted: false,
|
||||
marketingAccepted: false,
|
||||
error: ''
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case 'change-credentials':
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -55,7 +61,7 @@ const Intro = () => {
|
|||
}
|
||||
},
|
||||
{
|
||||
form: SIGNUP_FORM,
|
||||
form: [LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form')) ? queryParams.get('form') : SIGNUP_FORM,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
|
|
@ -65,27 +71,127 @@ const Intro = () => {
|
|||
error: ''
|
||||
}
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const onEvent = ({ event, args }) => {
|
||||
switch (event) {
|
||||
case 'CtxActionErr': {
|
||||
const [, error] = args;
|
||||
dispatch({ type: 'error', error: error.args.message });
|
||||
break;
|
||||
}
|
||||
case 'CtxChanged': {
|
||||
const state = core.getState();
|
||||
if (state.ctx.content.auth !== null) {
|
||||
window.location.replace('#/');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (routeFocused) {
|
||||
core.on('Event', onEvent);
|
||||
}
|
||||
return () => {
|
||||
core.off('Event', onEvent);
|
||||
};
|
||||
}, [routeFocused]);
|
||||
const loginWithFacebook = React.useCallback(() => {
|
||||
alert('TODO: Facebook login');
|
||||
}, []);
|
||||
FB.login((response) => {
|
||||
if (response.status === 'connected') {
|
||||
fetch('https://www.strem.io/fb-login-with-token/' + encodeURIComponent(response.authResponse.accessToken), { timeout: 10 * 1000 })
|
||||
.then((resp) => {
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw new Error('Login failed at getting token from Stremio with status ' + resp.status);
|
||||
} else {
|
||||
return resp.json();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'Login',
|
||||
args: {
|
||||
email: state.email,
|
||||
password: response.authResponse.accessToken
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => { });
|
||||
}
|
||||
});
|
||||
}, [state.email, state.password]);
|
||||
const loginWithEmail = React.useCallback(() => {
|
||||
if (typeof state.email !== 'string' || state.email.length === 0) {
|
||||
dispatch({ type: 'error', error: 'Invalid email' });
|
||||
return;
|
||||
}
|
||||
|
||||
alert('TODO: Login');
|
||||
if (typeof state.password !== 'string' || state.password.length === 0) {
|
||||
dispatch({ type: 'error', error: 'Invalid password' });
|
||||
return;
|
||||
}
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'Login',
|
||||
args: {
|
||||
email: state.email,
|
||||
password: state.password
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [state.email, state.password]);
|
||||
const loginAsGuest = React.useCallback(() => {
|
||||
if (!state.termsAccepted) {
|
||||
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
|
||||
return;
|
||||
}
|
||||
|
||||
alert('TODO: Guest login');
|
||||
}, [state.termsAccepted, state.privacyPolicyAccepted, state.marketingAccepted]);
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'Logout'
|
||||
}
|
||||
});
|
||||
window.location.replace('#/');
|
||||
}, [state.termsAccepted]);
|
||||
const signup = React.useCallback(() => {
|
||||
alert('TODO: Signup');
|
||||
if (typeof state.email !== 'string' || state.email.length === 0) {
|
||||
dispatch({ type: 'error', error: 'Invalid email' });
|
||||
return;
|
||||
}
|
||||
if (typeof state.password !== 'string' || state.password.length === 0) {
|
||||
dispatch({ type: 'error', error: 'Invalid password' });
|
||||
return;
|
||||
}
|
||||
if (state.password !== state.confirmPassword) {
|
||||
dispatch({ type: 'error', error: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
if (!state.termsAccepted) {
|
||||
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
|
||||
return;
|
||||
}
|
||||
if (!state.privacyPolicyAccepted) {
|
||||
dispatch({ type: 'error', error: 'You must accept the Privacy Policy' });
|
||||
return;
|
||||
}
|
||||
core.dispatch({
|
||||
action: 'UserOp',
|
||||
args: {
|
||||
userOp: 'Register',
|
||||
args: {
|
||||
email: state.email,
|
||||
password: state.password,
|
||||
gdpr_consent: {
|
||||
tos: state.termsAccepted,
|
||||
privacy: state.privacyPolicyAccepted,
|
||||
marketing: state.marketingAccepted,
|
||||
time: new Date(),
|
||||
from: 'web'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [state.email, state.password, state.confirmPassword, state.termsAccepted, state.privacyPolicyAccepted, state.marketingAccepted]);
|
||||
const emailOnChange = React.useCallback((event) => {
|
||||
dispatch({
|
||||
|
|
@ -130,9 +236,11 @@ const Intro = () => {
|
|||
const toggleMarketingAccepted = React.useCallback(() => {
|
||||
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
|
||||
}, []);
|
||||
const switchForm = React.useCallback(() => {
|
||||
dispatch({ type: 'switch-form' });
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
|
||||
dispatch({ type: 'set-form', form: queryParams.get('form') });
|
||||
}
|
||||
}, [queryParams]);
|
||||
React.useEffect(() => {
|
||||
if (typeof state.error === 'string' && state.error.length > 0) {
|
||||
errorRef.current.scrollIntoView();
|
||||
|
|
@ -150,7 +258,7 @@ const Intro = () => {
|
|||
<Icon className={styles['icon']} icon={'ic_facebook'} />
|
||||
<div className={styles['label']}>Continue with Facebook</div>
|
||||
</Button>
|
||||
<div className={styles['facebook-statement']}>We won't post anything on your behalf</div>
|
||||
<div className={styles['facebook-statement']}>We won't post anything on your behalf</div>
|
||||
<CredentialsTextInput
|
||||
ref={emailRef}
|
||||
className={styles['credentials-text-input']}
|
||||
|
|
@ -188,7 +296,7 @@ const Intro = () => {
|
|||
link={'Terms and conditions'}
|
||||
href={'https://www.stremio.com/tos'}
|
||||
checked={state.termsAccepted}
|
||||
toggle={toggleTermsAccepted}
|
||||
onToggle={toggleTermsAccepted}
|
||||
/>
|
||||
<ConsentCheckbox
|
||||
ref={privacyPolicyRef}
|
||||
|
|
@ -197,14 +305,14 @@ const Intro = () => {
|
|||
link={'Privacy Policy'}
|
||||
href={'https://www.stremio.com/privacy'}
|
||||
checked={state.privacyPolicyAccepted}
|
||||
toggle={togglePrivacyPolicyAccepted}
|
||||
onToggle={togglePrivacyPolicyAccepted}
|
||||
/>
|
||||
<ConsentCheckbox
|
||||
ref={marketingRef}
|
||||
className={styles['consent-checkbox']}
|
||||
label={'I agree to receive marketing communications from Stremio'}
|
||||
checked={state.marketingAccepted}
|
||||
toggle={toggleMarketingAccepted}
|
||||
onToggle={toggleMarketingAccepted}
|
||||
/>
|
||||
</React.Fragment>
|
||||
:
|
||||
|
|
@ -229,7 +337,7 @@ const Intro = () => {
|
|||
:
|
||||
null
|
||||
}
|
||||
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} onClick={switchForm}>
|
||||
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} href={state.form === SIGNUP_FORM ? '#/intro?form=login' : '#/intro?form=signup'}>
|
||||
<div className={styles['label']}>{state.form === SIGNUP_FORM ? 'LOG IN' : 'SING UP WITH EMAIL'}</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -237,4 +345,8 @@ const Intro = () => {
|
|||
);
|
||||
};
|
||||
|
||||
Intro.propTypes = {
|
||||
queryParams: PropTypes.instanceOf(URLSearchParams)
|
||||
};
|
||||
|
||||
module.exports = Intro;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,80 @@
|
|||
const React = require('react');
|
||||
const { Dropdown, MainNavBar, MetaItem } = require('stremio/common');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Button, Multiselect, MainNavBar, MetaItem } = require('stremio/common');
|
||||
const useLibrary = require('./useLibrary');
|
||||
const useSelectableInputs = require('./useSelectableInputs');
|
||||
const useItemOptions = require('./useItemOptions');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Library = ({ urlParams, queryParams }) => {
|
||||
const [metaItems, dropdowns] = useLibrary(urlParams.type, queryParams.get('sort'));
|
||||
const library = useLibrary(urlParams, queryParams);
|
||||
const [typeSelect, sortPropSelect] = useSelectableInputs(library);
|
||||
const [options, optionOnSelect] = useItemOptions();
|
||||
return (
|
||||
<div className={styles['library-container']}>
|
||||
<MainNavBar className={styles['nav-bar']} />
|
||||
<div className={styles['library-content']}>
|
||||
<div className={styles['dropdowns-container']}>
|
||||
{dropdowns.map((dropdown) => (
|
||||
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{metaItems.map((metaItem) => (
|
||||
<MetaItem
|
||||
{...metaItem}
|
||||
key={metaItem.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
library.library_state.type === 'Ready' && library.library_state.content.uid !== null && library.type_names.length > 0 ?
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
<Multiselect {...typeSelect} className={styles['select-input-container']} />
|
||||
<Multiselect {...sortPropSelect} className={styles['select-input-container']} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
library.library_state.type === 'Ready' && library.library_state.content.uid === null ?
|
||||
<div className={classnames(styles['message-container'], styles['no-user-message-container'])}>
|
||||
<div className={styles['message-label']}>Library is only availavle for logged in users</div>
|
||||
<Button className={styles['login-button-container']} href={'#/intro'}>
|
||||
<div className={styles['label']}>LOG IN</div>
|
||||
</Button>
|
||||
</div>
|
||||
:
|
||||
library.library_state.type !== 'Ready' ?
|
||||
<div className={styles['message-container']}>
|
||||
<div className={styles['message-label']}>Loading</div>
|
||||
</div>
|
||||
:
|
||||
library.type_names.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<div className={styles['message-label']}>Empty library</div>
|
||||
</div>
|
||||
:
|
||||
library.selected === null ?
|
||||
<div className={styles['message-container']}>
|
||||
<div className={styles['message-label']}>Please select a type</div>
|
||||
</div>
|
||||
:
|
||||
library.lib_items.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<div className={styles['message-label']}>There are no items for the selected type</div>
|
||||
</div>
|
||||
:
|
||||
<div className={styles['meta-items-container']}>
|
||||
{library.lib_items.map(({ id, videoId, ...libItem }, index) => (
|
||||
<MetaItem
|
||||
{...libItem}
|
||||
key={index}
|
||||
dataset={{ id, videoId, type: libItem.type }}
|
||||
options={options}
|
||||
optionOnSelect={optionOnSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Library.propTypes = {
|
||||
urlParams: PropTypes.exact({
|
||||
type: PropTypes.string,
|
||||
}),
|
||||
queryParams: PropTypes.instanceOf(URLSearchParams)
|
||||
};
|
||||
|
||||
module.exports = Library;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
:import('~stremio/common/Multiselect/styles.less') {
|
||||
multiselect-menu-container: menu-container;
|
||||
}
|
||||
|
||||
.library-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -15,38 +19,98 @@
|
|||
.library-content {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"dropdowns-area switch-view-area"
|
||||
"meta-items-area meta-items-area";
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dropdowns-container {
|
||||
grid-area: dropdowns-area;
|
||||
.selectable-inputs-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 2rem;
|
||||
margin: 1.5rem;
|
||||
overflow: visible;
|
||||
|
||||
.dropdown {
|
||||
.select-input-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
height: 3rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.multiselect-menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
&:first-child {
|
||||
.message-label {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-user-message-container {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
|
||||
.login-button-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--color-primarydark);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 4.8em;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-surfacelighter);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-label {
|
||||
flex: none;
|
||||
max-height: 4.8em;
|
||||
font-size: 2rem;
|
||||
color: var(--color-surfacelighter);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-items-container {
|
||||
grid-area: meta-items-area;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
grid-gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0 2rem;
|
||||
grid-gap: 1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +120,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(10, auto);
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +130,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(9, auto);
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +140,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(8, auto);
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +150,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(7, auto);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +160,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +170,7 @@
|
|||
.library-container {
|
||||
.library-content {
|
||||
.meta-items-container {
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/routes/Library/useItemOptions.js
Normal file
25
src/routes/Library/useItemOptions.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const React = require('react');
|
||||
|
||||
const PLAY_OPTION = {
|
||||
label: 'Play',
|
||||
value: 'play'
|
||||
};
|
||||
|
||||
const DISMISS_OPTION = {
|
||||
label: 'Dismiss',
|
||||
value: 'dismiss'
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
// TODO {{event.value}} {{event.dataset}}
|
||||
};
|
||||
|
||||
const useItemOptions = () => {
|
||||
const options = React.useMemo(() => ([
|
||||
PLAY_OPTION,
|
||||
DISMISS_OPTION
|
||||
]), []);
|
||||
return [options, onSelect];
|
||||
};
|
||||
|
||||
module.exports = useItemOptions;
|
||||
|
|
@ -1,99 +1,95 @@
|
|||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const DEFAULT_TYPE = 'movie';
|
||||
const libraryItems = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'movie',
|
||||
name: 'Stremio demo item movie 1',
|
||||
poster: '/images/intro_background.jpg',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'poster'
|
||||
const initLibraryState = () => ({
|
||||
library_state: {
|
||||
type: 'NotLoaded'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'movie',
|
||||
name: 'Stremio demo item movie 2',
|
||||
poster: '/images/intro_background.jpg',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'poster'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'series',
|
||||
name: 'Stremio demo item series 1',
|
||||
poster: '/images/default_avatar.png',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'poster'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'series',
|
||||
name: 'Stremio demo item series 2',
|
||||
poster: '/images/default_avatar.png',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'poster'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'channel',
|
||||
name: 'Stremio demo item channel 1',
|
||||
poster: '/images/anonymous.png',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'square'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'channel',
|
||||
name: 'Stremio demo item channel 2',
|
||||
poster: '/images/anonymous.png',
|
||||
logo: '/images/default_avatar.png',
|
||||
posterShape: 'square'
|
||||
selected: null,
|
||||
type_names: [],
|
||||
lib_items: []
|
||||
});
|
||||
|
||||
const mapLibraryState = (library) => {
|
||||
const library_state = library.library_state;
|
||||
const selected = library.selected;
|
||||
const type_names = library.type_names;
|
||||
const lib_items = library.lib_items.map((lib_item) => ({
|
||||
id: lib_item._id,
|
||||
type: lib_item.type,
|
||||
name: lib_item.name,
|
||||
poster: lib_item.poster,
|
||||
posterShape: lib_item.posterShape,
|
||||
progress: lib_item.state.timeOffset > 0 && lib_item.state.duration > 0 ?
|
||||
lib_item.state.timeOffset / lib_item.state.duration
|
||||
:
|
||||
null,
|
||||
videoId: lib_item.state.video_id,
|
||||
href: `#/metadetails/${encodeURIComponent(lib_item.type)}/${encodeURIComponent(lib_item._id)}${lib_item.state.video_id !== null ? `/${encodeURIComponent(lib_item.state.video_id)}` : ''}`
|
||||
}));
|
||||
return { library_state, selected, type_names, lib_items };
|
||||
};
|
||||
|
||||
const onNewLibraryState = (library) => {
|
||||
if (library.selected === null && library.type_names.length > 0) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'LibraryFiltered',
|
||||
args: {
|
||||
type_name: library.type_names[0],
|
||||
sort_prop: null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const useLibrary = (type, sort) => {
|
||||
type = typeof type === 'string' && type.length > 0 ? type : DEFAULT_TYPE;
|
||||
const items = React.useMemo(() => {
|
||||
return libraryItems.filter(item => item.type === type);
|
||||
}, [type, sort]);
|
||||
const onSelect = React.useCallback((event) => {
|
||||
const { name, value } = event.currentTarget.dataset;
|
||||
if (name === 'type') {
|
||||
const nextQuery = new URLSearchParams({ sort: typeof sort === 'string' ? sort : '' });
|
||||
const nextType = typeof value === 'string' ? value : '';
|
||||
window.location.replace(`#/library/${nextType}?${nextQuery}`);
|
||||
} else if (name === 'sort') {
|
||||
const nextQuery = new URLSearchParams({ sort: typeof value === 'string' ? value : '' });
|
||||
const nextType = typeof type === 'string' ? type : '';
|
||||
window.location.replace(`#/library/${nextType}?${nextQuery}`);
|
||||
const useLibrary = (urlParams, queryParams) => {
|
||||
const { core } = useServices();
|
||||
const loadLibraryAction = React.useMemo(() => {
|
||||
if (typeof urlParams.type === 'string') {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'LibraryFiltered',
|
||||
args: {
|
||||
type_name: urlParams.type,
|
||||
sort_prop: queryParams.has('sort_prop') ?
|
||||
queryParams.get('sort_prop')
|
||||
:
|
||||
null
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const library = core.getState('library');
|
||||
if (library.type_names.length > 0) {
|
||||
return {
|
||||
action: 'Load',
|
||||
args: {
|
||||
load: 'LibraryFiltered',
|
||||
args: {
|
||||
type_name: library.type_names[0],
|
||||
sort_prop: null
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: 'Unload'
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [type, sort]);
|
||||
const typeDropdown = React.useMemo(() => {
|
||||
const selected = typeof type === 'string' && type.length > 0 ? [type] : [];
|
||||
const options = libraryItems
|
||||
.map(({ type }) => type)
|
||||
.concat(selected)
|
||||
.filter((type, index, types) => types.indexOf(type) === index)
|
||||
.map((type) => ({ label: type, value: type }));
|
||||
return {
|
||||
name: 'type',
|
||||
selected,
|
||||
options,
|
||||
onSelect
|
||||
};
|
||||
}, [type, onSelect]);
|
||||
const sortDropdown = React.useMemo(() => {
|
||||
const selected = typeof sort === 'string' && sort.length > 0 ? [sort] : [];
|
||||
const options = [{ label: 'A-Z', value: 'a-z' }, { label: 'Recent', value: 'recent' }];
|
||||
return {
|
||||
name: 'sort',
|
||||
selected,
|
||||
options,
|
||||
onSelect
|
||||
};
|
||||
}, [sort, onSelect]);
|
||||
return [items, [typeDropdown, sortDropdown]];
|
||||
}, [urlParams, queryParams]);
|
||||
return useModelState({
|
||||
model: 'library',
|
||||
action: loadLibraryAction,
|
||||
map: mapLibraryState,
|
||||
init: initLibraryState,
|
||||
onNewState: onNewLibraryState
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = useLibrary;
|
||||
|
|
|
|||
52
src/routes/Library/useSelectableInputs.js
Normal file
52
src/routes/Library/useSelectableInputs.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const React = require('react');
|
||||
|
||||
const SORT_PROP_OPTIONS = [
|
||||
{ label: 'Recent', value: '_ctime' },
|
||||
{ label: 'A-Z', value: 'name' },
|
||||
{ label: 'Year', value: 'year' },
|
||||
];
|
||||
|
||||
const mapSelectableInputs = (library) => {
|
||||
const typeSelect = {
|
||||
title: 'Select type',
|
||||
selected: library.selected !== null ?
|
||||
[library.selected.type_name]
|
||||
:
|
||||
[],
|
||||
options: library.type_names
|
||||
.map((type) => ({ label: type, value: type })),
|
||||
onSelect: (event) => {
|
||||
const queryParams = new URLSearchParams(
|
||||
library.selected !== null ?
|
||||
[['sort_prop', library.selected.sort_prop]]
|
||||
:
|
||||
[]
|
||||
);
|
||||
window.location.replace(`#/library/${encodeURIComponent(event.value)}?${queryParams.toString()}`);
|
||||
}
|
||||
};
|
||||
const sortPropSelect = {
|
||||
title: 'Select sort',
|
||||
selected: library.selected !== null ?
|
||||
[library.selected.sort_prop]
|
||||
:
|
||||
[],
|
||||
options: SORT_PROP_OPTIONS,
|
||||
onSelect: (event) => {
|
||||
const queryParams = new URLSearchParams([['sort_prop', event.value]]);
|
||||
if (library.selected !== null) {
|
||||
window.location.replace(`#/library/${encodeURIComponent(library.selected.type_name)}?${queryParams.toString()}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
return [typeSelect, sortPropSelect];
|
||||
};
|
||||
|
||||
const useSelectableInputs = (library) => {
|
||||
const selectableInputs = React.useMemo(() => {
|
||||
return mapSelectableInputs(library);
|
||||
}, [library]);
|
||||
return selectableInputs;
|
||||
};
|
||||
|
||||
module.exports = useSelectableInputs;
|
||||
123
src/routes/MetaDetails/MetaDetails.js
Normal file
123
src/routes/MetaDetails/MetaDetails.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const { NavBar, MetaPreview, useInLibrary } = require('stremio/common');
|
||||
const VideosList = require('./VideosList');
|
||||
const StreamsList = require('./StreamsList');
|
||||
const useMetaDetails = require('./useMetaDetails');
|
||||
const useSelectableResource = require('./useSelectableResource');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaDetails = ({ urlParams }) => {
|
||||
const metaDetails = useMetaDetails(urlParams);
|
||||
const [metaResourceRef, metaResources, selectedMetaResource] = useSelectableResource(metaDetails.selected.meta_resource_ref, metaDetails.meta_resources);
|
||||
const streamsResourceRef = metaDetails.selected.streams_resource_ref;
|
||||
const streamsResources = metaDetails.streams_resources;
|
||||
const metaItem = React.useMemo(() => {
|
||||
return selectedMetaResource !== null ?
|
||||
selectedMetaResource.content.content
|
||||
:
|
||||
metaResourceRef !== null ?
|
||||
{
|
||||
id: metaResourceRef.id,
|
||||
type: metaResourceRef.type_name,
|
||||
name: ''
|
||||
}
|
||||
:
|
||||
null;
|
||||
}, [metaResourceRef, selectedMetaResource]);
|
||||
const [inLibrary, toggleInLibrary] = useInLibrary(metaItem);
|
||||
return (
|
||||
<div className={styles['metadetails-container']}>
|
||||
<NavBar
|
||||
className={styles['nav-bar']}
|
||||
backButton={true}
|
||||
title={selectedMetaResource !== null ? selectedMetaResource.content.content.name : null}
|
||||
/>
|
||||
<div className={styles['metadetails-content']}>
|
||||
{
|
||||
metaResourceRef === null ?
|
||||
<MetaPreview
|
||||
className={styles['meta-preview']}
|
||||
name={'No meta was selected'}
|
||||
/>
|
||||
:
|
||||
metaResources.length === 0 ?
|
||||
<MetaPreview
|
||||
className={styles['meta-preview']}
|
||||
name={'No addons ware requested for this meta'}
|
||||
inLibrary={inLibrary}
|
||||
toggleInLibrary={toggleInLibrary}
|
||||
/>
|
||||
:
|
||||
metaResources.every((metaResource) => metaResource.content.type === 'Err') ?
|
||||
<MetaPreview
|
||||
className={styles['meta-preview']}
|
||||
name={'No metadata was found'}
|
||||
inLibrary={inLibrary}
|
||||
toggleInLibrary={toggleInLibrary}
|
||||
/>
|
||||
:
|
||||
selectedMetaResource !== null ?
|
||||
<React.Fragment>
|
||||
{
|
||||
typeof selectedMetaResource.content.content.background === 'string' &&
|
||||
selectedMetaResource.content.content.background.length > 0 ?
|
||||
<div className={styles['background-image-layer']}>
|
||||
<img
|
||||
className={styles['background-image']}
|
||||
src={selectedMetaResource.content.content.background}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<MetaPreview
|
||||
className={styles['meta-preview']}
|
||||
name={selectedMetaResource.content.content.name}
|
||||
logo={selectedMetaResource.content.content.logo}
|
||||
background={null}
|
||||
runtime={selectedMetaResource.content.content.runtime}
|
||||
releaseInfo={selectedMetaResource.content.content.releaseInfo}
|
||||
released={selectedMetaResource.content.content.released}
|
||||
description={selectedMetaResource.content.content.description}
|
||||
links={selectedMetaResource.content.content.links}
|
||||
trailer={selectedMetaResource.content.content.trailer}
|
||||
inLibrary={inLibrary}
|
||||
toggleInLibrary={toggleInLibrary}
|
||||
/>
|
||||
</React.Fragment>
|
||||
:
|
||||
<MetaPreview.Placeholder
|
||||
className={styles['meta-preview']}
|
||||
/>
|
||||
}
|
||||
{
|
||||
streamsResourceRef !== null ?
|
||||
<StreamsList
|
||||
className={styles['streams-list']}
|
||||
streamsResources={streamsResources}
|
||||
/>
|
||||
:
|
||||
metaResourceRef !== null ?
|
||||
<VideosList
|
||||
className={styles['videos-list']}
|
||||
metaResource={selectedMetaResource}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MetaDetails.propTypes = {
|
||||
urlParams: PropTypes.exact({
|
||||
type: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
videoId: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = MetaDetails;
|
||||
66
src/routes/MetaDetails/StreamsList/Stream/Stream.js
Normal file
66
src/routes/MetaDetails/StreamsList/Stream/Stream.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button, Image } = require('stremio/common');
|
||||
const StreamPlaceholder = require('./StreamPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Stream = ({ className, addonName, title, thumbnail, progress, ...props }) => {
|
||||
return (
|
||||
<Button {...props} className={classnames(className, styles['stream-container'])} title={title}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']}>
|
||||
<Image
|
||||
className={styles['thumbnail']}
|
||||
src={thumbnail}
|
||||
alt={' '}
|
||||
renderFallback={() => (
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={'ic_broken_link'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addon-name-container']}>
|
||||
<div className={styles['addon-name']}>{addonName}</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles['info-container']}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['addon-name-label']}>{addonName}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['title-label']}>{title}</div>
|
||||
</div>
|
||||
<div className={styles['play-icon-container']}>
|
||||
<Icon className={styles['play-icon']} icon={'ic_play'} />
|
||||
</div>
|
||||
{
|
||||
progress !== null && !isNaN(progress) && progress > 0 ?
|
||||
<div className={styles['progress-bar-container']}>
|
||||
<div className={styles['progress-bar']} style={{ width: `${Math.min(progress, 1) * 100}%` }} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
Stream.Placeholder = StreamPlaceholder;
|
||||
|
||||
Stream.propTypes = {
|
||||
className: PropTypes.string,
|
||||
addonName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
progress: PropTypes.number,
|
||||
};
|
||||
|
||||
module.exports = Stream;
|
||||
|
|
@ -36,12 +36,14 @@
|
|||
|
||||
.play-icon-container {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
.play-icon {
|
||||
display: block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-placeholder);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue