mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 12:26:40 +07:00
add backlinks
This commit is contained in:
parent
6222b75563
commit
811e7740a9
21 changed files with 257 additions and 56 deletions
|
@ -34,6 +34,11 @@ module.exports = withBundleAnalyzer(
|
|||
hostname: '*.ytimg.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
@ -76,7 +76,11 @@ export const apiGetNode = (
|
|||
api
|
||||
.get<ApiGetNodeResponse>(API.NODES.GET(id), config)
|
||||
.then(cleanResult)
|
||||
.then((data) => ({ node: data.node, last_seen: data.last_seen }));
|
||||
.then((data) => ({
|
||||
node: data.node,
|
||||
last_seen: data.last_seen,
|
||||
backlinks: data.backlinks,
|
||||
}));
|
||||
|
||||
export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => {
|
||||
const cancelToken = axios.CancelToken.source();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC, ReactNode, useCallback } from 'react';
|
||||
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { WithDescription } from '~/components/common/WithDescription';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
@ -31,20 +31,12 @@ const BorisContactItem: FC<Props> = ({
|
|||
return (
|
||||
<div>
|
||||
{prefix}
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={styles.item}
|
||||
role={link ? 'button' : 'none'}
|
||||
>
|
||||
<div className={styles.icon}>
|
||||
<Icon icon={icon} size={32} />
|
||||
</div>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.subtitle}>{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<WithDescription
|
||||
icon={<Icon icon={icon} size={32} />}
|
||||
title={title}
|
||||
link={link}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
|
|
39
src/components/common/WithDescription/index.tsx
Normal file
39
src/components/common/WithDescription/index.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { FC, ReactNode, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
const WithDescription: FC<Props> = ({ icon, title, subtitle, link }) => {
|
||||
const onClick = useCallback(() => {
|
||||
if (!link) return;
|
||||
|
||||
window.open(link);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(styles.item, { [styles.link]: link })}
|
||||
role={link ? 'button' : 'none'}
|
||||
>
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{!!subtitle?.trim() && (
|
||||
<div className={styles.subtitle}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { WithDescription };
|
|
@ -9,11 +9,14 @@
|
|||
color: $gray_50;
|
||||
padding: $gap;
|
||||
min-height: 42px;
|
||||
|
||||
&.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: currentColor;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.info {
|
|
@ -8,10 +8,25 @@ import styles from './styles.module.scss';
|
|||
|
||||
export type CardProps = DivProps & {
|
||||
seamless?: boolean;
|
||||
elevation?: -1 | 0 | 1;
|
||||
};
|
||||
|
||||
const Card: FC<CardProps> = ({ className, children, seamless, ...props }) => (
|
||||
<div className={classNames(styles.card, className, { seamless })} {...props}>
|
||||
const Card: FC<CardProps> = ({
|
||||
className,
|
||||
children,
|
||||
seamless,
|
||||
elevation = 1,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.card,
|
||||
{ seamless },
|
||||
styles[`elevation-${elevation}`],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,18 @@
|
|||
border-radius: $panel_radius;
|
||||
padding: $gap;
|
||||
|
||||
@include outer_shadow();
|
||||
&.elevation--1 {
|
||||
@include inner_shadow;
|
||||
background: linear-gradient(135deg, $content_bg_dark, $content_bg);
|
||||
}
|
||||
|
||||
&.elevation-1 {
|
||||
@include outer_shadow();
|
||||
}
|
||||
|
||||
&.elevation-0 {
|
||||
background: $content_bg_light;
|
||||
}
|
||||
|
||||
&:global(.seamless) {
|
||||
padding: 0;
|
||||
|
|
22
src/components/node/Backlink/index.tsx
Normal file
22
src/components/node/Backlink/index.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { WithDescription } from '~/components/common/WithDescription';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
||||
interface BacklinkProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const Backlink: FC<BacklinkProps> = ({ icon, title, subtitle, link }) => (
|
||||
<WithDescription
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={icon && <Icon icon={icon} />}
|
||||
link={link}
|
||||
/>
|
||||
);
|
||||
|
||||
export { Backlink };
|
|
@ -1,6 +1,7 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import { Avatar } from '~/components/common/Avatar';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { useUserDescription } from '~/hooks/auth/useUserDescription';
|
||||
import { INodeUser } from '~/types';
|
||||
|
||||
|
@ -20,14 +21,14 @@ const NodeAuthorBlock: FC<Props> = ({ user }) => {
|
|||
const { fullname, username, photo } = user;
|
||||
|
||||
return (
|
||||
<div className={styles.block}>
|
||||
<Card className={styles.block} elevation={-1}>
|
||||
<Avatar username={username} url={photo?.url} className={styles.avatar} />
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.username}>{fullname || username}</div>
|
||||
<div className={styles.description}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
@import 'src/styles/variables.scss';
|
||||
|
||||
div.block {
|
||||
@include inner_shadow_active;
|
||||
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, $content_bg_dark, $content_bg);
|
||||
padding: $gap;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
|
@ -13,9 +9,6 @@ div.block {
|
|||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.info {
|
||||
}
|
||||
|
||||
.username {
|
||||
font: $font_16_semibold;
|
||||
line-height: 21px;
|
||||
|
|
|
@ -5,3 +5,9 @@ export const SOCIAL_ICONS: Record<OAuthProvider, string> = {
|
|||
google: 'google',
|
||||
telegram: 'telegram',
|
||||
};
|
||||
|
||||
export type BacklinkSource = 'vkontakte';
|
||||
|
||||
export const BACKLINK_TITLES: Record<BacklinkSource, string> = {
|
||||
vkontakte: 'Суицидальные роботы',
|
||||
};
|
||||
|
|
52
src/containers/node/NodeBacklinks/index.tsx
Normal file
52
src/containers/node/NodeBacklinks/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { FC, useMemo } from 'react';
|
||||
|
||||
import { SubTitle } from '~/components/common/SubTitle';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { Padder } from '~/components/containers/Padder';
|
||||
import { Backlink } from '~/components/node/Backlink';
|
||||
import { NodeBackLink } from '~/types';
|
||||
import { has } from '~/utils/ramda';
|
||||
|
||||
import { BACKLINK_TITLES, SOCIAL_ICONS } from '../../../constants/auth/socials';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface NodeBacklinksProps {
|
||||
list?: NodeBackLink[];
|
||||
}
|
||||
|
||||
const NodeBacklinks: FC<NodeBacklinksProps> = ({ list }) => {
|
||||
const validBacklinks = useMemo(
|
||||
() => (list || []).filter((it) => it.provider && it.link),
|
||||
[list],
|
||||
);
|
||||
|
||||
if (!validBacklinks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SubTitle className={styles.subtitle}>Расшарено:</SubTitle>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{validBacklinks.map((it) => (
|
||||
<Card elevation={-1} seamless key={it.link} className={styles.card}>
|
||||
<Backlink
|
||||
icon={SOCIAL_ICONS[it.provider]}
|
||||
title={
|
||||
has(it.provider, BACKLINK_TITLES)
|
||||
? BACKLINK_TITLES[it.provider]
|
||||
: it.provider
|
||||
}
|
||||
subtitle={it.provider}
|
||||
link={it.link}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeBacklinks };
|
14
src/containers/node/NodeBacklinks/styles.module.scss
Normal file
14
src/containers/node/NodeBacklinks/styles.module.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
@import '~/styles/variables.scss';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding: $gap;
|
||||
}
|
||||
|
||||
div.card {
|
||||
padding-right: $gap !important;
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Padder } from '~/components/containers/Padder';
|
||||
import { Sticky } from '~/components/containers/Sticky';
|
||||
|
@ -9,6 +11,7 @@ import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
|||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||
import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock';
|
||||
import { NodeTagsBlock } from '~/components/node/NodeTagsBlock';
|
||||
import { NodeBacklinks } from '~/containers/node/NodeBacklinks';
|
||||
import { NodeComments } from '~/containers/node/NodeComments';
|
||||
import { useNodeBlocks } from '~/hooks/node/useNodeBlocks';
|
||||
import { useCommentContext } from '~/utils/context/CommentContextProvider';
|
||||
|
@ -25,7 +28,7 @@ interface IProps {
|
|||
|
||||
const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
|
||||
const user = useUserContext();
|
||||
const { node, isLoading } = useNodeContext();
|
||||
const { node, isLoading, backlinks } = useNodeContext();
|
||||
const {
|
||||
comments,
|
||||
isLoading: isLoadingComments,
|
||||
|
@ -63,6 +66,12 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
|
|||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.subheader}>
|
||||
<Filler className={styles.backlinks}>
|
||||
<NodeBacklinks list={backlinks} />
|
||||
</Filler>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<aside className={styles.panel}>
|
||||
|
|
|
@ -46,3 +46,15 @@
|
|||
.left_item {
|
||||
padding-bottom: $gap * 2;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
display: flex;
|
||||
gap: $gap;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.backlinks {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ export const useLoadNode = (id: number, fallbackData?: ApiGetNodeResponse) => {
|
|||
|
||||
return {
|
||||
node: data?.node || EMPTY_NODE,
|
||||
backlinks: data?.backlinks,
|
||||
isLoading: isValidating && !data,
|
||||
update,
|
||||
lastSeen: data?.last_seen,
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Footer } from '~/components/main/Footer';
|
|||
import { NodeTitle } from '~/components/node/NodeTitle';
|
||||
import { Container } from '~/containers/main/Container';
|
||||
import { SidebarRouter } from '~/containers/main/SidebarRouter';
|
||||
import { NodeBacklinks } from '~/containers/node/NodeBacklinks';
|
||||
import { NodeBottomBlock } from '~/containers/node/NodeBottomBlock';
|
||||
import { useNodeActions } from '~/hooks/node/useNodeActions';
|
||||
import { useNodeBlocks } from '~/hooks/node/useNodeBlocks';
|
||||
|
|
|
@ -103,7 +103,7 @@ type Props = RouteComponentProps<{ id: string }> &
|
|||
|
||||
const NodePage: FC<Props> = observer((props) => {
|
||||
const id = useNodePageParams();
|
||||
const { node, isLoading, update, lastSeen } = useLoadNode(
|
||||
const { node, isLoading, update, lastSeen, backlinks } = useLoadNode(
|
||||
parseInt(id, 10),
|
||||
props.fallbackData,
|
||||
);
|
||||
|
@ -133,7 +133,12 @@ const NodePage: FC<Props> = observer((props) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<NodeContextProvider node={node} isLoading={isLoading} update={update}>
|
||||
<NodeContextProvider
|
||||
node={node}
|
||||
isLoading={isLoading}
|
||||
update={update}
|
||||
backlinks={backlinks}
|
||||
>
|
||||
<NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}>
|
||||
<CommentContextProvider
|
||||
onSaveComment={onSaveComment}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Context } from "react";
|
||||
import { Context } from 'react';
|
||||
|
||||
import { ERRORS } from "~/constants/errors";
|
||||
import { IUser } from "~/types/auth";
|
||||
import { ERRORS } from '~/constants/errors';
|
||||
import { IUser } from '~/types/auth';
|
||||
|
||||
export interface ITag {
|
||||
ID: number;
|
||||
|
@ -22,7 +22,7 @@ export type ContextValue<T> = T extends Context<infer U> ? U : never;
|
|||
|
||||
export type UUID = string;
|
||||
|
||||
export type IUploadType = "image" | "text" | "audio" | "video" | "other";
|
||||
export type IUploadType = 'image' | 'text' | 'audio' | 'video' | 'other';
|
||||
|
||||
export interface IFile {
|
||||
id: number;
|
||||
|
@ -55,21 +55,21 @@ export interface IFile {
|
|||
}
|
||||
|
||||
export interface IBlockText {
|
||||
type: "text";
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IBlockEmbed {
|
||||
type: "video";
|
||||
type: 'video';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type IBlock = IBlockText | IBlockEmbed;
|
||||
export type FlowDisplayVariant =
|
||||
| "single"
|
||||
| "vertical"
|
||||
| "horizontal"
|
||||
| "quadro";
|
||||
| 'single'
|
||||
| 'vertical'
|
||||
| 'horizontal'
|
||||
| 'quadro';
|
||||
export interface FlowDisplay {
|
||||
display: FlowDisplayVariant;
|
||||
show_description: boolean;
|
||||
|
@ -98,6 +98,7 @@ export interface INode {
|
|||
like_count?: number;
|
||||
|
||||
flow: FlowDisplay;
|
||||
backlinks?: NodeBackLink[];
|
||||
|
||||
tags: ITag[];
|
||||
|
||||
|
@ -109,9 +110,13 @@ export interface INode {
|
|||
|
||||
export type IFlowNode = Pick<
|
||||
INode,
|
||||
"id" | "flow" | "description" | "title" | "thumbnail" | "created_at"
|
||||
'id' | 'flow' | 'description' | 'title' | 'thumbnail' | 'created_at'
|
||||
>;
|
||||
|
||||
export interface NodeBackLink {
|
||||
provider: string;
|
||||
link: string;
|
||||
}
|
||||
export interface IComment {
|
||||
id: number;
|
||||
text: string;
|
||||
|
@ -123,7 +128,7 @@ export interface IComment {
|
|||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export type IMessage = Omit<IComment, "user" | "node"> & {
|
||||
export type IMessage = Omit<IComment, 'user' | 'node'> & {
|
||||
from: IUser;
|
||||
to: IUser;
|
||||
};
|
||||
|
@ -132,7 +137,7 @@ export interface ICommentGroup {
|
|||
user: IUser;
|
||||
comments: IComment[];
|
||||
distancesInDays: number[];
|
||||
ids: IComment["id"][];
|
||||
ids: IComment['id'][];
|
||||
hasNew: boolean;
|
||||
}
|
||||
|
||||
|
@ -140,19 +145,19 @@ export type IUploadProgressHandler = (progress: ProgressEvent) => void;
|
|||
export type IError = ValueOf<typeof ERRORS>;
|
||||
|
||||
export const NOTIFICATION_TYPES = {
|
||||
message: "message",
|
||||
comment: "comment",
|
||||
node: "node",
|
||||
message: 'message',
|
||||
comment: 'comment',
|
||||
node: 'node',
|
||||
};
|
||||
|
||||
export type IMessageNotification = {
|
||||
type: typeof NOTIFICATION_TYPES["message"];
|
||||
type: typeof NOTIFICATION_TYPES['message'];
|
||||
content: Partial<IMessage>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ICommentNotification = {
|
||||
type: typeof NOTIFICATION_TYPES["comment"];
|
||||
type: typeof NOTIFICATION_TYPES['comment'];
|
||||
content: Partial<IComment>;
|
||||
created_at: string;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IComment, INode, ITag } from '~/types';
|
||||
import { IComment, INode, ITag, NodeBackLink } from '~/types';
|
||||
|
||||
export interface IEditorComponentProps {}
|
||||
|
||||
|
@ -30,7 +30,11 @@ export type PostCellViewResult = unknown; // TODO: update it with actual type
|
|||
export type ApiGetNodeRequest = {
|
||||
id: string | number;
|
||||
};
|
||||
export type ApiGetNodeResponse = { node: INode; last_seen?: string | null };
|
||||
export type ApiGetNodeResponse = {
|
||||
node: INode;
|
||||
backlinks?: NodeBackLink[];
|
||||
last_seen?: string | null;
|
||||
};
|
||||
|
||||
export type ApiGetNodeRelatedRequest = {
|
||||
id: INode['id'];
|
||||
|
|
|
@ -1,22 +1,29 @@
|
|||
import React, { createContext, FC, useContext } from 'react';
|
||||
|
||||
import { EMPTY_NODE } from '~/constants/node';
|
||||
import { INode } from '~/types';
|
||||
import { INode, NodeBackLink } from '~/types';
|
||||
|
||||
export interface NodeContextProps {
|
||||
node: INode;
|
||||
backlinks?: NodeBackLink[];
|
||||
update: (node: Partial<INode>) => Promise<unknown>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const NodeContext = createContext<NodeContextProps>({
|
||||
node: EMPTY_NODE,
|
||||
backlinks: [] as NodeBackLink[] | undefined,
|
||||
update: async () => {},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export const NodeContextProvider: FC<NodeContextProps> = ({ children, ...contextValue }) => {
|
||||
return <NodeContext.Provider value={contextValue}>{children}</NodeContext.Provider>;
|
||||
export const NodeContextProvider: FC<NodeContextProps> = ({
|
||||
children,
|
||||
...contextValue
|
||||
}) => {
|
||||
return (
|
||||
<NodeContext.Provider value={contextValue}>{children}</NodeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNodeContext = () => useContext(NodeContext);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue