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

add backlinks

This commit is contained in:
Fedor Katurov 2023-06-11 20:16:55 +06:00
parent 6222b75563
commit 811e7740a9
21 changed files with 257 additions and 56 deletions

View file

@ -34,6 +34,11 @@ module.exports = withBundleAnalyzer(
hostname: '*.ytimg.com', hostname: '*.ytimg.com',
pathname: '/**', pathname: '/**',
}, },
{
protocol: 'http',
hostname: 'localhost',
pathname: '/**',
},
], ],
}, },
}) })

View file

@ -76,7 +76,11 @@ export const apiGetNode = (
api api
.get<ApiGetNodeResponse>(API.NODES.GET(id), config) .get<ApiGetNodeResponse>(API.NODES.GET(id), config)
.then(cleanResult) .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) => { export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => {
const cancelToken = axios.CancelToken.source(); const cancelToken = axios.CancelToken.source();

View file

@ -1,6 +1,6 @@
import React, { FC, ReactNode, useCallback } from 'react'; 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 { Icon } from '~/components/input/Icon';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
@ -31,20 +31,12 @@ const BorisContactItem: FC<Props> = ({
return ( return (
<div> <div>
{prefix} {prefix}
<div <WithDescription
onClick={onClick} icon={<Icon icon={icon} size={32} />}
className={styles.item} title={title}
role={link ? 'button' : 'none'} link={link}
> subtitle={subtitle}
<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>
{suffix} {suffix}
</div> </div>
); );

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

View file

@ -9,11 +9,14 @@
color: $gray_50; color: $gray_50;
padding: $gap; padding: $gap;
min-height: 42px; min-height: 42px;
&.link {
cursor: pointer;
}
} }
.icon { .icon {
fill: currentColor; fill: currentColor;
height: 32px;
} }
.info { .info {

View file

@ -8,10 +8,25 @@ import styles from './styles.module.scss';
export type CardProps = DivProps & { export type CardProps = DivProps & {
seamless?: boolean; seamless?: boolean;
elevation?: -1 | 0 | 1;
}; };
const Card: FC<CardProps> = ({ className, children, seamless, ...props }) => ( const Card: FC<CardProps> = ({
<div className={classNames(styles.card, className, { seamless })} {...props}> className,
children,
seamless,
elevation = 1,
...props
}) => (
<div
className={classNames(
styles.card,
{ seamless },
styles[`elevation-${elevation}`],
className,
)}
{...props}
>
{children} {children}
</div> </div>
); );

View file

@ -5,7 +5,18 @@
border-radius: $panel_radius; border-radius: $panel_radius;
padding: $gap; padding: $gap;
&.elevation--1 {
@include inner_shadow;
background: linear-gradient(135deg, $content_bg_dark, $content_bg);
}
&.elevation-1 {
@include outer_shadow(); @include outer_shadow();
}
&.elevation-0 {
background: $content_bg_light;
}
&:global(.seamless) { &:global(.seamless) {
padding: 0; padding: 0;

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

View file

@ -1,6 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC } from 'react';
import { Avatar } from '~/components/common/Avatar'; import { Avatar } from '~/components/common/Avatar';
import { Card } from '~/components/containers/Card';
import { useUserDescription } from '~/hooks/auth/useUserDescription'; import { useUserDescription } from '~/hooks/auth/useUserDescription';
import { INodeUser } from '~/types'; import { INodeUser } from '~/types';
@ -20,14 +21,14 @@ const NodeAuthorBlock: FC<Props> = ({ user }) => {
const { fullname, username, photo } = user; const { fullname, username, photo } = user;
return ( return (
<div className={styles.block}> <Card className={styles.block} elevation={-1}>
<Avatar username={username} url={photo?.url} className={styles.avatar} /> <Avatar username={username} url={photo?.url} className={styles.avatar} />
<div className={styles.info}> <div className={styles.info}>
<div className={styles.username}>{fullname || username}</div> <div className={styles.username}>{fullname || username}</div>
<div className={styles.description}>{description}</div> <div className={styles.description}>{description}</div>
</div> </div>
</div> </Card>
); );
}; };

View file

@ -1,10 +1,6 @@
@import 'src/styles/variables.scss'; @import 'src/styles/variables.scss';
div.block { div.block {
@include inner_shadow_active;
cursor: pointer;
background: linear-gradient(135deg, $content_bg_dark, $content_bg);
padding: $gap; padding: $gap;
border-radius: $radius; border-radius: $radius;
display: flex; display: flex;
@ -13,9 +9,6 @@ div.block {
justify-content: stretch; justify-content: stretch;
} }
.info {
}
.username { .username {
font: $font_16_semibold; font: $font_16_semibold;
line-height: 21px; line-height: 21px;

View file

@ -5,3 +5,9 @@ export const SOCIAL_ICONS: Record<OAuthProvider, string> = {
google: 'google', google: 'google',
telegram: 'telegram', telegram: 'telegram',
}; };
export type BacklinkSource = 'vkontakte';
export const BACKLINK_TITLES: Record<BacklinkSource, string> = {
vkontakte: 'Суицидальные роботы',
};

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

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

View file

@ -1,5 +1,7 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { Card } from '~/components/containers/Card';
import { Filler } from '~/components/containers/Filler';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { Padder } from '~/components/containers/Padder'; import { Padder } from '~/components/containers/Padder';
import { Sticky } from '~/components/containers/Sticky'; import { Sticky } from '~/components/containers/Sticky';
@ -9,6 +11,7 @@ import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeNoComments } from '~/components/node/NodeNoComments';
import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock'; import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock';
import { NodeTagsBlock } from '~/components/node/NodeTagsBlock'; import { NodeTagsBlock } from '~/components/node/NodeTagsBlock';
import { NodeBacklinks } from '~/containers/node/NodeBacklinks';
import { NodeComments } from '~/containers/node/NodeComments'; import { NodeComments } from '~/containers/node/NodeComments';
import { useNodeBlocks } from '~/hooks/node/useNodeBlocks'; import { useNodeBlocks } from '~/hooks/node/useNodeBlocks';
import { useCommentContext } from '~/utils/context/CommentContextProvider'; import { useCommentContext } from '~/utils/context/CommentContextProvider';
@ -25,7 +28,7 @@ interface IProps {
const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => { const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
const user = useUserContext(); const user = useUserContext();
const { node, isLoading } = useNodeContext(); const { node, isLoading, backlinks } = useNodeContext();
const { const {
comments, comments,
isLoading: isLoadingComments, isLoading: isLoadingComments,
@ -63,6 +66,12 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
user={user} user={user}
/> />
)} )}
<div className={styles.subheader}>
<Filler className={styles.backlinks}>
<NodeBacklinks list={backlinks} />
</Filler>
</div>
</Group> </Group>
<aside className={styles.panel}> <aside className={styles.panel}>

View file

@ -46,3 +46,15 @@
.left_item { .left_item {
padding-bottom: $gap * 2; padding-bottom: $gap * 2;
} }
.subheader {
display: flex;
gap: $gap;
width: 100%;
align-items: flex-start;
}
.backlinks {
min-height: 100%;
display: flex;
}

View file

@ -40,6 +40,7 @@ export const useLoadNode = (id: number, fallbackData?: ApiGetNodeResponse) => {
return { return {
node: data?.node || EMPTY_NODE, node: data?.node || EMPTY_NODE,
backlinks: data?.backlinks,
isLoading: isValidating && !data, isLoading: isValidating && !data,
update, update,
lastSeen: data?.last_seen, lastSeen: data?.last_seen,

View file

@ -9,6 +9,7 @@ import { Footer } from '~/components/main/Footer';
import { NodeTitle } from '~/components/node/NodeTitle'; import { NodeTitle } from '~/components/node/NodeTitle';
import { Container } from '~/containers/main/Container'; import { Container } from '~/containers/main/Container';
import { SidebarRouter } from '~/containers/main/SidebarRouter'; import { SidebarRouter } from '~/containers/main/SidebarRouter';
import { NodeBacklinks } from '~/containers/node/NodeBacklinks';
import { NodeBottomBlock } from '~/containers/node/NodeBottomBlock'; import { NodeBottomBlock } from '~/containers/node/NodeBottomBlock';
import { useNodeActions } from '~/hooks/node/useNodeActions'; import { useNodeActions } from '~/hooks/node/useNodeActions';
import { useNodeBlocks } from '~/hooks/node/useNodeBlocks'; import { useNodeBlocks } from '~/hooks/node/useNodeBlocks';

View file

@ -103,7 +103,7 @@ type Props = RouteComponentProps<{ id: string }> &
const NodePage: FC<Props> = observer((props) => { const NodePage: FC<Props> = observer((props) => {
const id = useNodePageParams(); const id = useNodePageParams();
const { node, isLoading, update, lastSeen } = useLoadNode( const { node, isLoading, update, lastSeen, backlinks } = useLoadNode(
parseInt(id, 10), parseInt(id, 10),
props.fallbackData, props.fallbackData,
); );
@ -133,7 +133,12 @@ const NodePage: FC<Props> = observer((props) => {
} }
return ( 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}> <NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}>
<CommentContextProvider <CommentContextProvider
onSaveComment={onSaveComment} onSaveComment={onSaveComment}

View file

@ -1,7 +1,7 @@
import { Context } from "react"; import { Context } from 'react';
import { ERRORS } from "~/constants/errors"; import { ERRORS } from '~/constants/errors';
import { IUser } from "~/types/auth"; import { IUser } from '~/types/auth';
export interface ITag { export interface ITag {
ID: number; ID: number;
@ -22,7 +22,7 @@ export type ContextValue<T> = T extends Context<infer U> ? U : never;
export type UUID = string; export type UUID = string;
export type IUploadType = "image" | "text" | "audio" | "video" | "other"; export type IUploadType = 'image' | 'text' | 'audio' | 'video' | 'other';
export interface IFile { export interface IFile {
id: number; id: number;
@ -55,21 +55,21 @@ export interface IFile {
} }
export interface IBlockText { export interface IBlockText {
type: "text"; type: 'text';
text: string; text: string;
} }
export interface IBlockEmbed { export interface IBlockEmbed {
type: "video"; type: 'video';
url: string; url: string;
} }
export type IBlock = IBlockText | IBlockEmbed; export type IBlock = IBlockText | IBlockEmbed;
export type FlowDisplayVariant = export type FlowDisplayVariant =
| "single" | 'single'
| "vertical" | 'vertical'
| "horizontal" | 'horizontal'
| "quadro"; | 'quadro';
export interface FlowDisplay { export interface FlowDisplay {
display: FlowDisplayVariant; display: FlowDisplayVariant;
show_description: boolean; show_description: boolean;
@ -98,6 +98,7 @@ export interface INode {
like_count?: number; like_count?: number;
flow: FlowDisplay; flow: FlowDisplay;
backlinks?: NodeBackLink[];
tags: ITag[]; tags: ITag[];
@ -109,9 +110,13 @@ export interface INode {
export type IFlowNode = Pick< export type IFlowNode = Pick<
INode, 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 { export interface IComment {
id: number; id: number;
text: string; text: string;
@ -123,7 +128,7 @@ export interface IComment {
deleted_at?: string; deleted_at?: string;
} }
export type IMessage = Omit<IComment, "user" | "node"> & { export type IMessage = Omit<IComment, 'user' | 'node'> & {
from: IUser; from: IUser;
to: IUser; to: IUser;
}; };
@ -132,7 +137,7 @@ export interface ICommentGroup {
user: IUser; user: IUser;
comments: IComment[]; comments: IComment[];
distancesInDays: number[]; distancesInDays: number[];
ids: IComment["id"][]; ids: IComment['id'][];
hasNew: boolean; hasNew: boolean;
} }
@ -140,19 +145,19 @@ export type IUploadProgressHandler = (progress: ProgressEvent) => void;
export type IError = ValueOf<typeof ERRORS>; export type IError = ValueOf<typeof ERRORS>;
export const NOTIFICATION_TYPES = { export const NOTIFICATION_TYPES = {
message: "message", message: 'message',
comment: "comment", comment: 'comment',
node: "node", node: 'node',
}; };
export type IMessageNotification = { export type IMessageNotification = {
type: typeof NOTIFICATION_TYPES["message"]; type: typeof NOTIFICATION_TYPES['message'];
content: Partial<IMessage>; content: Partial<IMessage>;
created_at: string; created_at: string;
}; };
export type ICommentNotification = { export type ICommentNotification = {
type: typeof NOTIFICATION_TYPES["comment"]; type: typeof NOTIFICATION_TYPES['comment'];
content: Partial<IComment>; content: Partial<IComment>;
created_at: string; created_at: string;
}; };

View file

@ -1,4 +1,4 @@
import { IComment, INode, ITag } from '~/types'; import { IComment, INode, ITag, NodeBackLink } from '~/types';
export interface IEditorComponentProps {} export interface IEditorComponentProps {}
@ -30,7 +30,11 @@ export type PostCellViewResult = unknown; // TODO: update it with actual type
export type ApiGetNodeRequest = { export type ApiGetNodeRequest = {
id: string | number; 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 = { export type ApiGetNodeRelatedRequest = {
id: INode['id']; id: INode['id'];

View file

@ -1,22 +1,29 @@
import React, { createContext, FC, useContext } from 'react'; import React, { createContext, FC, useContext } from 'react';
import { EMPTY_NODE } from '~/constants/node'; import { EMPTY_NODE } from '~/constants/node';
import { INode } from '~/types'; import { INode, NodeBackLink } from '~/types';
export interface NodeContextProps { export interface NodeContextProps {
node: INode; node: INode;
backlinks?: NodeBackLink[];
update: (node: Partial<INode>) => Promise<unknown>; update: (node: Partial<INode>) => Promise<unknown>;
isLoading: boolean; isLoading: boolean;
} }
export const NodeContext = createContext<NodeContextProps>({ export const NodeContext = createContext<NodeContextProps>({
node: EMPTY_NODE, node: EMPTY_NODE,
backlinks: [] as NodeBackLink[] | undefined,
update: async () => {}, update: async () => {},
isLoading: false, isLoading: false,
}); });
export const NodeContextProvider: FC<NodeContextProps> = ({ children, ...contextValue }) => { export const NodeContextProvider: FC<NodeContextProps> = ({
return <NodeContext.Provider value={contextValue}>{children}</NodeContext.Provider>; children,
...contextValue
}) => {
return (
<NodeContext.Provider value={contextValue}>{children}</NodeContext.Provider>
);
}; };
export const useNodeContext = () => useContext(NodeContext); export const useNodeContext = () => useContext(NodeContext);