1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

splitting comment text by blocks

This commit is contained in:
Fedor Katurov 2019-11-26 10:25:43 +07:00
parent d74c9ee739
commit 93afa626db
8 changed files with 206 additions and 158 deletions

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { ICommentBlock } from '~/constants/comment';
import styles from './styles.scss';
interface IProps {
block: ICommentBlock;
}
const CommentTextBlock: FC<IProps> = ({ block }) => {
return (
<div
className={styles.text}
dangerouslySetInnerHTML={{
__html: `<p>${block.content}</p>`,
}}
/>
);
};
export { CommentTextBlock };

View file

@ -0,0 +1,14 @@
.text {
padding: $gap;
font-weight: 300;
font: $font_16_medium;
line-height: 20px;
box-sizing: border-box;
position: relative;
color: #cccccc;
word-break: break-word;
b {
font-weight: 600;
}
}

View file

@ -1,4 +1,4 @@
import React, { FC, useMemo, memo } from 'react'; import React, { FC, useMemo, memo, createElement } from 'react';
import { IComment, IFile } from '~/redux/types'; import { IComment, IFile } from '~/redux/types';
import path from 'ramda/es/path'; import path from 'ramda/es/path';
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom'; import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
@ -11,6 +11,7 @@ import reduce from 'ramda/es/reduce';
import { AudioPlayer } from '~/components/media/AudioPlayer'; import { AudioPlayer } from '~/components/media/AudioPlayer';
import classnames from 'classnames'; import classnames from 'classnames';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
interface IProps { interface IProps {
comment: IComment; comment: IComment;
@ -31,12 +32,11 @@ const CommentContent: FC<IProps> = memo(({ comment }) => {
<> <>
{comment.text && ( {comment.text && (
<div className={styles.block}> <div className={styles.block}>
<Group {formatCommentText(path(['user', 'username'], comment), comment.text).map(
className={styles.text} (block, key) =>
dangerouslySetInnerHTML={{ COMMENT_BLOCK_RENDERERS[block.type] &&
__html: formatCommentText(path(['user', 'username'], comment), comment.text), createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
}} )}
/>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div> </div>

View file

@ -10,6 +10,7 @@
position: relative; position: relative;
padding-bottom: 10px; padding-bottom: 10px;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column;
&:first-child { &:first-child {
border-top-right-radius: $radius; border-top-right-radius: $radius;
@ -36,21 +37,6 @@
} }
} }
.text {
padding: $gap;
font-weight: 300;
font: $font_16_medium;
line-height: 20px;
box-sizing: border-box;
position: relative;
color: #cccccc;
word-break: break-word;
b {
font-weight: 600;
}
}
.date { .date {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View file

@ -1,56 +1,51 @@
import React, { FC, useState, useEffect, useCallback } from "react"; import React, { FC, useState, useEffect, useCallback } from 'react';
import styles from "./styles.scss"; import styles from './styles.scss';
import { connect } from "react-redux"; import { connect } from 'react-redux';
import classNames from "classnames"; import classNames from 'classnames';
import { selectAuthUser, selectAuthProfile } from "~/redux/auth/selectors"; import { selectAuthUser, selectAuthProfile } from '~/redux/auth/selectors';
import { Textarea } from "~/components/input/Textarea"; import { Textarea } from '~/components/input/Textarea';
import { Button } from "~/components/input/Button"; import { Button } from '~/components/input/Button';
import { Group } from "~/components/containers/Group"; import { Group } from '~/components/containers/Group';
import { Filler } from "~/components/containers/Filler"; import { Filler } from '~/components/containers/Filler';
import { TextInput } from "~/components/input/TextInput"; import { TextInput } from '~/components/input/TextInput';
import { InputText } from "~/components/input/InputText"; import { InputText } from '~/components/input/InputText';
import reject from "ramda/es/reject"; import reject from 'ramda/es/reject';
import * as AUTH_ACTIONS from "~/redux/auth/actions"; import * as AUTH_ACTIONS from '~/redux/auth/actions';
import { ERROR_LITERAL } from "~/constants/errors"; import { ERROR_LITERAL } from '~/constants/errors';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
user: selectAuthUser(state), user: selectAuthUser(state),
profile: selectAuthProfile(state) profile: selectAuthProfile(state),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
authPatchUser: AUTH_ACTIONS.authPatchUser, authPatchUser: AUTH_ACTIONS.authPatchUser,
authSetProfile: AUTH_ACTIONS.authSetProfile authSetProfile: AUTH_ACTIONS.authSetProfile,
}; };
type IProps = ReturnType<typeof mapStateToProps> & type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
typeof mapDispatchToProps & {};
const ProfileSettingsUnconnected: FC<IProps> = ({ const ProfileSettingsUnconnected: FC<IProps> = ({
user, user,
profile: { patch_errors }, profile: { patch_errors },
authPatchUser, authPatchUser,
authSetProfile authSetProfile,
}) => { }) => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState('');
const [new_password, setNewPassword] = useState(""); const [new_password, setNewPassword] = useState('');
const [data, setData] = useState(user); const [data, setData] = useState(user);
const setDescription = useCallback( const setDescription = useCallback(description => setData({ ...data, description }), [
description => setData({ ...data, description }),
[data, setData]
);
const setEmail = useCallback(email => setData({ ...data, email }), [
data, data,
setData setData,
]); ]);
const setUsername = useCallback(username => setData({ ...data, username }), [ const setEmail = useCallback(email => setData({ ...data, email }), [data, setData]);
data,
setData const setUsername = useCallback(username => setData({ ...data, username }), [data, setData]);
]);
const setFullname = useCallback(fullname => setData({ ...data, fullname }), [data, setData]);
const onSubmit = useCallback( const onSubmit = useCallback(
event => { event => {
@ -58,10 +53,11 @@ const ProfileSettingsUnconnected: FC<IProps> = ({
const fields = reject(el => !el)({ const fields = reject(el => !el)({
email: data.email !== user.email && data.email, email: data.email !== user.email && data.email,
fullname: data.fullname !== user.fullname && data.fullname,
username: data.username !== user.username && data.username, username: data.username !== user.username && data.username,
password: password.length > 0 && password, password: password.length > 0 && password,
new_password: new_password.length > 0 && new_password, new_password: new_password.length > 0 && new_password,
description: data.description !== user.description && data.description description: data.description !== user.description && data.description,
}); });
if (Object.values(fields).length === 0) return; if (Object.values(fields).length === 0) return;
@ -78,15 +74,18 @@ const ProfileSettingsUnconnected: FC<IProps> = ({
return ( return (
<form className={styles.wrap} onSubmit={onSubmit}> <form className={styles.wrap} onSubmit={onSubmit}>
<Group> <Group>
<Textarea <InputText
value={data.description} value={data.fullname}
handler={setDescription} handler={setFullname}
title="Описание" title="Полное имя"
error={patch_errors.fullname && ERROR_LITERAL[patch_errors.fullname]}
/> />
<Textarea value={data.description} handler={setDescription} title="Описание" />
<div className={styles.small}> <div className={styles.small}>
Описание будет видно на странице профиля. Здесь работают те же правила Описание будет видно на странице профиля. Здесь работают те же правила оформления, что и в
оформления, что и в комментариях. комментариях.
</div> </div>
<Group className={styles.pad}> <Group className={styles.pad}>
@ -94,9 +93,7 @@ const ProfileSettingsUnconnected: FC<IProps> = ({
value={data.username} value={data.username}
handler={setUsername} handler={setUsername}
title="Логин" title="Логин"
error={ error={patch_errors.username && ERROR_LITERAL[patch_errors.username]}
patch_errors.username && ERROR_LITERAL[patch_errors.username]
}
/> />
<InputText <InputText
@ -111,10 +108,7 @@ const ProfileSettingsUnconnected: FC<IProps> = ({
handler={setNewPassword} handler={setNewPassword}
title="Новый пароль" title="Новый пароль"
type="password" type="password"
error={ error={patch_errors.new_password && ERROR_LITERAL[patch_errors.new_password]}
patch_errors.new_password &&
ERROR_LITERAL[patch_errors.new_password]
}
/> />
<div /> <div />
@ -124,9 +118,7 @@ const ProfileSettingsUnconnected: FC<IProps> = ({
handler={setPassword} handler={setPassword}
title="Старый пароль" title="Старый пароль"
type="password" type="password"
error={ error={patch_errors.password && ERROR_LITERAL[patch_errors.password]}
patch_errors.password && ERROR_LITERAL[patch_errors.password]
}
/> />
<div className={styles.small}> <div className={styles.small}>

33
src/constants/comment.ts Normal file
View file

@ -0,0 +1,33 @@
import { CommentTextBlock } from '~/components/comment/CommentTextBlock';
export const COMMENT_BLOCK_TYPES = {
TEXT: 'TEXT',
MARK: 'MARK',
EMBED: 'EMBED',
};
export const COMMENT_BLOCK_DETECTORS = [
{
type: COMMENT_BLOCK_TYPES.EMBED,
test: /(https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?[\w\-]+)/gim,
},
{
type: COMMENT_BLOCK_TYPES.MARK,
test: /^[\n\s]{0,}?&lt;\.{3}&gt;[\n\s]{0,}$/gi,
},
{
type: COMMENT_BLOCK_TYPES.TEXT,
test: /^.*$/gi,
},
];
export type ICommentBlock = {
type: typeof COMMENT_BLOCK_TYPES[keyof typeof COMMENT_BLOCK_TYPES];
content: string;
};
export const COMMENT_BLOCK_RENDERERS = {
[COMMENT_BLOCK_TYPES.TEXT]: CommentTextBlock,
[COMMENT_BLOCK_TYPES.MARK]: CommentTextBlock,
[COMMENT_BLOCK_TYPES.EMBED]: CommentTextBlock,
};

View file

@ -21,19 +21,21 @@ render(
/* /*
[Stage 0]: [Stage 0]:
- <...> format
- youtube embeds
- check if email is registered at social login - check if email is registered at social login
- friendship - friendship
- signup?
- cover change - cover change
- sticky header
- mobile header
- profile cover upload - profile cover upload
- user access time update
- illustrate restoreRequestDialog - illustrate restoreRequestDialog
- illustrate 404 - illustrate 404
- illustrate login - illustrate login
[stage 1] [stage 1]
- signup?
- import videos - import videos
- import graffiti - import graffiti
- text post can also has songs http://vault48.org/post5052 - text post can also has songs http://vault48.org/post5052
@ -46,6 +48,8 @@ render(
- comment editing - comment editing
Done: Done:
- mobile header
- sticky header
- password restore - password restore
- avatar upload - avatar upload
- flow updates - flow updates

View file

@ -1,23 +1,20 @@
import { IFile } from "~/redux/types"; import { IFile, ValueOf } from '~/redux/types';
import formatDistanceToNow from "date-fns/formatDistanceToNow"; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import { ru } from "date-fns/locale"; import { ru } from 'date-fns/locale';
import Axios from "axios"; import Axios from 'axios';
import { PRESETS } from "~/constants/urls"; import { PRESETS } from '~/constants/urls';
import { ICommentBlock, COMMENT_BLOCK_DETECTORS, COMMENT_BLOCK_TYPES } from '~/constants/comment';
export const getStyle = (oElm: any, strCssRule: string) => { export const getStyle = (oElm: any, strCssRule: string) => {
if (document.defaultView && document.defaultView.getComputedStyle) { if (document.defaultView && document.defaultView.getComputedStyle) {
return document.defaultView return document.defaultView.getComputedStyle(oElm, '').getPropertyValue(strCssRule);
.getComputedStyle(oElm, "")
.getPropertyValue(strCssRule);
} }
if (oElm.currentStyle) { if (oElm.currentStyle) {
return oElm.currentStyle[ return oElm.currentStyle[strCssRule.replace(/-(\w)/g, (strMatch, p1) => p1.toUpperCase())];
strCssRule.replace(/-(\w)/g, (strMatch, p1) => p1.toUpperCase())
];
} }
return ""; return '';
}; };
function polarToCartesian(centerX, centerY, radius, angleInDegrees) { function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
@ -25,7 +22,7 @@ function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
return { return {
x: centerX + radius * Math.cos(angleInRadians), x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians) y: centerY + radius * Math.sin(angleInRadians),
}; };
} }
@ -42,10 +39,10 @@ export const describeArc = (
const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1; const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1;
return [ return [
"M", 'M',
start.x, start.x,
start.y, start.y,
"A", 'A',
radius, radius,
radius, radius,
0, 0,
@ -53,62 +50,66 @@ export const describeArc = (
0, 0,
end.x, end.x,
end.y, end.y,
"L", 'L',
x, x,
y, y,
"L", 'L',
start.x, start.x,
start.y start.y,
].join(" "); ].join(' ');
}; };
export const getURL = ( export const getURL = (file: Partial<IFile>, size?: typeof PRESETS[keyof typeof PRESETS]) => {
file: Partial<IFile>,
size?: typeof PRESETS[keyof typeof PRESETS]
) => {
if (!file || !file.url) return null; if (!file || !file.url) return null;
if (size) { if (size) {
return file.url return file.url
.replace( .replace('REMOTE_CURRENT://', `${process.env.REMOTE_CURRENT}cache/${size}/`)
"REMOTE_CURRENT://", .replace('REMOTE_OLD://', process.env.REMOTE_OLD);
`${process.env.REMOTE_CURRENT}cache/${size}/`
)
.replace("REMOTE_OLD://", process.env.REMOTE_OLD);
} }
return file.url return file.url
.replace("REMOTE_CURRENT://", process.env.REMOTE_CURRENT) .replace('REMOTE_CURRENT://', process.env.REMOTE_CURRENT)
.replace("REMOTE_OLD://", process.env.REMOTE_OLD); .replace('REMOTE_OLD://', process.env.REMOTE_OLD);
}; };
export const formatText = (text: string): string => export const formatText = (text: string): string =>
!text !text
? "" ? ''
: text : text
.replace(/\n{1,}/gim, "\n") .replace(
.replace(/</g, "&lt;") /(https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?[\w\-]+)/gim,
.replace(/>/g, "&gt;") '\n$1\n'
)
.replace(/\n{1,}/gim, '\n')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace( .replace(
/~([\wа-яА-Я\-]+)/giu, /~([\wа-яА-Я\-]+)/giu,
"<span class=\"username\" onClick=\"window.postMessage({ type: 'username', username: '$1'});\">~$1</span>" '<span class="username" onClick="window.postMessage({ type: \'username\', username: \'$1\'});">~$1</span>'
) )
.replace(/:\/\//gim, ":|--|") .replace(/:\/\//gim, ':|--|')
.replace(/(\/\/[^\n]+)/gim, '<span class="grey">$1</span>') .replace(/(\/\/[^\n]+)/gim, '<span class="grey">$1</span>')
.replace(/(\/\*[\s\S]*?\*\/)/gim, '<span class="grey">$1</span>') .replace(/(\/\*[\s\S]*?\*\/)/gim, '<span class="grey">$1</span>')
.replace(/:\|--\|/gim, "://") .replace(/:\|--\|/gim, '://')
.split("\n") .split('\n')
.filter(el => el.trim().length) .filter(el => el.trim().length)
.map(el => `<p>${el}</p>`) // .map(el => `<p>${el}</p>`)
.join(""); .join('\n');
export const formatCommentText = (author: string, text: string): string => export const findBlockType = (line: string): ValueOf<typeof COMMENT_BLOCK_TYPES> => {
text const match = Object.values(COMMENT_BLOCK_DETECTORS).find(detector => line.match(detector.test));
? formatText(text).replace( return (match && match.type) || COMMENT_BLOCK_TYPES.TEXT;
/^<p>/, };
author ? `<p><b class="comment-author">${author}: </b>` : "<p>"
) export const splitCommentByBlocks = (text: string): ICommentBlock[] =>
: ""; text.split('\n').map(line => ({
type: findBlockType(line),
content: line,
}));
export const formatCommentText = (author: string, text: string): ICommentBlock[] =>
text ? splitCommentByBlocks(formatText(text)) : null;
export const formatCellText = (text: string): string => formatText(text); export const formatCellText = (text: string): string => formatText(text);
@ -116,13 +117,11 @@ export const getPrettyDate = (date: string): string =>
formatDistanceToNow(new Date(date), { formatDistanceToNow(new Date(date), {
locale: ru, locale: ru,
includeSeconds: true, includeSeconds: true,
addSuffix: true addSuffix: true,
}); });
export const getYoutubeTitle = async (id: string) => { export const getYoutubeTitle = async (id: string) => {
Axios.get(`http://youtube.com/get_video_info?video_id=${id}`).then( Axios.get(`http://youtube.com/get_video_info?video_id=${id}`).then(console.log);
console.log
);
}; };
(<any>window).getYoutubeTitle = getYoutubeTitle; (<any>window).getYoutubeTitle = getYoutubeTitle;