1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

Merge pull request #50 from muerwre/master

synced master and develop
This commit is contained in:
muerwre 2021-03-11 13:55:58 +07:00 committed by GitHub
commit 7f62ebcc39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 311 additions and 70 deletions

View file

@ -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"

View file

@ -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<IProps> = ({ 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<IProps> = ({ stats }) => {
<img src="https://jenkins.vault48.org/api/badges/muerwre/vault-golang/status.svg" />
</div>
{stats.git
.filter(data => data.commit && data.timestamp && data.subject)
.slice(0, 5)
.map(data => (
<BorisStatsGitCard data={data} key={data.commit} />
))}
{open.map(data => (
<BorisStatsGitCard data={data} key={data.id} />
))}
{closed.map(data => (
<BorisStatsGitCard data={data} key={data.id} />
))}
</div>
);
};

View file

@ -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<IStatGitRow>;
data: IGithubIssue;
}
const BorisStatsGitCard: FC<IProps> = ({ data: { timestamp, subject } }) => {
if (!subject || !timestamp) return null;
const stateLabels: Record<IGithubIssue['state'], string> = {
open: 'Ожидает',
closed: 'Сделано',
};
const BorisStatsGitCard: FC<IProps> = ({ data: { created_at, title, html_url, state } }) => {
if (!title || !created_at) return null;
const date = useMemo(() => getPrettyDate(created_at), [created_at]);
return (
<div className={styles.wrap}>
<div className={styles.time}>
{getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())}
<span className={classNames(styles.icon, styles[state])}>{stateLabels[state]}</span>
{date}
</div>
<div className={styles.subject}>{subject}</div>
<a className={styles.subject} href={html_url} target="_blank">
{title}
</a>
</div>
);
};

View file

@ -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;
}
}

View file

@ -30,6 +30,8 @@ const CommentEmbedBlockUnconnected: FC<Props> = 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<Props> = memo(
return (
<div className={styles.embed}>
<a href={id[0]} target="_blank" />
<a href={url} target="_blank" />
<div className={styles.preview}>
<div style={{ backgroundImage: `url("${preview}")` }}>

View file

@ -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<IProps> = ({ comment, nodeId, onCancelEdit }) => {
}, [formik]);
const error = formik.status || formik.errors.text;
useInputPasteUpload(textarea, uploader.uploadFiles);
return (
<CommentFormDropzone onUpload={uploader.uploadFiles}>
@ -65,34 +66,40 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
<CommentFormAttaches />
<Group horizontal className={styles.buttons}>
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
<div className={styles.buttons}>
<div className={styles.buttons_attach}>
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
</div>
{!!textarea && (
<CommentFormFormatButtons
element={textarea}
handler={formik.handleChange('text')}
/>
)}
<div className={styles.buttons_format}>
{!!textarea && (
<CommentFormFormatButtons
element={textarea}
handler={formik.handleChange('text')}
/>
)}
</div>
{isLoading && <LoaderCircle size={20} />}
<div className={styles.buttons_submit}>
{isLoading && <LoaderCircle size={20} />}
{isEditing && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
{isEditing && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
</Button>
)}
<Button
type="submit"
size="small"
color="gray"
iconRight={!isEditing ? 'enter' : 'check'}
disabled={isLoading}
>
{!isEditing ? 'Сказать' : 'Сохранить'}
</Button>
)}
<Button
type="submit"
size="small"
color="gray"
iconRight={!isEditing ? 'enter' : 'check'}
disabled={isLoading}
>
{!isEditing ? 'Сказать' : 'Сохранить'}
</Button>
</Group>
</div>
</div>
</FileUploaderProvider>
</FormikProvider>
</form>

View file

@ -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 {

View file

@ -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%;
}

View file

@ -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<HTMLDivElement> {}
(window as any).StickySidebar = StickySidebar;
(window as any).ResizeSensor = ResizeSensor;
const Sticky: FC<IProps> = ({ children }) => {
const ref = useRef(null);
const sb = useRef<StickySidebar>(null);

View file

@ -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<IProps> = ({
</Group>
<div className={styles.panel}>
<Sticky>
<StickyBox className={styles.sticky} offsetTop={72}>
<Group style={{ flex: 1, minWidth: 0 }}>
<NodeTagsBlock node={node} isLoading={isLoading} />
<NodeRelatedBlock isLoading={isLoading} node={node} related={related} />
</Group>
</Sticky>
</StickyBox>
</div>
</Group>
</Padder>

View file

@ -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;
}

View file

@ -43,6 +43,7 @@ const NodeImageSwiperBlock: FC<IProps> = ({ 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<IProps> = ({ node }) => {
observeParents
resizeObserver
watchOverflow
updateOnImagesReady
onInit={resetSwiper}
zoom
>

View file

@ -9,4 +9,8 @@
@include tablet {
padding: 0;
}
@media (max-width: $content_width + $gap * 4) {
padding: 0;
}
}

View file

@ -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<IProps> = () => {
</Card>
<Group className={styles.stats}>
<Sticky>
<StickyBox className={styles.sticky} offsetTop={72} offsetBottom={10}>
<Group className={styles.stats__container}>
<div className={styles.stats__about}>
<h4>Господи-боженьки, где это я?</h4>
@ -106,7 +106,7 @@ const BorisLayout: FC<IProps> = () => {
<BorisStats stats={stats} />
</div>
</Group>
</Sticky>
</StickyBox>
</Group>
</div>
</div>

View file

@ -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<IBorisState['stats']['git']>(git);
export const getBorisBackendStats = () =>
api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult);
export const getGithubIssues = () => {
return axios
.get<IGetGithubIssuesResult>('https://api.github.com/repos/muerwre/vault-frontend/issues', {
params: { state: 'all', sort: 'created' },
})
.then(result => result.data)
.catch(() => []);
};

View file

@ -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<IStatGitRow>[];
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,
},

View file

@ -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<typeof getBorisGitStats> = yield call(getBorisGitStats);
const backend: Unwrap<typeof getBorisBackendStats> = yield call(getBorisBackendStats);
const issues: Unwrap<typeof getGithubIssues> = yield call(getGithubIssues);
yield put(borisSetStats({ git, backend }));
yield put(borisSetStats({ issues, backend }));
} catch (e) {
yield put(borisSetStats({ git: [], backend: undefined }));
} finally {

12
src/redux/boris/types.ts Normal file
View file

@ -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[];

View file

@ -75,7 +75,9 @@ export const sagaMiddleware = createSagaMiddleware();
export const history = createBrowserHistory();
const composeEnhancers =
typeof window === 'object' && (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
typeof window === 'object' &&
(<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ &&
process.env.NODE_ENV === 'development'
? (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
: compose;

View file

@ -11,7 +11,7 @@ import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api';
import { Unwrap } from '~/redux/types';
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
yield put(tagSetNodes({ isLoading: true, list: [] }));
yield put(tagSetNodes({ isLoading: true }));
try {
const { list }: ReturnType<typeof selectTagNodes> = yield select(selectTagNodes);

View file

@ -55,6 +55,10 @@ $margin: 1em;
p {
margin-bottom: $margin;
&:last-child {
margin-bottom: 0;
}
}
h5, h4, h3, h2, h1 {

View file

@ -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]);
};

View file

@ -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<File | undefined> => {
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);
});
};

View file

@ -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"