feat: add timeline page

This commit is contained in:
shuai 2022-11-22 17:41:48 +08:00
commit 9359f27729
29 changed files with 698 additions and 31 deletions

1
.gitignore vendored
View File

@ -20,6 +20,5 @@
Thumbs*.db
tmp
vendor/
.husky
/answer-data/
/answer

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ui && pnpm commitlint --edit $1 --config commitlint.config.js

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ui && npm run pre-commit

View File

@ -517,6 +517,7 @@ ui:
setting: Settings
logout: Log out
admin: Admin
review: Review
search:
placeholder: Search
footer:
@ -726,6 +727,9 @@ ui:
logout: Log out
verify: Verify
add_question: Add question
approve: Approve
reject: Reject
skip: Skip
search:
title: Search Results
keywords: Keywords
@ -1173,3 +1177,29 @@ ui:
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
page_review:
review: Reivew
proposed: proposed
question_edit: Question edit
answer_edit: Answer edit
tag_edit: Tag edit
edit_summary: Edit summary
empty: No review tasks left.
timeline:
undeleted: undeleted
deleted: deleted
downvote: downvote
upvote: upvote
accept: accept
cancelled: cancelled
commented: commented
rollback: rollback
edited: edited
answered: answered
asked: asked
closed: closed
reopened: reopened
created: created
title: "History for"
show_votes: "Show votes"
n_or_a: N/A

View File

@ -23,8 +23,9 @@ module.exports = {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: ['react', '@typescript-eslint'],
plugins: ['react', '@typescript-eslint', 'prettier'],
rules: {
'prettier/prettier': 'error',
'no-unused-vars': 'off',
'no-console': 'off',
'import/prefer-default-export': 'off',

View File

@ -1,5 +1,5 @@
{
"*.{js,jsx,ts,tsx}": ["eslint --cache --fix"],
"*.{js,jsx,less,md,json}": ["prettier --write"],
"*.{js,jsx,scss,md,json}": ["prettier --write"],
"*.ts?(x)": ["prettier --parser=typescript --write"]
}

View File

@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/routes-conventional'],
extends: ['@commitlint/config-conventional'],
};

View File

@ -9,7 +9,8 @@
"lint": "eslint . --cache --fix --ext .ts,.tsx",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js"
"preinstall": "node ./scripts/preinstall.js",
"pre-commit": "lint-staged"
},
"dependencies": {
"axios": "^0.27.2",
@ -39,6 +40,7 @@
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",
"@commitlint/config-conventional": "^17.2.0",
"@fullhuman/postcss-purgecss": "^4.1.3",
"@testing-library/dom": "^8.17.1",
"@testing-library/jest-dom": "^4.2.4",

View File

@ -2,6 +2,7 @@ lockfileVersion: 5.4
specifiers:
'@commitlint/cli': ^17.0.3
'@commitlint/config-conventional': ^17.2.0
'@fullhuman/postcss-purgecss': ^4.1.3
'@testing-library/dom': ^8.17.1
'@testing-library/jest-dom': ^4.2.4
@ -93,6 +94,7 @@ dependencies:
devDependencies:
'@commitlint/cli': 17.1.2
'@commitlint/config-conventional': 17.2.0
'@fullhuman/postcss-purgecss': 4.1.3_postcss@8.4.16
'@testing-library/dom': 8.18.1
'@testing-library/jest-dom': 4.2.4
@ -1421,6 +1423,13 @@ packages:
- '@swc/wasm'
dev: true
/@commitlint/config-conventional/17.2.0:
resolution: {integrity: sha512-g5hQqRa80f++SYS233dbDSg16YdyounMTAhVcmqtInNeY/GF3aA4st9SVtJxpeGrGmueMrU4L+BBb+6Vs5wrcg==}
engines: {node: '>=v14'}
dependencies:
conventional-changelog-conventionalcommits: 5.0.0
dev: true
/@commitlint/config-validator/17.1.0:
resolution: {integrity: sha512-Q1rRRSU09ngrTgeTXHq6ePJs2KrI+axPTgkNYDWSJIuS1Op4w3J30vUfSXjwn5YEJHklK3fSqWNHmBhmTR7Vdg==}
engines: {node: '>=v14'}
@ -3765,6 +3774,15 @@ packages:
q: 1.5.1
dev: true
/conventional-changelog-conventionalcommits/5.0.0:
resolution: {integrity: sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==}
engines: {node: '>=10'}
dependencies:
compare-func: 2.0.0
lodash: 4.17.21
q: 1.5.1
dev: true
/conventional-commits-parser/3.2.4:
resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
engines: {node: '>=10'}

View File

@ -557,3 +557,10 @@ export const TIMEZONES = [
},
];
export const DEFAULT_TIMEZONE = 'UTC+0';
export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
'undeleted',
'deleted',
'downvote',
'upvote',
];

View File

@ -124,6 +124,7 @@ export interface UserInfoRes extends UserInfoBase {
*/
mail_status: number;
language: string;
is_admin: boolean;
e_mail?: string;
[prop: string]: any;
}
@ -373,3 +374,36 @@ export interface AdminDashboard {
};
};
}
export interface TimelineReq {
object_type: string;
show_vote: boolean;
object_id?: string;
tag_slug_name?: string;
}
export interface TimelineItem {
activity_id: number;
revision_id: number;
created_at: number;
activity_type: string;
username: string;
user_display_name: string;
comment: string;
object_id: string;
object_type: string;
cancelled: boolean;
cancelled_at: any;
}
export interface TimelineObject {
title: string;
object_type: string;
question_id: string;
answer_id: string;
}
export interface TimelineRes {
object_info: TimelineObject;
timeline: TimelineItem[];
}

View File

@ -8,6 +8,7 @@ interface Props {
data: any;
showAvatar?: boolean;
avatarSize?: string;
showReputation?: boolean;
avatarSearchStr?: string;
className?: string;
}
@ -18,6 +19,7 @@ const Index: FC<Props> = ({
avatarSize = '20px',
className = 'fs-14',
avatarSearchStr = 's=48',
showReputation = true,
}) => {
return (
<div className={`text-secondary ${className}`}>
@ -47,9 +49,11 @@ const Index: FC<Props> = ({
</>
)}
<span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
{showReputation && (
<span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
)}
</div>
);
};

View File

@ -0,0 +1,83 @@
import { FC, memo } from 'react';
import { Tag } from '@/components';
import { diffText } from '@/utils';
interface Props {
currentData: Record<string, any>;
prevData?: Record<string, any>;
className?: string;
}
const Index: FC<Props> = ({ currentData, prevData, className = '' }) => {
if (!currentData?.content) return null;
let tag;
if (prevData?.tags) {
const addTags = currentData.tags.filter(
(c) => !prevData?.tags?.find((p) => p.slug_name === c.slug_name),
);
let deleteTags = prevData?.tags
.filter(
(c) => !currentData?.tags.find((p) => p.slug_name === c.slug_name),
)
.map((v) => ({ ...v, state: 'delete' }));
deleteTags = deleteTags?.map((v) => {
const index = prevData?.tags?.findIndex(
(c) => c.slug_name === v.slug_name,
);
return {
...v,
pre_index: index,
};
});
tag = currentData.tags.map((item) => {
const find = addTags.find((c) => c.slug_name === item.slug_name);
if (find) {
return {
...find,
state: 'add',
};
}
return item;
});
deleteTags.forEach((v) => {
tag.splice(v.pre_index, 0, v);
});
}
return (
<div className={className}>
<h5
dangerouslySetInnerHTML={{
__html: diffText(currentData.title, prevData?.title),
}}
className="mb-3"
/>
<div className="mb-4">
{tag.map((item) => {
return (
<Tag
key={item.slug_name}
className="me-1"
data={item}
textClassName={`d-inline-block review-text-${item.state}`}
/>
);
})}
</div>
<div
dangerouslySetInnerHTML={{
__html: diffText(currentData.content, prevData?.content),
}}
className="pre-line"
/>
</div>
);
};
export default memo(Index);

View File

@ -1,13 +1,15 @@
import { FC, memo } from 'react';
import { FC, memo, ReactNode } from 'react';
import { Trans } from 'react-i18next';
const Index: FC = () => {
const Index: FC<{ children?: ReactNode }> = ({ children }) => {
return (
<div className="text-center py-5">
<Trans i18nKey="personal.list_empty">
We couldn't find anything. <br /> Try different or less specific
keywords.
</Trans>
{children || (
<Trans i18nKey="personal.list_empty">
We couldn't find anything. <br /> Try different or less specific
keywords.
</Trans>
)}
</div>
);
};

View File

@ -56,6 +56,12 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
) : null}
{/* TODO: use review permission */}
{userInfo?.is_admin ? (
<Dropdown.Item href="/review">
{t('header.nav.review')}
</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={logOut}>
{t('header.nav.logout')}

View File

@ -122,9 +122,6 @@ const Header: FC = () => {
<NavLink className="nav-link" to="/tags">
{t('header.nav.tag')}
</NavLink>
<NavLink className="nav-link d-none" to="/users">
{t('header.nav.user')}
</NavLink>
</Nav>
</Col>
<hr className="hr lg-none mt-2" />

View File

@ -8,9 +8,15 @@ interface IProps {
data: Tag;
href?: string;
className?: string;
textClassName?: string;
}
const Index: FC<IProps> = ({ className = '', href, data }) => {
const Index: FC<IProps> = ({
data,
href,
className = '',
textClassName = '',
}) => {
href ||= `/tags/${encodeURIComponent(
data.main_tag_slug_name || data.slug_name,
)}`.toLowerCase();
@ -24,7 +30,7 @@ const Index: FC<IProps> = ({ className = '', href, data }) => {
data.recommend && 'badge-tag-required',
className,
)}>
{data.slug_name}
<span className={textClassName}>{data.slug_name}</span>
</a>
);
};

View File

@ -27,6 +27,7 @@ import QueryGroup from './QueryGroup';
import BrandUpload from './BrandUpload';
import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm';
import Labels from './LabelsCard';
import DiffContent from './DiffContent';
export {
Avatar,
@ -60,5 +61,6 @@ export {
SchemaForm,
initFormData,
Labels,
DiffContent,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -256,7 +256,7 @@ a {
.review-text-delete {
color: #842029;
background-color: #F8D7DA;
background-color: #f8d7da;
text-decoration: line-through;
.review-text-add {
text-decoration: none;
@ -264,7 +264,22 @@ a {
}
.review-text-add {
color: #0F5132;
background-color: #D1E7DD;
color: #0f5132;
background-color: #d1e7dd;
text-decoration: none !important;
}
.rotate-90-deg {
display: inline-block;
transform: rotate(90deg);
transition: transform 0.2s;
}
.rotate-0-deg {
display: inline-block;
transform: rotate(0deg);
transition: transform 0.2s;
}
.pre-line {
white-space: pre-line;
}

View File

@ -0,0 +1,74 @@
import { FC } from 'react';
import {
Container,
Row,
Col,
Alert,
Badge,
Stack,
Button,
} from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BaseUserCard, FormatTime, Empty } from '@/components';
import { loggedUserInfoStore } from '@/stores';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
const { user } = loggedUserInfoStore.getState();
return (
<Container className="pt-2 mt-4 mb-5">
<Row>
<Col lg={{ span: 7, offset: 1 }}>
<h3 className="mb-4">{t('review')}</h3>
<Alert variant="secondary">
<Stack className="align-items-start">
<Badge bg="secondary" className="mb-2">
{t('question_edit')}
</Badge>
<Link to="/review">
How do I test whether variable against multiple
</Link>
<p className="mb-0">
{t('edit_summary')}: Editing part of the code and correcting the
grammar.
</p>
</Stack>
<Stack
direction="horizontal"
gap={1}
className="align-items-baseline mt-2">
<BaseUserCard data={user} avatarSize="24" />
<FormatTime
time={Date.now()}
className="small text-secondary"
preFix={t('proposed')}
/>
</Stack>
</Alert>
</Col>
<Col lg={{ span: 7, offset: 1 }}>Content</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Stack direction="horizontal" gap={2}>
<Button variant="outline-primary">
{t('approve', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
{t('reject', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
{t('skip', { keyPrefix: 'btns' })}
</Button>
</Stack>
</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Empty>{t('empty')}</Empty>
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -0,0 +1,204 @@
import { FC, useState } from 'react';
import { Button, Row, Col } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Icon, BaseUserCard, DiffContent, FormatTime } from '@/components';
import { TIMELINE_NORMAL_ACTIVITY_TYPE } from '@/common/constants';
import * as Type from '@/common/interface';
const data1 = {
title: '不是管理员提一个问题看看能不能编辑resserved tag?',
tags: [
{
display_name: 'bug',
slug_name: 'bug',
recommend: true,
reserved: false,
},
{
display_name: '黄马甲',
slug_name: '黄马甲',
recommend: false,
reserved: true,
},
{
display_name: 'go',
slug_name: 'go',
recommend: false,
reserved: false,
},
],
content: `# 前言
Promise
## Promise
Promise CommonJS ES6 Promise ES6 BluebirdQ
Promise
Promise3-2-1
pendingfulfilledrejected
* pending fulfilledresolve
* pending rejectedreject
then
catch Promise.all/race/allSettled`,
};
const data2 = {
title: '提一个问题看看能不能编辑 resserved tag?',
tags: [
{
display_name: 'discussion',
slug_name: 'discussion',
recommend: true,
reserved: false,
},
{
display_name: '黄马甲',
slug_name: '黄马甲',
recommend: false,
reserved: true,
},
{
display_name: 'go',
slug_name: 'go',
recommend: false,
reserved: false,
},
],
content: `# 前言
Promise
## titlte
## Promise
Promise CommonJS ES6 Promise ES6 BluebirdQ
Promise
Promise3-2-1
* pending fulfilledresolve
* pending rejectedreject
then
`,
};
interface Props {
data: Type.TimelineItem;
objectInfo: Type.TimelineObject;
source: 'question' | 'answer' | 'tag';
isAdmin: boolean;
}
const Index: FC<Props> = ({
data,
isAdmin,
source = 'question',
objectInfo,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
const [isOpen, setIsOpen] = useState(false);
const handleItemClick = () => {
setIsOpen(!isOpen);
};
return (
<>
<tr>
<td>
<FormatTime time={data.created_at} />
<br />
{data.cancelled_at > 0 && <FormatTime time={data.cancelled_at} />}
</td>
<td>
{(data.activity_type === 'rollback' ||
data.activity_type === 'edited' ||
data.activity_type === 'asked' ||
data.activity_type === 'created' ||
(source === 'answer' && data.activity_type === 'answered')) && (
<Button
onClick={handleItemClick}
variant="link"
className="text-body p-0 btn-no-border">
<Icon
name="caret-right-fill"
className={`me-1 ${isOpen ? 'rotate-90-deg' : 'rotate-0-deg'}`}
/>
{t(data.activity_type)}
</Button>
)}
{data.activity_type === 'accept' && (
<Link to={`/question/${objectInfo.question_id}`}>
{t(data.activity_type)}
</Link>
)}
{source === 'question' && data.activity_type === 'answered' && (
<Link
to={`/question/${objectInfo.question_id}/${objectInfo.answer_id}`}>
{t(data.activity_type)}
</Link>
)}
{data.activity_type === 'commented' && (
<Link
to={
data.object_type === 'answer'
? `/question/${objectInfo.question_id}/${objectInfo.answer_id}?commentId=${data.object_id}`
: `/question/${objectInfo.question_id}?commentId=${data.object_id}`
}>
{t(data.activity_type)}
</Link>
)}
{TIMELINE_NORMAL_ACTIVITY_TYPE.includes(data.activity_type) && (
<div>{t(data.activity_type)}</div>
)}
{data.cancelled && (
<div className="text-danger"> {t('cancelled')}</div>
)}
</td>
<td>
{data.activity_type === 'downvote' && !isAdmin ? (
<div>{t('n_or_a')}</div>
) : (
<BaseUserCard
className="fs-normal"
data={{
username: data.username,
display_name: data.user_display_name,
}}
showAvatar={false}
showReputation={false}
/>
)}
</td>
<td>{data.comment}</td>
</tr>
<tr className={isOpen ? '' : 'd-none'}>
{/* <td /> */}
<td colSpan={5} className="p-0 py-5">
<Row className="justify-content-center">
<Col xxl={8}>
<DiffContent currentData={data1} prevData={data2} />
</Col>
</Row>
</td>
</tr>
</>
);
};
export default Index;

View File

@ -0,0 +1,140 @@
import { FC } from 'react';
import { Container, Row, Col, Form, Table } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { loggedUserInfoStore } from '@/stores';
import { useTimelineData } from '@/services';
import HistoryItem from './components/Item';
// const list = [
// {
// activity_id: 1,
// revision_id: 1,
// created_at: 1669084579,
// activity_type: 'deleted',
// username: 'John Doe',
// user_display_name: 'John Doe',
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
// object_id: '1',
// object_type: 'question',
// cancelled: false,
// cancelled_at: null,
// },
// {
// activity_id: 2,
// revision_id: 2,
// created_at: 1669084579,
// activity_type: 'undeleted',
// username: 'John Doe2',
// user_display_name: 'John Doe2',
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
// object_id: '2',
// object_type: 'question',
// cancelled: false,
// cancelled_at: null,
// },
// {
// activity_id: 3,
// revision_id: 3,
// created_at: 1669084579,
// activity_type: 'downvote',
// username: 'johndoe3',
// user_display_name: 'John Doe3',
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
// object_id: '3',
// object_type: 'question',
// cancelled: true,
// cancelled_at: 1637021579,
// },
// {
// activity_id: 4,
// revision_id: 4,
// created_at: 1669084579,
// activity_type: 'rollback',
// username: 'johndoe4',
// user_display_name: 'John Doe4',
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
// object_id: '4',
// object_type: 'question',
// cancelled: false,
// cancelled_at: null,
// },
// {
// activity_id: 5,
// revision_id: 5,
// created_at: 1669084579,
// activity_type: 'edited',
// username: 'johndoe4',
// user_display_name: 'John Doe4',
// object_id: '5',
// object_type: 'question',
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
// cancelled: false,
// cancelled_at: null,
// },
// ];
// const object_info = {
// title: '问题标题,当回答时也是问题标题,当为 tag 时是 slug_name',
// object_type: 'question', // question/answer/tag
// question_id: 'xxxxxxxxxxxxxxxxxxx',
// answer_id: 'xxxxxxxxxxxxxxxx',
// };
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
const { is_admin } = loggedUserInfoStore((state) => state.user);
const { data: timelineData } = useTimelineData({
object_id: '10010000000000001',
object_type: 'question',
show_vote: false,
});
console.log('timelineData=', timelineData);
return (
<Container className="py-3">
<Row className="py-3 justify-content-center">
<Col xxl={10}>
<h5 className="mb-4">
{t('title')} <Link to="/">{timelineData?.object_info?.title}</Link>
</h5>
<Form.Check
className="mb-4"
type="switch"
id="custom-switch"
label={t('show_votes')}
/>
<Table hover>
<thead>
<tr>
<th style={{ width: '20%' }}>Datetime</th>
<th style={{ width: '15%' }}>Type</th>
<th style={{ width: '19%' }}>By</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{timelineData?.timeline?.map((item) => {
return (
<HistoryItem
data={item}
objectInfo={timelineData?.object_info}
key={item.revision_id}
isAdmin={is_admin}
source="question"
/>
);
})}
</tbody>
</Table>
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -83,7 +83,7 @@ const routes: RouteNode[] = [
return guard.activated();
},
},
// users
// for users
{
path: 'users/:username',
page: 'pages/Users/Personal',
@ -202,8 +202,11 @@ const routes: RouteNode[] = [
},
},
{
path: '/revision',
page: 'pages/Revision',
path: '/history',
page: 'pages/Timeline',
guard: async () => {
return guard.logged();
},
},
// for admin
{
@ -268,6 +271,11 @@ const routes: RouteNode[] = [
},
],
},
// for review
{
path: 'review',
page: 'pages/Review',
},
{
path: '*',
page: 'pages/404',

View File

@ -6,3 +6,4 @@ export * from './search';
export * from './tag';
export * from './settings';
export * from './legal';
export * from './timeline';

View File

@ -0,0 +1,19 @@
import useSWR from 'swr';
import qs from 'qs';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const useTimelineData = (params: Type.TimelineReq) => {
const apiUrl = '/answer/api/v1/activity/timeline';
const { data, error, mutate } = useSWR<Type.TimelineRes, Error>(
`${apiUrl}?${qs.stringify(params, { skipNulls: true })}`,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -26,6 +26,7 @@ const initUser: UserInfoRes = {
status: '',
mail_status: 1,
language: 'Default',
is_admin: false,
};
const loggedUserInfoStore = create<UserInfoStore>((set) => ({

View File

@ -166,6 +166,16 @@ function handleFormError(
}
function diffText(newText: string, oldText: string): string {
if (!newText) {
return '';
}
if (!oldText) {
return newText
?.replace(/\n/gi, '<br>')
?.replace(/<iframe/gi, '&lt;iframe')
?.replace(/<input/gi, '&lt;input');
}
const diff = Diff.diffChars(newText, oldText);
const result = diff.map((part) => {
if (part.added) {
@ -176,9 +186,9 @@ function diffText(newText: string, oldText: string): string {
}
return part.value;
});
return result
.join('')
?.replace(/\n/gi, '<br>')
?.replace(/<iframe/gi, '&lt;iframe')
?.replace(/<input/gi, '&lt;input');
}