mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
Merge branch 'master' into 23-labs
# Conflicts: # src/redux/node/constants.ts
This commit is contained in:
commit
8316b46efe
56 changed files with 1085 additions and 527 deletions
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}")` }}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
@include tablet {
|
||||
:global(.comment-author) {
|
||||
display: none !important;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,32 +1,34 @@
|
|||
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);
|
||||
let sb;
|
||||
const sb = useRef<StickySidebar>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
sb = new StickySidebar(ref.current, {
|
||||
sb.current = new StickySidebar(ref.current, {
|
||||
resizeSensor: true,
|
||||
topSpacing: 72,
|
||||
bottomSpacing: 10,
|
||||
});
|
||||
|
||||
return () => sb.destroy();
|
||||
}, [ref.current, children]);
|
||||
return () => sb.current?.destroy();
|
||||
}, [ref.current, sb.current, children]);
|
||||
|
||||
if (sb) {
|
||||
sb.updateSticky();
|
||||
sb.current?.updateSticky();
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,6 +8,8 @@ import { selectUploads } from '~/redux/uploads/selectors';
|
|||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||
import styles from './styles.module.scss';
|
||||
import { NodeEditorProps } from '~/redux/node/types';
|
||||
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
|
||||
import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios';
|
||||
|
||||
const mapStateToProps = selectUploads;
|
||||
const mapDispatchToProps = {
|
||||
|
@ -17,10 +19,7 @@ const mapDispatchToProps = {
|
|||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & NodeEditorProps;
|
||||
|
||||
const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
||||
const images = useMemo(
|
||||
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
||||
[data.files]
|
||||
);
|
||||
const images = useNodeImages(data);
|
||||
|
||||
const pending_images = useMemo(
|
||||
() =>
|
||||
|
@ -30,10 +29,7 @@ const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) =
|
|||
[temp, statuses]
|
||||
);
|
||||
|
||||
const audios = useMemo(
|
||||
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
||||
[data.files]
|
||||
);
|
||||
const audios = useNodeAudios(data);
|
||||
|
||||
const pending_audios = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -11,7 +11,6 @@ const FlowRecent: FC<IProps> = ({ recent, updated }) => {
|
|||
return (
|
||||
<>
|
||||
{updated && updated.map(node => <FlowRecentItem node={node} key={node.id} has_new />)}
|
||||
|
||||
{recent && recent.map(node => <FlowRecentItem node={node} key={node.id} />)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
@include outer_shadow();
|
||||
|
||||
input {
|
||||
color: red;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
|
75
src/components/node/NodeBottomBlock/index.tsx
Normal file
75
src/components/node/NodeBottomBlock/index.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React, { FC } from 'react';
|
||||
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Padder } from '~/components/containers/Padder';
|
||||
import { NodeCommentsBlock } from '~/components/node/NodeCommentsBlock';
|
||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||
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;
|
||||
isLoading: boolean;
|
||||
commentsOrder: 'ASC' | 'DESC';
|
||||
comments: IComment[];
|
||||
commentsCount: number;
|
||||
isLoadingComments: boolean;
|
||||
related: INodeRelated;
|
||||
}
|
||||
|
||||
const NodeBottomBlock: FC<IProps> = ({
|
||||
node,
|
||||
isLoading,
|
||||
isLoadingComments,
|
||||
comments,
|
||||
commentsCount,
|
||||
commentsOrder,
|
||||
related,
|
||||
}) => {
|
||||
const { inline } = useNodeBlocks(node, isLoading);
|
||||
const { is_user } = useUser();
|
||||
|
||||
if (node.deleted_at) {
|
||||
return <NodeDeletedBadge />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Padder>
|
||||
<Group horizontal className={styles.content}>
|
||||
<Group className={styles.comments}>
|
||||
{inline && <div className={styles.inline}>{inline}</div>}
|
||||
|
||||
<NodeCommentsBlock
|
||||
isLoading={isLoading}
|
||||
isLoadingComments={isLoadingComments}
|
||||
comments={comments}
|
||||
count={commentsCount}
|
||||
order={commentsOrder}
|
||||
node={node}
|
||||
/>
|
||||
|
||||
{is_user && !isLoading && <NodeCommentForm nodeId={node.id} />}
|
||||
</Group>
|
||||
|
||||
<div className={styles.panel}>
|
||||
<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>
|
||||
</StickyBox>
|
||||
</div>
|
||||
</Group>
|
||||
</Padder>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeBottomBlock };
|
48
src/components/node/NodeBottomBlock/styles.module.scss
Normal file
48
src/components/node/NodeBottomBlock/styles.module.scss
Normal 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;
|
||||
}
|
28
src/components/node/NodeCommentsBlock/index.tsx
Normal file
28
src/components/node/NodeCommentsBlock/index.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import React, { FC } from 'react';
|
||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||
import { NodeComments } from '~/components/node/NodeComments';
|
||||
import { IComment, INode } from '~/redux/types';
|
||||
import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks';
|
||||
import { useUser } from '~/utils/hooks/user/userUser';
|
||||
|
||||
interface IProps {
|
||||
order: 'ASC' | 'DESC';
|
||||
node: INode;
|
||||
comments: IComment[];
|
||||
count: number;
|
||||
isLoading: boolean;
|
||||
isLoadingComments: boolean;
|
||||
}
|
||||
|
||||
const NodeCommentsBlock: FC<IProps> = ({ isLoading, isLoadingComments, node, comments, count }) => {
|
||||
const user = useUser();
|
||||
const { inline } = useNodeBlocks(node, isLoading);
|
||||
|
||||
return isLoading || isLoadingComments || (!comments.length && !inline) ? (
|
||||
<NodeNoComments is_loading={isLoadingComments || isLoading} />
|
||||
) : (
|
||||
<NodeComments count={count} comments={comments} user={user} order="DESC" />
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeCommentsBlock };
|
|
@ -8,21 +8,24 @@ import { PRESETS } from '~/constants/urls';
|
|||
import { throttle } from 'throttle-debounce';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { useArrows } from '~/utils/hooks/keys';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { modalShowPhotoswipe } from '~/redux/modal/actions';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import { selectModal } from '~/redux/modal/selectors';
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
interface IProps extends INodeComponentProps {
|
||||
updateLayout?: () => void;
|
||||
}
|
||||
|
||||
const getX = event =>
|
||||
(event.touches && event.touches.length) || (event.changedTouches && event.changedTouches.length)
|
||||
? (event.touches.length && event.touches[0].clientX) || event.changedTouches[0].clientX
|
||||
: event.clientX;
|
||||
|
||||
const NodeImageSlideBlock: FC<IProps> = ({
|
||||
node,
|
||||
is_loading,
|
||||
is_modal_shown,
|
||||
updateLayout,
|
||||
modalShowPhotoswipe,
|
||||
}) => {
|
||||
const NodeImageSlideBlock: FC<IProps> = ({ node, isLoading, updateLayout = () => {} }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { is_shown } = useShallowSelect(selectModal);
|
||||
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [height, setHeight] = useState(window.innerHeight - 143);
|
||||
const [max_height, setMaxHeight] = useState(960);
|
||||
|
@ -88,7 +91,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
const { width } = wrap.current.getBoundingClientRect();
|
||||
const fallback = window.innerHeight - 143;
|
||||
|
||||
if (is_loading) {
|
||||
if (isLoading) {
|
||||
setHeight(fallback);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
|
@ -118,7 +121,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
}, [is_dragging, wrap, offset, heights, max_height, images, is_loading, updateLayout]);
|
||||
}, [is_dragging, wrap, offset, heights, max_height, images, isLoading, updateLayout]);
|
||||
|
||||
const onDrag = useCallback(
|
||||
event => {
|
||||
|
@ -162,8 +165,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
normalizeOffset();
|
||||
}, [wrap, setMaxHeight, normalizeOffset]);
|
||||
|
||||
const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [
|
||||
modalShowPhotoswipe,
|
||||
const onOpenPhotoSwipe = useCallback(() => dispatch(modalShowPhotoswipe(images, current)), [
|
||||
dispatch,
|
||||
images,
|
||||
current,
|
||||
]);
|
||||
|
@ -241,7 +244,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
images,
|
||||
]);
|
||||
|
||||
useArrows(onNext, onPrev, is_modal_shown);
|
||||
useArrows(onNext, onPrev, is_shown);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
|
@ -249,7 +252,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={classNames(styles.cutter, { [styles.is_loading]: is_loading })} ref={wrap}>
|
||||
<div className={classNames(styles.cutter, { [styles.is_loading]: isLoading })} ref={wrap}>
|
||||
<div
|
||||
className={classNames(styles.image_container, { [styles.is_dragging]: is_dragging })}
|
||||
style={{
|
||||
|
@ -261,7 +264,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
|
|||
onTouchStart={startDragging}
|
||||
ref={slide}
|
||||
>
|
||||
{!is_loading &&
|
||||
{!isLoading &&
|
||||
images.map((file, index) => (
|
||||
<div
|
||||
className={classNames(styles.image_wrap, {
|
||||
|
|
104
src/components/node/NodeImageSwiperBlock/index.tsx
Normal file
104
src/components/node/NodeImageSwiperBlock/index.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { INodeComponentProps } from '~/redux/node/constants';
|
||||
import SwiperCore, { A11y, Pagination, Navigation, SwiperOptions, Keyboard } from 'swiper';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
|
||||
import 'swiper/swiper.scss';
|
||||
import 'swiper/components/pagination/pagination.scss';
|
||||
import 'swiper/components/scrollbar/scrollbar.scss';
|
||||
import 'swiper/components/zoom/zoom.scss';
|
||||
import 'swiper/components/navigation/navigation.scss';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { PRESETS } from '~/constants/urls';
|
||||
import SwiperClass from 'swiper/types/swiper-class';
|
||||
import { modalShowPhotoswipe } from '~/redux/modal/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
SwiperCore.use([Navigation, Pagination, A11y]);
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
const breakpoints: SwiperOptions['breakpoints'] = {
|
||||
599: {
|
||||
spaceBetween: 20,
|
||||
navigation: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [controlledSwiper, setControlledSwiper] = useState<SwiperClass | undefined>(undefined);
|
||||
|
||||
const images = useNodeImages(node);
|
||||
|
||||
const updateSwiper = useCallback(() => {
|
||||
if (!controlledSwiper) return;
|
||||
|
||||
controlledSwiper.updateSlides();
|
||||
controlledSwiper.updateSize();
|
||||
controlledSwiper.update();
|
||||
}, [controlledSwiper]);
|
||||
|
||||
const resetSwiper = useCallback(() => {
|
||||
if (!controlledSwiper) return;
|
||||
controlledSwiper.slideTo(0, 0);
|
||||
setTimeout(() => controlledSwiper.slideTo(0, 0), 300);
|
||||
}, [controlledSwiper]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSwiper();
|
||||
resetSwiper();
|
||||
}, [images, updateSwiper, resetSwiper]);
|
||||
|
||||
const onOpenPhotoSwipe = useCallback(
|
||||
() => dispatch(modalShowPhotoswipe(images, controlledSwiper?.activeIndex || 0)),
|
||||
[dispatch, images, controlledSwiper]
|
||||
);
|
||||
|
||||
if (!images?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Swiper
|
||||
initialSlide={0}
|
||||
slidesPerView="auto"
|
||||
centeredSlides
|
||||
onSwiper={setControlledSwiper}
|
||||
grabCursor
|
||||
autoHeight
|
||||
breakpoints={breakpoints}
|
||||
pagination={{ type: 'fraction' }}
|
||||
observeSlideChildren
|
||||
observeParents
|
||||
resizeObserver
|
||||
watchOverflow
|
||||
updateOnImagesReady
|
||||
onInit={resetSwiper}
|
||||
keyboard={{
|
||||
enabled: true,
|
||||
onlyInViewport: false,
|
||||
}}
|
||||
zoom
|
||||
>
|
||||
{images.map(file => (
|
||||
<SwiperSlide className={styles.slide} key={file.id}>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={getURL(file, PRESETS['1600'])}
|
||||
alt={node.title}
|
||||
onLoad={updateSwiper}
|
||||
onClick={onOpenPhotoSwipe}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeImageSwiperBlock };
|
85
src/components/node/NodeImageSwiperBlock/styles.module.scss
Normal file
85
src/components/node/NodeImageSwiperBlock/styles.module.scss
Normal file
|
@ -0,0 +1,85 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrapper {
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.swiper-pagination) {
|
||||
left: 50%;
|
||||
bottom: $gap * 2;
|
||||
transform: translate(-50%, 0);
|
||||
background: darken($comment_bg, 4%);
|
||||
width: auto;
|
||||
padding: 5px 10px;
|
||||
border-radius: 10px;
|
||||
font: $font_10_semibold;
|
||||
}
|
||||
|
||||
:global(.swiper-container) {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
:global(.swiper-button-next),
|
||||
:global(.swiper-button-prev) {
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
|
||||
&::after {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.slide {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font: $font_32_bold;
|
||||
display: flex;
|
||||
border-radius: $radius;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
max-width: 100vw;
|
||||
opacity: 1;
|
||||
transform: translate(0, 10px);
|
||||
filter: brightness(50%) saturate(0.5);
|
||||
transition: opacity 0.5s, filter 0.5s, transform 0.5s;
|
||||
padding-bottom: $gap * 1.5;
|
||||
padding-top: $gap;
|
||||
|
||||
&:global(.swiper-slide-active) {
|
||||
opacity: 1;
|
||||
filter: brightness(100%);
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
max-height: calc(100vh - 70px - 70px);
|
||||
max-width: 100%;
|
||||
border-radius: $radius;
|
||||
transition: box-shadow 1s;
|
||||
box-shadow: transparentize(black, 0.7) 0 3px 5px;
|
||||
|
||||
:global(.swiper-slide-active) & {
|
||||
box-shadow: transparentize(black, 0.9) 0 10px 5px 4px,
|
||||
transparentize(black, 0.7) 0 5px 5px,
|
||||
transparentize(white, 0.95) 0 -1px 2px,
|
||||
transparentize(white, 0.95) 0 -1px;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
padding-bottom: 0;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
|
@ -1,85 +1,35 @@
|
|||
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||
import React, { FC, memo } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { INode } from '~/redux/types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||
import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions';
|
||||
import { useNodeActions } from '~/utils/hooks/node/useNodeActions';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
|
||||
interface IProps {
|
||||
node: Partial<INode>;
|
||||
layout: {};
|
||||
|
||||
can_edit: boolean;
|
||||
can_like: boolean;
|
||||
can_star: boolean;
|
||||
|
||||
is_loading?: boolean;
|
||||
|
||||
onEdit: () => void;
|
||||
onLike: () => void;
|
||||
onStar: () => void;
|
||||
onLock: () => void;
|
||||
node: INode;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const NodePanel: FC<IProps> = memo(
|
||||
({ node, layout, can_edit, can_like, can_star, is_loading, onEdit, onLike, onStar, onLock }) => {
|
||||
const [stack, setStack] = useState(false);
|
||||
const NodePanel: FC<IProps> = memo(({ node, isLoading }) => {
|
||||
const [can_edit, can_like, can_star] = useNodePermissions(node);
|
||||
const { onEdit, onLike, onStar, onLock } = useNodeActions(node);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const { bottom } = ref.current!.getBoundingClientRect();
|
||||
|
||||
setStack(bottom > window.innerHeight);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => getPlace(), [layout]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout, getPlace]);
|
||||
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{/*
|
||||
stack &&
|
||||
createPortal(
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
onLock={onLock}
|
||||
is_loading={is_loading}
|
||||
stack
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
*/}
|
||||
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
onLock={onLock}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
is_loading={!!is_loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className={styles.place}>
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
onLock={onLock}
|
||||
canEdit={can_edit}
|
||||
canLike={can_like}
|
||||
canStar={can_star}
|
||||
isLoading={!!isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, shallowEqual);
|
||||
|
||||
export { NodePanel };
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import React, { FC, memo } from 'react';
|
||||
import styles from './styles.module.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';
|
||||
|
@ -12,11 +10,11 @@ interface IProps {
|
|||
node: Partial<INode>;
|
||||
stack?: boolean;
|
||||
|
||||
can_edit: boolean;
|
||||
can_like: boolean;
|
||||
can_star: boolean;
|
||||
canEdit: boolean;
|
||||
canLike: boolean;
|
||||
canStar: boolean;
|
||||
|
||||
is_loading: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
onEdit: () => void;
|
||||
onLike: () => void;
|
||||
|
@ -29,11 +27,11 @@ const NodePanelInner: FC<IProps> = memo(
|
|||
node: { title, user, is_liked, is_heroic, deleted_at, created_at, like_count },
|
||||
stack,
|
||||
|
||||
can_star,
|
||||
can_edit,
|
||||
can_like,
|
||||
canStar,
|
||||
canEdit,
|
||||
canLike,
|
||||
|
||||
is_loading,
|
||||
isLoading,
|
||||
|
||||
onStar,
|
||||
onEdit,
|
||||
|
@ -45,12 +43,12 @@ const NodePanelInner: FC<IProps> = memo(
|
|||
<div className={styles.content}>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.title}>
|
||||
{is_loading ? <Placeholder width="40%" /> : title || '...'}
|
||||
{isLoading ? <Placeholder width="40%" /> : title || '...'}
|
||||
</div>
|
||||
|
||||
{user && user.username && (
|
||||
<div className={styles.name}>
|
||||
{is_loading ? (
|
||||
{isLoading ? (
|
||||
<Placeholder width="100px" />
|
||||
) : (
|
||||
`~${user.username.toLocaleLowerCase()}, ${getPrettyDate(created_at)}`
|
||||
|
@ -59,14 +57,14 @@ const NodePanelInner: FC<IProps> = memo(
|
|||
)}
|
||||
</div>
|
||||
|
||||
{can_edit && (
|
||||
{canEdit && (
|
||||
<div className={styles.editor_menu}>
|
||||
<div className={styles.editor_menu_button}>
|
||||
<Icon icon="dots-vertical" size={24} />
|
||||
</div>
|
||||
|
||||
<div className={styles.editor_buttons}>
|
||||
{can_star && (
|
||||
{canStar && (
|
||||
<div className={classNames(styles.star, { is_heroic })}>
|
||||
{is_heroic ? (
|
||||
<Icon icon="star_full" size={24} onClick={onStar} />
|
||||
|
@ -88,7 +86,7 @@ const NodePanelInner: FC<IProps> = memo(
|
|||
)}
|
||||
|
||||
<div className={styles.buttons}>
|
||||
{can_like && (
|
||||
{canLike && (
|
||||
<div className={classNames(styles.like, { is_liked })}>
|
||||
{is_liked ? (
|
||||
<Icon icon="heart_full" size={24} onClick={onLike} />
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
grid-row-gap: $gap;
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
44
src/components/node/NodeRelatedBlock/index.tsx
Normal file
44
src/components/node/NodeRelatedBlock/index.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React, { FC } from 'react';
|
||||
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
|
||||
import { NodeRelated } from '~/components/node/NodeRelated';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { INode } from '~/redux/types';
|
||||
import { INodeRelated } from '~/redux/node/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface IProps {
|
||||
isLoading: boolean;
|
||||
node: INode;
|
||||
related: INodeRelated;
|
||||
}
|
||||
|
||||
const NodeRelatedBlock: FC<IProps> = ({ isLoading, node, related }) => {
|
||||
if (isLoading) {
|
||||
return <NodeRelatedPlaceholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{related &&
|
||||
related.albums &&
|
||||
!!node?.id &&
|
||||
Object.keys(related.albums)
|
||||
.filter(album => related.albums[album].length > 0)
|
||||
.map(album => (
|
||||
<NodeRelated
|
||||
title={
|
||||
<Link to={URLS.NODE_TAG_URL(node.id!, encodeURIComponent(album))}>{album}</Link>
|
||||
}
|
||||
items={related.albums[album]}
|
||||
key={album}
|
||||
/>
|
||||
))}
|
||||
|
||||
{related && related.similar && related.similar.length > 0 && (
|
||||
<NodeRelated title="ПОХОЖИЕ" items={related.similar} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeRelatedBlock };
|
52
src/components/node/NodeTagsBlock/index.tsx
Normal file
52
src/components/node/NodeTagsBlock/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import { INode, ITag } from '~/redux/types';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { nodeUpdateTags } from '~/redux/node/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import { NodeTags } from '~/components/node/NodeTags';
|
||||
import { useUser } from '~/utils/hooks/user/userUser';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const { is_user } = useUser();
|
||||
|
||||
const onTagsChange = useCallback(
|
||||
(tags: string[]) => {
|
||||
dispatch(nodeUpdateTags(node.id, tags));
|
||||
},
|
||||
[dispatch, node]
|
||||
);
|
||||
|
||||
const onTagClick = useCallback(
|
||||
(tag: Partial<ITag>) => {
|
||||
if (!node?.id || !tag?.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title)));
|
||||
},
|
||||
[history, node]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeTags
|
||||
is_editable={is_user}
|
||||
tags={node.tags}
|
||||
onChange={onTagsChange}
|
||||
onTagClick={onTagClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeTagsBlock };
|
Loading…
Add table
Add a link
Reference in a new issue