1
0
Fork 0
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:
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',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'localhost',
pathname: '/**',
},
],
},
})

View file

@ -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();

View file

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

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;
padding: $gap;
min-height: 42px;
&.link {
cursor: pointer;
}
}
.icon {
fill: currentColor;
height: 32px;
}
.info {

View file

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

View file

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

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 { 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>
);
};

View file

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

View file

@ -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: 'Суицидальные роботы',
};

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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