diff --git a/src/components/node/NodePanel/index.tsx b/src/components/node/NodePanel/index.tsx index ff862110..ba745934 100644 --- a/src/components/node/NodePanel/index.tsx +++ b/src/components/node/NodePanel/index.tsx @@ -3,67 +3,77 @@ import * as styles from './styles.scss'; import { INode } from '~/redux/types'; import { createPortal } from 'react-dom'; import { NodePanelInner } from '~/components/node/NodePanelInner'; +import pick from 'ramda/es/pick'; interface IProps { - node: INode; + node: Partial; layout: {}; can_edit: boolean; can_like: boolean; + can_star: boolean; + onEdit: () => void; onLike: () => void; + onStar: () => void; } -const NodePanel: FC = memo(({ node, layout, can_edit, can_like, onEdit, onLike }) => { - const [stack, setStack] = useState(false); +const NodePanel: FC = memo( + ({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => { + const [stack, setStack] = useState(false); - const ref = useRef(null); - const getPlace = useCallback(() => { - if (!ref.current) return; + const ref = useRef(null); + const getPlace = useCallback(() => { + if (!ref.current) return; - const { offsetTop } = ref.current; - const { height } = ref.current.getBoundingClientRect(); - const { scrollY, innerHeight } = window; + const { offsetTop } = ref.current; + const { height } = ref.current.getBoundingClientRect(); + const { scrollY, innerHeight } = window; - setStack(offsetTop > scrollY + innerHeight - height); - }, [ref]); + setStack(offsetTop > scrollY + innerHeight - height); + }, [ref]); - useEffect(() => { - getPlace(); - window.addEventListener('scroll', getPlace); - window.addEventListener('resize', getPlace); + useEffect(() => { + getPlace(); + window.addEventListener('scroll', getPlace); + window.addEventListener('resize', getPlace); - return () => { - window.removeEventListener('scroll', getPlace); - window.removeEventListener('resize', getPlace); - }; - }, [layout]); + return () => { + window.removeEventListener('scroll', getPlace); + window.removeEventListener('resize', getPlace); + }; + }, [layout]); - return ( -
- {stack ? ( - createPortal( + return ( +
+ {stack ? ( + createPortal( + , + document.body + ) + ) : ( , - document.body - ) - ) : ( - - )} -
- ); -}); + can_star={can_star} + /> + )} +
+ ); + } +); export { NodePanel }; diff --git a/src/components/node/NodePanelInner/index.tsx b/src/components/node/NodePanelInner/index.tsx index 92719751..caaf6275 100644 --- a/src/components/node/NodePanelInner/index.tsx +++ b/src/components/node/NodePanelInner/index.tsx @@ -7,20 +7,24 @@ import { INode } from '~/redux/types'; import classNames from 'classnames'; interface IProps { - node: INode; + node: Partial; stack?: boolean; can_edit: boolean; can_like: boolean; + can_star: boolean; onEdit: () => void; onLike: () => void; + onStar: () => void; } const NodePanelInner: FC = ({ - node: { title, user, is_liked }, + node: { title, user, is_liked, is_heroic }, stack, + can_star, can_edit, can_like, + onStar, onEdit, onLike, }) => { @@ -35,6 +39,15 @@ const NodePanelInner: FC = ({
+ {can_star && ( +
+ {is_heroic ? ( + + ) : ( + + )} +
+ )} {can_edit && (
diff --git a/src/components/node/NodePanelInner/styles.scss b/src/components/node/NodePanelInner/styles.scss index 58358e10..3e1790b3 100644 --- a/src/components/node/NodePanelInner/styles.scss +++ b/src/components/node/NodePanelInner/styles.scss @@ -166,3 +166,18 @@ animation: pulse 0.75s infinite; } } + +.star { + transition: fill, stroke 0.25s; + will-change: transform; + + &:global(.is_heroic) { + svg { + fill: $orange; + } + } + + &:hover { + fill: $orange; + } +} diff --git a/src/constants/api.ts b/src/constants/api.ts index 9fcc0800..a83ce381 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -15,5 +15,6 @@ export const API = { COMMENT: (id: INode['id']) => `/node/${id}/comment`, UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`, POST_LIKE: (id: INode['id']) => `/node/${id}/like`, + POST_STAR: (id: INode['id']) => `/node/${id}/heroic`, }, }; diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index 7bae3e15..f45a6dc9 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -1,7 +1,7 @@ import React, { FC, createElement, useEffect, useCallback, useState, useMemo, memo } from 'react'; import { RouteComponentProps } from 'react-router'; import { connect } from 'react-redux'; -import { canEditNode, canLikeNode } from '~/utils/node'; +import { canEditNode, canLikeNode, canStarNode } from '~/utils/node'; import { selectNode } from '~/redux/node/selectors'; import { Card } from '~/components/containers/Card'; @@ -17,6 +17,7 @@ import { NODE_COMPONENTS, NODE_INLINES } from '~/redux/node/constants'; import * as NODE_ACTIONS from '~/redux/node/actions'; import { CommentForm } from '~/components/node/CommentForm'; import { selectUser } from '~/redux/auth/selectors'; +import pick from 'ramda/es/pick'; const mapStateToProps = state => ({ node: selectNode(state), @@ -29,6 +30,7 @@ const mapDispatchToProps = { nodeSetCoverImage: NODE_ACTIONS.nodeSetCoverImage, nodeEdit: NODE_ACTIONS.nodeEdit, nodeLike: NODE_ACTIONS.nodeLike, + nodeStar: NODE_ACTIONS.nodeStar, }; type IProps = ReturnType & @@ -40,13 +42,14 @@ const NodeLayoutUnconnected: FC = memo( match: { params: { id }, }, - node: { is_loading, is_loading_comments, comments = [], current: node, current_cover_image }, + node: { is_loading, is_loading_comments, comments = [], current: node }, user, user: { is_user }, nodeLoadNode, nodeUpdateTags, nodeEdit, nodeLike, + nodeStar, nodeSetCoverImage, }) => { const [layout, setLayout] = useState({}); @@ -67,12 +70,14 @@ const NodeLayoutUnconnected: FC = memo( const can_edit = useMemo(() => canEditNode(node, user), [node, user]); const can_like = useMemo(() => canLikeNode(node, user), [node, user]); + const can_star = useMemo(() => canStarNode(node, user), [node, user]); const block = node && node.type && NODE_COMPONENTS[node.type]; const inline_block = node && node.type && NODE_INLINES[node.type]; const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]); const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]); + const onStar = useCallback(() => nodeStar(node.id), [nodeStar, node]); useEffect(() => { if (!node.cover) return; @@ -85,12 +90,14 @@ const NodeLayoutUnconnected: FC = memo( {block && createElement(block, { node, is_loading, updateLayout, layout })} diff --git a/src/redux/node/actions.ts b/src/redux/node/actions.ts index 56dd72a3..f15cfa68 100644 --- a/src/redux/node/actions.ts +++ b/src/redux/node/actions.ts @@ -80,6 +80,11 @@ export const nodeLike = (id: INode['id']) => ({ id, }); +export const nodeStar = (id: INode['id']) => ({ + type: NODE_ACTIONS.STAR, + id, +}); + export const nodeSetEditor = (editor: INode) => ({ type: NODE_ACTIONS.SET_EDITOR, editor, diff --git a/src/redux/node/api.ts b/src/redux/node/api.ts index 1ca5da81..29e586b2 100644 --- a/src/redux/node/api.ts +++ b/src/redux/node/api.ts @@ -1,7 +1,7 @@ import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api'; import { INode, IResultWithStatus, IComment } from '../types'; import { API } from '~/constants/api'; -import { nodeUpdateTags } from './actions'; +import { nodeUpdateTags, nodeLike, nodeStar } from './actions'; export const postNode = ({ access, @@ -79,10 +79,21 @@ export const updateNodeTags = ({ export const postNodeLike = ({ id, access, -}: ReturnType & { access: string }): Promise< +}: ReturnType & { access: string }): Promise< IResultWithStatus<{ is_liked: INode['is_liked'] }> > => api .post(API.NODE.POST_LIKE(id), {}, configWithToken(access)) .then(resultMiddleware) .catch(errorMiddleware); + +export const postNodeStar = ({ + id, + access, +}: ReturnType & { access: string }): Promise< + IResultWithStatus<{ is_liked: INode['is_liked'] }> +> => + api + .post(API.NODE.POST_STAR(id), {}, configWithToken(access)) + .then(resultMiddleware) + .catch(errorMiddleware); diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index d964e2fc..457150a4 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -21,6 +21,7 @@ export const NODE_ACTIONS = { EDIT: `${prefix}EDIT`, LIKE: `${prefix}LIKE`, + STAR: `${prefix}STAR`, CREATE: `${prefix}CREATE`, SET_SAVE_ERRORS: `${prefix}SET_SAVE_ERRORS`, diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 854ad486..70340422 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -27,6 +27,7 @@ import { getNodeComments, updateNodeTags, postNodeLike, + postNodeStar, } from './api'; import { reqWrapper } from '../auth/sagas'; import { flowSetNodes } from '../flow/actions'; @@ -197,6 +198,21 @@ function* onLikeSaga({ id }: ReturnType) { yield call(updateNodeEverythere, { ...current, is_liked }); } +function* onStarSaga({ id }: ReturnType) { + const { + current, + current: { is_heroic }, + } = yield select(selectNode); + + yield call(updateNodeEverythere, { ...current, is_heroic: !is_heroic }); + + const { data, error } = yield call(reqWrapper, postNodeStar, { id }); + + if (!error || data.is_heroic === !is_heroic) return; // ok and matches + + yield call(updateNodeEverythere, { ...current, is_heroic }); +} + export default function* nodeSaga() { yield takeLatest(NODE_ACTIONS.SAVE, onNodeSave); yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad); @@ -205,4 +221,5 @@ export default function* nodeSaga() { yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga); yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga); yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga); + yield takeLatest(NODE_ACTIONS.STAR, onStarSaga); } diff --git a/src/sprites/Sprites.tsx b/src/sprites/Sprites.tsx index 391f9a54..8a407350 100644 --- a/src/sprites/Sprites.tsx +++ b/src/sprites/Sprites.tsx @@ -44,6 +44,16 @@ const Sprites: FC<{}> = () => ( + + + + + + + + + + diff --git a/src/utils/node.ts b/src/utils/node.ts index 2b81e9e8..f73a9559 100644 --- a/src/utils/node.ts +++ b/src/utils/node.ts @@ -9,3 +9,6 @@ export const canEditNode = (node: Partial, user: Partial): boolean export const canLikeNode = (node: Partial, user: Partial): boolean => path(['role'], user) && path(['role'], user) !== USER_ROLES.GUEST; + +export const canStarNode = (node: Partial, user: Partial): boolean => + path(['role'], user) && path(['role'], user) === USER_ROLES.ADMIN;