mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 04:46:40 +07:00
Merge remote-tracking branch 'origin/master'
# Conflicts: # src/components/node/CommentForm/index.tsx
This commit is contained in:
commit
1e269e08cd
76 changed files with 7203 additions and 18321 deletions
50
.eslintrc.js
50
.eslintrc.js
|
@ -1,5 +1,11 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['plugin:@typescript-eslint/recommended', 'prettier/@typescript-eslint', 'airbnb', 'airbnb-base'],
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier/@typescript-eslint',
|
||||||
|
'airbnb',
|
||||||
|
'airbnb-base',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
|
@ -25,6 +31,18 @@ module.exports = {
|
||||||
'global-require': 1,
|
'global-require': 1,
|
||||||
'react/no-multi-comp': 1,
|
'react/no-multi-comp': 1,
|
||||||
'react/jsx-filename-extension': 0,
|
'react/jsx-filename-extension': 0,
|
||||||
|
'react/jsx-wrap-multilines': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
declaration: 'parens',
|
||||||
|
assignment: 'parens',
|
||||||
|
return: 'parens',
|
||||||
|
arrow: 'parens',
|
||||||
|
condition: 'ignore',
|
||||||
|
logical: 'ignore',
|
||||||
|
prop: 'ignore',
|
||||||
|
},
|
||||||
|
],
|
||||||
'@typescript-eslint/camelcase': 0,
|
'@typescript-eslint/camelcase': 0,
|
||||||
'@typescript-eslint/interface-name-prefix': 0,
|
'@typescript-eslint/interface-name-prefix': 0,
|
||||||
camelcase: 0,
|
camelcase: 0,
|
||||||
|
@ -43,18 +61,21 @@ module.exports = {
|
||||||
'max-line-length': [true, 100],
|
'max-line-length': [true, 100],
|
||||||
// 'max-len': 100,
|
// 'max-len': 100,
|
||||||
// 'max-len': { "code": 100 },
|
// 'max-len': { "code": 100 },
|
||||||
'max-len': ["warn", { "code": 100 }],
|
'max-len': ['warn', { code: 100 }],
|
||||||
"template-curly-spacing": "off",
|
'template-curly-spacing': 'off',
|
||||||
"comma-dangle": ["warn", {
|
'comma-dangle': [
|
||||||
"arrays": "always-multiline",
|
'warn',
|
||||||
"objects": "always-multiline",
|
{
|
||||||
"imports": "always-multiline",
|
arrays: 'always-multiline',
|
||||||
"exports": "always-multiline",
|
objects: 'always-multiline',
|
||||||
"functions": "never"
|
imports: 'always-multiline',
|
||||||
}],
|
exports: 'always-multiline',
|
||||||
indent: "off",
|
functions: 'never',
|
||||||
"import/order": "off",
|
},
|
||||||
"arrow-parens": ["warn", "as-needed"],
|
],
|
||||||
|
indent: 'off',
|
||||||
|
'import/order': 'off',
|
||||||
|
'arrow-parens': ['warn', 'as-needed'],
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
document: false,
|
document: false,
|
||||||
|
@ -62,5 +83,8 @@ module.exports = {
|
||||||
HTMLInputElement: false,
|
HTMLInputElement: false,
|
||||||
HTMLDivElement: false,
|
HTMLDivElement: false,
|
||||||
FormData: false,
|
FormData: false,
|
||||||
|
FileReader: false,
|
||||||
|
Audio: false,
|
||||||
|
CustomEvent: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
14218
package-lock.json
generated
14218
package-lock.json
generated
File diff suppressed because it is too large
Load diff
54
package.json
54
package.json
|
@ -14,14 +14,14 @@
|
||||||
"url": "https://github.com/muerwre/my-empty-react-project"
|
"url": "https://github.com/muerwre/my-empty-react-project"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.0.0-rc.1",
|
"@babel/cli": "^7.6.3",
|
||||||
"@babel/preset-env": "^7.0.0-rc.1",
|
"@babel/preset-env": "^7.6.3",
|
||||||
"@babel/types": "7.5.5",
|
"@babel/types": "7.5.5",
|
||||||
"@types/react-router": "^5.0.3",
|
"@types/react-router": "^5.0.3",
|
||||||
"autoresponsive-react": "^1.1.31",
|
"autoresponsive-react": "^1.1.31",
|
||||||
"awesome-typescript-loader": "^5.2.1",
|
"awesome-typescript-loader": "^5.2.1",
|
||||||
"babel-core": "^6.26.3",
|
"babel-core": "^6.26.3",
|
||||||
"babel-eslint": "^10.0.1",
|
"babel-eslint": "^10.0.3",
|
||||||
"babel-loader": "^7.1.4",
|
"babel-loader": "^7.1.4",
|
||||||
"babel-plugin-lodash": "^3.3.4",
|
"babel-plugin-lodash": "^3.3.4",
|
||||||
"babel-plugin-transform-runtime": "^6.23.0",
|
"babel-plugin-transform-runtime": "^6.23.0",
|
||||||
|
@ -39,21 +39,20 @@
|
||||||
"prettier": "^1.18.2",
|
"prettier": "^1.18.2",
|
||||||
"resolve-url-loader": "^3.0.1",
|
"resolve-url-loader": "^3.0.1",
|
||||||
"style-loader": "^0.21.0",
|
"style-loader": "^0.21.0",
|
||||||
"ts-node": "^8.0.1",
|
"ts-node": "^8.4.1",
|
||||||
"typescript": "^3.2.4",
|
"typescript": "^3.6.4",
|
||||||
"uglifyjs-webpack-plugin": "^1.3.0",
|
"uglifyjs-webpack-plugin": "^1.3.0",
|
||||||
"webpack": "^4.6.0",
|
"webpack": "^4.41.0",
|
||||||
"webpack-cli": "^3.2.3",
|
"webpack-cli": "^3.3.9",
|
||||||
"webpack-dev-server": "^3.1.14"
|
"webpack-dev-server": "^3.8.2"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hot-loader/react-dom": "^16.8.1",
|
"@hot-loader/react-dom": "^16.10.2",
|
||||||
"@types/classnames": "^2.2.7",
|
"@types/classnames": "^2.2.7",
|
||||||
"@types/node": "^11.9.0",
|
"@types/node": "^11.13.22",
|
||||||
"@types/ramda": "^0.26.19",
|
"@types/ramda": "^0.26.29",
|
||||||
"@types/react": "16.8.23",
|
"@types/react": "16.8.23",
|
||||||
"@types/redux-saga": "^0.10.5",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^1.13.0",
|
"@typescript-eslint/eslint-plugin": "^1.13.0",
|
||||||
"@typescript-eslint/parser": "^1.13.0",
|
"@typescript-eslint/parser": "^1.13.0",
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
|
@ -61,12 +60,12 @@
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"clean-webpack-plugin": "^0.1.9",
|
"clean-webpack-plugin": "^0.1.9",
|
||||||
"connected-react-router": "^6.3.2",
|
"connected-react-router": "^6.3.2",
|
||||||
"date-fns": "^2.0.0-alpha.27",
|
"date-fns": "^2.4.1",
|
||||||
"dotenv": "^8.0.0",
|
"dotenv": "^8.1.0",
|
||||||
"dotenv-webpack": "^1.7.0",
|
"dotenv-webpack": "^1.7.0",
|
||||||
"eslint": "^5.16.0",
|
"eslint": "^5.16.0",
|
||||||
"eslint-config-airbnb": "^17.1.1",
|
"eslint-config-airbnb": "^17.1.1",
|
||||||
"eslint-config-prettier": "^6.0.0",
|
"eslint-config-prettier": "^6.4.0",
|
||||||
"eslint-import-resolver-babel-module": "^4.0.0",
|
"eslint-import-resolver-babel-module": "^4.0.0",
|
||||||
"eslint-import-resolver-typescript": "^1.1.1",
|
"eslint-import-resolver-typescript": "^1.1.1",
|
||||||
"eslint-import-resolver-webpack": "^0.9.0",
|
"eslint-import-resolver-webpack": "^0.9.0",
|
||||||
|
@ -74,40 +73,41 @@
|
||||||
"eslint-plugin-babel": "^5.3.0",
|
"eslint-plugin-babel": "^5.3.0",
|
||||||
"eslint-plugin-import": "^2.18.2",
|
"eslint-plugin-import": "^2.18.2",
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||||
"eslint-plugin-prettier": "^3.1.0",
|
"eslint-plugin-prettier": "^3.1.1",
|
||||||
"eslint-plugin-react": "^7.14.3",
|
"eslint-plugin-react": "^7.16.0",
|
||||||
"eslint-plugin-react-hooks": "^1.7.0",
|
"eslint-plugin-react-hooks": "^1.7.0",
|
||||||
"history": "^4.7.2",
|
"flexbin": "^0.2.0",
|
||||||
|
"history": "^4.10.1",
|
||||||
"http-errors": "~1.6.2",
|
"http-errors": "~1.6.2",
|
||||||
"less": "^3.8.1",
|
"less": "^3.10.3",
|
||||||
"less-middleware": "~2.2.1",
|
"less-middleware": "~2.2.1",
|
||||||
"lodash": "^4.17.10",
|
"lodash": "^4.17.10",
|
||||||
"node-sass": "^4.11.0",
|
"node-sass": "^4.11.0",
|
||||||
"raleway-cyrillic": "^4.0.2",
|
"raleway-cyrillic": "^4.0.2",
|
||||||
"ramda": "^0.26.1",
|
"ramda": "^0.26.1",
|
||||||
"react": "16.8.6",
|
"react": "16.8.6",
|
||||||
"react-dom": "^16.8.6",
|
"react-dom": "^16.10.2",
|
||||||
"react-grid-layout": "^0.16.6",
|
"react-grid-layout": "^0.16.6",
|
||||||
"react-hot-loader": "^4.1.1",
|
"react-hot-loader": "^4.12.15",
|
||||||
"react-layout-pack": "^0.2.3",
|
"react-layout-pack": "^0.2.3",
|
||||||
"react-packery-component": "^1.0.2",
|
"react-packery-component": "^1.0.2",
|
||||||
"react-redux": "^6.0.1",
|
"react-redux": "^6.0.1",
|
||||||
"react-router": "^4.3.1",
|
"react-router": "^4.3.1",
|
||||||
"react-router-dom": "^4.3.1",
|
"react-router-dom": "^4.3.1",
|
||||||
"react-scrollbar": "^0.5.4",
|
"react-scrollbar": "^0.5.4",
|
||||||
"react-sortable-hoc": "^1.9.1",
|
"react-sortable-hoc": "^1.10.1",
|
||||||
"react-stack-grid": "^0.7.1",
|
"react-stack-grid": "^0.7.1",
|
||||||
"redux": "^4.0.1",
|
"redux": "^4.0.1",
|
||||||
"redux-persist": "^5.10.0",
|
"redux-persist": "^5.10.0",
|
||||||
"redux-saga": "^1.0.5",
|
"redux-saga": "^1.1.1",
|
||||||
"reduxsauce": "^1.0.0",
|
"reduxsauce": "^1.0.0",
|
||||||
"sass-loader": "^7.1.0",
|
"sass-loader": "^7.3.1",
|
||||||
"sass-resources-loader": "^2.0.0",
|
"sass-resources-loader": "^2.0.0",
|
||||||
"scrypt": "^6.0.3",
|
"scrypt": "^6.0.3",
|
||||||
"throttle-debounce": "^2.1.0",
|
"throttle-debounce": "^2.1.0",
|
||||||
"tslint": "^5.18.0",
|
"tslint": "^5.20.0",
|
||||||
"tslint-config-airbnb": "^5.11.1",
|
"tslint-config-airbnb": "^5.11.2",
|
||||||
"tslint-react": "^4.0.0",
|
"tslint-react": "^4.1.0",
|
||||||
"tslint-react-hooks": "^2.2.1",
|
"tslint-react-hooks": "^2.2.1",
|
||||||
"tt-react-custom-scrollbars": "latest",
|
"tt-react-custom-scrollbars": "latest",
|
||||||
"uuid4": "^1.1.4"
|
"uuid4": "^1.1.4"
|
||||||
|
|
44
src/components/bars/PlayerBar/index.tsx
Normal file
44
src/components/bars/PlayerBar/index.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
import { PLAYER_STATES } from '~/redux/player/constants';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import pick from 'ramda/es/pick';
|
||||||
|
import { selectPlayer } from '~/redux/player/selectors';
|
||||||
|
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||||
|
|
||||||
|
const mapStateToProps = state => pick(['status'], selectPlayer(state));
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||||
|
playerPause: PLAYER_ACTIONS.playerPause,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
|
const PlayerBarUnconnected: FC<IProps> = ({ status }) => {
|
||||||
|
if (status === PLAYER_STATES.UNSET) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.place}>
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.status}>
|
||||||
|
<div className={styles.playpause}>
|
||||||
|
<Icon icon="play" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Filler />
|
||||||
|
|
||||||
|
<div className={styles.close}>
|
||||||
|
<Icon icon="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlayerBar = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(PlayerBarUnconnected);
|
62
src/components/bars/PlayerBar/styles.scss
Normal file
62
src/components/bars/PlayerBar/styles.scss
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
.place {
|
||||||
|
position: relative;
|
||||||
|
height: 54px;
|
||||||
|
flex: 0 1 500px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.seeker {
|
||||||
|
transform: translate(0, -64px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
border-radius: 27px;
|
||||||
|
background: $green_gradient;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.5) 0 2px 5px, inset rgba(255, 255, 255, 0.3) 0 1px,
|
||||||
|
inset rgba(0, 0, 0, 0.3) 0 -1px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 54px;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
flex: 0 0 54px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playpause,
|
||||||
|
.close {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
fill: $content_bg;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,14 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
import classNames = require('classnames');
|
import classNames from 'classnames';
|
||||||
|
|
||||||
type IProps = React.HTMLAttributes<HTMLDivElement> & {
|
type IProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
seamless?: boolean;
|
seamless?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Card: FC<IProps> = ({
|
const Card: FC<IProps> = ({ className, children, seamless, ...props }) => (
|
||||||
className,
|
<div className={classNames(styles.card, className, { seamless })} {...props}>
|
||||||
children,
|
|
||||||
seamless,
|
|
||||||
...props
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.card, className, { seamless })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import React, {
|
import React, { FC, HTMLAttributes } from 'react';
|
||||||
FC, HTMLAttributes, ReactChild, ReactChildren
|
|
||||||
} from 'react';
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
import classNames = require('classnames');
|
import classNames = require('classnames');
|
||||||
|
@ -8,14 +6,9 @@ import classNames = require('classnames');
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
children: any;
|
children: any;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const CellGrid: FC<IProps> = ({
|
const CellGrid: FC<IProps> = ({ children, size, className, ...props }) => (
|
||||||
children,
|
|
||||||
size,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}) => (
|
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.grid, className)}
|
className={classNames(styles.grid, className)}
|
||||||
style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${size}px, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${size}px, 1fr))` }}
|
||||||
|
|
|
@ -8,6 +8,7 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
photo?: string;
|
photo?: string;
|
||||||
is_empty?: boolean;
|
is_empty?: boolean;
|
||||||
is_loading?: boolean;
|
is_loading?: boolean;
|
||||||
|
is_same?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CommentWrapper: FC<IProps> = ({
|
const CommentWrapper: FC<IProps> = ({
|
||||||
|
@ -16,15 +17,16 @@ const CommentWrapper: FC<IProps> = ({
|
||||||
is_empty,
|
is_empty,
|
||||||
is_loading,
|
is_loading,
|
||||||
className,
|
className,
|
||||||
|
is_same,
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<Card
|
<Card
|
||||||
className={classNames(styles.wrap, className, { is_empty, is_loading })}
|
className={classNames(styles.wrap, className, { is_empty, is_loading, is_same })}
|
||||||
seamless
|
seamless
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className={styles.thumb}>
|
<div className={styles.thumb}>
|
||||||
{photo && (
|
{!is_same && photo && (
|
||||||
<div className={styles.thumb_image} style={{ backgroundImage: `url("${photo}")` }} />
|
<div className={styles.thumb_image} style={{ backgroundImage: `url("${photo}")` }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,26 +1,35 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
background: $comment_bg;
|
background: $comment_bg;
|
||||||
min-height: 64px;
|
min-height: $comment_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-shadow: $comment_shadow;
|
position: relative;
|
||||||
|
box-shadow: none;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&:global(.is_empty) {
|
&:global(.is_empty) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:global(.is_same) {
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex: 0 0 64px;
|
flex: 0 0 $comment_height;
|
||||||
background: transparentize(black, 0.9);
|
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
|
background-color: transparentize(black, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb_image {
|
.thumb_image {
|
||||||
height: 64px;
|
height: $comment_height;
|
||||||
background: transparentize(white, 0.97);
|
background: transparentize(white, 0.97) no-repeat 50% 50%;
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import React, {
|
import React, { FC, useCallback, ChangeEventHandler, DragEventHandler } from 'react';
|
||||||
FC, useCallback, ChangeEventHandler, DragEventHandler,
|
|
||||||
} from 'react';
|
|
||||||
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||||
|
@ -31,7 +29,7 @@ const SortableList = SortableContainer(
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{items.map((file, index) => (
|
{items.map((file, index) => (
|
||||||
<SortableItem key={file.id} index={index} collection={0}>
|
<SortableItem key={file.id} index={index} collection={0}>
|
||||||
<ImageUpload id={file.id} thumb={getURL(file.url)} />
|
<ImageUpload id={file.id} thumb={getURL(file)} />
|
||||||
</SortableItem>
|
</SortableItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
@ -44,9 +42,7 @@ const SortableList = SortableContainer(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const ImageGrid: FC<IProps> = ({
|
const ImageGrid: FC<IProps> = ({ items, locked, onFileMove, onUpload }) => {
|
||||||
items, locked, onFileMove, onUpload,
|
|
||||||
}) => {
|
|
||||||
const onMove = useCallback(({ oldIndex, newIndex }) => onFileMove(oldIndex, newIndex), [
|
const onMove = useCallback(({ oldIndex, newIndex }) => onFileMove(oldIndex, newIndex), [
|
||||||
onFileMove,
|
onFileMove,
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import React, { FC, useState, useCallback } from 'react';
|
import React, { FC, useState, useCallback } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { URLS } from '~/constants/urls';
|
import { URLS } from '~/constants/urls';
|
||||||
import { getImageSize } from '~/utils/dom';
|
import { getImageSize, getURL } from '~/utils/dom';
|
||||||
import classNames = require('classnames');
|
import classNames = require('classnames');
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
@ -38,11 +37,11 @@ const Cell: FC<IProps> = ({ node: { id, title, brief, type }, onSelect, is_text
|
||||||
<div
|
<div
|
||||||
className={styles.thumbnail}
|
className={styles.thumbnail}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("${getImageSize(brief.thumbnail, 'medium')}")`,
|
backgroundImage: `url("${getURL({ url: brief.thumbnail })}")`,
|
||||||
opacity: is_loaded ? 1 : 0,
|
opacity: is_loaded ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={getImageSize(brief.thumbnail, 'medium')} onLoad={onImageLoad} alt="" />
|
<img src={getURL({ url: brief.thumbnail })} onLoad={onImageLoad} alt="" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,12 +49,3 @@ const Cell: FC<IProps> = ({ node: { id, title, brief, type }, onSelect, is_text
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Cell };
|
export { Cell };
|
||||||
|
|
||||||
/*
|
|
||||||
{is_text && (
|
|
||||||
<div className={styles.text}>
|
|
||||||
<div className={styles.text_title}>{node.title}</div>
|
|
||||||
{TEXTS.LOREM_IPSUM}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
*/
|
|
||||||
|
|
|
@ -7,9 +7,9 @@ $cols: $content_width / $cell;
|
||||||
|
|
||||||
.grid_test {
|
.grid_test {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax($cell, 1fr));
|
||||||
grid-template-rows: 360px;
|
grid-template-rows: $cell;
|
||||||
grid-auto-rows: 256px;
|
grid-auto-rows: $cell;
|
||||||
grid-auto-flow: row dense;
|
grid-auto-flow: row dense;
|
||||||
grid-column-gap: $grid_line;
|
grid-column-gap: $grid_line;
|
||||||
grid-row-gap: $grid_line;
|
grid-row-gap: $grid_line;
|
||||||
|
@ -22,8 +22,8 @@ $cols: $content_width / $cell;
|
||||||
.hero {
|
.hero {
|
||||||
grid-row-start: 1;
|
grid-row-start: 1;
|
||||||
grid-row-end: span 1;
|
grid-row-end: span 1;
|
||||||
grid-column-start: 0;
|
grid-column-start: 1;
|
||||||
grid-column-end: span 4;
|
grid-column-end: -1;
|
||||||
// gridRow: "1 / 2",
|
// gridRow: "1 / 2",
|
||||||
// gridColumn: "1 / -1",
|
// gridColumn: "1 / -1",
|
||||||
background: darken($content_bg, 2%);
|
background: darken($content_bg, 2%);
|
||||||
|
|
|
@ -8,7 +8,7 @@ type IButtonProps = DetailedHTMLProps<
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> & {
|
> & {
|
||||||
size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro';
|
size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro' | 'small';
|
||||||
iconLeft?: IIcon;
|
iconLeft?: IIcon;
|
||||||
iconRight?: IIcon;
|
iconRight?: IIcon;
|
||||||
seamless?: boolean;
|
seamless?: boolean;
|
||||||
|
@ -19,6 +19,7 @@ type IButtonProps = DetailedHTMLProps<
|
||||||
non_submitting?: boolean;
|
non_submitting?: boolean;
|
||||||
is_loading?: boolean;
|
is_loading?: boolean;
|
||||||
stretchy?: boolean;
|
stretchy?: boolean;
|
||||||
|
iconOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button: FC<IButtonProps> = ({
|
export const Button: FC<IButtonProps> = ({
|
||||||
|
@ -36,6 +37,7 @@ export const Button: FC<IButtonProps> = ({
|
||||||
title,
|
title,
|
||||||
stretchy,
|
stretchy,
|
||||||
disabled,
|
disabled,
|
||||||
|
iconOnly,
|
||||||
...props
|
...props
|
||||||
}) =>
|
}) =>
|
||||||
createElement(
|
createElement(
|
||||||
|
@ -49,7 +51,7 @@ export const Button: FC<IButtonProps> = ({
|
||||||
disabled,
|
disabled,
|
||||||
is_loading,
|
is_loading,
|
||||||
stretchy,
|
stretchy,
|
||||||
icon: (iconLeft || iconRight) && !title && !children,
|
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||||
has_icon_left: !!iconLeft,
|
has_icon_left: !!iconLeft,
|
||||||
has_icon_right: !!iconRight,
|
has_icon_right: !!iconRight,
|
||||||
}),
|
}),
|
||||||
|
@ -57,7 +59,7 @@ export const Button: FC<IButtonProps> = ({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
||||||
title ? <span key={1}>{title}</span> : (children && <span key={1}>{children}</span>) || null,
|
title ? <span>{title}</span> : children || null,
|
||||||
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
@ -27,10 +27,12 @@
|
||||||
stroke: white;
|
stroke: white;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
filter: grayscale(0);
|
filter: grayscale(0);
|
||||||
|
|
||||||
transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s;
|
transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s;
|
||||||
|
@ -38,6 +40,22 @@
|
||||||
|
|
||||||
@include outer_shadow();
|
@include outer_shadow();
|
||||||
|
|
||||||
|
input {
|
||||||
|
color: red;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: white;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
@ -89,14 +107,22 @@
|
||||||
|
|
||||||
&:global(.disabled),
|
&:global(.disabled),
|
||||||
&:global(.grey) {
|
&:global(.grey) {
|
||||||
opacity: 0.3;
|
background: transparentize(white, 0.9);
|
||||||
background: lighten(black, 2%);
|
// background: lighten(white, 0.5);
|
||||||
// filter: grayscale(100%);
|
// filter: grayscale(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:global(.disabled) {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
&:global(.icon) {
|
&:global(.icon) {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:global(.is_loading) {
|
&:global(.is_loading) {
|
||||||
|
@ -138,6 +164,15 @@
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: $radius / 2;
|
border-radius: $radius / 2;
|
||||||
}
|
}
|
||||||
|
.small {
|
||||||
|
height: 32px;
|
||||||
|
// border-radius: $radius / 2;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.normal {
|
.normal {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
6
src/components/input/ButtonGroup/index.tsx
Normal file
6
src/components/input/ButtonGroup/index.tsx
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import React, { HTMLAttributes } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
type IProps = HTMLAttributes<HTMLDivElement> & {};
|
||||||
|
|
||||||
|
export const ButtonGroup = ({ children }: IProps) => <div className={styles.wrap}>{children}</div>;
|
21
src/components/input/ButtonGroup/styles.scss
Normal file
21
src/components/input/ButtonGroup/styles.scss
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: transparentize($color: #000000, $amount: 0.1) 1px 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 $input_radius $input_radius 0;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: $input_radius 0 0 $input_radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,8 +10,15 @@ import { selectUser } from '~/redux/auth/selectors';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
import { DIALOGS } from '~/redux/modal/constants';
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
|
import { pick } from 'ramda';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { url } from 'inspector';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
|
||||||
const mapStateToProps = selectUser;
|
const mapStateToProps = state => ({
|
||||||
|
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
||||||
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
push: historyPush,
|
push: historyPush,
|
||||||
|
@ -20,7 +27,7 @@ const mapDispatchToProps = {
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const HeaderUnconnected: FC<IProps> = ({ username, is_user, showDialog }) => {
|
const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, showDialog }) => {
|
||||||
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
||||||
const onOpenEditor = useCallback(() => showDialog(DIALOGS.EDITOR), [showDialog]);
|
const onOpenEditor = useCallback(() => showDialog(DIALOGS.EDITOR), [showDialog]);
|
||||||
|
|
||||||
|
@ -31,17 +38,16 @@ const HeaderUnconnected: FC<IProps> = ({ username, is_user, showDialog }) => {
|
||||||
<Filler />
|
<Filler />
|
||||||
|
|
||||||
<div className={style.plugs}>
|
<div className={style.plugs}>
|
||||||
<Link to="/">flow</Link>
|
|
||||||
<Link to="/examples/image">image</Link>
|
|
||||||
<div onClick={onOpenEditor}>editor</div>
|
<div onClick={onOpenEditor}>editor</div>
|
||||||
|
<Link to="/">flow</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filler />
|
|
||||||
|
|
||||||
{is_user && (
|
{is_user && (
|
||||||
<Group horizontal className={style.user_button}>
|
<Group horizontal className={style.user_button}>
|
||||||
<div>{username}</div>
|
<div>{username}</div>
|
||||||
<div className={style.user_avatar} />
|
<div className={style.user_avatar} style={{ backgroundImage: `url('${getURL(photo)}')` }}>
|
||||||
|
{(!photo || !photo.id) && <Icon icon="profile" />}
|
||||||
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
height: 80px;
|
height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
|
@ -47,11 +47,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
padding-right: 0;
|
// padding-right: 0;
|
||||||
|
|
||||||
&::after {
|
// &::after {
|
||||||
display: none;
|
// display: none;
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,16 +65,31 @@
|
||||||
.user_button {
|
.user_button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: $input_radius;
|
border-radius: $input_radius;
|
||||||
font: $font_16_medium;
|
font: $font_16_semibold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
flex: 0 !important;
|
flex: 0 !important;
|
||||||
opacity: 0.3;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-left: $gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_avatar {
|
.user_avatar {
|
||||||
width: 20px;
|
@include outer_shadow();
|
||||||
height: 20px;
|
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
margin-left: ($gap + 2px) !important;
|
||||||
|
background: 50% 50% no-repeat $wisegreen;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-size: cover;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: #222222;
|
||||||
|
stroke: #222222;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
101
src/components/media/AudioPlayer/index.tsx
Normal file
101
src/components/media/AudioPlayer/index.tsx
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import React, { useCallback, useState, useEffect } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { selectPlayer } from '~/redux/player/selectors';
|
||||||
|
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { PLAYER_STATES } from '~/redux/player/constants';
|
||||||
|
import { Player, IPlayerProgress } from '~/utils/player';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
player: selectPlayer(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
playerSetFile: PLAYER_ACTIONS.playerSetFile,
|
||||||
|
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||||
|
playerPause: PLAYER_ACTIONS.playerPause,
|
||||||
|
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
file: IFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioPlayerUnconnected = ({
|
||||||
|
file,
|
||||||
|
player: { file: current, status },
|
||||||
|
|
||||||
|
playerSetFile,
|
||||||
|
playerPlay,
|
||||||
|
playerPause,
|
||||||
|
playerSeek,
|
||||||
|
}: Props) => {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
||||||
|
|
||||||
|
const onPlay = useCallback(() => {
|
||||||
|
if (current && current.id === file.id) {
|
||||||
|
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||||
|
return playerPlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
playerSetFile(file);
|
||||||
|
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
({ detail }: { detail: IPlayerProgress }) => {
|
||||||
|
if (!detail || !detail.total) return;
|
||||||
|
setProgress(detail);
|
||||||
|
},
|
||||||
|
[setProgress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSeek = useCallback(
|
||||||
|
event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const { clientX, target } = event;
|
||||||
|
const { left, width } = target.getBoundingClientRect();
|
||||||
|
playerSeek((clientX - left) / width);
|
||||||
|
},
|
||||||
|
[playerSeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = current && current.id === file.id;
|
||||||
|
setPlaying(current && current.id === file.id);
|
||||||
|
|
||||||
|
if (active) Player.on('playprogress', onProgress);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (active) Player.off('playprogress', onProgress);
|
||||||
|
};
|
||||||
|
}, [file, current, setPlaying, onProgress]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
file.metadata &&
|
||||||
|
(file.metadata.title ||
|
||||||
|
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||||
|
<div className={styles.playpause}>
|
||||||
|
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.progress} onClick={onSeek}>
|
||||||
|
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.title}>{title || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AudioPlayer = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(AudioPlayerUnconnected);
|
104
src/components/media/AudioPlayer/styles.scss
Normal file
104
src/components/media/AudioPlayer/styles.scss
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
&:global(.playing) {
|
||||||
|
.progress {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
touch-action: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
top: 20px;
|
||||||
|
opacity: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-right: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playpause {
|
||||||
|
flex: 0 0 $comment_height;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
fill: transparentize($color: white, $amount: 0.5);
|
||||||
|
stroke: transparentize($color: white, $amount: 0.5);
|
||||||
|
transition: fill 250ms, stroke 250ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
fill: white;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 $gap * 2 0 $gap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.5s;
|
||||||
|
font: $font_16_medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 20px;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
left: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparentize(white, 0.95);
|
||||||
|
width: 100%;
|
||||||
|
top: 5px;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
background: linear-gradient(270deg, $green, $wisegreen);
|
||||||
|
position: absolute;
|
||||||
|
height: 10px;
|
||||||
|
left: 0;
|
||||||
|
top: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
min-width: 10px;
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
|
@ -1,28 +1,75 @@
|
||||||
import React, { FC, HTMLAttributes } from 'react';
|
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
||||||
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
||||||
import { IComment } from '~/redux/types';
|
import { IComment, IFile } from '~/redux/types';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { formatCommentText } from '~/utils/dom';
|
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import assocPath from 'ramda/es/assocPath';
|
||||||
|
import append from 'ramda/es/append';
|
||||||
|
import reduce from 'ramda/es/reduce';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
is_empty?: boolean;
|
is_empty?: boolean;
|
||||||
is_loading?: boolean;
|
is_loading?: boolean;
|
||||||
photo?: string;
|
|
||||||
comment?: IComment;
|
comment?: IComment;
|
||||||
|
is_same?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Comment: FC<IProps> = ({ comment, is_empty, is_loading, className, photo, ...props }) => (
|
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
|
||||||
<CommentWrapper is_empty={is_empty} is_loading={is_loading} photo={photo} {...props}>
|
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||||
{comment.text && (
|
() =>
|
||||||
<Group
|
reduce(
|
||||||
className={styles.text}
|
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||||
dangerouslySetInnerHTML={{
|
{},
|
||||||
__html: formatCommentText(comment.user && comment.user.username, comment.text),
|
comment.files
|
||||||
}}
|
),
|
||||||
/>
|
[comment]
|
||||||
)}
|
);
|
||||||
</CommentWrapper>
|
|
||||||
);
|
return (
|
||||||
|
<CommentWrapper
|
||||||
|
className={className}
|
||||||
|
is_empty={is_empty}
|
||||||
|
is_loading={is_loading}
|
||||||
|
photo={getURL(comment.user.photo)}
|
||||||
|
is_same={is_same}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{comment.text && (
|
||||||
|
<Group
|
||||||
|
className={styles.text}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: formatCommentText(
|
||||||
|
!is_same && comment.user && comment.user.username,
|
||||||
|
comment.text
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||||
|
|
||||||
|
{groupped.image && (
|
||||||
|
<div className={styles.images}>
|
||||||
|
{groupped.image.map(file => (
|
||||||
|
<div key={file.id}>
|
||||||
|
<img src={getURL(file)} alt={file.name} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupped.audio && (
|
||||||
|
<div className={styles.audios}>
|
||||||
|
{groupped.audio.map(file => (
|
||||||
|
<AudioPlayer key={file.id} file={file} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export { Comment };
|
export { Comment };
|
||||||
|
|
|
@ -1,8 +1,48 @@
|
||||||
|
@import 'flexbin/flexbin.scss';
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
padding: $gap / 2;
|
// @include outer_shadow();
|
||||||
|
|
||||||
|
padding: $gap;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
font: $font_16_medium;
|
||||||
|
min-height: $comment_height;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
color: #cccccc;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
font: $font_12_regular;
|
||||||
|
color: transparentize($color: white, $amount: 0.8);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 0 0 $radius 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images {
|
||||||
|
@include flexbin(240px, 5px);
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audios {
|
||||||
|
& > div {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
height: $comment_height;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import * as styles from './styles.scss';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { Filler } from '~/components/containers/Filler';
|
||||||
import { Button } from '~/components/input/Button';
|
import { Button } from '~/components/input/Button';
|
||||||
import assocPath from 'ramda/es/assocPath';
|
import assocPath from 'ramda/es/assocPath';
|
||||||
import { InputHandler, IFileWithUUID, IFile, IComment } from '~/redux/types';
|
import { InputHandler, IFileWithUUID } from '~/redux/types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
import { selectNode } from '~/redux/node/selectors';
|
import { selectNode } from '~/redux/node/selectors';
|
||||||
|
@ -16,12 +16,15 @@ import uuid from 'uuid4';
|
||||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
import { selectUploads } from '~/redux/uploads/selectors';
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
import { IState } from '~/redux/store';
|
import { IState } from '~/redux/store';
|
||||||
import pipe from 'ramda/es/pipe';
|
import { getFileType } from '~/utils/uploader';
|
||||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
import { getImageSize } from '~/utils/dom';
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { ButtonGroup } from '~/components/input/ButtonGroup';
|
||||||
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
|
|
||||||
const mapStateToProps = (state: IState) => ({
|
const mapStateToProps = (state: IState) => ({
|
||||||
node: selectNode(state),
|
node: selectNode(state),
|
||||||
|
user: selectUser(state),
|
||||||
uploads: selectUploads(state),
|
uploads: selectUploads(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -39,12 +42,12 @@ type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
const CommentFormUnconnected: FC<IProps> = ({
|
const CommentFormUnconnected: FC<IProps> = ({
|
||||||
node: { comment_data, is_sending_comment },
|
node: { comment_data, is_sending_comment },
|
||||||
uploads: { statuses, files },
|
uploads: { statuses, files },
|
||||||
|
user: { photo },
|
||||||
id,
|
id,
|
||||||
nodePostComment,
|
nodePostComment,
|
||||||
nodeSetCommentData,
|
nodeSetCommentData,
|
||||||
uploadUploadFiles,
|
uploadUploadFiles,
|
||||||
}) => {
|
}) => {
|
||||||
// const [data, setData] = useState<IComment>({ ...EMPTY_COMMENT });
|
|
||||||
const onInputChange = useCallback(
|
const onInputChange = useCallback(
|
||||||
event => {
|
event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -57,7 +60,7 @@ const CommentFormUnconnected: FC<IProps> = ({
|
||||||
temp_id: uuid(),
|
temp_id: uuid(),
|
||||||
subject: UPLOAD_SUBJECTS.COMMENT,
|
subject: UPLOAD_SUBJECTS.COMMENT,
|
||||||
target: UPLOAD_TARGETS.COMMENTS,
|
target: UPLOAD_TARGETS.COMMENTS,
|
||||||
type: UPLOAD_TYPES.IMAGE,
|
type: getFileType(file),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -94,68 +97,79 @@ const CommentFormUnconnected: FC<IProps> = ({
|
||||||
[onSubmit]
|
[onSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFileAdd = useCallback(
|
|
||||||
(file: IFile, temp_id: string) => {
|
|
||||||
const comment = comment_data[id];
|
|
||||||
nodeSetCommentData(id, pipe(
|
|
||||||
assocPath(['files'], [...comment.files, file]),
|
|
||||||
assocPath(['temp_ids'], comment.temp_ids.filter(el => el !== temp_id))
|
|
||||||
)(comment) as IComment);
|
|
||||||
},
|
|
||||||
[nodeSetCommentData, comment_data, id]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Object.entries(statuses).forEach(([file_id, status]) => {
|
const temp_ids = (comment_data && comment_data[id] && comment_data[id].temp_ids) || [];
|
||||||
const comment = comment_data[id];
|
const added_files = temp_ids
|
||||||
|
.map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid)
|
||||||
|
.map(el => !!el && files[el])
|
||||||
|
.filter(el => !!el && !comment_data[id].files.some(file => file.id === el.id));
|
||||||
|
|
||||||
if (comment.temp_ids.includes(file_id) && !!status.uuid && files[status.uuid]) {
|
const filtered_temps = temp_ids.filter(
|
||||||
onFileAdd(files[status.uuid], file_id);
|
temp_id =>
|
||||||
}
|
statuses[temp_id] &&
|
||||||
});
|
(!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid))
|
||||||
}, [statuses, comment_data, id, nodeSetCommentData, onFileAdd, files]);
|
);
|
||||||
|
|
||||||
|
if (added_files.length) {
|
||||||
|
nodeSetCommentData(id, {
|
||||||
|
...comment_data[id],
|
||||||
|
temp_ids: filtered_temps,
|
||||||
|
files: [...comment_data[id].files, ...added_files],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [statuses, files]);
|
||||||
|
|
||||||
|
const comment = comment_data[id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommentWrapper>
|
<CommentWrapper photo={getURL(photo)}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit} className={styles.wrap}>
|
||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={comment_data[id].text}
|
value={comment.text}
|
||||||
handler={onInput}
|
handler={onInput}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
disabled={is_sending_comment}
|
disabled={is_sending_comment}
|
||||||
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.uploads}>
|
|
||||||
{comment_data[id].files.map(file => (
|
|
||||||
<ImageUpload id={file.id} thumb={getImageSize(file.url)} key={file.id} />
|
|
||||||
))}
|
|
||||||
{comment_data[id].temp_ids.map(
|
|
||||||
status =>
|
|
||||||
statuses[status] && (
|
|
||||||
<ImageUpload
|
|
||||||
id={statuses[status].uuid}
|
|
||||||
thumb={statuses[status].preview}
|
|
||||||
key={status}
|
|
||||||
progress={statuses[status].progress}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group horizontal className={styles.buttons}>
|
<Group horizontal className={styles.buttons}>
|
||||||
<input type="file" onInput={onInputChange} />
|
<ButtonGroup>
|
||||||
|
<Button iconLeft="image" size="small" grey iconOnly>
|
||||||
|
<input type="file" onInput={onInputChange} multiple accept="image/*" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button iconRight="enter" size="small" grey iconOnly>
|
||||||
|
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
<Filler />
|
<Filler />
|
||||||
|
|
||||||
{is_sending_comment && <LoaderCircle size={20} />}
|
{is_sending_comment && <LoaderCircle size={20} />}
|
||||||
|
|
||||||
<Button size="mini" grey iconRight="enter" disabled={is_sending_comment}>
|
<Button size="small" grey iconRight="enter" disabled={is_sending_comment}>
|
||||||
Сказать
|
Сказать
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{comment.temp_ids.map(
|
||||||
|
temp_id =>
|
||||||
|
statuses[temp_id] &&
|
||||||
|
statuses[temp_id].is_uploading && (
|
||||||
|
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comment.files.map(file => {
|
||||||
|
if (file.type === UPLOAD_TYPES.AUDIO) {
|
||||||
|
return <AudioPlayer file={file} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>file.name</div>;
|
||||||
|
})}
|
||||||
</CommentWrapper>
|
</CommentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 62px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: $gap / 2;
|
padding: ($gap / 2) ($gap / 2 + 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
|
@ -14,12 +18,7 @@
|
||||||
background: transparentize(black, 0.8);
|
background: transparentize(black, 0.8);
|
||||||
padding: $gap / 2;
|
padding: $gap / 2;
|
||||||
border-radius: 0 0 $radius $radius;
|
border-radius: 0 0 $radius $radius;
|
||||||
|
flex-wrap: wrap;
|
||||||
svg {
|
|
||||||
fill: transparentize(white, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include outer_shadow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploads {
|
.uploads {
|
||||||
|
|
|
@ -11,18 +11,22 @@ interface IProps {
|
||||||
onChange: (current: number) => void;
|
onChange: (current: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageSwitcher: FC<IProps> = ({ total, current, onChange, loaded }) => (
|
const ImageSwitcher: FC<IProps> = ({ total, current, onChange, loaded }) => {
|
||||||
<div className={styles.wrap}>
|
if (total <= 1) return null;
|
||||||
<div className={styles.switcher}>
|
|
||||||
{range(0, total).map(item => (
|
return (
|
||||||
<div
|
<div className={styles.wrap}>
|
||||||
className={classNames({ is_active: item === current, is_loaded: loaded[item] })}
|
<div className={styles.switcher}>
|
||||||
key={item}
|
{range(0, total).map(item => (
|
||||||
onClick={() => onChange(item)}
|
<div
|
||||||
/>
|
className={classNames({ is_active: item === current, is_loaded: loaded[item] })}
|
||||||
))}
|
key={item}
|
||||||
|
onClick={() => onChange(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export { ImageSwitcher };
|
export { ImageSwitcher };
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import range from 'ramda/es/range';
|
|
||||||
import { Comment } from '../Comment';
|
import { Comment } from '../Comment';
|
||||||
import { INode } from '~/redux/types';
|
|
||||||
import { CommentForm } from '../CommentForm';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
comments?: any;
|
comments?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSameComment = (comments, index) =>
|
||||||
|
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
|
||||||
|
|
||||||
const NodeComments: FC<IProps> = ({ comments }) => (
|
const NodeComments: FC<IProps> = ({ comments }) => (
|
||||||
<Group className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
{comments.map(comment => (
|
{comments.map((comment, index) => (
|
||||||
<Comment key={comment.id} comment={comment} />
|
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Filler />
|
<Filler />
|
||||||
</Group>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export { NodeComments };
|
export { NodeComments };
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
|
& > div {
|
||||||
|
margin: $gap 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
// display: flex;
|
// display: flex;
|
||||||
// flex-direction: column !important;
|
// flex-direction: column !important;
|
||||||
|
|
||||||
|
|
|
@ -18,9 +18,11 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
interface IProps {
|
interface IProps {
|
||||||
is_loading: boolean;
|
is_loading: boolean;
|
||||||
node: INode;
|
node: INode;
|
||||||
|
layout: {};
|
||||||
|
updateLayout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
const NodeImageBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
||||||
const [is_animated, setIsAnimated] = useState(false);
|
const [is_animated, setIsAnimated] = useState(false);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [height, setHeight] = useState(320);
|
const [height, setHeight] = useState(320);
|
||||||
|
@ -39,6 +41,8 @@ const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
||||||
loaded,
|
loaded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => updateLayout(), [loaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refs || !refs.current[current] || !loaded[current]) return setHeight(320);
|
if (!refs || !refs.current[current] || !loaded[current]) return setHeight(320);
|
||||||
|
|
||||||
|
@ -78,7 +82,7 @@ const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
src={getImageSize(file.url, 'node')}
|
src={getImageSize(file, 'node')}
|
||||||
alt=""
|
alt=""
|
||||||
key={file.id}
|
key={file.id}
|
||||||
onLoad={onImageLoad(index)}
|
onLoad={onImageLoad(index)}
|
||||||
|
|
|
@ -31,6 +31,8 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font: $font_18_semibold;
|
font: $font_18_semibold;
|
||||||
color: transparentize(white, 0.5);
|
color: transparentize(white, 0.5);
|
||||||
|
flex: 0 0 $comment_height;
|
||||||
|
|
||||||
@include outer_shadow();
|
@include outer_shadow();
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
|
|
|
@ -1,30 +1,48 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { INode } from '~/redux/types';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { createPortal } from 'react-dom';
|
||||||
import {Icon} from "~/components/input/Icon";
|
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||||
|
|
||||||
interface IProps {}
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
layout: {};
|
||||||
|
}
|
||||||
|
|
||||||
const NodePanel: FC<IProps> = () => (
|
const NodePanel: FC<IProps> = ({ node, layout }) => {
|
||||||
<div className={styles.wrap}>
|
const [stack, setStack] = useState(false);
|
||||||
<Group horizontal className={styles.panel}>
|
|
||||||
<Filler>
|
|
||||||
<div className={styles.title}>Node title</div>
|
|
||||||
<div className={styles.name}>~author</div>
|
|
||||||
</Filler>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div className={styles.buttons}>
|
const ref = useRef(null);
|
||||||
<Icon icon="edit" size={24} />
|
const getPlace = useCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
<div className={styles.sep} />
|
const { offsetTop } = ref.current;
|
||||||
|
const { height } = ref.current.getBoundingClientRect();
|
||||||
|
const { scrollY, innerHeight } = window;
|
||||||
|
|
||||||
<Icon icon="heart" size={24} />
|
setStack(offsetTop > scrollY + innerHeight - height);
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPlace();
|
||||||
|
window.addEventListener('scroll', getPlace);
|
||||||
|
window.addEventListener('resize', getPlace);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', getPlace);
|
||||||
|
window.removeEventListener('resize', getPlace);
|
||||||
|
};
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.place} ref={ref}>
|
||||||
|
{stack ? (
|
||||||
|
createPortal(<NodePanelInner node={node} stack />, document.body)
|
||||||
|
) : (
|
||||||
|
<NodePanelInner node={node} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export { NodePanel };
|
export { NodePanel };
|
||||||
|
|
||||||
// <div className={styles.mark} />
|
|
||||||
|
|
|
@ -1,91 +1,6 @@
|
||||||
.wrap {
|
.place {
|
||||||
background: $node_bg;
|
height: 72px;
|
||||||
padding: $gap;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: stretch;
|
|
||||||
border-radius: $radius $radius 0 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: -$radius;
|
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
margin-top: -$radius;
|
||||||
|
|
||||||
.title {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font: $font_24_semibold;
|
|
||||||
height: 24px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font: $font_12_regular;
|
|
||||||
color: transparentize(white, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
fill: transparentize(white, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
flex: 0;
|
|
||||||
padding-right: $gap;
|
|
||||||
fill: transparentize(white, 0.7);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin: 0 $gap;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
&:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//height: 54px;
|
|
||||||
//border-radius: $radius $radius 0 0;
|
|
||||||
//background: linear-gradient(176deg, #f42a00, #5c1085);
|
|
||||||
//position: absolute;
|
|
||||||
//bottom: 0;
|
|
||||||
//right: 10px;
|
|
||||||
//width: 270px;
|
|
||||||
//display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark {
|
|
||||||
flex: 0 0 32px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: ' ';
|
|
||||||
position: absolute;
|
|
||||||
top: -38px;
|
|
||||||
right: 4px;
|
|
||||||
width: 24px;
|
|
||||||
height: 52px;
|
|
||||||
background: $green_gradient;
|
|
||||||
box-shadow: transparentize(black, 0.8) 4px 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sep {
|
|
||||||
flex: 0 0 6px;
|
|
||||||
height: 6px;
|
|
||||||
width: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparentize(black, 0.7);
|
|
||||||
}
|
}
|
||||||
|
|
39
src/components/node/NodePanelInner/index.tsx
Normal file
39
src/components/node/NodePanelInner/index.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
stack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodePanelInner: FC<IProps> = ({ node: { title, user }, stack }) => {
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.wrap, { stack })}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Group horizontal className={styles.panel}>
|
||||||
|
<Filler>
|
||||||
|
<div className={styles.title}>{title || '...'}</div>
|
||||||
|
{user && user.username && <div className={styles.name}>~ {user.username}</div>}
|
||||||
|
</Filler>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Icon icon="edit" size={24} />
|
||||||
|
|
||||||
|
<div className={styles.sep} />
|
||||||
|
|
||||||
|
<Icon icon="heart" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NodePanelInner };
|
||||||
|
|
||||||
|
// <div className={styles.mark} />
|
109
src/components/node/NodePanelInner/styles.scss
Normal file
109
src/components/node/NodePanelInner/styles.scss
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
position: relative;
|
||||||
|
height: 72px;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:global(.stack) {
|
||||||
|
padding: 0 $gap;
|
||||||
|
bottom: 0;
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 0 1 $content_width;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: $gap;
|
||||||
|
background: $node_bg;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_24_semibold;
|
||||||
|
height: 24px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font: $font_14_regular;
|
||||||
|
color: transparentize(white, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
fill: transparentize(white, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
flex: 0;
|
||||||
|
padding-right: $gap;
|
||||||
|
fill: transparentize(white, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: 0 $gap;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//height: 54px;
|
||||||
|
//border-radius: $radius $radius 0 0;
|
||||||
|
//background: linear-gradient(176deg, #f42a00, #5c1085);
|
||||||
|
//position: absolute;
|
||||||
|
//bottom: 0;
|
||||||
|
//right: 10px;
|
||||||
|
//width: 270px;
|
||||||
|
//display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
top: -38px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 52px;
|
||||||
|
background: $green_gradient;
|
||||||
|
box-shadow: transparentize(black, 0.8) 4px 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
flex: 0 0 6px;
|
||||||
|
height: 6px;
|
||||||
|
width: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparentize(black, 0.7);
|
||||||
|
}
|
|
@ -1,18 +1,15 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { Tags } from '../Tags';
|
import { Tags } from '../Tags';
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
|
||||||
interface IProps {}
|
interface IProps {
|
||||||
|
is_editable?: boolean;
|
||||||
|
tags: ITag[];
|
||||||
|
onChange?: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const NodeTags: FC<IProps> = ({}) => (
|
const NodeTags: FC<IProps> = ({ is_editable, tags, onChange }) => (
|
||||||
<Tags
|
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||||
tags={[
|
|
||||||
{ title: 'Избранный', feature: 'red' },
|
|
||||||
{ title: 'Плейлист', feature: 'green' },
|
|
||||||
{ title: 'Просто' },
|
|
||||||
{ title: '+ фото', feature: 'black' },
|
|
||||||
{ title: '+ с музыкой', feature: 'black' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export { NodeTags };
|
export { NodeTags };
|
||||||
|
|
|
@ -1,25 +1,41 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, ChangeEventHandler, KeyboardEventHandler, FocusEventHandler } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { ITag } from '~/redux/types';
|
import { ITag } from '~/redux/types';
|
||||||
|
|
||||||
import classNames = require('classnames');
|
import classNames = require('classnames');
|
||||||
|
|
||||||
|
const getTagFeature = (tag: Partial<ITag>) => {
|
||||||
|
if (tag.title.substr(0, 1) === '/') return 'green';
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
title: ITag['title'];
|
tag: Partial<ITag>;
|
||||||
feature?: ITag['feature'];
|
|
||||||
|
|
||||||
is_hoverable?: boolean;
|
is_hoverable?: boolean;
|
||||||
|
onInput?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
onKeyUp?: KeyboardEventHandler;
|
||||||
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tag: FC<IProps> = ({
|
const Tag: FC<IProps> = ({ tag, is_hoverable, onInput, onKeyUp, onBlur }) => (
|
||||||
title,
|
<div className={classNames(styles.tag, getTagFeature(tag), { is_hoverable, input: !!onInput })}>
|
||||||
feature,
|
|
||||||
|
|
||||||
is_hoverable,
|
|
||||||
}) => (
|
|
||||||
<div className={classNames(styles.tag, feature, { is_hoverable })}>
|
|
||||||
<div className={styles.hole} />
|
<div className={styles.hole} />
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{tag.title}</div>
|
||||||
|
|
||||||
|
{onInput && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tag.title}
|
||||||
|
size={1}
|
||||||
|
placeholder="Добавить"
|
||||||
|
maxLength={24}
|
||||||
|
onChange={onInput}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -6,11 +6,12 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2);
|
border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2);
|
||||||
font: $font_12_semibold;
|
font: $font_14_semibold;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0 8px 0 0;
|
padding: 0 8px 0 0;
|
||||||
box-shadow: $shadow_depth_2;
|
box-shadow: $shadow_depth_2;
|
||||||
margin: ($gap / 2) $gap ($gap / 2) 0;
|
margin: ($gap / 2) $gap ($gap / 2) 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:global(.is_hoverable) {
|
&:global(.is_hoverable) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -37,9 +38,35 @@
|
||||||
background: transparentize(black, 0.7);
|
background: transparentize(black, 0.7);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: transparentize(white, 0.6);
|
color: transparentize(white, 0.6);
|
||||||
font: $font_12_medium;
|
font: $font_14_medium;
|
||||||
|
|
||||||
.hole::after { background: transparentize(white, 0.98); }
|
.hole::after {
|
||||||
|
background: transparentize(white, 0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:global(.input) {
|
||||||
|
color: transparent !important;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
outline: none;
|
||||||
|
display: inline-flex;
|
||||||
|
position: absolute;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 23px;
|
||||||
|
padding-right: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,6 +77,7 @@
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
|
@ -63,4 +91,7 @@
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,91 @@
|
||||||
import React, { FC, HTMLAttributes } from 'react';
|
import React, {
|
||||||
|
FC,
|
||||||
|
HTMLAttributes,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
KeyboardEvent,
|
||||||
|
ChangeEvent,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import { TagField } from '~/components/containers/TagField';
|
import { TagField } from '~/components/containers/TagField';
|
||||||
import { ITag } from '~/redux/types';
|
import { ITag } from '~/redux/types';
|
||||||
import { Tag } from '~/components/node/Tag';
|
import { Tag } from '~/components/node/Tag';
|
||||||
|
import uniq from 'ramda/es/uniq';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
tags: ITag[];
|
tags: Partial<ITag>[];
|
||||||
}
|
is_editable?: boolean;
|
||||||
|
onTagsChange?: (tags: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const Tags: FC<IProps> = ({
|
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props }) => {
|
||||||
tags,
|
const [input, setInput] = useState('');
|
||||||
...props
|
const [data, setData] = useState([]);
|
||||||
}) => (
|
const timer = useRef(null);
|
||||||
<TagField {...props}>
|
|
||||||
{
|
const onInput = useCallback(
|
||||||
tags.map(tag => (
|
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
||||||
<Tag
|
clearTimeout(timer.current);
|
||||||
key={tag.title}
|
setInput(value);
|
||||||
title={tag.title}
|
},
|
||||||
feature={tag.feature}
|
[setInput, timer]
|
||||||
/>
|
);
|
||||||
))
|
|
||||||
}
|
const onKeyUp = useCallback(
|
||||||
</TagField>
|
({ key }: KeyboardEvent) => {
|
||||||
);
|
if (key === 'Backspace' && input === '' && data.length) {
|
||||||
|
setData(data.slice(0, data.length - 1));
|
||||||
|
setInput(data[data.length - 1].title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === ',' || key === 'Comma') {
|
||||||
|
setData(
|
||||||
|
uniq([
|
||||||
|
...data,
|
||||||
|
...input
|
||||||
|
.split(',')
|
||||||
|
.map((title: string) =>
|
||||||
|
title
|
||||||
|
.trim()
|
||||||
|
.substr(0, 32)
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
.filter(el => el.length > 0)
|
||||||
|
.filter(el => !tags.some(tag => tag.title.trim() === el.trim()))
|
||||||
|
.map(title => ({
|
||||||
|
title,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[input, setInput, data, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(() => {
|
||||||
|
if (!data.length) return;
|
||||||
|
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
|
||||||
|
}, [tags, data, onTagsChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagField {...props}>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<Tag key={tag.title} tag={tag} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.map(tag => (
|
||||||
|
<Tag key={tag.title} tag={tag} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{is_editable && (
|
||||||
|
<Tag tag={{ title: input }} onInput={onInput} onKeyUp={onKeyUp} onBlur={onSubmit} />
|
||||||
|
)}
|
||||||
|
</TagField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -11,9 +11,7 @@ interface IProps {
|
||||||
is_uploading?: boolean;
|
is_uploading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageUpload: FC<IProps> = ({
|
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
|
||||||
thumb, id, progress, is_uploading,
|
|
||||||
}) => (
|
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
||||||
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
||||||
|
|
|
@ -4,7 +4,7 @@ export const API = {
|
||||||
BASE: process.env.API_HOST,
|
BASE: process.env.API_HOST,
|
||||||
USER: {
|
USER: {
|
||||||
LOGIN: '/auth/login',
|
LOGIN: '/auth/login',
|
||||||
ME: '/auth/me', //
|
ME: '/auth/', //
|
||||||
UPLOAD: (target, type) => `/upload/${target}/${type}`,
|
UPLOAD: (target, type) => `/upload/${target}/${type}`,
|
||||||
},
|
},
|
||||||
NODE: {
|
NODE: {
|
||||||
|
@ -13,5 +13,6 @@ export const API = {
|
||||||
GET_NODE: (id: number | string) => `/node/${id}`,
|
GET_NODE: (id: number | string) => `/node/${id}`,
|
||||||
|
|
||||||
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
||||||
|
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { Modal } from '~/containers/dialogs/Modal';
|
||||||
import { selectModal } from '~/redux/modal/selectors';
|
import { selectModal } from '~/redux/modal/selectors';
|
||||||
import { BlurWrapper } from '~/components/containers/BlurWrapper';
|
import { BlurWrapper } from '~/components/containers/BlurWrapper';
|
||||||
import { NodeLayout } from './node/NodeLayout';
|
import { NodeLayout } from './node/NodeLayout';
|
||||||
|
import { BottomContainer } from '~/containers/main/BottomContainer';
|
||||||
|
|
||||||
const mapStateToProps = selectModal;
|
const mapStateToProps = selectModal;
|
||||||
const mapDispatchToProps = {};
|
const mapDispatchToProps = {};
|
||||||
|
@ -23,22 +24,26 @@ type IProps = typeof mapDispatchToProps & ReturnType<typeof mapStateToProps> & {
|
||||||
|
|
||||||
const Component: FC<IProps> = ({ is_shown }) => (
|
const Component: FC<IProps> = ({ is_shown }) => (
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<BlurWrapper is_blurred={is_shown}>
|
<div>
|
||||||
<MainLayout>
|
<BlurWrapper is_blurred={is_shown}>
|
||||||
<Modal />
|
<MainLayout>
|
||||||
<Sprites />
|
<Modal />
|
||||||
|
<Sprites />
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path={URLS.BASE} component={FlowLayout} />
|
<Route exact path={URLS.BASE} component={FlowLayout} />
|
||||||
<Route path={URLS.EXAMPLES.IMAGE} component={ImageExample} />
|
<Route path={URLS.EXAMPLES.IMAGE} component={ImageExample} />
|
||||||
<Route path={URLS.EXAMPLES.EDITOR} component={EditorExample} />
|
<Route path={URLS.EXAMPLES.EDITOR} component={EditorExample} />
|
||||||
<Route path="/examples/horizontal" component={HorizontalExample} />
|
<Route path="/examples/horizontal" component={HorizontalExample} />
|
||||||
<Route path="/post:id" component={NodeLayout} />
|
<Route path="/post:id" component={NodeLayout} />
|
||||||
|
|
||||||
<Redirect to="/" />
|
<Redirect to="/" />
|
||||||
</Switch>
|
</Switch>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
</BlurWrapper>
|
</BlurWrapper>
|
||||||
|
|
||||||
|
<BottomContainer />
|
||||||
|
</div>
|
||||||
</ConnectedRouter>
|
</ConnectedRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
|
|
||||||
.children {
|
.children {
|
||||||
background: $content_bg;
|
background: $content_bg;
|
||||||
border-radius: $radius;
|
border-radius: $radius $radius 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top_cap {
|
.top_cap {
|
||||||
|
@ -124,7 +124,7 @@
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
content: " ";
|
content: ' ';
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $radius;
|
height: $radius;
|
||||||
background: linear-gradient($content_bg, transparentize($content_bg, 1));
|
background: linear-gradient($content_bg, transparentize($content_bg, 1));
|
||||||
|
@ -207,4 +207,4 @@
|
||||||
&::before {
|
&::before {
|
||||||
animation: spin_2 0.5s forwards;
|
animation: spin_2 0.5s forwards;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,9 +19,7 @@ const EditorExample: FC<IProps> = () => (
|
||||||
<Card className={styles.wrap} seamless>
|
<Card className={styles.wrap} seamless>
|
||||||
<Group horizontal className={styles.group} seamless>
|
<Group horizontal className={styles.group} seamless>
|
||||||
<div className={styles.editor}>
|
<div className={styles.editor}>
|
||||||
<Panel
|
<Panel className={classNames(styles.editor_panel, styles.editor_image_panel)}>
|
||||||
className={classNames(styles.editor_panel, styles.editor_image_panel)}
|
|
||||||
>
|
|
||||||
<Scroll>
|
<Scroll>
|
||||||
<CellGrid className={styles.editor_image_container} size={200}>
|
<CellGrid className={styles.editor_image_container} size={200}>
|
||||||
<div className={styles.editor_image} />
|
<div className={styles.editor_image} />
|
||||||
|
@ -48,11 +46,11 @@ const EditorExample: FC<IProps> = () => (
|
||||||
|
|
||||||
<Tags
|
<Tags
|
||||||
tags={[
|
tags={[
|
||||||
{ title: 'Избранный', feature: 'red' },
|
{ title: 'Избранный' },
|
||||||
{ title: 'Плейлист', feature: 'green' },
|
{ title: 'Плейлист' },
|
||||||
{ title: 'Просто' },
|
{ title: 'Просто' },
|
||||||
{ title: '+ фото', feature: 'black' },
|
{ title: '+ фото' },
|
||||||
{ title: '+ с музыкой', feature: 'black' }
|
{ title: '+ с музыкой' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
15
src/containers/main/BottomContainer/index.tsx
Normal file
15
src/containers/main/BottomContainer/index.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { PlayerBar } from '~/components/bars/PlayerBar';
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
const BottomContainer: FC<IProps> = ({}) => (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<PlayerBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { BottomContainer };
|
30
src/containers/main/BottomContainer/styles.scss
Normal file
30
src/containers/main/BottomContainer/styles.scss
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
.wrap {
|
||||||
|
position: fixed;
|
||||||
|
transform: translateZ(0);
|
||||||
|
bottom: $gap;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
height: 54px;
|
||||||
|
display: flex;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 $gap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 1 $content_width;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
pointer-events: all;
|
||||||
|
touch-action: all;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { SidePane } from '~/components/main/SidePane';
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Header } from '~/components/main/Header';
|
import { Header } from '~/components/main/Header';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, createElement, useEffect } from 'react';
|
import React, { FC, createElement, useEffect, useCallback, useState } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
@ -16,10 +16,16 @@ import { NodeTags } from '~/components/node/NodeTags';
|
||||||
import { NODE_COMPONENTS } from '~/redux/node/constants';
|
import { NODE_COMPONENTS } from '~/redux/node/constants';
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
import { CommentForm } from '~/components/node/CommentForm';
|
import { CommentForm } from '~/components/node/CommentForm';
|
||||||
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
node: selectNode(state),
|
||||||
|
user: selectUser(state),
|
||||||
|
});
|
||||||
|
|
||||||
const mapStateToProps = selectNode;
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
|
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
|
||||||
|
nodeUpdateTags: NODE_ACTIONS.nodeUpdateTags,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> &
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
@ -30,30 +36,39 @@ const NodeLayoutUnconnected: FC<IProps> = ({
|
||||||
match: {
|
match: {
|
||||||
params: { id },
|
params: { id },
|
||||||
},
|
},
|
||||||
is_loading,
|
node: { is_loading, is_loading_comments, comments = [], current: node },
|
||||||
is_loading_comments,
|
user: { is_user },
|
||||||
comments = [],
|
|
||||||
current: node,
|
|
||||||
nodeLoadNode,
|
nodeLoadNode,
|
||||||
|
nodeUpdateTags,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [layout, setLayout] = useState({});
|
||||||
|
|
||||||
|
const updateLayout = useCallback(() => setLayout({}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (is_loading) return;
|
if (is_loading) return;
|
||||||
nodeLoadNode(parseInt(id, 10), null);
|
nodeLoadNode(parseInt(id, 10), null);
|
||||||
}, [nodeLoadNode, id]);
|
}, [nodeLoadNode, id]);
|
||||||
|
|
||||||
|
const onTagsChange = useCallback(
|
||||||
|
(tags: string[]) => {
|
||||||
|
nodeUpdateTags(node.id, tags);
|
||||||
|
},
|
||||||
|
[node, nodeUpdateTags]
|
||||||
|
);
|
||||||
const block = node && node.type && NODE_COMPONENTS[node.type] && NODE_COMPONENTS[node.type];
|
const block = node && node.type && NODE_COMPONENTS[node.type] && NODE_COMPONENTS[node.type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={styles.node} seamless>
|
<Card className={styles.node} seamless>
|
||||||
{block && createElement(block, { node, is_loading })}
|
{block && createElement(block, { node, is_loading, updateLayout, layout })}
|
||||||
|
|
||||||
<NodePanel />
|
<NodePanel node={node} layout={layout} />
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Padder>
|
<Padder>
|
||||||
<Group horizontal className={styles.content}>
|
<Group horizontal className={styles.content}>
|
||||||
<Group className={styles.comments}>
|
<Group className={styles.comments}>
|
||||||
<CommentForm id={0} />
|
{is_user && <CommentForm id={0} />}
|
||||||
|
|
||||||
{is_loading_comments || !comments.length ? (
|
{is_loading_comments || !comments.length ? (
|
||||||
<NodeNoComments is_loading={is_loading_comments} />
|
<NodeNoComments is_loading={is_loading_comments} />
|
||||||
|
@ -63,8 +78,8 @@ const NodeLayoutUnconnected: FC<IProps> = ({
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<Group style={{ flex: 1 }}>
|
<Group style={{ flex: 1, minWidth: 0 }}>
|
||||||
<NodeTags />
|
<NodeTags is_editable={is_user} tags={node.tags} onChange={onTagsChange} />
|
||||||
|
|
||||||
<NodeRelated title="First album" />
|
<NodeRelated title="First album" />
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
.comments {
|
.comments {
|
||||||
flex: 3 1;
|
flex: 3 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
@ -14,6 +15,7 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding-left: $gap / 2;
|
padding-left: $gap / 2;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
@include tablet {
|
@include tablet {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
|
@ -1,5 +1,45 @@
|
||||||
/*
|
/*
|
||||||
sortable grid: http://clauderic.github.io/react-sortable-hoc/#/basic-configuration/grid?_k=hjqdj1
|
sortable grid: http://clauderic.github.io/react-sortable-hoc/#/basic-configuration/grid?_k=hjqdj1
|
||||||
|
|
||||||
|
[BUGS]
|
||||||
|
|
||||||
|
FIXME: flow hangs on empty response
|
||||||
|
|
||||||
|
[CONTENT]
|
||||||
|
|
||||||
|
TODO: adding photos to comments
|
||||||
|
TODO: adding media to comments
|
||||||
|
TODO: adding media to posts
|
||||||
|
TODO: adding text to posts
|
||||||
|
TODO: covers for posts
|
||||||
|
TODO: wallpapers for posts
|
||||||
|
TODO: changing flow appearance for posts
|
||||||
|
|
||||||
|
TODO: display for all node types
|
||||||
|
TODO: related by tags
|
||||||
|
TODO: sticky left column
|
||||||
|
TODO: adaptive relates count
|
||||||
|
|
||||||
|
TODO: boris page
|
||||||
|
TODO: user profile and settings
|
||||||
|
|
||||||
|
[IMPORT]
|
||||||
|
|
||||||
|
TODO: import users properly
|
||||||
|
TODO: auth using md5 fallback
|
||||||
|
TODO: import files properly
|
||||||
|
TODO: import nodes properly
|
||||||
|
TODO: import comments properly
|
||||||
|
TODO: convert comment content properly
|
||||||
|
TODO: convert post text properly
|
||||||
|
|
||||||
|
[NOT TODAY]
|
||||||
|
|
||||||
|
TODO: swipable image slideshow
|
||||||
|
TODO: notifications
|
||||||
|
TODO: PMs
|
||||||
|
TODO:
|
||||||
|
|
||||||
*/
|
*/
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { render } from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
|
|
|
@ -1,13 +1,8 @@
|
||||||
import {
|
import { api, errorMiddleware, resultMiddleware, configWithToken } from '~/utils/api';
|
||||||
api,
|
|
||||||
authMiddleware,
|
|
||||||
errorMiddleware,
|
|
||||||
resultMiddleware,
|
|
||||||
configWithToken,
|
|
||||||
} from '~/utils/api';
|
|
||||||
import { API } from '~/constants/api';
|
import { API } from '~/constants/api';
|
||||||
import { IResultWithStatus } from '~/redux/types';
|
import { IResultWithStatus } from '~/redux/types';
|
||||||
import { userLoginTransform } from '~/redux/auth/transforms';
|
import { userLoginTransform } from '~/redux/auth/transforms';
|
||||||
|
import { IUser } from './types';
|
||||||
|
|
||||||
export const apiUserLogin = ({
|
export const apiUserLogin = ({
|
||||||
username,
|
username,
|
||||||
|
@ -15,8 +10,15 @@ export const apiUserLogin = ({
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}): Promise<IResultWithStatus<{ token: string; status?: number }>> => api
|
}): Promise<IResultWithStatus<{ token: string; status?: number }>> =>
|
||||||
.post(API.USER.LOGIN, { username, password })
|
api
|
||||||
.then(resultMiddleware)
|
.post(API.USER.LOGIN, { username, password })
|
||||||
.catch(errorMiddleware)
|
.then(resultMiddleware)
|
||||||
.then(userLoginTransform);
|
.catch(errorMiddleware)
|
||||||
|
.then(userLoginTransform);
|
||||||
|
|
||||||
|
export const apiAuthGetUser = ({ access }): Promise<IResultWithStatus<{ user: IUser }>> =>
|
||||||
|
api
|
||||||
|
.get(API.USER.ME, configWithToken(access))
|
||||||
|
.then(resultMiddleware)
|
||||||
|
.catch(errorMiddleware);
|
||||||
|
|
|
@ -8,8 +8,9 @@ export const AUTH_USER_ACTIONS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const USER_ERRORS = {
|
export const USER_ERRORS = {
|
||||||
|
UNAUTHORIZED: 'Вы не авторизованы',
|
||||||
INVALID_CREDENTIALS: 'Неверное имя пользователя или пароль. Очень жаль.',
|
INVALID_CREDENTIALS: 'Неверное имя пользователя или пароль. Очень жаль.',
|
||||||
EMPTY_CREDENTIALS: 'Давайте введем логин и пароль. Это обязательно.'
|
EMPTY_CREDENTIALS: 'Давайте введем логин и пароль. Это обязательно.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const USER_STATUSES = {
|
export const USER_STATUSES = {
|
||||||
|
@ -34,6 +35,7 @@ export const EMPTY_USER: IUser = {
|
||||||
name: null,
|
name: null,
|
||||||
username: null,
|
username: null,
|
||||||
photo: null,
|
photo: null,
|
||||||
|
cover: null,
|
||||||
is_activated: false,
|
is_activated: false,
|
||||||
is_user: false,
|
is_user: false,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
|
import { call, put, takeLatest, select } from 'redux-saga/effects';
|
||||||
|
import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS } from '~/redux/auth/constants';
|
||||||
import {
|
import {
|
||||||
call, put, takeLatest, select,
|
authSetToken,
|
||||||
} from 'redux-saga/effects';
|
userSetLoginError,
|
||||||
import { SagaIterator } from 'redux-saga';
|
authSetUser,
|
||||||
import { push } from 'connected-react-router';
|
userSendLoginRequest,
|
||||||
import { AUTH_USER_ACTIONS } from '~/redux/auth/constants';
|
} from '~/redux/auth/actions';
|
||||||
import * as ActionCreators from '~/redux/auth/actions';
|
import { apiUserLogin, apiAuthGetUser } from '~/redux/auth/api';
|
||||||
import { authSetToken, userSetLoginError, authSetUser } from '~/redux/auth/actions';
|
import { modalSetShown } from '~/redux/modal/actions';
|
||||||
import { apiUserLogin } from '~/redux/auth/api';
|
|
||||||
import { modalSetShown, modalShowDialog } from '~/redux/modal/actions';
|
|
||||||
import { selectToken } from './selectors';
|
import { selectToken } from './selectors';
|
||||||
import { URLS } from '~/constants/urls';
|
|
||||||
import { DIALOGS } from '../modal/constants';
|
|
||||||
import { IResultWithStatus } from '../types';
|
import { IResultWithStatus } from '../types';
|
||||||
import { IUser } from './types';
|
import { IUser } from './types';
|
||||||
|
import { REHYDRATE, RehydrateAction } from 'redux-persist';
|
||||||
|
|
||||||
export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> {
|
export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> {
|
||||||
const access = yield select(selectToken);
|
const access = yield select(selectToken);
|
||||||
|
@ -20,19 +19,13 @@ export function* reqWrapper(requestAction, props = {}): ReturnType<typeof reques
|
||||||
const result = yield call(requestAction, { access, ...props });
|
const result = yield call(requestAction, { access, ...props });
|
||||||
|
|
||||||
if (result && result.status === 401) {
|
if (result && result.status === 401) {
|
||||||
yield put(push(URLS.BASE));
|
return { error: USER_ERRORS.UNAUTHORIZED, data: {} };
|
||||||
yield put(modalShowDialog(DIALOGS.LOGIN));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function* sendLoginRequestSaga({
|
function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSendLoginRequest>) {
|
||||||
username,
|
|
||||||
password,
|
|
||||||
}: ReturnType<typeof ActionCreators.userSendLoginRequest>) {
|
|
||||||
if (!username || !password) return;
|
if (!username || !password) return;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -53,8 +46,31 @@ function* sendLoginRequestSaga({
|
||||||
yield put(modalSetShown(false));
|
yield put(modalSetShown(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function* mySaga() {
|
function* checkUserSaga({ key }: RehydrateAction) {
|
||||||
|
if (key !== 'auth') return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
data: { user },
|
||||||
|
}: IResultWithStatus<{ user: IUser }> = yield call(reqWrapper, apiAuthGetUser);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
yield put(
|
||||||
|
authSetUser({
|
||||||
|
...EMPTY_USER,
|
||||||
|
is_user: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield put(authSetUser({ ...user, is_user: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function* authSaga() {
|
||||||
|
yield takeLatest(REHYDRATE, checkUserSaga);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga);
|
yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default mySaga;
|
export default authSaga;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { IFile } from '../types';
|
||||||
|
|
||||||
export interface IToken {
|
export interface IToken {
|
||||||
access: string;
|
access: string;
|
||||||
refresh: string;
|
refresh: string;
|
||||||
|
@ -8,7 +10,8 @@ export interface IUser {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
photo: string;
|
photo: IFile;
|
||||||
|
cover: IFile;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
is_activated: boolean;
|
is_activated: boolean;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { INode, IValidationErrors, IComment } from '../types';
|
import { INode, IValidationErrors, IComment, ITag } from '../types';
|
||||||
import { NODE_ACTIONS } from './constants';
|
import { NODE_ACTIONS } from './constants';
|
||||||
import { INodeState } from './reducer';
|
import { INodeState } from './reducer';
|
||||||
|
|
||||||
|
@ -53,3 +53,14 @@ export const nodeSetCommentData = (id: number, comment: IComment) => ({
|
||||||
comment,
|
comment,
|
||||||
type: NODE_ACTIONS.SET_COMMENT_DATA,
|
type: NODE_ACTIONS.SET_COMMENT_DATA,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const nodeUpdateTags = (id: INode['id'], tags: string[]) => ({
|
||||||
|
type: NODE_ACTIONS.UPDATE_TAGS,
|
||||||
|
id,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nodeSetTags = (tags: ITag[]) => ({
|
||||||
|
type: NODE_ACTIONS.SET_TAGS,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api';
|
import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api';
|
||||||
import { INode, IResultWithStatus, IComment } from '../types';
|
import { INode, IResultWithStatus, IComment } from '../types';
|
||||||
import { API } from '~/constants/api';
|
import { API } from '~/constants/api';
|
||||||
|
import { nodeUpdateTags } from './actions';
|
||||||
|
|
||||||
export const postNode = ({
|
export const postNode = ({
|
||||||
access,
|
access,
|
||||||
|
@ -50,10 +51,24 @@ export const postNodeComment = ({
|
||||||
|
|
||||||
export const getNodeComments = ({
|
export const getNodeComments = ({
|
||||||
id,
|
id,
|
||||||
|
access,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
|
access: string;
|
||||||
}): Promise<IResultWithStatus<{ comment: Comment }>> =>
|
}): Promise<IResultWithStatus<{ comment: Comment }>> =>
|
||||||
api
|
api
|
||||||
.get(API.NODE.COMMENT(id))
|
.get(API.NODE.COMMENT(id), configWithToken(access))
|
||||||
|
.then(resultMiddleware)
|
||||||
|
.catch(errorMiddleware);
|
||||||
|
|
||||||
|
export const updateNodeTags = ({
|
||||||
|
id,
|
||||||
|
tags,
|
||||||
|
access,
|
||||||
|
}: ReturnType<typeof nodeUpdateTags> & { access: string }): Promise<
|
||||||
|
IResultWithStatus<{ node: INode }>
|
||||||
|
> =>
|
||||||
|
api
|
||||||
|
.post(API.NODE.UPDATE_TAGS(id), { tags }, configWithToken(access))
|
||||||
.then(resultMiddleware)
|
.then(resultMiddleware)
|
||||||
.catch(errorMiddleware);
|
.catch(errorMiddleware);
|
||||||
|
|
|
@ -16,6 +16,9 @@ export const NODE_ACTIONS = {
|
||||||
|
|
||||||
POST_COMMENT: `${prefix}POST_COMMENT`,
|
POST_COMMENT: `${prefix}POST_COMMENT`,
|
||||||
SET_COMMENTS: `${prefix}SET_COMMENTS`,
|
SET_COMMENTS: `${prefix}SET_COMMENTS`,
|
||||||
|
|
||||||
|
UPDATE_TAGS: `${prefix}UPDATE_TAGS`,
|
||||||
|
SET_TAGS: `${prefix}SET_TAGS`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_BLOCK: IBlock = {
|
export const EMPTY_BLOCK: IBlock = {
|
||||||
|
@ -28,7 +31,7 @@ export const EMPTY_BLOCK: IBlock = {
|
||||||
export const EMPTY_NODE: INode = {
|
export const EMPTY_NODE: INode = {
|
||||||
id: null,
|
id: null,
|
||||||
|
|
||||||
user_id: null,
|
user: null,
|
||||||
|
|
||||||
title: '',
|
title: '',
|
||||||
files: [],
|
files: [],
|
||||||
|
@ -37,6 +40,7 @@ export const EMPTY_NODE: INode = {
|
||||||
type: null,
|
type: null,
|
||||||
|
|
||||||
blocks: [],
|
blocks: [],
|
||||||
|
tags: [],
|
||||||
|
|
||||||
options: {
|
options: {
|
||||||
flow: {
|
flow: {
|
||||||
|
@ -53,7 +57,10 @@ export const NODE_TYPES = {
|
||||||
TEXT: 'text',
|
TEXT: 'text',
|
||||||
};
|
};
|
||||||
|
|
||||||
type INodeComponents = Record<ValueOf<typeof NODE_TYPES>, FC<{ node: INode; is_loading: boolean }>>;
|
type INodeComponents = Record<
|
||||||
|
ValueOf<typeof NODE_TYPES>,
|
||||||
|
FC<{ node: INode; is_loading: boolean; layout: {}; updateLayout: () => void }>
|
||||||
|
>;
|
||||||
|
|
||||||
export const NODE_COMPONENTS: INodeComponents = {
|
export const NODE_COMPONENTS: INodeComponents = {
|
||||||
[NODE_TYPES.IMAGE]: NodeImageBlock,
|
[NODE_TYPES.IMAGE]: NodeImageBlock,
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
nodeSetSendingComment,
|
nodeSetSendingComment,
|
||||||
nodeSetComments,
|
nodeSetComments,
|
||||||
nodeSetCommentData,
|
nodeSetCommentData,
|
||||||
|
nodeSetTags,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { INodeState } from './reducer';
|
import { INodeState } from './reducer';
|
||||||
|
|
||||||
|
@ -38,6 +39,9 @@ const setCommentData = (
|
||||||
{ id, comment }: ReturnType<typeof nodeSetCommentData>
|
{ id, comment }: ReturnType<typeof nodeSetCommentData>
|
||||||
) => assocPath(['comment_data', id], comment, state);
|
) => assocPath(['comment_data', id], comment, state);
|
||||||
|
|
||||||
|
const setTags = (state: INodeState, { tags }: ReturnType<typeof nodeSetTags>) =>
|
||||||
|
assocPath(['current', 'tags'], tags, state);
|
||||||
|
|
||||||
export const NODE_HANDLERS = {
|
export const NODE_HANDLERS = {
|
||||||
[NODE_ACTIONS.SAVE]: setSaveErrors,
|
[NODE_ACTIONS.SAVE]: setSaveErrors,
|
||||||
[NODE_ACTIONS.SET_LOADING]: setLoading,
|
[NODE_ACTIONS.SET_LOADING]: setLoading,
|
||||||
|
@ -46,4 +50,5 @@ export const NODE_HANDLERS = {
|
||||||
[NODE_ACTIONS.SET_SENDING_COMMENT]: setSendingComment,
|
[NODE_ACTIONS.SET_SENDING_COMMENT]: setSendingComment,
|
||||||
[NODE_ACTIONS.SET_COMMENTS]: setComments,
|
[NODE_ACTIONS.SET_COMMENTS]: setComments,
|
||||||
[NODE_ACTIONS.SET_COMMENT_DATA]: setCommentData,
|
[NODE_ACTIONS.SET_COMMENT_DATA]: setCommentData,
|
||||||
|
[NODE_ACTIONS.SET_TAGS]: setTags,
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,8 +13,10 @@ import {
|
||||||
nodeSetSendingComment,
|
nodeSetSendingComment,
|
||||||
nodeSetComments,
|
nodeSetComments,
|
||||||
nodeSetCommentData,
|
nodeSetCommentData,
|
||||||
|
nodeUpdateTags,
|
||||||
|
nodeSetTags,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { postNode, getNode, postNodeComment, getNodeComments } from './api';
|
import { postNode, getNode, postNodeComment, getNodeComments, updateNodeTags } from './api';
|
||||||
import { reqWrapper } from '../auth/sagas';
|
import { reqWrapper } from '../auth/sagas';
|
||||||
import { flowSetNodes } from '../flow/actions';
|
import { flowSetNodes } from '../flow/actions';
|
||||||
import { ERRORS } from '~/constants/errors';
|
import { ERRORS } from '~/constants/errors';
|
||||||
|
@ -22,6 +24,7 @@ import { modalSetShown } from '../modal/actions';
|
||||||
import { selectFlowNodes } from '../flow/selectors';
|
import { selectFlowNodes } from '../flow/selectors';
|
||||||
import { URLS } from '~/constants/urls';
|
import { URLS } from '~/constants/urls';
|
||||||
import { selectNode } from './selectors';
|
import { selectNode } from './selectors';
|
||||||
|
import { IResultWithStatus, INode } from '../types';
|
||||||
|
|
||||||
function* onNodeSave({ node }: ReturnType<typeof nodeSave>) {
|
function* onNodeSave({ node }: ReturnType<typeof nodeSave>) {
|
||||||
yield put(nodeSetSaveErrors({}));
|
yield put(nodeSetSaveErrors({}));
|
||||||
|
@ -69,7 +72,7 @@ function* onNodeLoad({ id, node_type }: ReturnType<typeof nodeLoadNode>) {
|
||||||
// todo: load comments
|
// todo: load comments
|
||||||
const {
|
const {
|
||||||
data: { comments },
|
data: { comments },
|
||||||
} = yield call(getNodeComments, { id });
|
} = yield call(reqWrapper, getNodeComments, { id });
|
||||||
|
|
||||||
yield put(nodeSetComments(comments || []));
|
yield put(nodeSetComments(comments || []));
|
||||||
|
|
||||||
|
@ -83,7 +86,7 @@ function* onPostComment({ id }: ReturnType<typeof nodePostComment>) {
|
||||||
|
|
||||||
yield put(nodeSetSendingComment(true));
|
yield put(nodeSetSendingComment(true));
|
||||||
const {
|
const {
|
||||||
data: { comment, id: target_id },
|
data: { comment },
|
||||||
error,
|
error,
|
||||||
} = yield call(reqWrapper, postNodeComment, { data: comment_data[id], id: current.id });
|
} = yield call(reqWrapper, postNodeComment, { data: comment_data[id], id: current.id });
|
||||||
yield put(nodeSetSendingComment(false));
|
yield put(nodeSetSendingComment(false));
|
||||||
|
@ -102,8 +105,22 @@ function* onPostComment({ id }: ReturnType<typeof nodePostComment>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
|
||||||
|
yield delay(1000);
|
||||||
|
const {
|
||||||
|
data: { node },
|
||||||
|
}: IResultWithStatus<{ node: INode }> = yield call(reqWrapper, updateNodeTags, { id, tags });
|
||||||
|
|
||||||
|
const { current } = yield select(selectNode);
|
||||||
|
|
||||||
|
if (!node || !node.id || node.id !== current.id) return;
|
||||||
|
|
||||||
|
yield put(nodeSetTags(node.tags));
|
||||||
|
}
|
||||||
|
|
||||||
export default function* nodeSaga() {
|
export default function* nodeSaga() {
|
||||||
yield takeLatest(NODE_ACTIONS.SAVE, onNodeSave);
|
yield takeLatest(NODE_ACTIONS.SAVE, onNodeSave);
|
||||||
yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad);
|
yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad);
|
||||||
yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment);
|
yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment);
|
||||||
|
yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags);
|
||||||
}
|
}
|
||||||
|
|
25
src/redux/player/actions.ts
Normal file
25
src/redux/player/actions.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { IPlayerState } from './reducer';
|
||||||
|
import { PLAYER_ACTIONS } from './constants';
|
||||||
|
|
||||||
|
export const playerSetFile = (file: IPlayerState['file']) => ({
|
||||||
|
type: PLAYER_ACTIONS.SET_FILE,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const playerSetStatus = (status: IPlayerState['status']) => ({
|
||||||
|
type: PLAYER_ACTIONS.SET_STATUS,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const playerPlay = () => ({
|
||||||
|
type: PLAYER_ACTIONS.PLAY,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const playerPause = () => ({
|
||||||
|
type: PLAYER_ACTIONS.PAUSE,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const playerSeek = (seek: number) => ({
|
||||||
|
type: PLAYER_ACTIONS.SEEK,
|
||||||
|
seek,
|
||||||
|
});
|
16
src/redux/player/constants.ts
Normal file
16
src/redux/player/constants.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
const prefix = 'PLAYER.';
|
||||||
|
|
||||||
|
export const PLAYER_ACTIONS = {
|
||||||
|
SET_FILE: `${prefix}SET_FILE`,
|
||||||
|
SET_STATUS: `${prefix}SET_STATUS`,
|
||||||
|
|
||||||
|
PLAY: `${prefix}PLAY`,
|
||||||
|
PAUSE: `${prefix}PAUSE`,
|
||||||
|
SEEK: `${prefix}SEEK`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAYER_STATES = {
|
||||||
|
PLAYING: 'PLAYING',
|
||||||
|
PAUSED: 'PAUSED',
|
||||||
|
UNSET: 'UNSET',
|
||||||
|
};
|
14
src/redux/player/handlers.ts
Normal file
14
src/redux/player/handlers.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { PLAYER_ACTIONS } from './constants';
|
||||||
|
import assocPath from 'ramda/es/assocPath';
|
||||||
|
import { playerSetFile, playerSetStatus } from './actions';
|
||||||
|
|
||||||
|
const setFile = (state, { file }: ReturnType<typeof playerSetFile>) =>
|
||||||
|
assocPath(['file'], file, state);
|
||||||
|
|
||||||
|
const setStatus = (state, { status }: ReturnType<typeof playerSetStatus>) =>
|
||||||
|
assocPath(['status'], status, state);
|
||||||
|
|
||||||
|
export const PLAYER_HANDLERS = {
|
||||||
|
[PLAYER_ACTIONS.SET_FILE]: setFile,
|
||||||
|
[PLAYER_ACTIONS.SET_STATUS]: setStatus,
|
||||||
|
};
|
16
src/redux/player/reducer.ts
Normal file
16
src/redux/player/reducer.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { createReducer } from '~/utils/reducer';
|
||||||
|
import { PLAYER_HANDLERS } from './handlers';
|
||||||
|
import { PLAYER_STATES } from './constants';
|
||||||
|
import { IFile } from '../types';
|
||||||
|
|
||||||
|
export type IPlayerState = Readonly<{
|
||||||
|
status: typeof PLAYER_STATES[keyof typeof PLAYER_STATES];
|
||||||
|
file: IFile;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const INITIAL_STATE: IPlayerState = {
|
||||||
|
status: PLAYER_STATES.UNSET,
|
||||||
|
file: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createReducer(INITIAL_STATE, PLAYER_HANDLERS);
|
29
src/redux/player/sagas.ts
Normal file
29
src/redux/player/sagas.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { takeLatest } from 'redux-saga/effects';
|
||||||
|
import { PLAYER_ACTIONS } from './constants';
|
||||||
|
import { playerSetFile, playerSeek } from './actions';
|
||||||
|
import { Player } from '~/utils/player';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
|
||||||
|
function setFileSaga({ file }: ReturnType<typeof playerSetFile>) {
|
||||||
|
Player.set(getURL(file));
|
||||||
|
Player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSaga() {
|
||||||
|
Player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseSaga() {
|
||||||
|
Player.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekSaga({ seek }: ReturnType<typeof playerSeek>) {
|
||||||
|
Player.jumpToPercent(seek * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function* playerSaga() {
|
||||||
|
yield takeLatest(PLAYER_ACTIONS.SET_FILE, setFileSaga);
|
||||||
|
yield takeLatest(PLAYER_ACTIONS.PAUSE, pauseSaga);
|
||||||
|
yield takeLatest(PLAYER_ACTIONS.PLAY, playSaga);
|
||||||
|
yield takeLatest(PLAYER_ACTIONS.SEEK, seekSaga);
|
||||||
|
}
|
3
src/redux/player/selectors.ts
Normal file
3
src/redux/player/selectors.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
|
||||||
|
export const selectPlayer = (state: IState) => state.player;
|
|
@ -19,6 +19,9 @@ import flowSaga from '~/redux/flow/sagas';
|
||||||
import uploadReducer, { IUploadState } from '~/redux/uploads/reducer';
|
import uploadReducer, { IUploadState } from '~/redux/uploads/reducer';
|
||||||
import uploadSaga from '~/redux/uploads/sagas';
|
import uploadSaga from '~/redux/uploads/sagas';
|
||||||
|
|
||||||
|
import playerReducer, { IPlayerState } from '~/redux/player/reducer';
|
||||||
|
import playerSaga from '~/redux/player/sagas';
|
||||||
|
|
||||||
import { IAuthState } from '~/redux/auth/types';
|
import { IAuthState } from '~/redux/auth/types';
|
||||||
|
|
||||||
import modalReducer, { IModalState } from '~/redux/modal/reducer';
|
import modalReducer, { IModalState } from '~/redux/modal/reducer';
|
||||||
|
@ -36,6 +39,7 @@ export interface IState {
|
||||||
node: INodeState;
|
node: INodeState;
|
||||||
uploads: IUploadState;
|
uploads: IUploadState;
|
||||||
flow: IFlowState;
|
flow: IFlowState;
|
||||||
|
player: IPlayerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sagaMiddleware = createSagaMiddleware();
|
export const sagaMiddleware = createSagaMiddleware();
|
||||||
|
@ -54,6 +58,7 @@ export const store = createStore(
|
||||||
node: nodeReducer,
|
node: nodeReducer,
|
||||||
uploads: uploadReducer,
|
uploads: uploadReducer,
|
||||||
flow: flowReducer,
|
flow: flowReducer,
|
||||||
|
player: playerReducer,
|
||||||
}),
|
}),
|
||||||
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
|
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
|
||||||
);
|
);
|
||||||
|
@ -63,6 +68,7 @@ export function configureStore(): { store: Store<IState>; persistor: Persistor }
|
||||||
sagaMiddleware.run(nodeSaga);
|
sagaMiddleware.run(nodeSaga);
|
||||||
sagaMiddleware.run(uploadSaga);
|
sagaMiddleware.run(uploadSaga);
|
||||||
sagaMiddleware.run(flowSaga);
|
sagaMiddleware.run(flowSaga);
|
||||||
|
sagaMiddleware.run(playerSaga);
|
||||||
|
|
||||||
const persistor = persistStore(store);
|
const persistor = persistStore(store);
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,15 @@ import { ERRORS } from '~/constants/errors';
|
||||||
import { IUser } from './auth/types';
|
import { IUser } from './auth/types';
|
||||||
|
|
||||||
export interface ITag {
|
export interface ITag {
|
||||||
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
feature?: 'red' | 'blue' | 'green' | 'olive' | 'black';
|
|
||||||
|
data: Record<string, string>;
|
||||||
|
user: IUser;
|
||||||
|
nodes: INode[];
|
||||||
|
|
||||||
|
readonly created_at: string;
|
||||||
|
readonly updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IInputTextProps = DetailedHTMLProps<
|
export type IInputTextProps = DetailedHTMLProps<
|
||||||
|
@ -64,6 +71,15 @@ export interface IFile {
|
||||||
|
|
||||||
type: IUploadType;
|
type: IUploadType;
|
||||||
mime: string;
|
mime: string;
|
||||||
|
metadata?: {
|
||||||
|
id3title?: string;
|
||||||
|
id3artist?: string;
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
duration?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
@ -86,7 +102,7 @@ export interface IBlock {
|
||||||
|
|
||||||
export interface INode {
|
export interface INode {
|
||||||
id?: number;
|
id?: number;
|
||||||
user_id: UUID;
|
user: Partial<IUser>;
|
||||||
|
|
||||||
title: string;
|
title: string;
|
||||||
files: IFile[];
|
files: IFile[];
|
||||||
|
@ -110,6 +126,8 @@ export interface INode {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tags: ITag[];
|
||||||
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { IFile, IUploadType } from '~/redux/types';
|
import { IFile, IUploadType } from '~/redux/types';
|
||||||
import { IUploadState, IUploadStatus } from './reducer';
|
import { IUploadStatus } from './reducer';
|
||||||
|
|
||||||
const prefix = 'UPLOAD.';
|
const prefix = 'UPLOAD.';
|
||||||
|
|
||||||
|
@ -60,3 +60,10 @@ export const UPLOAD_TYPES: Record<string, IUploadType> = {
|
||||||
VIDEO: 'video',
|
VIDEO: 'video',
|
||||||
OTHER: 'other',
|
OTHER: 'other',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FILE_MIMES = {
|
||||||
|
[UPLOAD_TYPES.VIDEO]: [],
|
||||||
|
[UPLOAD_TYPES.IMAGE]: ['image/jpeg', 'image/jpg', 'image/png'],
|
||||||
|
[UPLOAD_TYPES.AUDIO]: ['audio/mpeg3', 'audio/mpeg', 'audio/mp3'],
|
||||||
|
[UPLOAD_TYPES.OTHER]: [],
|
||||||
|
};
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import {
|
import { takeEvery, all, spawn, call, put, take, fork, race } from 'redux-saga/effects';
|
||||||
takeEvery, all, spawn, call, put, take, fork, race,
|
|
||||||
} from 'redux-saga/effects';
|
|
||||||
import { postUploadFile } from './api';
|
import { postUploadFile } from './api';
|
||||||
import { UPLOAD_ACTIONS } from '~/redux/uploads/constants';
|
import { UPLOAD_ACTIONS, FILE_MIMES } from '~/redux/uploads/constants';
|
||||||
import {
|
import {
|
||||||
uploadUploadFiles,
|
uploadUploadFiles,
|
||||||
uploadSetStatus,
|
uploadSetStatus,
|
||||||
|
@ -13,7 +11,6 @@ import {
|
||||||
import { reqWrapper } from '../auth/sagas';
|
import { reqWrapper } from '../auth/sagas';
|
||||||
import { createUploader, uploadGetThumb } from '~/utils/uploader';
|
import { createUploader, uploadGetThumb } from '~/utils/uploader';
|
||||||
import { HTTP_RESPONSES } from '~/utils/api';
|
import { HTTP_RESPONSES } from '~/utils/api';
|
||||||
import { VALIDATORS } from '~/utils/validators';
|
|
||||||
import { IFileWithUUID, IFile, IUploadProgressHandler } from '../types';
|
import { IFileWithUUID, IFile, IUploadProgressHandler } from '../types';
|
||||||
|
|
||||||
function* uploadCall({
|
function* uploadCall({
|
||||||
|
@ -49,9 +46,7 @@ function* uploadCancelWorker(id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function* uploadWorker({
|
function* uploadWorker({ file, temp_id, target, type }: IFileWithUUID) {
|
||||||
file, temp_id, target, type,
|
|
||||||
}: IFileWithUUID) {
|
|
||||||
const [promise, chan] = createUploader<Partial<IFileWithUUID>, Partial<IFileWithUUID>>(
|
const [promise, chan] = createUploader<Partial<IFileWithUUID>, Partial<IFileWithUUID>>(
|
||||||
uploadCall,
|
uploadCall,
|
||||||
{ temp_id, target, type }
|
{ temp_id, target, type }
|
||||||
|
@ -69,10 +64,8 @@ function* uploadWorker({
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function* uploadFile({
|
function* uploadFile({ file, temp_id, type, target }: IFileWithUUID) {
|
||||||
file, temp_id, type, target,
|
if (!file.type || !file.type || !FILE_MIMES[type].includes(file.type)) {
|
||||||
}: IFileWithUUID) {
|
|
||||||
if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) {
|
|
||||||
return { error: 'File_Not_Image', status: HTTP_RESPONSES.BAD_REQUEST, data: {} };
|
return { error: 'File_Not_Image', status: HTTP_RESPONSES.BAD_REQUEST, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,13 +96,13 @@ function* uploadFile({
|
||||||
// add here CANCEL_UPLOADS worker, that will watch for subject
|
// add here CANCEL_UPLOADS worker, that will watch for subject
|
||||||
// cancel_editing: take(UPLOAD_ACTIONS.CANCEL_EDITING),
|
// cancel_editing: take(UPLOAD_ACTIONS.CANCEL_EDITING),
|
||||||
// save_inventory: take(INVENTORY_ACTIONS.SAVE_INVENTORY),
|
// save_inventory: take(INVENTORY_ACTIONS.SAVE_INVENTORY),
|
||||||
}) as any;
|
});
|
||||||
|
|
||||||
if (cancel || cancel_editing) {
|
if (cancel || cancel_editing) {
|
||||||
return yield put(uploadDropStatus(temp_id));
|
return yield put(uploadDropStatus(temp_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error }: { data: IFile & { detail: any }; error: string } = result;
|
const { data, error }: { data: IFile & { detail: string }; error: string } = result;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return yield put(
|
return yield put(
|
||||||
|
@ -137,6 +130,6 @@ function* uploadFiles({ files }: ReturnType<typeof uploadUploadFiles>) {
|
||||||
yield all(files.map(file => spawn(uploadFile, file)));
|
yield all(files.map(file => spawn(uploadFile, file)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function* () {
|
export default function*() {
|
||||||
yield takeEvery(UPLOAD_ACTIONS.UPLOAD_FILES, uploadFiles);
|
yield takeEvery(UPLOAD_ACTIONS.UPLOAD_FILES, uploadFiles);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,10 @@ const Sprites: FC<{}> = () => (
|
||||||
<path d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18c.62-.39.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z" />
|
<path d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18c.62-.39.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<g id="pause">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" strokeWidth="0" />
|
||||||
|
</g>
|
||||||
|
|
||||||
<g id="plus" stroke="none">
|
<g id="plus" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
|
@ -79,6 +83,21 @@ const Sprites: FC<{}> = () => (
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
<path d="M11 9l1.42 1.42L8.83 14H18V4h2v12H8.83l3.59 3.58L11 21l-6-6 6-6z" />
|
<path d="M11 9l1.42 1.42L8.83 14H18V4h2v12H8.83l3.59 3.58L11 21l-6-6 6-6z" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<g id="photo" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="image" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5.04-6.71l-2.75 3.54-1.96-2.36L6.5 17h11l-3.54-4.71z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="profile" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -15,11 +15,11 @@ $green_gradient: linear-gradient(170deg, adjust_hue($wisegreen, 15deg) 0%, $wise
|
||||||
$purple_gradient: linear-gradient(170deg, $red, $dark_blue);
|
$purple_gradient: linear-gradient(170deg, $red, $dark_blue);
|
||||||
$cyan_gradient: linear-gradient(260deg, #3c75ff -50%, #7b2653 150%);
|
$cyan_gradient: linear-gradient(260deg, #3c75ff -50%, #7b2653 150%);
|
||||||
|
|
||||||
$main_bg_color: #2b2c34;
|
$main_bg_color: #2c2b2b;
|
||||||
$main_text_color: white;
|
$main_text_color: white;
|
||||||
|
|
||||||
$content_bg: darken($main_bg_color, 4%);
|
$content_bg: darken($main_bg_color, 6%);
|
||||||
$content_bg_secondary: darken($content_bg, 3%);
|
$content_bg_secondary: darken($content_bg, 2%);
|
||||||
|
|
||||||
$cell_bg: lighten($main_bg_color, 0%);
|
$cell_bg: lighten($main_bg_color, 0%);
|
||||||
$card_bg: lighten($main_bg_color, 0%);
|
$card_bg: lighten($main_bg_color, 0%);
|
||||||
|
@ -35,7 +35,7 @@ $button_bg_color: $red_gradient;
|
||||||
$comment_bg: lighten($content_bg, 2%);
|
$comment_bg: lighten($content_bg, 2%);
|
||||||
$panel_bg: transparent;
|
$panel_bg: transparent;
|
||||||
$node_bg: darken($content_bg, 4%);
|
$node_bg: darken($content_bg, 4%);
|
||||||
$node_image_bg: darken($main_bg_color, 2%);
|
$node_image_bg: darken($main_bg_color, 7%);
|
||||||
$node_title_background: darken($main_bg_color, 8%);
|
$node_title_background: darken($main_bg_color, 8%);
|
||||||
|
|
||||||
$editor_bg: $content_bg;
|
$editor_bg: $content_bg;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import "~raleway-cyrillic/raleway.css";
|
@import '~raleway-cyrillic/raleway.css';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
@ -7,24 +7,23 @@ html {
|
||||||
body {
|
body {
|
||||||
background: darken($main_bg_color, 12%);
|
background: darken($main_bg_color, 12%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: url("http://vault48.org/pixmaps/texture.jpg");
|
background: url('http://vault48.org/pixmaps/texture.jpg');
|
||||||
color: $main_text_color;
|
color: $main_text_color;
|
||||||
font-family: Raleway, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
font: $font_16_regular;
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
font-size: 16px;
|
// font-size: 16px;
|
||||||
fill: white;
|
fill: white;
|
||||||
stroke: white;
|
stroke: white;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
background: url("http://vault48.org/pixmaps/texture_alt.jpg");
|
background: url('http://vault48.org/pixmaps/texture_alt.jpg');
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -80,4 +79,3 @@ body {
|
||||||
:global(h2) {
|
:global(h2) {
|
||||||
font: $font_24_bold;
|
font: $font_24_bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
@import "colors";
|
@import 'colors';
|
||||||
|
|
||||||
$cell: 256px;
|
$cell: 320px;
|
||||||
$content_width: 1100px;
|
$grid_line: 4px;
|
||||||
|
$content_width: $cell * 4 + $grid_line * 3;
|
||||||
$gap: 10px;
|
$gap: 10px;
|
||||||
$spc: $gap * 2;
|
$spc: $gap * 2;
|
||||||
|
$comment_height: 72px;
|
||||||
|
|
||||||
$radius: 6px;
|
$radius: 8px;
|
||||||
$cell_radius: 4px;
|
$cell_radius: $radius;
|
||||||
$panel_radius: $radius;
|
$panel_radius: $radius;
|
||||||
|
$input_radius: $radius;
|
||||||
$grid_line: 4px;
|
|
||||||
|
|
||||||
$input_height: 36px;
|
$input_height: 36px;
|
||||||
$input_radius: $radius;
|
|
||||||
$info_height: 24px;
|
$info_height: 24px;
|
||||||
|
|
||||||
$panel_size: 64px;
|
$panel_size: 64px;
|
||||||
|
@ -25,9 +25,9 @@ $medium: 500;
|
||||||
$light: 300;
|
$light: 300;
|
||||||
$extra_light: 200;
|
$extra_light: 200;
|
||||||
|
|
||||||
$font: Montserrat, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
$font: Montserrat, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
"Noto Color Emoji";
|
'Noto Color Emoji';
|
||||||
|
|
||||||
$font_48_semibold: $semibold 48px $font;
|
$font_48_semibold: $semibold 48px $font;
|
||||||
$font_24_bold: $bold 24px $font;
|
$font_24_bold: $bold 24px $font;
|
||||||
|
@ -48,6 +48,8 @@ $font_12_semibold: $semibold 12px $font;
|
||||||
$font_12_regular: $regular 12px $font;
|
$font_12_regular: $regular 12px $font;
|
||||||
$font_10_regular: $regular 10px $font;
|
$font_10_regular: $regular 10px $font;
|
||||||
$font_10_semibold: $semibold 10px $font;
|
$font_10_semibold: $semibold 10px $font;
|
||||||
|
$font_8_regular: $regular 8px $font;
|
||||||
|
$font_8_semibold: $semibold 8px $font;
|
||||||
|
|
||||||
$font_cell_title: $font_24_bold;
|
$font_cell_title: $font_24_bold;
|
||||||
$font_hero_title: $font_48_semibold;
|
$font_hero_title: $font_48_semibold;
|
||||||
|
@ -89,7 +91,7 @@ $input_shadow_filled: $input_shadow;
|
||||||
position: $position;
|
position: $position;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: " ";
|
content: ' ';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -107,7 +109,9 @@ $input_shadow_filled: $input_shadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin tablet {
|
@mixin tablet {
|
||||||
@media (max-width: 599px) { @content; }
|
@media (max-width: 599px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin vertical_at_tablet {
|
@mixin vertical_at_tablet {
|
||||||
|
@ -117,8 +121,12 @@ $input_shadow_filled: $input_shadow;
|
||||||
& > * {
|
& > * {
|
||||||
margin: $gap/2 0 !important;
|
margin: $gap/2 0 !important;
|
||||||
|
|
||||||
&:first-child { margin-top: 0 !important; }
|
&:first-child {
|
||||||
&:last-child { margin-bottom: 0 !important; }
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
|
||||||
export const getStyle = (oElm: any, strCssRule: string) => {
|
export const getStyle = (oElm: any, strCssRule: string) => {
|
||||||
if (document.defaultView && document.defaultView.getComputedStyle) {
|
if (document.defaultView && document.defaultView.getComputedStyle) {
|
||||||
return document.defaultView.getComputedStyle(oElm, '').getPropertyValue(strCssRule);
|
return document.defaultView.getComputedStyle(oElm, '').getPropertyValue(strCssRule);
|
||||||
|
@ -52,10 +56,16 @@ export const describeArc = (
|
||||||
].join(' ');
|
].join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getURL = url => `${process.env.API_HOST}${url}`;
|
export const getURL = (file: Partial<IFile>) => {
|
||||||
|
if (!file || !file.url) return null;
|
||||||
|
|
||||||
export const getImageSize = (image: string, size?: string): string =>
|
return file.url
|
||||||
`${process.env.API_HOST}${image}`.replace('{size}', size);
|
.replace('REMOTE_CURRENT://', process.env.REMOTE_CURRENT)
|
||||||
|
.replace('REMOTE_OLD://', process.env.REMOTE_OLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageSize = (file: IFile, size?: string): string => getURL(file);
|
||||||
|
// `${process.env.API_HOST}${image}`.replace('{size}', size);
|
||||||
|
|
||||||
export const formatCommentText = (author, text: string) =>
|
export const formatCommentText = (author, text: string) =>
|
||||||
text
|
text
|
||||||
|
@ -63,5 +73,10 @@ export const formatCommentText = (author, text: string) =>
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((el, index) => (index === 0 ? `<p><b>${author}</b>: ${el}</p>` : `<p>${el}</p>`))
|
.map((el, index) =>
|
||||||
|
index === 0 ? `${author ? `<p><b>${author}</b>: ` : ''}${el}</p>` : `<p>${el}</p>`
|
||||||
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
export const getPrettyDate = (date: string): string =>
|
||||||
|
formatDistanceToNow(new Date(date), { locale: ru, includeSeconds: true, addSuffix: true });
|
||||||
|
|
87
src/utils/player.ts
Normal file
87
src/utils/player.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { store } from '~/redux/store';
|
||||||
|
import { playerSetStatus } from '~/redux/player/actions';
|
||||||
|
import { PLAYER_STATES } from '~/redux/player/constants';
|
||||||
|
|
||||||
|
type PlayerEventType = keyof HTMLMediaElementEventMap;
|
||||||
|
|
||||||
|
type PlayerEventListener = (
|
||||||
|
this: HTMLAudioElement,
|
||||||
|
event: HTMLMediaElementEventMap[keyof HTMLMediaElementEventMap]
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface IPlayerProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayerClass {
|
||||||
|
public constructor() {
|
||||||
|
this.element.addEventListener('timeupdate', () => {
|
||||||
|
const { duration: total, currentTime: current } = this.element;
|
||||||
|
const progress = parseFloat(((current / total) * 100).toFixed(2));
|
||||||
|
|
||||||
|
this.current = current || 0;
|
||||||
|
this.total = total || 0;
|
||||||
|
|
||||||
|
this.element.dispatchEvent(
|
||||||
|
new CustomEvent('playprogress', {
|
||||||
|
detail: { current, total, progress },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public current: number = 0;
|
||||||
|
|
||||||
|
public total: number = 0;
|
||||||
|
|
||||||
|
public element: HTMLAudioElement = new Audio();
|
||||||
|
|
||||||
|
public duration: number = 0;
|
||||||
|
|
||||||
|
public set = (src: string): void => {
|
||||||
|
this.element.src = src;
|
||||||
|
};
|
||||||
|
|
||||||
|
public on = (type: string, callback) => {
|
||||||
|
this.element.addEventListener(type, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
public off = (type: string, callback) => {
|
||||||
|
this.element.removeEventListener(type, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
public load = () => {
|
||||||
|
this.element.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
public play = () => {
|
||||||
|
this.element.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
public pause = () => {
|
||||||
|
this.element.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
public getDuration = () => {
|
||||||
|
return this.element.currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
public jumpToTime = (time: number) => {
|
||||||
|
this.element.currentTime = time;
|
||||||
|
};
|
||||||
|
|
||||||
|
public jumpToPercent = (percent: number) => {
|
||||||
|
this.element.currentTime = (this.total * percent) / 100;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Player = new PlayerClass();
|
||||||
|
|
||||||
|
// Player.element.addEventListener('playprogress', ({ detail }: CustomEvent) => console.log(detail));
|
||||||
|
|
||||||
|
Player.on('play', () => store.dispatch(playerSetStatus(PLAYER_STATES.PLAYING)));
|
||||||
|
Player.on('pause', () => store.dispatch(playerSetStatus(PLAYER_STATES.PAUSED)));
|
||||||
|
|
||||||
|
export { Player };
|
|
@ -3,7 +3,7 @@ import { eventChannel, END, EventChannel } from 'redux-saga';
|
||||||
import { VALIDATORS } from '~/utils/validators';
|
import { VALIDATORS } from '~/utils/validators';
|
||||||
import { IResultWithStatus, IFile } from '~/redux/types';
|
import { IResultWithStatus, IFile } from '~/redux/types';
|
||||||
import { HTTP_RESPONSES } from './api';
|
import { HTTP_RESPONSES } from './api';
|
||||||
import { EMPTY_FILE } from '~/redux/uploads/constants';
|
import { EMPTY_FILE, FILE_MIMES, UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
|
||||||
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
|
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ export function createUploader<T extends {}, R extends {}>(
|
||||||
] {
|
] {
|
||||||
let emit;
|
let emit;
|
||||||
|
|
||||||
const chan = eventChannel((emitter) => {
|
const chan = eventChannel(emitter => {
|
||||||
emit = emitter;
|
emit = emitter;
|
||||||
return () => null;
|
return () => null;
|
||||||
});
|
});
|
||||||
|
@ -30,14 +30,16 @@ export function createUploader<T extends {}, R extends {}>(
|
||||||
return [wrappedCallback, chan];
|
return [wrappedCallback, chan];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadGetThumb = async (file) => {
|
export const uploadGetThumb = async file => {
|
||||||
if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return '';
|
if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return '';
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
const thumb = await new Promise(resolve => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => resolve(reader.result || '');
|
reader.onloadend = () => resolve(reader.result || '');
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return thumb;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fakeUploader = ({
|
export const fakeUploader = ({
|
||||||
|
@ -49,7 +51,7 @@ export const fakeUploader = ({
|
||||||
onProgress: (current: number, total: number) => void;
|
onProgress: (current: number, total: number) => void;
|
||||||
mustSucceed: boolean;
|
mustSucceed: boolean;
|
||||||
}): Promise<IResultWithStatus<IFile>> => {
|
}): Promise<IResultWithStatus<IFile>> => {
|
||||||
const { url, error } = file;
|
const { error } = file;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -70,3 +72,10 @@ export const fakeUploader = ({
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFileType = (file: File): keyof typeof UPLOAD_TYPES => {
|
||||||
|
return (
|
||||||
|
(file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -18,6 +18,6 @@
|
||||||
"~/*": ["src/*"]
|
"~/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*", "./custom.d.ts"],
|
"include": ["./src/index.tsx", "./custom.d.ts"],
|
||||||
"exclude": ["./__tests__/**/*"]
|
"exclude": ["./__tests__/**/*"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue