diff --git a/package.json b/package.json index ddd058ae..5ca469e5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-router-dom": "^5.1.2", "react-scripts": "3.4.4", "react-sortable-hoc": "^1.11", + "react-sticky-box": "^0.9.3", "redux": "^4.0.1", "redux-persist": "^5.10.0", "redux-saga": "^1.1.1", @@ -71,8 +72,8 @@ "@types/node": "^11.13.22", "@types/ramda": "^0.26.33", "@types/react-redux": "^7.1.11", - "@types/yup": "^0.29.11", "@types/swiper": "^5.4.2", + "@types/yup": "^0.29.11", "craco-alias": "^2.1.1", "craco-fast-refresh": "^1.0.2", "prettier": "^1.18.2" diff --git a/src/components/boris/BorisStatsGit/index.tsx b/src/components/boris/BorisStatsGit/index.tsx index d81ac721..030699a3 100644 --- a/src/components/boris/BorisStatsGit/index.tsx +++ b/src/components/boris/BorisStatsGit/index.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { IBorisState } from '~/redux/boris/reducer'; import styles from './styles.module.scss'; import { Placeholder } from '~/components/placeholders/Placeholder'; @@ -9,7 +9,17 @@ interface IProps { } const BorisStatsGit: FC = ({ stats }) => { - if (!stats.git.length) return null; + if (!stats.issues.length) return null; + + const open = useMemo( + () => stats.issues.filter(el => !el.pull_request && el.state === 'open').slice(0, 5), + [stats.issues] + ); + + const closed = useMemo( + () => stats.issues.filter(el => !el.pull_request && el.state === 'closed').slice(0, 5), + [stats.issues] + ); if (stats.is_loading) { return ( @@ -35,12 +45,13 @@ const BorisStatsGit: FC = ({ stats }) => { - {stats.git - .filter(data => data.commit && data.timestamp && data.subject) - .slice(0, 5) - .map(data => ( - - ))} + {open.map(data => ( + + ))} + + {closed.map(data => ( + + ))} ); }; diff --git a/src/components/boris/BorisStatsGitCard/index.tsx b/src/components/boris/BorisStatsGitCard/index.tsx index f393b710..20f4d469 100644 --- a/src/components/boris/BorisStatsGitCard/index.tsx +++ b/src/components/boris/BorisStatsGitCard/index.tsx @@ -1,22 +1,33 @@ -import React, { FC } from 'react'; -import { IStatGitRow } from '~/redux/boris/reducer'; +import React, { FC, useMemo } from 'react'; import styles from './styles.module.scss'; import { getPrettyDate } from '~/utils/dom'; +import { IGithubIssue } from '~/redux/boris/types'; +import classNames from 'classnames'; interface IProps { - data: Partial; + data: IGithubIssue; } -const BorisStatsGitCard: FC = ({ data: { timestamp, subject } }) => { - if (!subject || !timestamp) return null; +const stateLabels: Record = { + open: 'Ожидает', + closed: 'Сделано', +}; + +const BorisStatsGitCard: FC = ({ data: { created_at, title, html_url, state } }) => { + if (!title || !created_at) return null; + + const date = useMemo(() => getPrettyDate(created_at), [created_at]); return (
- {getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())} + {stateLabels[state]} + {date}
-
{subject}
+ + {title} +
); }; diff --git a/src/components/boris/BorisStatsGitCard/styles.module.scss b/src/components/boris/BorisStatsGitCard/styles.module.scss index 37bd0b23..eaad031a 100644 --- a/src/components/boris/BorisStatsGitCard/styles.module.scss +++ b/src/components/boris/BorisStatsGitCard/styles.module.scss @@ -12,10 +12,28 @@ .time { font: $font_12_regular; line-height: 17px; - opacity: 0.3; + color: transparentize(white, 0.7) } .subject { font: $font_14_regular; word-break: break-word; + text-decoration: none; + color: inherit; +} + +.icon { + font: $font_10_semibold; + margin-right: 5px; + border-radius: 2px; + padding: 2px 0; + text-transform: uppercase; + + &.open { + color: $red; + } + + &.closed { + color: $green; + } } diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index 77a374d2..e07b7ecf 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -30,6 +30,8 @@ const CommentEmbedBlockUnconnected: FC = memo( return (match && match[1]) || ''; }, [block.content]); + const url = useMemo(() => `https://youtube.com/watch?v=${id}`, [id]); + const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); useEffect(() => { @@ -47,7 +49,7 @@ const CommentEmbedBlockUnconnected: FC = memo( return (
- +
diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index b644509d..d1a9337e 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -14,7 +14,7 @@ import { EMPTY_COMMENT } from '~/redux/node/constants'; import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone'; import styles from './styles.module.scss'; import { ERROR_LITERAL } from '~/constants/errors'; -import { Group } from '~/components/containers/Group'; +import { useInputPasteUpload } from '~/utils/hooks/useInputPasteUpload'; interface IProps { comment?: IComment; @@ -47,6 +47,7 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { }, [formik]); const error = formik.status || formik.errors.text; + useInputPasteUpload(textarea, uploader.uploadFiles); return ( @@ -65,34 +66,40 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { - - +
+
+ +
- {!!textarea && ( - - )} +
+ {!!textarea && ( + + )} +
- {isLoading && } +
+ {isLoading && } - {isEditing && ( - + )} + + - )} - - - +
+
diff --git a/src/components/comment/CommentForm/styles.module.scss b/src/components/comment/CommentForm/styles.module.scss index 12798bf2..fb629c05 100644 --- a/src/components/comment/CommentForm/styles.module.scss +++ b/src/components/comment/CommentForm/styles.module.scss @@ -21,13 +21,42 @@ position: relative; z-index: 1; - display: flex; - flex-direction: row; + display: grid; background: transparentize(black, 0.8); padding: $gap / 2; border-radius: 0 0 $radius $radius; flex-wrap: wrap; + column-gap: $gap; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr; + grid-template-areas: "attach format submit"; + @media(max-width: 470px) { + padding: $gap; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr 1fr; + grid-template-areas: + "attach format" + "submit submit"; + row-gap: $gap; + } + + &_attach { + grid-area: attach; + } + + &_format { + grid-area: format; + } + + &_submit { + grid-area: submit; + display: grid; + grid-auto-flow: column; + align-items: flex-end; + justify-content: flex-end; + column-gap: $gap / 2; + } } .uploads { diff --git a/src/components/comment/CommentFormFormatButtons/styles.module.scss b/src/components/comment/CommentFormFormatButtons/styles.module.scss index e63a68c1..d07205e9 100644 --- a/src/components/comment/CommentFormFormatButtons/styles.module.scss +++ b/src/components/comment/CommentFormFormatButtons/styles.module.scss @@ -2,11 +2,8 @@ .wrap { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; height: 32px; flex: 1; - - @media(max-width: 480px) { - display: none; - } + width: 100%; } diff --git a/src/components/containers/Sticky/index.tsx b/src/components/containers/Sticky/index.tsx index f3e817d5..dfeda268 100644 --- a/src/components/containers/Sticky/index.tsx +++ b/src/components/containers/Sticky/index.tsx @@ -1,14 +1,16 @@ import React, { DetailsHTMLAttributes, FC, useEffect, useRef } from 'react'; import styles from './styles.module.scss'; -import StickySidebar from 'sticky-sidebar'; -import classnames from 'classnames'; + import ResizeSensor from 'resize-sensor'; +(window as any).ResizeSensor = ResizeSensor; + +import StickySidebar from 'sticky-sidebar'; +(window as any).StickySidebar = StickySidebar; + +import classnames from 'classnames'; interface IProps extends DetailsHTMLAttributes {} -(window as any).StickySidebar = StickySidebar; -(window as any).ResizeSensor = ResizeSensor; - const Sticky: FC = ({ children }) => { const ref = useRef(null); const sb = useRef(null); diff --git a/src/components/node/NodeBottomBlock/index.tsx b/src/components/node/NodeBottomBlock/index.tsx index 73a114e6..d06da73c 100644 --- a/src/components/node/NodeBottomBlock/index.tsx +++ b/src/components/node/NodeBottomBlock/index.tsx @@ -2,16 +2,16 @@ import React, { FC } from 'react'; import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { Group } from '~/components/containers/Group'; import { Padder } from '~/components/containers/Padder'; -import styles from '~/containers/node/NodeLayout/styles.module.scss'; import { NodeCommentsBlock } from '~/components/node/NodeCommentsBlock'; import { NodeCommentForm } from '~/components/node/NodeCommentForm'; -import { Sticky } from '~/components/containers/Sticky'; import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock'; import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks'; import { IComment, INode } from '~/redux/types'; import { useUser } from '~/utils/hooks/user/userUser'; import { NodeTagsBlock } from '~/components/node/NodeTagsBlock'; import { INodeRelated } from '~/redux/node/types'; +import StickyBox from 'react-sticky-box/dist/esnext'; +import styles from './styles.module.scss'; interface IProps { node: INode; @@ -59,12 +59,12 @@ const NodeBottomBlock: FC = ({
- + - +
diff --git a/src/components/node/NodeBottomBlock/styles.module.scss b/src/components/node/NodeBottomBlock/styles.module.scss new file mode 100644 index 00000000..a34d0d9b --- /dev/null +++ b/src/components/node/NodeBottomBlock/styles.module.scss @@ -0,0 +1,48 @@ +@import "~/styles/variables.scss"; + +.sticky { + width: 100%; +} + +.content { + align-items: stretch !important; + @include vertical_at_tablet; +} + +.comments { + flex: 3 1; + min-width: 0; + display: flex; + align-items: stretch; + justify-content: flex-start; + flex-direction: column; + + @media (max-width: 1024px) { + flex: 2 1; + } +} + + +.panel { + flex: 1 3; + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding-left: $gap / 2; + min-width: 0; + position: relative; + z-index: 10; + + @media (max-width: 1024px) { + padding-left: 0; + padding-top: $comment_height / 2; + flex: 1 2; + } +} + +.buttons { + background: $node_buttons_bg; + flex: 1; + border-radius: $panel_radius; + box-shadow: $comment_shadow; +} diff --git a/src/components/node/NodeImageSwiperBlock/index.tsx b/src/components/node/NodeImageSwiperBlock/index.tsx index c6011813..75ff0291 100644 --- a/src/components/node/NodeImageSwiperBlock/index.tsx +++ b/src/components/node/NodeImageSwiperBlock/index.tsx @@ -43,6 +43,7 @@ const NodeImageSwiperBlock: FC = ({ node }) => { const resetSwiper = useCallback(() => { if (!controlledSwiper) return; controlledSwiper.slideTo(0, 0); + setTimeout(() => controlledSwiper.slideTo(0, 0), 300); }, [controlledSwiper]); useEffect(() => { @@ -74,6 +75,7 @@ const NodeImageSwiperBlock: FC = ({ node }) => { observeParents resizeObserver watchOverflow + updateOnImagesReady onInit={resetSwiper} zoom > diff --git a/src/containers/main/Container/styles.module.scss b/src/containers/main/Container/styles.module.scss index cbf85b78..736fa115 100644 --- a/src/containers/main/Container/styles.module.scss +++ b/src/containers/main/Container/styles.module.scss @@ -9,4 +9,8 @@ @include tablet { padding: 0; } + + @media (max-width: $content_width + $gap * 4) { + padding: 0; + } } diff --git a/src/containers/node/BorisLayout/index.tsx b/src/containers/node/BorisLayout/index.tsx index 57127226..d2593ad4 100644 --- a/src/containers/node/BorisLayout/index.tsx +++ b/src/containers/node/BorisLayout/index.tsx @@ -12,7 +12,6 @@ import { NodeCommentForm } from '~/components/node/NodeCommentForm'; import isBefore from 'date-fns/isBefore'; import { Card } from '~/components/containers/Card'; import { Footer } from '~/components/main/Footer'; -import { Sticky } from '~/components/containers/Sticky'; import { BorisStats } from '~/components/boris/BorisStats'; import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; import { selectBorisStats } from '~/redux/boris/selectors'; @@ -20,6 +19,7 @@ import { authSetUser } from '~/redux/auth/actions'; import { nodeLoadNode } from '~/redux/node/actions'; import { borisLoadStats } from '~/redux/boris/actions'; import { Container } from '~/containers/main/Container'; +import StickyBox from 'react-sticky-box/dist/esnext'; type IProps = {}; @@ -89,7 +89,7 @@ const BorisLayout: FC = () => { - +

Господи-боженьки, где это я?

@@ -106,7 +106,7 @@ const BorisLayout: FC = () => {
-
+
diff --git a/src/redux/boris/api.ts b/src/redux/boris/api.ts index c1bd5a72..d8cc8867 100644 --- a/src/redux/boris/api.ts +++ b/src/redux/boris/api.ts @@ -1,10 +1,20 @@ import git from '~/stats/git.json'; import { API } from '~/constants/api'; -import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { IBorisState, IStatBackend } from './reducer'; -import { IResultWithStatus } from '../types'; +import axios from 'axios'; +import { IGetGithubIssuesResult } from '~/redux/boris/types'; export const getBorisGitStats = () => Promise.resolve(git); export const getBorisBackendStats = () => api.get(API.BORIS.GET_BACKEND_STATS).then(cleanResult); + +export const getGithubIssues = () => { + return axios + .get('https://api.github.com/repos/muerwre/vault-frontend/issues', { + params: { state: 'all', sort: 'created' }, + }) + .then(result => result.data) + .catch(() => []); +}; diff --git a/src/redux/boris/reducer.ts b/src/redux/boris/reducer.ts index 2032c793..5e182674 100644 --- a/src/redux/boris/reducer.ts +++ b/src/redux/boris/reducer.ts @@ -1,5 +1,6 @@ import { createReducer } from '~/utils/reducer'; import { BORIS_HANDLERS } from './handlers'; +import { IGithubIssue } from '~/redux/boris/types'; export type IStatGitRow = { commit: string; @@ -31,6 +32,7 @@ export type IStatBackend = { export type IBorisState = Readonly<{ stats: { git: Partial[]; + issues: IGithubIssue[]; backend?: IStatBackend; is_loading: boolean; }; @@ -39,6 +41,7 @@ export type IBorisState = Readonly<{ const BORIS_INITIAL_STATE: IBorisState = { stats: { git: [], + issues: [], backend: undefined, is_loading: false, }, diff --git a/src/redux/boris/sagas.ts b/src/redux/boris/sagas.ts index a0b1d003..b17e2c16 100644 --- a/src/redux/boris/sagas.ts +++ b/src/redux/boris/sagas.ts @@ -1,17 +1,17 @@ -import { takeLatest, put, call } from 'redux-saga/effects'; +import { call, put, takeLatest } from 'redux-saga/effects'; import { BORIS_ACTIONS } from './constants'; import { borisSetStats } from './actions'; -import { getBorisGitStats, getBorisBackendStats } from './api'; +import { getBorisBackendStats, getGithubIssues } from './api'; import { Unwrap } from '../types'; function* loadStats() { try { yield put(borisSetStats({ is_loading: true })); - const git: Unwrap = yield call(getBorisGitStats); const backend: Unwrap = yield call(getBorisBackendStats); + const issues: Unwrap = yield call(getGithubIssues); - yield put(borisSetStats({ git, backend })); + yield put(borisSetStats({ issues, backend })); } catch (e) { yield put(borisSetStats({ git: [], backend: undefined })); } finally { diff --git a/src/redux/boris/types.ts b/src/redux/boris/types.ts new file mode 100644 index 00000000..73552b25 --- /dev/null +++ b/src/redux/boris/types.ts @@ -0,0 +1,12 @@ +export interface IGithubIssue { + id: string; + url: string; + html_url: string; + body: string; + title: string; + state: 'open' | 'closed'; + created_at: string; + pull_request?: unknown; +} + +export type IGetGithubIssuesResult = IGithubIssue[]; diff --git a/src/redux/store.ts b/src/redux/store.ts index eb9c60ff..ea16f709 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -75,7 +75,9 @@ export const sagaMiddleware = createSagaMiddleware(); export const history = createBrowserHistory(); const composeEnhancers = - typeof window === 'object' && (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + typeof window === 'object' && + (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && + process.env.NODE_ENV === 'development' ? (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose; diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index eb1c3f16..90c5cf1f 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -11,7 +11,7 @@ import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api'; import { Unwrap } from '~/redux/types'; function* loadTagNodes({ tag }: ReturnType) { - yield put(tagSetNodes({ isLoading: true, list: [] })); + yield put(tagSetNodes({ isLoading: true })); try { const { list }: ReturnType = yield select(selectTagNodes); diff --git a/src/styles/common/markdown.module.scss b/src/styles/common/markdown.module.scss index 9cfcb7e1..82cb22a5 100644 --- a/src/styles/common/markdown.module.scss +++ b/src/styles/common/markdown.module.scss @@ -55,6 +55,10 @@ $margin: 1em; p { margin-bottom: $margin; + + &:last-child { + margin-bottom: 0; + } } h5, h4, h3, h2, h1 { diff --git a/src/utils/hooks/useInputPasteUpload.ts b/src/utils/hooks/useInputPasteUpload.ts new file mode 100644 index 00000000..dff574a1 --- /dev/null +++ b/src/utils/hooks/useInputPasteUpload.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect } from 'react'; +import { getImageFromPaste } from '~/utils/uploader'; + +// useInputPasteUpload attaches event listener to input, that calls onUpload if user pasted any image +export const useInputPasteUpload = ( + input: HTMLTextAreaElement | HTMLInputElement | undefined, + onUpload: (files: File[]) => void +) => { + const onPaste = useCallback(async event => { + const image = await getImageFromPaste(event); + + if (!image) return; + + onUpload([image]); + }, []); + + useEffect(() => { + if (!input) return; + + input.addEventListener('paste', onPaste); + + return () => input.removeEventListener('paste', onPaste); + }, [input, onPaste]); +}; diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index c1ad5941..3ade4cf8 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -74,3 +74,37 @@ export const fakeUploader = ({ export const getFileType = (file: File): keyof typeof UPLOAD_TYPES | undefined => (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || undefined; + +// getImageFromPaste returns any images from paste event +export const getImageFromPaste = (event: ClipboardEvent): Promise => { + const items = event.clipboardData?.items; + + return new Promise(resolve => { + for (let index in items) { + const item = items[index]; + + if (item.kind === 'file' && item.type.match(/^image\//)) { + const blob = item.getAsFile(); + const reader = new FileReader(); + const type = item.type; + + reader.onload = function(e) { + if (!e.target?.result) { + return; + } + + resolve( + new File([e.target?.result], 'paste.png', { + type, + lastModified: new Date().getTime(), + }) + ); + }; + + reader.readAsArrayBuffer(blob); + } + } + + // resolve(undefined); + }); +}; diff --git a/yarn.lock b/yarn.lock index f0c085db..fe6bc4cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1109,6 +1109,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.1.5": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.10.5": version "7.13.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.7.tgz#d494e39d198ee9ca04f4dcb76d25d9d7a1dc961a" @@ -9454,6 +9461,14 @@ react-sortable-hoc@^1.11: invariant "^2.2.4" prop-types "^15.5.7" +react-sticky-box@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-0.9.3.tgz#8450d4cef8e4fdd7b0351520365bc98c97da11af" + integrity sha512-Y/qO7vTqAvXuRR6G6ZCW4fX2Bz0GZRwiiLTVeZN5CVz9wzs37ev0Xj3KSKF/PzF0jifwATivI4t24qXG8rSz4Q== + dependencies: + "@babel/runtime" "^7.1.5" + resize-observer-polyfill "^1.5.1" + react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" @@ -9780,6 +9795,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resize-sensor@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/resize-sensor/-/resize-sensor-0.0.6.tgz#75147dcb273de6832760e461d2e28de6dcf88c45"