chore: import upstream snapshot with attribution
CI / Check and lint (push) Failing after 1s
Lint / Lint (ubuntu-latest) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.collapse-indicator {
transition: all 0.2s ease;
}
.expanding .collapse-indicator {
transform: rotate(90deg);
}
#answerAccordion {
max-width: 208px;
.nav-link {
color: var(--an-side-nav-link);
}
.nav-link:focus-visible {
box-shadow: none;
}
.nav-link:hover {
color: var(--an-side-nav-link-hover-color);
background-color: var(--bs-gray-100);
}
.nav-link.active {
color: var(--an-side-nav-link-hover-color);
background-color: var(--bs-gray-200);
}
}
+231
View File
@@ -0,0 +1,231 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import { Accordion, Nav } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useNavigate, useMatch, NavLink } from 'react-router-dom';
import classNames from 'classnames';
import { floppyNavigation } from '@/utils';
import { Icon } from '@/components';
import './index.css';
export interface MenuItem {
name: string;
path?: string;
pathPrefix?: string;
icon?: string;
displayName?: string;
badgeContent?: string | number;
children?: MenuItem[];
}
function MenuNode({
menu,
callback,
activeKey,
expanding = false,
path = '/',
}: {
menu: MenuItem;
callback: (evt: any, menu: MenuItem, href: string, isLeaf: boolean) => void;
activeKey: string;
expanding?: boolean;
path?: string;
}) {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
const isLeaf = !menu.children || menu.children.length === 0;
const href = isLeaf ? `${path}${menu.path || ''}` : '#';
return (
<Nav.Item key={menu.path} className="w-100">
{isLeaf ? (
<Nav.Link
eventKey={menu.path}
as={NavLink}
to={href}
onClick={(evt) => {
callback(evt, menu, href, isLeaf);
}}
className={classNames(
'text-nowrap d-flex flex-nowrap align-items-center w-100',
{
expanding,
active:
activeKey === menu.path ||
(menu.path && activeKey.startsWith(`${menu.path}/`)) ||
// if pathPrefix is set, activate when activeKey starts with the pathPrefix
(menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)),
},
)}>
{menu?.icon && <Icon name={menu.icon} className="me-2" />}
<span className="me-auto text-truncate">
{menu.displayName ? menu.displayName : t(menu.name)}
</span>
{menu.badgeContent ? (
<span className="badge text-bg-dark">{menu.badgeContent}</span>
) : null}
{!isLeaf && (
<Icon className="collapse-indicator" name="chevron-right" />
)}
</Nav.Link>
) : (
<Nav.Link
eventKey={menu.path}
as="button"
href={href}
onClick={(evt) => {
callback(evt, menu, href, isLeaf);
}}
className={classNames(
'text-nowrap d-flex flex-nowrap align-items-center w-100',
{
expanding,
active:
activeKey === menu.path ||
(menu.path && activeKey.startsWith(`${menu.path}/`)) ||
(menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)),
},
)}>
{menu?.icon && <Icon name={menu.icon} className="me-2" />}
<span className="me-auto text-truncate">
{menu.displayName ? menu.displayName : t(menu.name)}
</span>
{menu.badgeContent ? (
<span className="badge text-bg-dark">{menu.badgeContent}</span>
) : null}
{!isLeaf && (
<Icon className="collapse-indicator" name="chevron-right" />
)}
</Nav.Link>
)}
{menu.children && menu.children.length > 0 ? (
<Accordion.Collapse eventKey={menu.path || menu.name} className="ms-4">
<>
{menu.children.map((leaf) => {
return (
<MenuNode
menu={leaf}
callback={callback}
activeKey={activeKey}
path={path}
key={leaf.path || leaf.name}
/>
);
})}
</>
</Accordion.Collapse>
) : null}
</Nav.Item>
);
}
interface AccordionProps {
menus: MenuItem[];
path?: string;
}
const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
const navigate = useNavigate();
const pathMatch = useMatch(`${path}*`);
// auto set menu fields
menus.forEach((m) => {
if (!m.path) {
m.path = m.name;
}
if (!Array.isArray(m.children)) {
m.children = [];
}
m.children.forEach((sm) => {
if (!sm.path) {
sm.path = sm.name;
}
if (!Array.isArray(sm.children)) {
sm.children = [];
}
});
});
const splat = pathMatch && pathMatch.params['*'];
let activeKey: string = menus[0]?.path || menus[0]?.name || '';
if (splat) {
activeKey = splat;
}
const getOpenKey = () => {
let openKey = '';
menus.forEach((li) => {
if (li.children && li.children.length > 0) {
const matchedChild = li.children.find((el) => {
// exact match or path prefix match
return (
el.path === activeKey ||
(el.path && activeKey.startsWith(`${el.path}/`)) ||
// if pathPrefix is set, activate when activeKey starts with the pathPrefix
(el.pathPrefix && activeKey.startsWith(el.pathPrefix))
);
});
if (matchedChild) {
openKey = li.path || li.name || '';
}
}
});
return openKey;
};
const [openKey, setOpenKey] = useState(getOpenKey());
const menuClick = (evt, menu, href, isLeaf) => {
evt.stopPropagation();
if (isLeaf) {
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
navigate(href);
}
} else {
setOpenKey(openKey === menu.path ? '' : menu.path);
}
};
useEffect(() => {
setOpenKey(getOpenKey());
}, [activeKey, menus]);
return (
<Accordion activeKey={openKey} flush id="answerAccordion">
<Nav variant="pills" className="flex-column" activeKey={activeKey}>
{menus.map((li) => {
return (
<MenuNode
menu={li}
path={path}
callback={menuClick}
activeKey={activeKey}
expanding={openKey === (li.path || li.name)}
key={li.path || li.name}
/>
);
})}
</Nav>
</Accordion>
);
};
export default AccordionNav;
+192
View File
@@ -0,0 +1,192 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, FC, useState, useEffect } from 'react';
import { Button, ButtonGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Icon } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { useToast } from '@/hooks';
import { useCaptchaPlugin } from '@/utils/pluginKit';
import { tryNormalLogged } from '@/utils/guard';
import { bookmark, postVote } from '@/services';
import * as Types from '@/common/interface';
interface Props {
className?: string;
source: 'question' | 'answer';
data: {
id: string;
votesCount: number;
isLike: boolean;
isHate: boolean;
hideCollect?: boolean;
collected: boolean;
collectCount: number;
username: string;
};
}
const Index: FC<Props> = ({ className, data, source }) => {
const [votes, setVotes] = useState(0);
const [like, setLike] = useState(false);
const [hate, setHated] = useState(false);
const [bookmarkState, setBookmark] = useState({
state: data?.collected,
count: data?.collectCount,
});
const { username = '' } = loggedUserInfoStore((state) => state.user);
const toast = useToast();
const { t } = useTranslation();
const vCaptcha = useCaptchaPlugin('vote');
useEffect(() => {
if (data) {
setVotes(data.votesCount);
setLike(data.isLike);
setHated(data.isHate);
setBookmark({
state: data?.collected,
count: data?.collectCount,
});
}
}, []);
const submitVote = (type) => {
const isCancel = (type === 'up' && like) || (type === 'down' && hate);
const imgCode: Types.ImgCodeReq = {
captcha_id: undefined,
captcha_code: undefined,
};
vCaptcha?.resolveCaptchaReq?.(imgCode);
postVote(
{
object_id: data?.id,
is_cancel: isCancel,
...imgCode,
},
type,
)
.then(async (res) => {
await vCaptcha?.close();
setVotes(res.votes);
setLike(res.vote_status === 'vote_up');
setHated(res.vote_status === 'vote_down');
})
.catch((err) => {
if (err?.isError) {
vCaptcha?.handleCaptchaError(err.list);
}
const errMsg = err?.value;
if (errMsg) {
toast.onShow({
msg: errMsg,
variant: 'danger',
});
}
});
};
const handleVote = (type: 'up' | 'down') => {
if (!tryNormalLogged(true)) {
return;
}
if (data.username === username) {
toast.onShow({
msg: t('cannot_vote_for_self'),
variant: 'danger',
});
return;
}
if (!vCaptcha) {
submitVote(type);
return;
}
vCaptcha.check(() => {
submitVote(type);
});
};
const handleBookmark = () => {
if (!tryNormalLogged(true)) {
return;
}
bookmark({
group_id: '0',
object_id: data?.id,
bookmark: !bookmarkState.state,
}).then((res) => {
setBookmark({
state: !bookmarkState.state,
count: res.object_collection_count,
});
});
};
return (
<div className={classNames(className)}>
<ButtonGroup>
<Button
title={
source === 'question'
? t('question_detail.question_useful')
: t('question_detail.answer_useful')
}
variant="outline-secondary"
active={like}
onClick={() => handleVote('up')}>
<Icon name="hand-thumbs-up-fill" />
</Button>
<Button variant="outline-secondary" className="opacity-100" disabled>
{votes}
</Button>
<Button
title={
source === 'question'
? t('question_detail.question_un_useful')
: t('question_detail.answer_un_useful')
}
variant="outline-secondary"
active={hate}
onClick={() => handleVote('down')}>
<Icon name="hand-thumbs-down-fill" />
</Button>
</ButtonGroup>
{!data?.hideCollect && (
<Button
variant="outline-secondary ms-3"
title={t('question_detail.question_bookmark')}
active={bookmarkState.state}
onClick={handleBookmark}>
<Icon name="bookmark-fill" />
<span style={{ paddingLeft: '10px' }}>{bookmarkState.count}</span>
</Button>
)}
</div>
);
};
export default memo(Index);
+84
View File
@@ -0,0 +1,84 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import cloneDeep from 'lodash/cloneDeep';
import { AccordionNav, Icon } from '@/components';
import type { MenuItem } from '@/components/AccordionNav';
import { ADMIN_NAV_MENUS } from '@/common/constants';
import { useQueryPlugins } from '@/services';
import { interfaceStore } from '@/stores';
const AdminSideNav = () => {
const { t } = useTranslation('translation', { keyPrefix: 'btns' });
const interfaceLang = interfaceStore((_) => _.interface.language);
const { data: configurablePlugins, mutate: updateConfigurablePlugins } =
useQueryPlugins({
status: 'active',
have_config: true,
});
const menus = cloneDeep(ADMIN_NAV_MENUS) as MenuItem[];
if (configurablePlugins && configurablePlugins.length > 0) {
menus.forEach((item) => {
if (item.name === 'plugins' && item.children) {
item.children = [
...item.children,
...configurablePlugins.map(
(plugin): MenuItem => ({
name: plugin.slug_name,
displayName: plugin.name,
}),
),
];
}
});
}
const observePlugins = (evt) => {
if (evt.data.msgType === 'refreshConfigurablePlugins') {
updateConfigurablePlugins();
}
};
useEffect(() => {
window.addEventListener('message', observePlugins);
return () => {
window.removeEventListener('message', observePlugins);
};
}, []);
useEffect(() => {
updateConfigurablePlugins();
}, [interfaceLang]);
return (
<div id="adminSideNav">
<NavLink to="/" className="pb-3 d-inline-block link-secondary">
<Icon name="arrow-left" className="me-2" />
<span>{t('back_sites')}</span>
</NavLink>
<AccordionNav menus={menus} path="/admin/" />
</div>
);
};
export default AdminSideNav;
+70
View File
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, FC } from 'react';
import classNames from 'classnames';
import DefaultAvatar from '@/assets/images/default-avatar.svg';
interface IProps {
/** avatar url */
avatar: string | { type: string; gravatar: string; custom: string };
/** size 48 96 128 256 */
size: string;
searchStr?: string;
className?: string;
alt: string;
}
const Index: FC<IProps> = ({
avatar,
size,
className,
searchStr = '',
alt,
}) => {
let url = '';
if (typeof avatar === 'string') {
if (avatar.length > 1) {
url = `${avatar}?${searchStr}${
avatar?.includes('gravatar') ? '&d=identicon' : ''
}`;
}
} else if (avatar?.type === 'gravatar' && avatar.gravatar) {
url = `${avatar.gravatar}?${searchStr}&d=identicon`;
} else if (avatar?.type === 'custom' && avatar.custom) {
url = `${avatar.custom}?${searchStr}`;
}
const roundedCls =
className && className.indexOf('rounded') !== -1 ? '' : 'rounded-circle';
return (
<img
src={url || DefaultAvatar}
width={size}
height={size}
className={classNames(roundedCls, className)}
alt={alt}
/>
);
};
export default memo(Index);
+95
View File
@@ -0,0 +1,95 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, FC } from 'react';
import { Link } from 'react-router-dom';
import { Avatar } from '@/components';
import { formatCount } from '@/utils';
interface Props {
data: any;
showAvatar?: boolean;
avatarSize?: string;
showReputation?: boolean;
avatarSearchStr?: string;
className?: string;
avatarClass?: string;
nameMaxWidth?: string;
}
const Index: FC<Props> = ({
data,
showAvatar = true,
avatarClass = '',
avatarSize = '24px',
className = 'small',
avatarSearchStr = 's=48',
showReputation = true,
nameMaxWidth = '300px',
}) => {
return (
<div className={`d-flex align-items-center text-secondary ${className}`}>
{data?.status !== 'deleted' ? (
<Link
to={`/users/${data?.username}`}
onClick={(e) => {
e.stopPropagation();
}}
className="d-flex align-items-center">
{showAvatar && (
<Avatar
avatar={data?.avatar}
size={avatarSize}
className={`me-1 ${avatarClass}`}
searchStr={avatarSearchStr}
alt={data?.display_name}
/>
)}
<span
className="me-1 name-ellipsis"
style={{ maxWidth: nameMaxWidth }}>
{data?.display_name}
</span>
</Link>
) : (
<>
{showAvatar && (
<Avatar
avatar={data?.avatar}
size={avatarSize}
className={`me-1 ${avatarClass}`}
searchStr={avatarSearchStr}
alt={data?.display_name}
/>
)}
<span className="me-1 name-ellipsis">{data?.display_name}</span>
</>
)}
{showReputation && (
<span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
)}
</div>
);
};
export default memo(Index);
+83
View File
@@ -0,0 +1,83 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { ButtonGroup, Button } from 'react-bootstrap';
import classNames from 'classnames';
import { Icon, UploadImg } from '@/components';
import { UploadType } from '@/common/interface';
interface Props {
type: UploadType;
value: string;
onChange: (value: string) => void;
acceptType?: string;
readOnly?: boolean;
imgClassNames?: classNames.Argument;
}
const Index: FC<Props> = ({
type = 'post',
value,
onChange,
acceptType,
readOnly = false,
imgClassNames = '',
}) => {
const onUpload = (imgPath: string) => {
onChange(imgPath);
};
const onRemove = () => {
onChange('');
};
return (
<div className="d-flex">
<div className="bg-gray-300 upload-img-wrap me-2 d-flex align-items-center justify-content-center">
<img
className={classNames(imgClassNames)}
src={value}
alt=""
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
</div>
<ButtonGroup vertical className="fit-content">
<UploadImg
type={type}
uploadCallback={onUpload}
className="mb-0"
disabled={readOnly}
acceptType={acceptType}>
<Icon name="cloud-upload" />
</UploadImg>
<Button
disabled={readOnly}
variant="outline-secondary"
onClick={onRemove}>
<Icon name="trash" />
</Button>
</ButtonGroup>
</div>
);
};
export default Index;
+353
View File
@@ -0,0 +1,353 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import copy from 'copy-to-clipboard';
import { markdownToHtml, voteConversation } from '@/services';
import { Icon, htmlRender } from '@/components';
interface IProps {
canType?: boolean;
chatId: string;
isLast: boolean;
isCompleted: boolean;
content: string;
reasoningContent?: string;
minHeight?: number;
actionData: {
helpful: number;
unhelpful: number;
};
}
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const renderPlainTextAsHtml = (text: string) =>
escapeHtml(text).replace(/\r?\n/g, '<br />');
const BubbleAi: FC<IProps> = ({
canType = false,
isLast,
isCompleted,
content,
reasoningContent = '',
chatId = '',
actionData,
minHeight = 0,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
const [displayContent, setDisplayContent] = useState('');
const [copyText, setCopyText] = useState<string>(t('copy'));
const [isHelpful, setIsHelpful] = useState(false);
const [isUnhelpful, setIsUnhelpful] = useState(false);
const [canShowAction, setCanShowAction] = useState(false);
const [isThinkingOpen, setIsThinkingOpen] = useState(true);
const [safeHtml, setSafeHtml] = useState('');
const typewriterRef = useRef<{
timer: NodeJS.Timeout | null;
index: number;
isTyping: boolean;
}>({
timer: null,
index: 0,
isTyping: false,
});
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
const renderTaskRef = useRef(0);
const fmtContainer = useRef<HTMLDivElement>(null);
// add ref for ScrollIntoView
const containerRef = useRef<HTMLDivElement>(null);
const handleCopy = () => {
const res = copy(displayContent);
if (res) {
setCopyText(t('copied', { keyPrefix: 'messages' }));
setTimeout(() => {
setCopyText(t('copy'));
}, 1200);
}
};
const handleVote = (voteType: 'helpful' | 'unhelpful') => {
const isCancel =
(voteType === 'helpful' && isHelpful) ||
(voteType === 'unhelpful' && isUnhelpful);
voteConversation({
chat_completion_id: chatId,
cancel: isCancel,
vote_type: voteType,
}).then(() => {
setIsHelpful(voteType === 'helpful' && !isCancel);
setIsUnhelpful(voteType === 'unhelpful' && !isCancel);
});
};
useEffect(() => {
if ((!canType || !isLast) && content) {
// 如果不是最后一个消息,直接返回,不进行打字效果
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
setDisplayContent(content);
setCanShowAction(true);
typewriterRef.current.timer = null;
typewriterRef.current.isTyping = false;
return;
}
// 当内容变化时,清理之前的计时器
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
// 如果内容为空,则直接返回
if (!content) {
setDisplayContent('');
return;
}
// 如果内容比当前显示的短,则重置
if (content.length < displayContent.length) {
setDisplayContent('');
typewriterRef.current.index = 0;
}
// 如果内容与显示内容相同,不需要做任何事
if (content === displayContent) {
return;
}
typewriterRef.current.isTyping = true;
// start typing animation
typewriterRef.current.timer = setInterval(() => {
const currentIndex = typewriterRef.current.index;
if (currentIndex < content.length) {
const remainingLength = content.length - currentIndex;
const baseRandomNum = Math.floor(Math.random() * 3) + 2;
let randomNum = Math.min(baseRandomNum, remainingLength);
// 简单的单词边界检查(可选)
const nextChar = content[currentIndex + randomNum];
const prevChar = content[currentIndex + randomNum - 1];
// 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处
if (
nextChar &&
/[a-zA-Z]/.test(nextChar) &&
/[a-zA-Z]/.test(prevChar)
) {
// 向前找1-2个字符,看看有没有空格
for (
let i = 1;
i <= 2 && currentIndex + randomNum - i > currentIndex;
i += 1
) {
if (content[currentIndex + randomNum - i] === ' ') {
randomNum = randomNum - i + 1;
break;
}
}
// 向后找1-2个字符,看看有没有空格
for (
let i = 1;
i <= 2 && currentIndex + randomNum + i < content.length;
i += 1
) {
if (content[currentIndex + randomNum + i] === ' ') {
randomNum = randomNum + i + 1;
break;
}
}
}
const nextIndex = currentIndex + randomNum;
const newContent = content.substring(0, nextIndex);
setDisplayContent(newContent);
typewriterRef.current.index = nextIndex;
setCanShowAction(false);
} else {
clearInterval(typewriterRef.current.timer as NodeJS.Timeout);
typewriterRef.current.timer = null;
typewriterRef.current.isTyping = false;
setCanShowAction(false);
}
}, 30);
// eslint-disable-next-line consistent-return
return () => {
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
};
}, [content, isCompleted]);
useEffect(() => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
renderTaskRef.current += 1;
const currentRenderTask = renderTaskRef.current;
if (!displayContent) {
setSafeHtml('');
return undefined;
}
// During streaming, render escaped plain text to avoid executing unsanitized HTML.
if (!isCompleted) {
setSafeHtml(renderPlainTextAsHtml(displayContent));
return undefined;
}
renderTimerRef.current = setTimeout(() => {
markdownToHtml(displayContent)
.then((resp) => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(resp || renderPlainTextAsHtml(displayContent));
})
.catch(() => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(renderPlainTextAsHtml(displayContent));
});
}, 0);
return () => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
};
}, [displayContent, isCompleted]);
useEffect(() => {
setIsHelpful(actionData.helpful > 0);
setIsUnhelpful(actionData.unhelpful > 0);
}, [actionData]);
// Auto-collapse the "Thinking" panel once the actual answer starts streaming
// (only while the message is being generated; users can still toggle manually).
useEffect(() => {
if (content && !isCompleted) {
setIsThinkingOpen(false);
}
}, [content, isCompleted]);
useEffect(() => {
if (fmtContainer.current && isCompleted && safeHtml) {
htmlRender(fmtContainer.current, {
copySuccessText: t('copied', { keyPrefix: 'messages' }),
copyText: t('copy', { keyPrefix: 'messages' }),
});
const links = fmtContainer.current.querySelectorAll('a');
links.forEach((link) => {
link.setAttribute('target', '_blank');
});
setCanShowAction(true);
}
}, [isCompleted, safeHtml, t]);
return (
<div
className="rounded bubble-ai"
ref={containerRef}
style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
<div id={chatId}>
{reasoningContent ? (
<div
className="bubble-ai-thinking mb-2 border-start border-2 ps-2 small text-secondary"
style={{ borderColor: 'var(--bs-border-color)' }}>
<Button
variant="link"
className="p-0 link-secondary small text-decoration-none d-inline-flex align-items-center"
onClick={() => setIsThinkingOpen((v) => !v)}>
<Icon name={isThinkingOpen ? 'chevron-down' : 'chevron-right'} />
<span className="ms-1">
{isCompleted ? t('thoughts') : t('thinking')}
</span>
</Button>
{isThinkingOpen && (
<div
className="mt-1 text-secondary"
style={{
whiteSpace: 'pre-wrap',
fontStyle: 'italic',
opacity: 0.85,
}}>
{reasoningContent}
</div>
)}
</div>
) : null}
<div
className="fmt text-break text-wrap"
ref={fmtContainer}
style={{ transition: 'all 0.2s ease' }}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
{canShowAction && (
<div className="action">
<Button
variant="link"
className="p-0 link-secondary small me-3"
onClick={handleCopy}>
<Icon name="copy" />
<span className="ms-1">{copyText}</span>
</Button>
<Button
variant="link"
className={`p-0 small me-3 ${isHelpful ? 'link-primary active' : 'link-secondary'}`}
onClick={() => handleVote('helpful')}>
<Icon name="hand-thumbs-up-fill" />
<span className="ms-1">Helpful</span>
</Button>
<Button
variant="link"
className={`p-0 small me-3 ${isUnhelpful ? 'link-primary active' : 'link-secondary'}`}
onClick={() => handleVote('unhelpful')}>
<Icon name="hand-thumbs-down-fill" />
<span className="ms-1">Unhelpful</span>
</Button>
</div>
)}
</div>
</div>
);
};
export default BubbleAi;
+29
View File
@@ -0,0 +1,29 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.bubble-user-wrap {
scroll-margin-top: 88px;
}
.bubble-user {
background-color: var(--bs-gray-200);
[data-bs-theme='dark'] & {
background-color: var(--bs-gray-800);
}
}
+37
View File
@@ -0,0 +1,37 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import './index.scss';
interface BubbleUserProps {
content?: string;
}
const BubbleUser: FC<BubbleUserProps> = ({ content }) => {
return (
<div className="text-end bubble-user-wrap">
<div className="d-inline-block text-start bubble-user p-3 rounded pre-line">
{content}
</div>
</div>
);
};
export default BubbleUser;
+26
View File
@@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.badge-card {
.label {
position: absolute;
top: 1rem;
right: 1rem;
}
}
+96
View File
@@ -0,0 +1,96 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useTranslation } from 'react-i18next';
import { FC } from 'react';
import { Card, Badge } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import { Icon } from '@/components';
import * as Type from '@/common/interface';
import { formatCount } from '@/utils';
import './index.scss';
interface IProps {
data: Type.BadgeListItem;
showAwardedCount?: boolean;
urlSearchParams?: string;
badgePillType?: 'earned' | 'count';
}
const Index: FC<IProps> = ({
data,
badgePillType = 'earned',
showAwardedCount = false,
urlSearchParams,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'badges' });
return (
<Link
className="card text-center badge-card"
to={`/badges/${data.id}${urlSearchParams ? `?${urlSearchParams}` : ''}`}>
<Card.Body>
{Number(data?.earned_count) > 0 && badgePillType === 'earned' && (
<Badge
bg="success"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}>
{`${t('earned')}${
Number(data?.earned_count) > 1 ? ` ×${data.earned_count}` : ''
}`}
</Badge>
)}
{badgePillType === 'count' && Number(data?.earned_count) > 1 && (
<Badge
pill
bg="secondary"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}>
×{data.earned_count}
</Badge>
)}
{data.icon.startsWith('http') ? (
<img src={data.icon} width={96} height={96} alt={data.name} />
) : (
<Icon
name={data.icon}
size="96px"
className={classnames(
'lh-1',
data.level === 1 && 'bronze',
data.level === 2 && 'silver',
data.level === 3 && 'gold',
)}
/>
)}
<h6 className="mb-0 mt-3 text-center">{data.name}</h6>
{showAwardedCount && (
<div className="small text-secondary mt-2">
{t('×_awarded', { number: formatCount(data.award_count) })}
</div>
)}
</Card.Body>
</Link>
);
};
export default Index;
@@ -0,0 +1,119 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { Button, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { Icon, FormatTime } from '@/components';
const ActionBar = ({
nickName,
username,
createdAt,
isVote,
voteCount = 0,
memberActions,
onReply,
onVote,
onAction,
userStatus = '',
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
return (
<div className="d-flex justify-content-between flex-wrap small">
<div className="d-flex align-items-center flex-wrap link-secondary">
{userStatus !== 'deleted' ? (
<Link
to={`/users/${username}`}
className="name-ellipsis"
style={{ maxWidth: '200px' }}>
{nickName}
</Link>
) : (
<span>{nickName}</span>
)}
<span className="mx-1"></span>
<FormatTime time={createdAt} className="me-3 flex-shrink-0" />
<Button
title={t('tip_vote')}
variant="link"
size="sm"
className={`flex-shrink-0 me-3 btn-no-border p-0 ${
isVote ? '' : 'link-secondary'
}`}
onClick={onVote}>
<Icon name="hand-thumbs-up-fill" />
{voteCount > 0 && (
<span className="ms-2 link-secondary">{voteCount}</span>
)}
</Button>
<Button
variant="link"
size="sm"
className="link-secondary m-0 p-0 btn-no-border"
onClick={onReply}>
{t('btn_reply')}
</Button>
</div>
<div className="align-items-center control-area d-none">
{memberActions.map((action, index) => {
return (
<Button
key={action.name}
variant="link"
size="sm"
className={classNames(
'link-secondary btn-no-border m-0 p-0',
index > 0 && 'ms-3',
)}
onClick={() => onAction(action)}>
{action.name}
</Button>
);
})}
</div>
<Dropdown className="d-block d-md-none">
<Dropdown.Toggle
as="div"
variant="success"
className="no-toggle"
id="dropdown-comment">
<Icon name="three-dots" className="text-secondary" />
</Dropdown.Toggle>
<Dropdown.Menu align="end">
{memberActions.map((action) => {
return (
<Dropdown.Item key={action.name} onClick={() => onAction(action)}>
{action.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
</div>
);
};
export default memo(ActionBar);
@@ -0,0 +1,124 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, memo } from 'react';
import { Button, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers, usePromptWithUnload } from '@/hooks';
import { parseEditMentionUser } from '@/utils';
const Index = ({
className = '',
value: initialValue = '',
onSendReply,
type = '',
onCancel,
mode,
}) => {
const [value, setValue] = useState('');
const [immData, setImmData] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
usePromptWithUnload({
when: type === 'edit' ? immData !== value : Boolean(value),
});
useEffect(() => {
if (!initialValue) {
return;
}
setImmData(initialValue);
setValue(initialValue);
}, [initialValue]);
const handleChange = (e) => {
setValue(e.target.value);
};
const handleSelected = (val) => {
setValue(val);
};
const handleSendReply = () => {
onSendReply(value).catch((ex) => {
if (ex.isError) {
setValidationErrorMsg(ex.msg);
}
});
};
return (
<div
className={classNames(
'd-flex align-items-start flex-column flex-md-row',
className,
)}>
<div className="w-100">
<div
className={classNames('custom-form-control', {
'is-invalid': validationErrorMsg,
})}>
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea
size="sm"
value={type === 'edit' ? parseEditMentionUser(value) : value}
onChange={handleChange}
isInvalid={validationErrorMsg !== ''}
/>
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
</div>
<Form.Control.Feedback type="invalid">
{validationErrorMsg}
</Form.Control.Feedback>
</div>
{type === 'edit' ? (
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button
size="sm"
className="text-nowrap "
onClick={() => handleSendReply()}>
{t('btn_save_edits')}
</Button>
<Button
variant="link"
size="sm"
className="text-nowrap btn-no-border ms-2 ms-md-0"
onClick={onCancel}>
{t('btn_cancel')}
</Button>
</div>
) : (
<Button
size="sm"
className="text-nowrap ms-0 ms-md-2 mt-2 mt-md-0"
onClick={() => handleSendReply()}>
{t('btn_add_comment')}
</Button>
)}
</div>
);
};
export default memo(Index);
@@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, memo } from 'react';
import { Button, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers, usePromptWithUnload } from '@/hooks';
const Index = ({ userName, onSendReply, onCancel, mode }) => {
const [value, setValue] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
usePromptWithUnload({
when: Boolean(value),
});
const handleChange = (e) => {
setValue(e.target.value);
};
const handleSelected = (val) => {
setValue(val);
};
const handleSendReply = () => {
onSendReply(value).catch((ex) => {
if (ex.isError) {
setValidationErrorMsg(ex.msg);
}
});
};
return (
<div className="mb-2">
<div className="small mb-2">
{t('reply_to')} {userName}
</div>
<div className="d-flex mb-1 align-items-start flex-column flex-md-row">
<div className="w-100">
<div
className={classNames('custom-form-control', {
'is-invalid': validationErrorMsg,
})}>
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea
size="sm"
value={value}
onChange={handleChange}
isInvalid={validationErrorMsg !== ''}
/>
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
</div>
<Form.Control.Feedback type="invalid">
{validationErrorMsg}
</Form.Control.Feedback>
</div>
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button
size="sm"
className="text-nowrap"
onClick={() => handleSendReply()}>
{t('btn_add_comment')}
</Button>
<Button
variant="link"
size="sm"
className="text-nowrap btn-no-border ms-2 ms-md-0"
onClick={onCancel}>
{t('btn_cancel')}
</Button>
</div>
</div>
</div>
);
};
export default memo(Index);
@@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Form from './Form';
import ActionBar from './ActionBar';
import Reply from './Reply';
export { Form, ActionBar, Reply };
+46
View File
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/_breakpoints';
.comments-wrap {
.comment-item {
&:hover {
@include media-breakpoint-up(md) {
.control-area {
display: flex !important;
}
}
}
border-bottom: 1px solid var(--an-comment-item-border-bottom);
}
.fmt {
display: inline;
p {
&:last-child {
display: inline;
}
}
img {
display: block;
}
}
}
+516
View File
@@ -0,0 +1,516 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useState, useEffect } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import unionBy from 'lodash/unionBy';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
import { usePageUsers, useReportModal, useCaptchaModal } from '@/hooks';
import {
matchedUsers,
parseUserInfo,
scrollToElementTop,
bgFadeOut,
} from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import { useCaptchaPlugin } from '@/utils/pluginKit';
import {
useQueryComments,
addComment,
deleteComment,
updateComment,
postVote,
} from '@/services';
import { commentReplyStore } from '@/stores';
import Reactions from '@/pages/Questions/Detail/components/Reactions';
import { Form, ActionBar, Reply } from './components';
import './index.scss';
interface IProps {
objectId: string;
mode?: 'answer' | 'question';
commentId?: string | null;
children?: React.ReactNode;
}
const Comment: FC<IProps> = ({ objectId, mode, commentId, children }) => {
const pageUsers = usePageUsers();
const [pageIndex, setPageIndex] = useState(0);
const [visibleComment, setVisibleComment] = useState(false);
const { id: currentReplyId, update: updateCurrentReplyId } =
commentReplyStore();
const pageSize = pageIndex === 0 ? 3 : 15;
const { data, mutate } = useQueryComments({
object_id: objectId,
comment_id: commentId,
page: pageIndex,
page_size: pageSize,
});
const [comments, setComments] = useState<any>([]);
const reportModal = useReportModal();
const addCaptcha = useCaptchaModal('comment');
const editCaptcha = useCaptchaPlugin('edit');
const dCaptcha = useCaptchaPlugin('delete');
const vCaptcha = useCaptchaPlugin('vote');
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
useEffect(() => {
if (pageIndex === 0 && commentId && comments.length !== 0) {
setTimeout(() => {
const el = document.getElementById(commentId);
scrollToElementTop(el);
bgFadeOut(el);
}, 100);
}
return () => {
updateCurrentReplyId('');
};
}, [comments]);
useEffect(() => {
if (!data) {
return;
}
if (data.count <= 3) {
data.list.sort((a, b) => a.created_at - b.created_at);
}
if (pageIndex === 1 || pageIndex === 0) {
setComments(data?.list);
} else {
setComments([...comments, ...data.list]);
}
const user: Types.PageUser[] = [];
data.list.forEach((item) => {
user.push({
id: item.user_id,
displayName: item.user_display_name,
userName: item.username,
});
user.push({
id: item.reply_comment_id,
displayName: item.reply_user_display_name,
userName: item.username,
});
});
pageUsers.setUsers(user);
}, [data]);
const handleReply = (id) => {
if (!tryNormalLogged(true)) {
return;
}
comments.forEach((item) => {
if (item.comment_id === id) {
updateCurrentReplyId(id);
}
});
};
const handleEdit = (id) => {
setComments(
comments.map((item) => {
if (item.comment_id === id) {
item.showEdit = !item.showEdit;
}
return item;
}),
);
};
const submitUpdateComment = (params, item) => {
const up = {
...params,
comment_id: item.comment_id,
captcha_code: undefined,
captcha_id: undefined,
};
editCaptcha?.resolveCaptchaReq(up);
return updateComment(up)
.then(async (res) => {
await editCaptcha?.close();
setComments(
comments.map((comment) => {
if (comment.comment_id === item.comment_id) {
comment.showEdit = false;
comment.parsed_text = res.parsed_text;
comment.original_text = res.original_text;
}
return comment;
}),
);
})
.catch((err) => {
if (err.isError) {
const captchaErr = editCaptcha?.handleCaptchaError(err.list);
// If it is not a CAPTCHA error, leave it to the subsequent error handling logic to continue processing.
if (!(captchaErr && err.list.length === 1)) {
return Promise.reject(err);
}
}
return Promise.resolve();
});
};
const submitAddComment = (params, item) => {
const req = {
...params,
captcha_code: undefined,
captcha_id: undefined,
};
addCaptcha?.resolveCaptchaReq(req);
return addComment(req)
.then(async (res) => {
await addCaptcha?.close();
if (item.type === 'reply') {
const index = comments.findIndex(
(comment) => comment.comment_id === item.comment_id,
);
updateCurrentReplyId('');
comments.splice(index + 1, 0, res);
setComments([...comments]);
} else {
setComments([
...comments.map((comment) => {
if (comment.comment_id === item.comment_id) {
updateCurrentReplyId('');
}
return comment;
}),
res,
]);
}
setVisibleComment(false);
})
.catch((ex) => {
if (ex.isError) {
const captchaErr = addCaptcha?.handleCaptchaError(ex.list);
// If it is not a CAPTCHA error, leave it to the subsequent error handling logic to continue processing.
if (!(captchaErr && ex.list.length === 1)) {
return Promise.reject(ex);
}
}
return Promise.resolve();
});
};
const handleSendReply = (item) => {
const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName));
const commentMarkDown = parseUserInfo(item.value);
const params = {
object_id: objectId,
original_text: commentMarkDown,
mention_username_list: userNames,
...(item.type === 'reply'
? {
reply_comment_id: item.comment_id,
}
: {}),
};
if (item.type === 'edit') {
if (!editCaptcha) {
return submitUpdateComment(params, item);
}
return editCaptcha.check(() => submitUpdateComment(params, item));
}
if (!addCaptcha) {
return submitAddComment(params, item);
}
return addCaptcha.check(() => submitAddComment(params, item));
};
const submitDeleteComment = (id) => {
const imgCode = { captcha_id: undefined, captcha_code: undefined };
dCaptcha?.resolveCaptchaReq(imgCode);
deleteComment(id, imgCode)
.then(async () => {
await dCaptcha?.close();
if (pageIndex === 0) {
mutate();
}
setComments(comments.filter((item) => item.comment_id !== id));
})
.catch((ex) => {
if (ex.isError) {
dCaptcha?.handleCaptchaError(ex.list);
}
});
};
const handleDelete = (id) => {
Modal.confirm({
title: t('title', { keyPrefix: 'delete' }),
content: t('other', { keyPrefix: 'delete' }),
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
onConfirm: () => {
if (!dCaptcha) {
submitDeleteComment(id);
return;
}
dCaptcha.check(() => {
submitDeleteComment(id);
});
},
});
};
const submitVoteComment = (id, is_cancel) => {
const imgCode: Types.ImgCodeReq = {
captcha_id: undefined,
captcha_code: undefined,
};
vCaptcha?.resolveCaptchaReq(imgCode);
postVote(
{
object_id: id,
is_cancel,
...imgCode,
},
'up',
)
.then(async () => {
await vCaptcha?.close();
setComments(
comments.map((item) => {
if (item.comment_id === id) {
item.vote_count = is_cancel
? item.vote_count - 1
: item.vote_count + 1;
item.is_vote = !is_cancel;
}
return item;
}),
);
})
.catch((ex) => {
if (ex.isError) {
vCaptcha?.handleCaptchaError(ex.list);
}
});
};
const handleVote = (id, is_cancel) => {
if (!tryNormalLogged(true)) {
return;
}
if (!vCaptcha) {
submitVoteComment(id, is_cancel);
return;
}
vCaptcha.check(() => {
submitVoteComment(id, is_cancel);
});
};
const handleAction = ({ action }, item) => {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'report') {
reportModal.onShow({
id: item.comment_id,
type: 'comment',
action: 'flag',
});
} else if (action === 'delete') {
handleDelete(item.comment_id);
} else if (action === 'edit') {
handleEdit(item.comment_id);
}
};
const handleCancel = (id) => {
setComments(
comments.map((item) => {
if (item.comment_id === id) {
item.showEdit = false;
updateCurrentReplyId('');
}
return item;
}),
);
};
const handleAddComment = () => {
if (!tryNormalLogged(true)) {
setVisibleComment(false);
return;
}
setVisibleComment(!visibleComment);
};
return (
<>
<div
className={classNames(
'd-flex flex-wrap justify-content-between align-items-center',
comments.length === 0 ? '' : 'mb-3',
)}>
<Reactions
objectId={objectId}
showAddCommentBtn={comments.length === 0}
handleClickComment={handleAddComment}
/>
{children}
</div>
<div
className={classNames(
'comments-wrap',
comments.length > 0 && 'bg-light px-3 py-2 rounded',
)}>
{comments.map((item) => {
return (
<div
key={item.comment_id}
id={item.comment_id}
className="py-2 comment-item">
{item.showEdit ? (
<Form
className="mt-2"
value={item.original_text}
type="edit"
mode={mode}
onSendReply={(value) =>
handleSendReply({ ...item, value, type: 'edit' })
}
onCancel={() => handleCancel(item.comment_id)}
/>
) : (
<div className="d-block">
{item.reply_user_display_name &&
(item.reply_user_status !== 'deleted' ? (
<Link
to={`/users/${item.reply_username}`}
className="small me-1 text-nowrap">
@{item.reply_user_display_name}
</Link>
) : (
<span className="small me-1 text-nowrap">
@{item.reply_user_display_name}
</span>
))}
<div
className="fmt small text-break text-wrap"
dangerouslySetInnerHTML={{ __html: item.parsed_text }}
/>
</div>
)}
{currentReplyId === item.comment_id ? (
<Reply
userName={item.user_display_name}
mode={mode}
onSendReply={(value) =>
handleSendReply({ ...item, value, type: 'reply' })
}
onCancel={() => handleCancel(item.comment_id)}
/>
) : null}
{item.showEdit || currentReplyId === item.comment_id ? null : (
<ActionBar
nickName={item.user_display_name}
username={item.username}
createdAt={item.created_at}
voteCount={item.vote_count}
isVote={item.is_vote}
memberActions={item.member_actions}
userStatus={item.user_status}
onReply={() => {
handleReply(item.comment_id);
}}
onAction={(action) => handleAction(action, item)}
onVote={(e) => {
e.preventDefault();
handleVote(item.comment_id, item.is_vote);
}}
/>
)}
</div>
);
})}
<div className={classNames(comments.length > 0 && 'py-2')}>
{comments.length > 0 && (
<Button
variant="link"
className="p-0 btn-no-border"
size="sm"
onClick={handleAddComment}>
{t('btn_add_comment')}
</Button>
)}
{data &&
(pageIndex || 1) < Math.ceil((data?.count || 0) / pageSize) && (
<Button
variant="link"
size="sm"
className="p-0 ms-3 btn-no-border"
onClick={() => {
setPageIndex(pageIndex + 1);
}}>
{t('show_more', {
count:
data.count - (pageIndex === 0 ? 3 : pageIndex * pageSize),
})}
</Button>
)}
</div>
{visibleComment && (
<Form
mode={mode}
className={classNames(
comments.length <= 0 ? 'mt-3' : 'mt-2',
comments.length <= 0 && 'bg-light p-3 rounded',
)}
onSendReply={(value) => handleSendReply({ value, type: 'comment' })}
onCancel={() => setVisibleComment(!visibleComment)}
/>
)}
</div>
</>
);
};
export default Comment;
+104
View File
@@ -0,0 +1,104 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import { useTranslation } from 'react-i18next';
import classname from 'classnames';
import { Icon } from '@/components';
import { formatCount } from '@/utils/common';
interface Props {
data: {
votes: number;
answers: number;
views: number;
};
showVotes?: boolean;
showAnswers?: boolean;
showViews?: boolean;
showAccepted?: boolean;
isAccepted?: boolean;
className?: string;
}
const Index: FC<Props> = ({
data,
showVotes = true,
showAnswers = true,
showViews = true,
isAccepted = false,
showAccepted = false,
className = '',
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'counts' });
return (
<div className={classname('d-flex align-items-center', className)}>
{showVotes && (
<div className="d-flex align-items-center flex-shrink-0 text-body">
<Icon name="hand-thumbs-up-fill me-1" />
<span className="fw-medium">{data.votes}</span>
<span className="ms-1">{t('votes')}</span>
</div>
)}
{showAccepted && (
<div className="d-flex align-items-center ms-3 text-success flex-shrink-0">
<Icon name="check-circle-fill me-1" />
<span>{t('accepted')}</span>
</div>
)}
{showAnswers && (
<div
className={`d-flex flex-shrink-0 align-items-center ms-3 ${
isAccepted ? 'text-bg-success rounded-pill px-2 ' : ''
}`}>
{isAccepted ? (
<Icon name="check-circle-fill me-1" />
) : (
<Icon name="chat-square-text-fill me-1" />
)}
<span className="fw-medium">{data.answers}</span>
<span className="ms-1">{t('answers')}</span>
</div>
)}
{showViews && (
<span
className={classname(
'summary-stat ms-3 flex-shrink-0',
data.views >= 100 * 1000
? 'view-level3'
: data.views >= 10000
? 'view-level2'
: data.views >= 1000
? 'view-level1'
: '',
)}>
<Icon name="bar-chart-fill" />
<span className="fw-medium ms-1">{formatCount(data.views)}</span>
<span className="ms-1">{t('views')}</span>
</span>
)}
</div>
);
};
export default memo(Index);
+30
View File
@@ -0,0 +1,30 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { customizeStore } from '@/stores';
const Index = () => {
const { custom_sidebar } = customizeStore((state) => state);
if (!custom_sidebar) return null;
return <div dangerouslySetInnerHTML={{ __html: custom_sidebar }} />;
};
export default memo(Index);
+168
View File
@@ -0,0 +1,168 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { customizeStore } from '@/stores';
const CUSTOM_MARK_HEAD = 'customize_head';
const CUSTOM_MARK_HEADER = 'customize_header';
const CUSTOM_MARK_FOOTER = 'customize_footer';
const makeMarker = (mark) => {
return `<!--${mark}-->`;
};
const ActivateScriptNodes = (el, part) => {
let startMarkNode;
const scriptList: HTMLScriptElement[] = [];
const { childNodes } = el;
for (let i = 0; i < childNodes.length; i += 1) {
const node = childNodes[i];
if (node.nodeType === 8 && node.nodeValue === part) {
if (!startMarkNode) {
startMarkNode = node;
} else {
// this is the endMarkNode
break;
}
}
if (
startMarkNode &&
node.nodeType === 1 &&
node.nodeName.toLowerCase() === 'script'
) {
scriptList.push(node);
}
}
scriptList?.forEach((so) => {
const script = document.createElement('script');
script.text = `(() => {${so.text}})();`;
for (let i = 0; i < so.attributes.length; i += 1) {
const attr = so.attributes[i];
script.setAttribute(attr.name, attr.value);
}
el.replaceChild(script, so);
});
};
type pos = 'afterbegin' | 'beforeend';
const renderCustomArea = (el, part, pos: pos, content: string = '') => {
let startMarkNode;
let endMarkNode;
const { childNodes } = el;
for (let i = 0; i < childNodes.length; i += 1) {
const node = childNodes[i];
if (node.nodeType === 8 && node.nodeValue === part) {
if (!startMarkNode) {
startMarkNode = node;
} else {
endMarkNode = node;
break;
}
}
}
if (startMarkNode && endMarkNode) {
while (
startMarkNode.nextSibling &&
startMarkNode.nextSibling !== endMarkNode
) {
el.removeChild(startMarkNode.nextSibling);
}
}
if (startMarkNode) {
el.removeChild(startMarkNode);
}
if (endMarkNode) {
el.removeChild(endMarkNode);
}
el.insertAdjacentHTML(pos, makeMarker(part));
el.insertAdjacentHTML(pos, content);
el.insertAdjacentHTML(pos, makeMarker(part));
ActivateScriptNodes(el, part);
};
const handleCustomHead = (content) => {
const el = document.head;
renderCustomArea(el, CUSTOM_MARK_HEAD, 'beforeend', content);
};
const handleCustomHeader = (content) => {
const el = document.body;
renderCustomArea(el, CUSTOM_MARK_HEADER, 'afterbegin', content);
};
const handleCustomFooter = (content) => {
const el = document.body;
renderCustomArea(el, CUSTOM_MARK_FOOTER, 'beforeend', content);
};
const Index: FC = () => {
const { custom_head, custom_header, custom_footer } = customizeStore(
(state) => state,
);
const { pathname } = useLocation();
useEffect(() => {
const isSeo = document.querySelector('meta[name="go-template"]');
if (!isSeo) {
setTimeout(() => {
handleCustomHead(custom_head);
}, 1000);
handleCustomHeader(custom_header);
handleCustomFooter(custom_footer);
} else {
isSeo.remove();
}
}, [custom_head, custom_header, custom_footer]);
useEffect(() => {
/**
* description: Activate scripts with data-client attribute when route changes
*/
const allScript = document.body.querySelectorAll('script[data-client]');
allScript.forEach((scriptNode) => {
const script = document.createElement('script');
script.setAttribute('data-client', 'true');
// If the script is already wrapped in an IIFE, use it directly; otherwise, wrap it in an IIFE
if (
/^\s*\(\s*function\s*\(\s*\)\s*{/.test(
(scriptNode as HTMLScriptElement).text,
) ||
/^\s*\(\s*\(\s*\)\s*=>\s*{/.test((scriptNode as HTMLScriptElement).text)
) {
script.text = (scriptNode as HTMLScriptElement).text;
} else {
script.text = `(() => {${(scriptNode as HTMLScriptElement).text}})();`;
}
for (let i = 0; i < scriptNode.attributes.length; i += 1) {
const attr = scriptNode.attributes[i];
if (attr.name !== 'data-client') {
script.setAttribute(attr.name, attr.value);
}
}
scriptNode.parentElement?.replaceChild(script, scriptNode);
});
}, [pathname]);
return null;
};
export default memo(Index);
+154
View File
@@ -0,0 +1,154 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useLayoutEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import Color from 'color';
import { shiftColor, tintColor, shadeColor } from '@/utils';
import { themeSettingStore } from '@/stores';
import { DEFAULT_THEME_COLOR } from '@/common/constants';
const Index: FC = () => {
const { theme, theme_config } = themeSettingStore((_) => _);
let primaryColor;
if (theme_config?.[theme]?.primary_color) {
primaryColor = Color(theme_config[theme].primary_color);
}
const setThemeColor = () => {
const themeMetaNode = document.querySelector('meta[name="theme-color"]');
if (themeMetaNode) {
const themeColor = primaryColor
? primaryColor.hex()
: DEFAULT_THEME_COLOR;
themeMetaNode.setAttribute('content', themeColor);
}
};
useLayoutEffect(() => {
setThemeColor();
}, [primaryColor]);
return (
<Helmet>
{primaryColor && (
<style>
{`
:root {
--bs-blue: ${primaryColor.hex()};
--bs-primary: ${primaryColor.hex()};
--bs-primary-rgb: ${primaryColor.rgb().array().join(',')};
--bs-link-color: ${primaryColor.hex()};
--bs-link-color-rgb: ${primaryColor.rgb().array().join(',')};
--bs-link-hover-color: ${shiftColor(primaryColor, 0.8).hex()};
--bs-link-hover-color-rgb: ${shiftColor(primaryColor, 0.8)
.round()
.array()}
}
:root[data-bs-theme='dark'] {
--bs-link-color: ${tintColor(primaryColor, 0.6).hex()};
--bs-link-color-rgb: ${tintColor(primaryColor, 0.6)
.round()
.array()};
--bs-link-hover-color: ${shiftColor(
tintColor(primaryColor, 0.6),
-0.8,
).hex()};
--bs-link-hover-color-rgb: ${shiftColor(
tintColor(primaryColor, 0.6),
-0.8,
)
.round()
.array()};
}
.nav-pills {
--bs-nav-pills-link-active-bg: ${primaryColor.hex()};
}
.btn-primary {
--bs-btn-bg: ${primaryColor.hex()};
--bs-btn-border-color: ${primaryColor.hex()};
--bs-btn-hover-bg: ${tintColor(primaryColor, 0.85)};
--bs-btn-hover-border-color: ${tintColor(primaryColor, 0.9)};
--bs-btn-focus-shadow-rgb: ${shadeColor(primaryColor, 0.85)};
--bs-btn-active-bg: ${tintColor(primaryColor, 0.8)};
--bs-btn-active-border-color: ${tintColor(primaryColor, 0.9)};
--bs-btn-disabled-bg: ${primaryColor.hex()};
--bs-btn-disabled-border-color: ${primaryColor.hex()};
}
.btn-outline-primary {
--bs-btn-color: ${primaryColor.hex()};
--bs-btn-border-color: ${primaryColor.hex()};
--bs-btn-hover-bg: ${primaryColor.hex()};
--bs-btn-hover-border-color: ${primaryColor.hex()};
--bs-btn-active-bg: ${primaryColor.hex()};
--bs-btn-active-border-color: ${primaryColor.hex()};
--bs-btn-disabled-color: ${primaryColor.hex()};
--bs-btn-disabled-border-color: ${primaryColor.hex()};
}
.pagination {
--bs-btn-color: ${primaryColor.hex()};
--bs-pagination-active-bg: ${primaryColor.hex()};
--bs-pagination-active-border-color: ${primaryColor.hex()};
}
.form-select:focus,
.form-control:focus,
.form-control.focus{
box-shadow: 0 0 0 0.25rem ${primaryColor
.fade(0.75)
.string()} !important;
border-color: ${tintColor(primaryColor, 0.5)} !important;
}
.form-check-input:checked {
background-color: ${primaryColor.hex()};
border-color: ${primaryColor.hex()};
}
.form-check-input:focus {
border-color: ${tintColor(primaryColor, 0.5)};
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), .4);
}
.form-switch .form-check-input:focus {
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%27-4 -4 8 8%27%3e%3ccircle r=%273%27 fill=%27${tintColor(
primaryColor,
0.5,
)}%27/%3e%3c/svg%3e");
}
.tag-selector-wrap--focus {
box-shadow: 0 0 0 0.25rem ${primaryColor
.fade(0.75)
.string()} !important;
border-color: ${tintColor(primaryColor, 0.5)} !important;
}
.dropdown-menu {
--bs-dropdown-link-active-bg: rgb(var(--bs-primary-rgb));
}
.link-primary {
color: ${primaryColor.hex()}!important;
}
.link-primary:hover, .link-primary:focus {
color: ${shadeColor(primaryColor, 0.8).hex()}!important;
}
`}
</style>
)}
</Helmet>
);
};
export default Index;
+141
View File
@@ -0,0 +1,141 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import classnames from 'classnames';
import { Tag } from '@/components';
import { diffText } from '@/utils';
interface Props {
objectType: string | 'question' | 'answer' | 'tag';
newData: Record<string, any>;
oldData?: Record<string, any>;
className?: string;
opts?: Partial<{
showTitle: boolean;
showTagUrlSlug: boolean;
}>;
}
const Index: FC<Props> = ({
objectType,
newData,
oldData,
className = '',
opts = {
showTitle: true,
showTagUrlSlug: true,
},
}) => {
if (!newData) return null;
let tag = newData.tags;
if (objectType === 'question' && oldData?.tags) {
const addTags = newData.tags.filter(
(c) => !oldData?.tags?.find((p) => p.slug_name === c.slug_name),
);
let deleteTags = oldData?.tags
.filter((c) => !newData?.tags.find((p) => p.slug_name === c.slug_name))
.map((v) => ({ ...v, state: 'delete' }));
deleteTags = deleteTags?.map((v) => {
const index = oldData?.tags?.findIndex(
(c) => c.slug_name === v.slug_name,
);
return {
...v,
pre_index: index,
};
});
tag = newData.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}>
{objectType !== 'answer' && opts?.showTitle && (
<h5
dangerouslySetInnerHTML={{
__html: diffText(
newData.title?.replace(/</gi, '&lt;'),
oldData?.title?.replace(/</gi, '&lt;'),
),
}}
className="mb-3"
/>
)}
{objectType === 'question' && (
<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>
)}
{objectType === 'tag' && opts?.showTagUrlSlug && (
<div
className={classnames(
'small font-monospace',
newData.original_text && 'mb-4',
)}
dangerouslySetInnerHTML={{
__html: `/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`,
}}
/>
)}
<div
dangerouslySetInnerHTML={{
__html: diffText(newData.original_text, oldData?.original_text),
}}
className="pre-line text-break font-monospace small"
/>
</div>
);
};
export default memo(Index);
+24
View File
@@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { Editor } from './types';
export const EditorContext = React.createContext<Editor | null>(null);
@@ -0,0 +1,98 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef } from 'react';
import { EditorView } from '@codemirror/view';
import { BaseEditorProps } from './types';
import { useEditor } from './utils';
interface MarkdownEditorProps extends BaseEditorProps {}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
onFocus,
onBlur,
placeholder,
autoFocus,
onEditorReady,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const lastSyncedValueRef = useRef<string>(value);
const isInitializedRef = useRef<boolean>(false);
const editor = useEditor({
editorRef,
onChange,
onFocus,
onBlur,
placeholder,
autoFocus,
initialValue: value,
});
useEffect(() => {
if (!editor || isInitializedRef.current) {
return;
}
isInitializedRef.current = true;
onEditorReady?.(editor);
}, [editor, onEditorReady]);
useEffect(() => {
if (!editor || value === lastSyncedValueRef.current) {
return;
}
const currentValue = editor.getValue();
if (currentValue !== value) {
editor.setValue(value || '');
lastSyncedValueRef.current = value || '';
}
}, [editor, value]);
useEffect(() => {
lastSyncedValueRef.current = value;
isInitializedRef.current = false;
return () => {
if (editor) {
const view = editor as unknown as EditorView;
if (view.destroy) {
view.destroy();
}
}
isInitializedRef.current = false;
};
}, []);
return (
<div className="content-wrap">
<div
className="md-editor position-relative w-100 h-100"
ref={editorRef}
/>
</div>
);
};
export default MarkdownEditor;
+107
View File
@@ -0,0 +1,107 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState } from 'react';
import { Dropdown, FormControl } from 'react-bootstrap';
interface IProps {
options;
value?;
onChange?;
placeholder?;
onSelect?;
}
const Select: FC<IProps> = ({
options = [],
value = '',
onChange,
placeholder = '',
onSelect,
}) => {
const [isFocus, setFocusState] = useState(false);
const [cursor, setCursor] = useState(0);
useEffect(() => {
setCursor(0);
}, [value]);
const handleKeyDown = (e) => {
const { keyCode } = e;
if (keyCode === 38 && cursor > 0) {
e.preventDefault();
setCursor(cursor - 1);
}
if (keyCode === 40 && cursor < options.length - 1) {
e.preventDefault();
setCursor(cursor + 1);
}
if (keyCode === 13 && cursor > -1 && cursor <= options.length - 1) {
const lang = options.filter((opt) =>
value ? opt.indexOf(value) === 0 : true,
)[cursor];
setFocusState(false);
onSelect(lang);
}
};
const result = options.filter((opt) =>
value ? opt.indexOf(value) === 0 : true,
);
return (
<div className="position-relative" onKeyDown={handleKeyDown}>
<FormControl
type="search"
value={value}
placeholder={placeholder}
onChange={(e) => {
setFocusState(true);
if (onChange instanceof Function) {
onChange(e);
}
}}
/>
{result.length > 0 && (
<Dropdown.Menu
show={value && isFocus}
className="border py-2 rounded w-100"
style={{ overflowY: 'auto', maxHeight: '250px' }}>
{result.map((opt, index) => {
return (
<Dropdown.Item
key={opt}
className={`${cursor === index ? 'active' : ''}`}
onClick={(e) => {
e.preventDefault();
setFocusState(false);
onSelect(opt);
}}>
{opt}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
)}
</div>
);
};
export default Select;
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const BlockQuote = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'quote',
keyMap: ['Ctrl-q'],
tip: `${t('blockquote.text')} (Ctrl+Q)`,
};
const handleClick = (editor: Editor) => {
editor.insertBlockquote(t('blockquote.text'));
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(BlockQuote);
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Bold = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'type-bold',
keyMap: ['Ctrl-b'],
tip: `${t('bold.text')} (Ctrl+b)`,
};
const DEFAULTTEXT = t('bold.text');
const handleClick = (editor: Editor) => {
editor.insertBold(DEFAULTTEXT);
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Bold);
+298
View File
@@ -0,0 +1,298 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState, memo } from 'react';
import { Button, Form, Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import Select from '../Select';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const codeLanguageType = [
'bash',
'sh',
'zsh',
'c',
'h',
'cpp',
'hpp',
'c++',
'h++',
'cc',
'hh',
'cxx',
'hxx',
'c-like',
'cs',
'csharp',
'c#',
'clojure',
'clj',
'coffee',
'coffeescript',
'cson',
'iced',
'css',
'dart',
'erl',
'erlang',
'go',
'golang',
'hs',
'haskell',
'html',
'xml',
'xsl',
'xhtml',
'rss',
'atom',
'xjb',
'xsd',
'plist',
'wsf',
'svg',
'http',
'https',
'ini',
'toml',
'java',
'jsp',
'js',
'javascript',
'jsx',
'mjs',
'cjs',
'json',
'kotlin',
'kt',
'latex',
'tex',
'less',
'lisp',
'lua',
'makefile',
'mk',
'mak',
'markdown',
'md',
'mkdown',
'mkd',
'matlab',
'objectivec',
'mm',
'objc',
'obj-c',
'ocaml',
'ml',
'pascal',
'delphi',
'dpr',
'dfm',
'pas',
'freepascal',
'lazarus',
'lpr',
'lfm',
'pl',
'perl',
'pm',
'php',
'php3',
'php4',
'php5',
'php6',
'php7',
'php-template',
'protobuf',
'py',
'python',
'gyp',
'ipython',
'r',
'rb',
'ruby',
'gemspec',
'podspec',
'thor',
'irb',
'rs',
'rust',
'scala',
'scheme',
'scss',
'shell',
'console',
'sql',
'swift',
'typescript',
'ts',
'vhdl',
'vbnet',
'vb',
'yaml',
'yml',
];
const Code = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'code-slash',
keyMap: ['Ctrl-k'],
tip: `${t('code.text')} (Ctrl+k)`,
};
const [code, setCode] = useState({
value: '',
isInvalid: false,
errorMsg: '',
});
const [visible, setVisible] = useState(false);
const [lang, setLang] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
const SINGLELINEMAXLENGTH = 40;
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
const addCode = (editor: Editor) => {
setCurrentEditor(editor);
const text = editor.getSelection();
if (!text) {
setVisible(true);
return;
}
if (text.length > SINGLELINEMAXLENGTH) {
editor.insertCodeBlock('', text);
} else {
editor.insertCode(text);
}
editor.focus();
};
useEffect(() => {
if (visible && inputRef.current) {
inputRef.current.focus();
}
}, [visible]);
const handleClick = () => {
if (!currentEditor) {
return;
}
if (!code.value.trim()) {
setCode({
...code,
errorMsg: t('code.form.fields.code.msg.empty'),
isInvalid: true,
});
return;
}
if (
code.value.split('\n').length > 1 ||
code.value.length >= SINGLELINEMAXLENGTH
) {
currentEditor.insertCodeBlock(lang || undefined, code.value);
} else {
currentEditor.insertCode(code.value);
}
setCode({
value: '',
isInvalid: false,
errorMsg: '',
});
setLang('');
setVisible(false);
currentEditor.focus();
};
const onHide = () => setVisible(false);
const onExited = () => currentEditor?.focus();
return (
<ToolItem {...item} onClick={addCode}>
<Modal
show={visible}
onHide={onHide}
onExited={onExited}
fullscreen="sm-down">
<Modal.Header closeButton>
<h5 className="mb-0">{t('code.add_code')}</h5>
</Modal.Header>
<Modal.Body>
<Form.Group controlId="editor.code" className="mb-3">
<Form.Label>{t('code.form.fields.code.label')}</Form.Label>
<Form.Control
ref={inputRef}
as="textarea"
rows={3}
value={code.value}
isInvalid={code.isInvalid}
className="font-monospace"
style={{ height: '200px' }}
onChange={(e) => setCode({ ...code, value: e.target.value })}
/>
{code.isInvalid && (
<Form.Control.Feedback type="invalid">
{code.errorMsg}
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group controlId="editor.codeLanguageType" className="mb-3">
<Form.Label>{`${t('code.form.fields.language.label')} ${t(
'optional',
{
keyPrefix: 'form',
},
)}`}</Form.Label>
<Select
options={codeLanguageType}
value={lang}
onChange={(e) => setLang(e.target.value)}
onSelect={(val) => setLang(val)}
placeholder={t('code.form.fields.language.placeholder')}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button
variant="link"
onClick={() => {
setVisible(false);
setCode({
value: '',
isInvalid: false,
errorMsg: '',
});
}}>
{t('code.btn_cancel')}
</Button>
<Button variant="primary" onClick={handleClick}>
{t('code.btn_confirm')}
</Button>
</Modal.Footer>
</Modal>
</ToolItem>
);
};
export default memo(Code);
+131
View File
@@ -0,0 +1,131 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useRef, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal as AnswerModal } from '@/components';
import ToolItem from '../toolItem';
import { EditorContext } from '../EditorContext';
import { uploadImage } from '@/services';
import { writeSettingStore } from '@/stores';
const File = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const { max_attachment_size = 8, authorized_attachment_extensions = [] } =
writeSettingStore((state) => state.write);
const fileInputRef = useRef<HTMLInputElement>(null);
const editor = useContext(EditorContext);
const item = {
label: 'paperclip',
tip: `${t('file.text')}`,
};
const addLink = () => {
fileInputRef.current?.click?.();
};
const verifyFileSize = (files: FileList) => {
if (files.length === 0) {
return false;
}
const unSupportFiles = Array.from(files).filter((file) => {
const fileName = file.name.toLowerCase();
return !authorized_attachment_extensions.find((v) =>
fileName.endsWith(v),
);
});
if (unSupportFiles.length > 0) {
AnswerModal.confirm({
content: t('file.not_supported', {
file_type: authorized_attachment_extensions.join(', '),
}),
showCancel: false,
});
return false;
}
const attachmentOverSizeFiles = Array.from(files).filter(
(file) => file.size / 1024 / 1024 > max_attachment_size,
);
if (attachmentOverSizeFiles.length > 0) {
AnswerModal.confirm({
content: t('file.max_size', { size: max_attachment_size }),
showCancel: false,
});
return false;
}
return true;
};
const onUpload = async (e) => {
if (!editor) {
return;
}
const files = e.target?.files || [];
const bool = verifyFileSize(files);
if (!bool) {
return;
}
const fileName = files[0].name;
const loadingText = `![${t('image.uploading')} ${fileName}...]()`;
const startPos = editor.getCursor();
const endPos = { ...startPos, ch: startPos.ch + loadingText.length };
editor.replaceSelection(loadingText);
editor.setReadOnly(true);
uploadImage({ file: e.target.files[0], type: 'post_attachment' })
.then((url) => {
const text = `[${fileName}](${url})`;
editor.replaceRange(text, startPos, endPos);
})
.catch(() => {
editor.replaceRange('', startPos, endPos);
})
.finally(() => {
editor.setReadOnly(false);
editor.focus();
});
};
if (!authorized_attachment_extensions?.length) {
return null;
}
return (
<ToolItem {...item} onClick={addLink}>
<input
type="file"
className="d-none"
accept={`.${authorized_attachment_extensions
.join(',.')
.toLocaleLowerCase()}`}
ref={fileInputRef}
onChange={onUpload}
/>
</ToolItem>
);
};
export default memo(File);
@@ -0,0 +1,116 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, memo } from 'react';
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor, Level } from '../types';
const Heading = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const headerList = [
{
text: `<h2 class="mb-0 h4">${t('heading.options.h2')}</h2>`,
level: 2,
label: t('heading.options.h2'),
},
{
text: `<h3 class="mb-0 h5">${t('heading.options.h3')}</h3>`,
level: 3,
label: t('heading.options.h3'),
},
{
text: `<h4 class="mb-0 h6">${t('heading.options.h4')}</h4>`,
level: 4,
label: t('heading.options.h4'),
},
{
text: `<h5 class="mb-0 small">${t('heading.options.h5')}</h5>`,
level: 5,
label: t('heading.options.h5'),
},
{
text: `<h6 class="mb-0 fs-12">${t('heading.options.h6')}</h6>`,
level: 6,
label: t('heading.options.h6'),
},
];
const item = {
label: 'type-h2',
keyMap: ['Ctrl-h'],
tip: `${t('heading.text')} (Ctrl+h)`,
};
const [isShow, setShowState] = useState(false);
const [isLocked, setLockState] = useState(false);
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
const handleClick = (level: Level = 2, label?: string) => {
if (!currentEditor) {
return;
}
currentEditor.insertHeading(level, label);
currentEditor.focus();
setShowState(false);
};
const onAddHeader = (editor: Editor) => {
setCurrentEditor(editor);
if (isLocked) {
return;
}
setShowState(!isShow);
};
const handleMouseEnter = () => {
setLockState(true);
};
const handleMouseLeave = () => {
setLockState(false);
};
return (
<ToolItem
as="dropdown"
{...item}
isShow={isShow}
onClick={onAddHeader}
onBlur={onAddHeader}>
<Dropdown.Menu
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{headerList.map((header) => {
return (
<Dropdown.Item
key={header.text}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClick(header.level as Level, header.label);
}}
dangerouslySetInnerHTML={{ __html: header.text }}
/>
);
})}
</Dropdown.Menu>
</ToolItem>
);
};
export default memo(Heading);
@@ -0,0 +1,39 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
const Help = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'question-circle-fill',
tip: t('help.text'),
};
const handleClick = () => {
window.open('https://commonmark.org/help/');
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Help);
+41
View File
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Hr = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'hr',
keyMap: ['Ctrl-r'],
tip: `${t('hr.text')} (Ctrl+r)`,
};
const handleClick = (editor: Editor) => {
editor.insertHorizontalRule();
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Hr);
+397
View File
@@ -0,0 +1,397 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, memo, useContext } from 'react';
import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { EditorContext } from '../EditorContext';
import { Editor } from '../types';
import { useImageUpload } from '../hooks/useImageUpload';
const Image = () => {
const editor = useContext(EditorContext);
const [editorState, setEditorState] = useState<Editor | null>(editor);
// Update editor state when editor context changes
// This ensures event listeners are re-bound when switching editor modes
useEffect(() => {
if (editor) {
setEditorState(editor);
}
}, [editor]);
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const { verifyImageSize, uploadFiles } = useImageUpload();
const loadingText = `![${t('image.uploading')}...]()`;
const item = {
label: 'image-fill',
keyMap: ['Ctrl-g'],
tip: `${t('image.text')} (Ctrl+G)`,
};
const [currentTab, setCurrentTab] = useState('localImage');
const [visible, setVisible] = useState(false);
const [link, setLink] = useState({
value: '',
isInvalid: false,
errorMsg: '',
type: '',
});
const [imageName, setImageName] = useState({
value: '',
isInvalid: false,
errorMsg: '',
});
function dragenter(e) {
e.stopPropagation();
e.preventDefault();
}
function dragover(e) {
e.stopPropagation();
e.preventDefault();
}
const drop = async (e) => {
const fileList = e.dataTransfer.files;
const bool = verifyImageSize(fileList);
if (!bool) {
return;
}
if (!editorState) {
return;
}
const startPos = editorState.getCursor();
const endPos = { ...startPos, ch: startPos.ch + loadingText.length };
editorState.replaceSelection(loadingText);
editorState.setReadOnly(true);
const urls = await uploadFiles(fileList)
.catch(() => {
editorState.replaceRange('', startPos, endPos);
})
.finally(() => {
editorState?.setReadOnly(false);
editorState?.focus();
});
const text: string[] = [];
if (Array.isArray(urls)) {
urls.forEach(({ name, url, type }) => {
if (name && url) {
text.push(`${type === 'post' ? '!' : ''}[${name}](${url})`);
}
});
}
if (text.length) {
editorState.replaceRange(text.join('\n'), startPos, endPos);
} else {
editorState?.replaceRange('', startPos, endPos);
}
};
const paste = async (event) => {
const clipboard = event.clipboardData;
const bool = verifyImageSize(clipboard.files);
if (bool) {
event.preventDefault();
if (!editorState) {
return;
}
const startPos = editorState.getCursor();
const endPos = { ...startPos, ch: startPos.ch + loadingText.length };
editorState?.replaceSelection(loadingText);
editorState?.setReadOnly(true);
uploadFiles(clipboard.files)
.then((urls) => {
const text = urls.map(({ name, url, type }) => {
return `${type === 'post' ? '!' : ''}[${name}](${url})`;
});
editorState.replaceRange(text.join('\n'), startPos, endPos);
})
.catch(() => {
editorState.replaceRange('', startPos, endPos);
})
.finally(() => {
editorState?.setReadOnly(false);
editorState?.focus();
});
return;
}
const htmlStr = clipboard.getData('text/html');
const imgRegex = /<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
if (!htmlStr.match(imgRegex)) {
return;
}
event.preventDefault();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlStr, 'text/html');
const { body } = doc;
let markdownText = '';
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
// text node
markdownText += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
// element node
const tagName = node.tagName.toLowerCase();
if (tagName === 'img') {
// img node
const src = node.getAttribute('src');
const alt = node.getAttribute('alt') || t('image.text');
markdownText += `![${alt}](${src})`;
} else if (tagName === 'br') {
// br node
markdownText += '\n';
} else {
for (let i = 0; i < node.childNodes.length; i += 1) {
traverse(node.childNodes[i]);
}
}
const blockLevelElements = [
'p',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
];
if (blockLevelElements.includes(tagName)) {
markdownText += '\n\n';
}
}
}
traverse(body);
markdownText = markdownText.replace(/[\n\s]+/g, (match) => {
return match.length > 1 ? '\n\n' : match;
});
if (editorState) {
editorState.replaceSelection(markdownText);
}
};
const handleClick = () => {
if (!link.value) {
setLink({ ...link, isInvalid: true });
return;
}
setLink({ ...link, type: '' });
if (editorState) {
editorState.insertImage(link.value, imageName.value || undefined);
}
setVisible(false);
editorState?.focus();
setLink({ ...link, value: '' });
setImageName({ ...imageName, value: '' });
};
useEffect(() => {
if (!editorState) {
return undefined;
}
editorState.on('dragenter', dragenter);
editorState.on('dragover', dragover);
editorState.on('drop', drop);
editorState.on('paste', paste);
return () => {
editorState.off('dragenter', dragenter);
editorState.off('dragover', dragover);
editorState.off('drop', drop);
editorState.off('paste', paste);
};
}, [editorState]);
useEffect(() => {
if (link.value && link.type === 'drop') {
handleClick();
}
}, [link.value]);
const addLink = (editorInstance: Editor) => {
setEditorState(editorInstance);
const text = editorInstance?.getSelection();
setImageName({ ...imageName, value: text });
setVisible(true);
};
const { uploadSingleFile } = useImageUpload();
const onUpload = async (e) => {
if (!editor) {
return;
}
const files = e.target?.files || [];
const bool = verifyImageSize(files);
if (!bool) {
return;
}
uploadSingleFile(e.target.files[0]).then((url) => {
setLink({ ...link, value: url });
setImageName({ ...imageName, value: files[0].name });
});
};
const onHide = () => setVisible(false);
const onExited = () => editor?.focus();
const handleSelect = (tab) => {
setCurrentTab(tab);
};
return (
<ToolItem {...item} onClick={addLink}>
<Modal
show={visible}
onHide={onHide}
onExited={onExited}
fullscreen="sm-down">
<Modal.Header closeButton>
<h5 className="mb-0">{t('image.add_image')}</h5>
</Modal.Header>
<Modal.Body>
<Tabs onSelect={handleSelect}>
<Tab eventKey="localImage" title={t('image.tab_image')}>
<Form className="mt-3" onSubmit={handleClick}>
<Form.Group controlId="editor.imgLink" className="mb-3">
<Form.Label>
{t('image.form_image.fields.file.label')}
</Form.Label>
<Form.Control
type="file"
onChange={onUpload}
isInvalid={currentTab === 'localImage' && link.isInvalid}
accept="image/*"
/>
<Form.Control.Feedback type="invalid">
{t('image.form_image.fields.file.msg.empty')}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="editor.imgDescription" className="mb-3">
<Form.Label>
{`${t('image.form_image.fields.desc.label')} ${t(
'optional',
{
keyPrefix: 'form',
},
)}`}
</Form.Label>
<Form.Control
type="text"
value={imageName.value}
onChange={(e) =>
setImageName({ ...imageName, value: e.target.value })
}
isInvalid={imageName.isInvalid}
/>
</Form.Group>
</Form>
</Tab>
<Tab eventKey="remoteImage" title={t('image.tab_url')}>
<Form className="mt-3" onSubmit={handleClick}>
<Form.Group controlId="editor.imgUrl" className="mb-3">
<Form.Label>
{t('image.form_url.fields.url.label')}
</Form.Label>
<Form.Control
type="text"
value={link.value}
onChange={(e) =>
setLink({ ...link, value: e.target.value })
}
isInvalid={currentTab === 'remoteImage' && link.isInvalid}
/>
<Form.Control.Feedback type="invalid">
{t('image.form_url.fields.url.msg.empty')}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="editor.imgName" className="mb-3">
<Form.Label>
{`${t('image.form_url.fields.name.label')} ${t('optional', {
keyPrefix: 'form',
})}`}
</Form.Label>
<Form.Control
type="text"
value={imageName.value}
onChange={(e) =>
setImageName({ ...imageName, value: e.target.value })
}
isInvalid={imageName.isInvalid}
/>
</Form.Group>
</Form>
</Tab>
</Tabs>
</Modal.Body>
<Modal.Footer>
<Button variant="link" onClick={() => setVisible(false)}>
{t('image.btn_cancel')}
</Button>
<Button variant="primary" onClick={handleClick}>
{t('image.btn_confirm')}
</Button>
</Modal.Footer>
</Modal>
</ToolItem>
);
};
export default memo(Image);
@@ -0,0 +1,40 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Indent = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'text-indent-left',
tip: t('indent.text'),
};
const handleClick = (editor: Editor) => {
editor.indent();
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Indent);
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Table from './table';
import OL from './ol';
import UL from './ul';
import Indent from './indent';
import Outdent from './outdent';
import Hr from './hr';
import Heading from './heading';
import Bold from './bold';
import Italice from './italic';
import Code from './code';
import Link from './link';
import BlockQuote from './blockquote';
import Image from './image';
import Help from './help';
import File from './file';
export {
Table,
OL,
UL,
Indent,
Outdent,
Hr,
Heading,
Bold,
Italice,
Code,
Link,
BlockQuote,
Image,
Help,
File,
};
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Italic = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'type-italic',
keyMap: ['Ctrl-i'],
tip: `${t('italic.text')} (Ctrl+i)`,
};
const DEFAULTTEXT = t('italic.text');
const handleClick = (editor: Editor) => {
editor.insertItalic(DEFAULTTEXT);
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Italic);
+136
View File
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState, memo } from 'react';
import { Button, Form, Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Link = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'link-45deg',
keyMap: ['Ctrl-l'],
tip: `${t('link.text')} (Ctrl+l)`,
};
const [visible, setVisible] = useState(false);
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
const [link, setLink] = useState({
value: 'https://',
isInvalid: false,
errorMsg: '',
});
const [name, setName] = useState({
value: '',
isInvalid: false,
errorMsg: '',
});
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (visible && inputRef.current) {
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
inputRef.current.focus();
}
}, [visible]);
const addLink = (editor: Editor) => {
setCurrentEditor(editor);
const text = editor.getSelection();
setName({ ...name, value: text });
setVisible(true);
};
const handleClick = () => {
if (!currentEditor) {
return;
}
if (!link.value) {
setLink({ ...link, isInvalid: true });
return;
}
currentEditor.insertLink(link.value, name.value || undefined);
setVisible(false);
currentEditor.focus();
setLink({ ...link, value: '' });
setName({ ...name, value: '' });
};
const onHide = () => setVisible(false);
const onExited = () => {
currentEditor?.focus();
};
return (
<>
<ToolItem {...item} onClick={addLink} />
<Modal
show={visible}
onHide={onHide}
onExited={onExited}
fullscreen="sm-down">
<Modal.Header closeButton>
<h5 className="mb-0">{t('link.add_link')}</h5>
</Modal.Header>
<Modal.Body>
<Form onSubmit={handleClick}>
<Form.Group controlId="editor.internetSite" className="mb-3">
<Form.Label>{t('link.form.fields.url.label')}</Form.Label>
<Form.Control
ref={inputRef}
type="text"
value={link.value}
onChange={(e) => setLink({ ...link, value: e.target.value })}
isInvalid={link.isInvalid}
/>
</Form.Group>
<Form.Group controlId="editor.internetSiteName" className="mb-3">
<Form.Label>{`${t('link.form.fields.name.label')} ${t(
'optional',
{
keyPrefix: 'form',
},
)}`}</Form.Label>
<Form.Control
type="text"
value={name.value}
onChange={(e) => setName({ ...name, value: e.target.value })}
isInvalid={name.isInvalid}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="link" onClick={() => setVisible(false)}>
{t('link.btn_cancel')}
</Button>
<Button variant="primary" onClick={handleClick}>
{t('link.btn_confirm')}
</Button>
</Modal.Footer>
</Modal>
</>
);
};
export default memo(Link);
+42
View File
@@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const OL = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'list-ol',
keyMap: ['Ctrl-o'],
tip: `${t('ordered_list.text')} (Ctrl+o)`,
};
const handleClick = (editor: Editor) => {
editor.insertOrderedList();
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(OL);
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Outdent = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'text-indent-right',
keyMap: ['Shift-Tab'],
tip: t('outdent.text'),
};
const handleClick = (editor: Editor) => {
editor.outdent();
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Outdent);
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const Table = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'table',
tip: t('table.text'),
};
const handleClick = (editor: Editor) => {
editor.insertTable(3, 3);
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(Table);
+42
View File
@@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolItem from '../toolItem';
import { Editor } from '../types';
const UL = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const item = {
label: 'list-ul',
keyMap: ['Ctrl-u'],
tip: `${t('unordered_list.text')} (Ctrl+u)`,
};
const handleClick = (editor: Editor) => {
editor.insertUnorderedList();
editor.focus();
};
return <ToolItem {...item} onClick={handleClick} />;
};
export default memo(UL);
+88
View File
@@ -0,0 +1,88 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
useEffect,
useRef,
useState,
memo,
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
import { markdownToHtml } from '@/services';
import ImgViewer from '@/components/ImgViewer';
import { htmlRender } from './utils';
let scrollTop = 0;
let renderTimer;
const Index = ({ value }, ref) => {
const [html, setHtml] = useState('');
const previewRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation('translation', { keyPrefix: 'messages' });
const renderMarkdown = (markdown) => {
clearTimeout(renderTimer);
const timeout = renderTimer ? 1000 : 0;
renderTimer = setTimeout(() => {
markdownToHtml(markdown).then((resp) => {
scrollTop = previewRef.current?.scrollTop || 0;
setHtml(resp);
});
}, timeout);
};
useEffect(() => {
renderMarkdown(value);
}, [value]);
useEffect(() => {
if (!html) {
return;
}
previewRef.current?.scrollTo(0, scrollTop);
htmlRender(previewRef.current, {
copySuccessText: t('copied', { keyPrefix: 'messages' }),
copyText: t('copy', { keyPrefix: 'messages' }),
});
}, [html]);
useImperativeHandle(ref, () => {
return {
getHtml: () => html,
element: previewRef.current,
};
});
return (
<ImgViewer>
<div
ref={previewRef}
className="preview-wrap position-relative p-3 rounded text-break text-wrap mt-2 fmt"
dangerouslySetInnerHTML={{ __html: html }}
/>
</ImgViewer>
);
};
export default memo(forwardRef(Index));
@@ -0,0 +1,129 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useTranslation } from 'react-i18next';
import { Modal as AnswerModal } from '@/components';
import { uploadImage } from '@/services';
import { writeSettingStore } from '@/stores';
export const useImageUpload = () => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
const {
max_image_size = 4,
max_attachment_size = 8,
authorized_image_extensions = [],
authorized_attachment_extensions = [],
} = writeSettingStore((state) => state.write);
const verifyImageSize = (files: FileList | File[]): boolean => {
const fileArray = Array.isArray(files) ? files : Array.from(files);
if (fileArray.length === 0) {
return false;
}
const canUploadAttachment = authorized_attachment_extensions.length > 0;
const allowedAllType = [
...authorized_image_extensions,
...authorized_attachment_extensions,
];
const unSupportFiles = fileArray.filter((file) => {
const fileName = file.name.toLowerCase();
return canUploadAttachment
? !allowedAllType.find((v) => fileName.endsWith(v))
: file.type.indexOf('image') === -1;
});
if (unSupportFiles.length > 0) {
AnswerModal.confirm({
content: canUploadAttachment
? t('file.not_supported', { file_type: allowedAllType.join(', ') })
: t('image.form_image.fields.file.msg.only_image'),
showCancel: false,
});
return false;
}
const otherFiles = fileArray.filter((file) => {
return file.type.indexOf('image') === -1;
});
if (canUploadAttachment && otherFiles.length > 0) {
const attachmentOverSizeFiles = otherFiles.filter(
(file) => file.size / 1024 / 1024 > max_attachment_size,
);
if (attachmentOverSizeFiles.length > 0) {
AnswerModal.confirm({
content: t('file.max_size', { size: max_attachment_size }),
showCancel: false,
});
return false;
}
}
const imageFiles = fileArray.filter(
(file) => file.type.indexOf('image') > -1,
);
const oversizedImages = imageFiles.filter(
(file) => file.size / 1024 / 1024 > max_image_size,
);
if (oversizedImages.length > 0) {
AnswerModal.confirm({
content: t('image.form_image.fields.file.msg.max_size', {
size: max_image_size,
}),
showCancel: false,
});
return false;
}
return true;
};
const uploadFiles = (
files: FileList | File[],
): Promise<{ url: string; name: string; type: string }[]> => {
const fileArray = Array.isArray(files) ? files : Array.from(files);
const promises = fileArray.map(async (file) => {
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
const url = await uploadImage({ file, type });
return {
name: file.name,
url,
type,
};
});
return Promise.all(promises);
};
const uploadSingleFile = async (file: File): Promise<string> => {
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
return uploadImage({ file, type });
};
return {
verifyImageSize,
uploadFiles,
uploadSingleFile,
};
};
+177
View File
@@ -0,0 +1,177 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.md-editor-wrap {
display: flex;
flex-direction: column;
background-color: var(-bs-body-bg);
border: 1px solid var(--an-ced4da);
overflow: hidden;
.toolbar-wrap {
border-bottom: 1px solid var(--an-ced4da);
.toolbar-divider {
float: left;
width: 1px;
height: 15px;
background-color: var(--an-toolbar-divider);
margin: 10px 8px;
}
.toolbar-item-wrap {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
margin: 0 2px;
line-height: 0;
.dropdown-menu {
line-height: 1.5;
}
&.right {
float: right;
}
.dropdown-toggle {
&::after {
display: none;
}
}
}
.toolbar {
box-sizing: border-box;
outline: none;
cursor: pointer;
background-color: var(--bs-body-bg);
height: 100%;
width: 100%;
border-radius: 3px;
font-size: 20px;
line-height: 20px;
&:hover {
background-color: var(--an-editor-toolbar-hover);
}
&:focus {
background-color: var(--ans-editor-toolbar-focus);
}
}
.popup-wrap {
position: absolute;
width: 500px;
margin-right: auto;
border: 1px solid #cacaca;
background: #fff;
z-index: 9999;
visibility: hidden;
&.heading-add {
width: 158px;
padding: 8px 0;
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 4px 24px;
cursor: pointer;
&:hover {
background-color: #eee;
}
h2 {
font-size: 24px;
margin: 0;
}
h3 {
font-size: 20px;
margin: 0;
}
h4 {
font-size: 19px;
margin: 0;
}
}
}
}
}
}
.content-wrap {
height: 264px;
}
.rich-editor-wrap {
height: 264px;
overflow-y: auto;
padding: 0.375rem 0.75rem;
.tiptap-editor {
outline: none;
min-height: 100%;
&:focus {
outline: none;
}
p {
margin: 0.5rem 0;
line-height: 1.6;
&.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--an-editor-placeholder-color);
pointer-events: none;
height: 0;
}
}
}
.editor-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--an-text-secondary, #6c757d);
}
}
.CodeMirror {
height: auto;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace !important;
font-size: 14px;
pre.CodeMirror-line,
pre.CodeMirror-line-like {
padding: 0 16px;
}
.CodeMirror-lines {
padding: 16px 0;
}
.CodeMirror-placeholder {
color: var(--an-editor-placeholder-color);
}
}
}
.preview-wrap {
overflow-y: auto;
min-height: 20px;
padding: 16px;
img {
max-width: 100%;
}
}
+241
View File
@@ -0,0 +1,241 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useRef,
useState,
ForwardRefRenderFunction,
forwardRef,
useImperativeHandle,
useCallback,
useEffect,
} from 'react';
import { Spinner } from 'react-bootstrap';
import classNames from 'classnames';
import {
PluginType,
useRenderPlugin,
getReplacementPlugin,
} from '@/utils/pluginKit';
import { writeSettingStore } from '@/stores';
import PluginRender, { PluginSlot } from '../PluginRender';
import { useImageUpload } from './hooks/useImageUpload';
import {
BlockQuote,
Bold,
Code,
Heading,
Help,
Hr,
Image,
Indent,
Italice,
Link as LinkItem,
OL,
Outdent,
Table,
UL,
File,
} from './ToolBars';
import { htmlRender } from './utils';
import Viewer from './Viewer';
import { EditorContext } from './EditorContext';
import MarkdownEditor from './MarkdownEditor';
import { Editor } from './types';
import './index.scss';
export interface EditorRef {
getHtml: () => string;
}
interface EventRef {
onChange?(value: string): void;
onFocus?(): void;
onBlur?(): void;
}
interface Props extends EventRef {
editorPlaceholder?;
className?;
value;
autoFocus?: boolean;
}
const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
{
editorPlaceholder = '',
className = '',
value,
onChange,
onFocus,
onBlur,
autoFocus = false,
},
ref,
) => {
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
const previewRef = useRef<{ getHtml; element } | null>(null);
const [fullEditorPlugin, setFullEditorPlugin] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const { verifyImageSize, uploadSingleFile } = useImageUpload();
const {
max_image_size = 4,
authorized_image_extensions = [],
authorized_attachment_extensions = [],
} = writeSettingStore((state) => state.write);
useEffect(() => {
let mounted = true;
const loadPlugin = async () => {
const plugin = await getReplacementPlugin(PluginType.EditorReplacement);
if (mounted) {
setFullEditorPlugin(plugin);
setIsLoading(false);
}
};
loadPlugin();
return () => {
mounted = false;
};
}, []);
useRenderPlugin(previewRef.current?.element);
const getHtml = useCallback(() => {
return previewRef.current?.getHtml();
}, []);
useImperativeHandle(
ref,
() => ({
getHtml,
}),
[getHtml],
);
const EditorComponent = MarkdownEditor;
if (isLoading) {
return (
<div className={classNames('md-editor-wrap rounded', className)}>
<div
className="d-flex justify-content-center align-items-center"
style={{ minHeight: '200px' }}>
<Spinner animation="border" variant="secondary" />
</div>
</div>
);
}
if (fullEditorPlugin) {
const FullEditorComponent = fullEditorPlugin.component;
const handleImageUpload = async (file: File | string): Promise<string> => {
if (typeof file === 'string') {
return file;
}
if (!verifyImageSize([file])) {
throw new Error('File validation failed');
}
return uploadSingleFile(file);
};
return (
<FullEditorComponent
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
placeholder={editorPlaceholder}
autoFocus={autoFocus}
imageUploadHandler={handleImageUpload}
uploadConfig={{
maxImageSizeMiB: max_image_size,
allowedExtensions: [
...authorized_image_extensions,
...authorized_attachment_extensions,
],
}}
/>
);
}
return (
<>
<div className={classNames('md-editor-wrap rounded', className)}>
<div className="toolbar-wrap px-3 d-flex align-items-center flex-wrap">
<EditorContext.Provider value={currentEditor}>
<PluginRender
type={PluginType.Editor}
className="d-flex align-items-center flex-wrap"
editor={currentEditor}
previewElement={previewRef.current?.element}>
<Heading />
<Bold />
<Italice />
<div className="toolbar-divider" />
<Code />
<LinkItem />
<BlockQuote />
<Image />
<File />
<Table />
<div className="toolbar-divider" />
<OL />
<UL />
<Indent />
<Outdent />
<Hr />
<div className="toolbar-divider" />
<PluginSlot />
<Help />
</PluginRender>
</EditorContext.Provider>
</div>
<EditorComponent
key="markdown-editor"
value={value}
onChange={(markdown) => {
onChange?.(markdown);
}}
onFocus={onFocus}
onBlur={onBlur}
placeholder={editorPlaceholder}
autoFocus={autoFocus}
onEditorReady={(editor) => {
setCurrentEditor(editor);
}}
/>
</div>
<Viewer ref={previewRef} value={value} />
</>
);
};
export { htmlRender };
export default forwardRef(MDEditor);
+119
View File
@@ -0,0 +1,119 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useContext, useEffect } from 'react';
import { Dropdown, Button } from 'react-bootstrap';
import { EditorContext } from './EditorContext';
import { Editor } from './types';
interface IProps {
keyMap?: string[];
onClick?: (editor: Editor) => void;
tip?: string;
className?: string;
as?: any;
children?;
label?: string;
disable?: boolean;
isShow?: boolean;
onBlur?: (editor: Editor) => void;
}
const ToolItem: FC<IProps> = (props) => {
const editor = useContext(EditorContext);
const {
label,
tip,
disable = false,
isShow,
keyMap,
onClick,
className,
as,
children,
onBlur,
} = props;
useEffect(() => {
if (!editor) {
return;
}
if (!keyMap) {
return;
}
keyMap.forEach((key) => {
editor?.addKeyMap({
[key]: () => {
onClick?.(editor);
return true;
},
});
});
}, [editor]);
const btnRender = () => (
<Button
variant="link"
title={tip}
className={`p-0 b-0 btn-no-border toolbar text-body ${
disable ? 'disabled' : ''
}`}
disabled={disable}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
if (editor) {
onClick?.(editor);
}
}}
onBlur={(e) => {
e.preventDefault();
if (editor) {
onBlur?.(editor);
}
}}>
<i className={`bi bi-${label}`} />
</Button>
);
if (!editor) {
return null;
}
return (
<div className={`toolbar-item-wrap ${className || ''}`}>
{as === 'dropdown' ? (
<Dropdown className="h-100 w-100" show={isShow}>
<Dropdown.Toggle as="div" className="h-100">
{btnRender()}
</Dropdown.Toggle>
{children}
</Dropdown>
) : (
<>
{btnRender()}
{children}
</>
)}
</div>
);
};
export default ToolItem;
+118
View File
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EditorView, Command } from '@codemirror/view';
export interface Position {
ch: number;
line: number;
sticky?: string | undefined;
}
export type Level = 1 | 2 | 3 | 4 | 5 | 6;
export interface ExtendEditor {
addKeyMap: (keyMap: Record<string, Command>) => void;
on: (
event:
| 'change'
| 'focus'
| 'blur'
| 'dragenter'
| 'dragover'
| 'drop'
| 'paste',
callback: (e?) => void,
) => void;
getValue: () => string;
setValue: (value: string) => void;
off: (
event:
| 'change'
| 'focus'
| 'blur'
| 'dragenter'
| 'dragover'
| 'drop'
| 'paste',
callback: (e?) => void,
) => void;
getSelection: () => string;
replaceSelection: (value: string) => void;
focus: () => void;
getCursor: () => Position;
replaceRange: (value: string, from: Position, to: Position) => void;
setSelection: (anchor: Position, head?: Position) => void;
setReadOnly: (readOnly: boolean) => void;
wrapText: (before: string, after?: string, defaultText?: string) => void;
replaceLines: (
replace: Parameters<Array<string>['map']>[0],
symbolLen?: number,
) => void;
appendBlock: (content: string) => void;
insertBold: (text?: string) => void;
insertItalic: (text?: string) => void;
insertCode: (text?: string) => void;
insertStrikethrough: (text?: string) => void;
insertHeading: (level: Level, text?: string) => void;
insertBlockquote: (text?: string) => void;
insertCodeBlock: (language?: string, code?: string) => void;
insertHorizontalRule: () => void;
insertOrderedList: () => void;
insertUnorderedList: () => void;
toggleOrderedList: () => void;
toggleUnorderedList: () => void;
insertLink: (url: string, text?: string) => void;
insertImage: (url: string, alt?: string) => void;
insertTable: (rows?: number, cols?: number) => void;
indent: () => void;
outdent: () => void;
isBold: () => boolean;
isItalic: () => boolean;
isHeading: (level?: number) => boolean;
isBlockquote: () => boolean;
isCodeBlock: () => boolean;
isOrderedList: () => boolean;
isUnorderedList: () => boolean;
}
export type Editor = EditorView & ExtendEditor;
export interface CodeMirrorEditor extends Editor {
display: any;
moduleType;
}
export interface BaseEditorProps {
value: string;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
autoFocus?: boolean;
onEditorReady?: (editor: Editor) => void;
}
@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Editor, ExtendEditor } from '../../types';
import { createBaseMethods } from './base';
import { createEventMethods } from './events';
import { createCommandMethods } from './commands';
/**
* Adapts CodeMirror editor to unified editor interface
*
* This adapter function extends CodeMirror editor with additional methods,
* enabling toolbar components to work properly in Markdown mode. The adapter
* implements the complete `ExtendEditor` interface, including base methods,
* event handling, and command methods.
*
* @param editor - CodeMirror editor instance
* @returns Extended editor instance that implements the unified Editor interface
*
* @example
* ```typescript
* const cmEditor = new EditorView({ ... });
* const adaptedEditor = createCodeMirrorAdapter(cmEditor as Editor);
* // Now you can use the unified API
* adaptedEditor.insertBold('text');
* adaptedEditor.insertHeading(1, 'Title');
* ```
*/
export function createCodeMirrorAdapter(editor: Editor): Editor {
const baseMethods = createBaseMethods(editor);
const eventMethods = createEventMethods(editor);
const commandMethods = createCommandMethods(editor);
const editorAdapter: ExtendEditor = {
...editor,
...baseMethods,
...eventMethods,
...commandMethods,
};
return editorAdapter as unknown as Editor;
}
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EditorSelection, StateEffect } from '@codemirror/state';
import { keymap, KeyBinding, Command } from '@codemirror/view';
import { Editor, Position } from '../../types';
/**
* Creates base methods module
*
* Provides core base methods for the editor, including:
* - Content getter and setter (getValue, setValue)
* - Selection operations (getSelection, replaceSelection)
* - Cursor and selection position (getCursor, setSelection)
* - Focus and keyboard mapping (focus, addKeyMap)
*
* @param editor - CodeMirror editor instance
* @returns Object containing base methods
*/
export function createBaseMethods(editor: Editor) {
return {
focus: () => {
editor.contentDOM.focus();
},
getCursor: () => {
const range = editor.state.selection.ranges[0];
const line = editor.state.doc.lineAt(range.from).number;
const { from, to } = editor.state.doc.line(line);
return { from, to, ch: range.from - from, line };
},
addKeyMap: (keyMap: Record<string, Command>) => {
const array = Object.entries(keyMap).map(([key, value]) => {
const keyBinding: KeyBinding = {
key,
preventDefault: true,
run: value,
};
return keyBinding;
});
editor.dispatch({
effects: StateEffect.appendConfig.of(keymap.of(array)),
});
},
getSelection: () => {
return editor.state.sliceDoc(
editor.state.selection.main.from,
editor.state.selection.main.to,
);
},
replaceSelection: (value: string) => {
editor.dispatch({
changes: [
{
from: editor.state.selection.main.from,
to: editor.state.selection.main.to,
insert: value,
},
],
selection: EditorSelection.cursor(
editor.state.selection.main.from + value.length,
),
});
},
setSelection: (anchor: Position, head?: Position) => {
editor.dispatch({
selection: EditorSelection.create([
EditorSelection.range(
editor.state.doc.line(anchor.line).from + anchor.ch,
head
? editor.state.doc.line(head.line).from + head.ch
: editor.state.doc.line(anchor.line).from + anchor.ch,
),
]),
});
},
replaceRange: (value: string, from: Position, to: Position) => {
const fromOffset = editor.state.doc.line(from.line).from + from.ch;
const toOffset = editor.state.doc.line(to.line).from + to.ch;
editor.dispatch({
changes: { from: fromOffset, to: toOffset, insert: value },
});
},
getValue: () => {
return editor.state.doc.toString();
},
setValue: (value: string) => {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
});
},
};
}
@@ -0,0 +1,279 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EditorSelection } from '@codemirror/state';
import { Editor, Level } from '../../types';
/**
* Creates command methods module
*
* Provides semantic command methods and low-level text manipulation methods:
* - Semantic methods: insertBold, insertHeading, insertImage, etc. (for toolbar use)
* - Low-level methods: wrapText, replaceLines, appendBlock (for internal use)
* - State query methods: isBold, isHeading, etc.
*
* @param editor - CodeMirror editor instance
* @returns Object containing all command methods
*/
export function createCommandMethods(editor: Editor) {
// Create methods object that allows self-reference
const methods = {
wrapText: (before: string, after = before, defaultText) => {
const range = editor.state.selection.ranges[0];
const selectedText = editor.state.sliceDoc(range.from, range.to);
const text = selectedText || defaultText || '';
const wrappedText = before + text + after;
const insertFrom = range.from;
const insertTo = range.to;
editor.dispatch({
changes: [
{
from: insertFrom,
to: insertTo,
insert: wrappedText,
},
],
selection: selectedText
? EditorSelection.cursor(insertFrom + before.length + text.length)
: EditorSelection.range(
insertFrom + before.length,
insertFrom + before.length + text.length,
),
});
},
replaceLines: (replace: Parameters<Array<string>['map']>[0]) => {
const { doc } = editor.state;
const lines: string[] = [];
for (let i = 1; i <= doc.lines; i += 1) {
lines.push(doc.line(i).text);
}
const newLines = lines.map(replace) as string[];
const newText = newLines.join('\n');
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: newText,
},
});
},
appendBlock: (content: string) => {
const { doc } = editor.state;
const currentText = doc.toString();
const newText = currentText ? `${currentText}\n\n${content}` : content;
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: newText,
},
});
},
insertBold: (text?: string) => {
methods.wrapText('**', '**', text || 'bold text');
},
insertItalic: (text?: string) => {
methods.wrapText('*', '*', text || 'italic text');
},
insertCode: (text?: string) => {
methods.wrapText('`', '`', text || 'code');
},
insertStrikethrough: (text?: string) => {
methods.wrapText('~~', '~~', text || 'strikethrough text');
},
insertHeading: (level: Level, text?: string) => {
const headingText = '#'.repeat(level);
methods.wrapText(`${headingText} `, '', text || 'heading');
},
insertBlockquote: (text?: string) => {
methods.wrapText('> ', '', text || 'quote');
},
insertCodeBlock: (language?: string, code?: string) => {
const lang = language || '';
const codeText = code || '';
const block = `\`\`\`${lang}\n${codeText}\n\`\`\``;
methods.appendBlock(block);
},
insertHorizontalRule: () => {
methods.appendBlock('---');
},
insertOrderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
if (/^\d+\.\s/.test(lineText)) {
return;
}
methods.replaceLines((lineItem) => {
if (lineItem.trim() === '') {
return lineItem;
}
return `1. ${lineItem}`;
});
},
insertUnorderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
if (/^[-*+]\s/.test(lineText)) {
return;
}
methods.replaceLines((lineItem) => {
if (lineItem.trim() === '') {
return lineItem;
}
return `- ${lineItem}`;
});
},
toggleOrderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
if (/^\d+\.\s/.test(lineText)) {
methods.replaceLines((lineItem) => {
return lineItem.replace(/^\d+\.\s/, '');
});
} else {
methods.insertOrderedList();
}
},
toggleUnorderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
if (/^[-*+]\s/.test(lineText)) {
methods.replaceLines((lineItem) => {
return lineItem.replace(/^[-*+]\s/, '');
});
} else {
methods.insertUnorderedList();
}
},
insertLink: (url: string, text?: string) => {
const linkText = text || url;
methods.wrapText('[', `](${url})`, linkText);
},
insertImage: (url: string, alt?: string) => {
const altText = alt || '';
methods.wrapText('![', `](${url})`, altText);
},
insertTable: (rows = 3, cols = 3) => {
const table: string[] = [];
for (let i = 0; i < rows; i += 1) {
const row: string[] = [];
for (let j = 0; j < cols; j += 1) {
row.push(i === 0 ? 'Header' : 'Cell');
}
table.push(`| ${row.join(' | ')} |`);
if (i === 0) {
table.push(`| ${'---'.repeat(cols).split('').join(' | ')} |`);
}
}
methods.appendBlock(table.join('\n'));
},
indent: () => {
methods.replaceLines((line) => {
if (line.trim() === '') {
return line;
}
return ` ${line}`;
});
},
outdent: () => {
methods.replaceLines((line) => {
if (line.trim() === '') {
return line;
}
return line.replace(/^ {2}/, '');
});
},
isBold: () => {
const selection = editor.getSelection();
return /^\*\*.*\*\*$/.test(selection) || /^__.*__$/.test(selection);
},
isItalic: () => {
const selection = editor.getSelection();
return /^\*.*\*$/.test(selection) || /^_.*_$/.test(selection);
},
isHeading: (level?: number) => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
if (level) {
return new RegExp(`^#{${level}}\\s`).test(lineText);
}
return /^#{1,6}\s/.test(lineText);
},
isBlockquote: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
return /^>\s/.test(lineText);
},
isCodeBlock: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
return /^```/.test(lineText);
},
isOrderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
return /^\d+\.\s/.test(lineText);
},
isUnorderedList: () => {
const cursor = editor.getCursor();
const line = editor.state.doc.line(cursor.line);
const lineText = line.text.trim();
return /^[-*+]\s/.test(lineText);
},
};
return methods;
}
@@ -0,0 +1,98 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { StateEffect } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { Editor } from '../../types';
/**
* Creates event methods module
*
* Provides event listener registration and removal for the editor.
* Handles various DOM events including focus, blur, drag, drop, and paste.
*
* @param editor - CodeMirror editor instance
* @returns Object containing event methods (on, off)
*/
export function createEventMethods(editor: Editor) {
return {
on: (event, callback) => {
if (event === 'change') {
const change = EditorView.updateListener.of((update) => {
if (update.docChanged) {
callback();
}
});
editor.dispatch({
effects: StateEffect.appendConfig.of(change),
});
}
if (event === 'focus') {
editor.contentDOM.addEventListener('focus', callback);
}
if (event === 'blur') {
editor.contentDOM.addEventListener('blur', callback);
}
if (event === 'dragenter') {
editor.contentDOM.addEventListener('dragenter', callback);
}
if (event === 'dragover') {
editor.contentDOM.addEventListener('dragover', callback);
}
if (event === 'drop') {
editor.contentDOM.addEventListener('drop', callback);
}
if (event === 'paste') {
editor.contentDOM.addEventListener('paste', callback);
}
},
off: (event, callback) => {
if (event === 'focus') {
editor.contentDOM.removeEventListener('focus', callback);
}
if (event === 'blur') {
editor.contentDOM.removeEventListener('blur', callback);
}
if (event === 'dragenter') {
editor.contentDOM.removeEventListener('dragenter', callback);
}
if (event === 'dragover') {
editor.contentDOM.removeEventListener('dragover', callback);
}
if (event === 'drop') {
editor.contentDOM.removeEventListener('drop', callback);
}
if (event === 'paste') {
editor.contentDOM.removeEventListener('paste', callback);
}
},
};
}
+250
View File
@@ -0,0 +1,250 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { minimalSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, placeholder } from '@codemirror/view';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import copy from 'copy-to-clipboard';
import Tooltip from 'bootstrap/js/dist/tooltip';
import { Editor } from '../types';
import { isDarkTheme } from '@/utils/common';
import { createCodeMirrorAdapter } from './codemirror/adapter';
const editableCompartment = new Compartment();
interface htmlRenderConfig {
copyText: string;
copySuccessText: string;
}
export function htmlRender(el: HTMLElement | null, config?: htmlRenderConfig) {
if (!el) return;
const { copyText = '', copySuccessText = '' } = config || {
copyText: 'Copy to clipboard',
copySuccessText: 'Copied!',
};
// Replace all br tags with newlines
// Fixed an issue where the BR tag in the editor block formula HTML caused rendering errors.
el.querySelectorAll('p').forEach((p) => {
if (p.innerHTML.startsWith('$$') && p.innerHTML.endsWith('$$')) {
const str = p.innerHTML.replace(/<br>/g, '\n');
p.innerHTML = str;
}
});
// change table style
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
// add rel nofollow for link not includes domain
el.querySelectorAll('a').forEach((a) => {
const base = window.location.origin;
const targetUrl = new URL(a.href, base);
if (targetUrl.origin !== base) {
a.rel = 'nofollow';
}
});
// Add copy button to all pre tags
el.querySelectorAll('pre').forEach((pre) => {
// Create copy button
const codeWrap = document.createElement('div');
codeWrap.className = 'position-relative a-code-wrap';
const codeTool = document.createElement('div');
codeTool.className = 'a-code-tool';
const uniqueId = `a-copy-code-${Date.now().toString().substring(5)}-${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}`;
const str = `
<a role="button" class="link-secondary a-copy-code" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${copyText}" id="${uniqueId}">
<i class="br bi-copy"></i>
</a>
`;
codeTool.innerHTML = str;
pre.style.position = 'relative';
codeWrap.appendChild(codeTool);
pre.parentNode?.replaceChild(codeWrap, pre);
codeWrap.appendChild(pre);
const tooltipTriggerList = el.querySelectorAll('.a-copy-code');
Array.from(tooltipTriggerList)?.map(
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl),
);
// Copy pre content on button click
const copyBtn = codeTool.querySelector('.a-copy-code');
copyBtn?.addEventListener('click', () => {
const textToCopy = pre.textContent || '';
copy(textToCopy);
// Change tooltip text on copy success
const tooltipInstance = Tooltip.getOrCreateInstance(`#${uniqueId}`);
tooltipInstance?.setContent({ '.tooltip-inner': copySuccessText });
const myTooltipEl = document.querySelector(`#${uniqueId}`);
myTooltipEl?.addEventListener('hidden.bs.tooltip', () => {
tooltipInstance.setContent({ '.tooltip-inner': copyText });
});
});
});
}
export const useEditor = ({
editorRef,
placeholder: placeholderText,
autoFocus,
initialValue,
onChange,
onFocus,
onBlur,
}) => {
const [editor, setEditor] = useState<Editor | null>(null);
const isInternalUpdateRef = useRef<boolean>(false);
const init = async () => {
const isDark = isDarkTheme();
const theme = EditorView.theme({
'&': {
height: '100%',
padding: '.375rem .75rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-content': {
width: '100%',
},
'.cm-line': {
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
},
'.ͼ7, .ͼ6': {
textDecoration: 'none',
},
'.cm-cursor': {
'border-left-color': isDark ? 'white' : 'black',
},
});
const startState = EditorState.create({
doc: initialValue || '',
extensions: [
minimalSetup,
markdown({
codeLanguages: languages,
base: markdownLanguage,
}),
theme,
placeholder(placeholderText),
EditorView.lineWrapping,
editableCompartment.of(EditorView.editable.of(true)),
EditorView.domEventHandlers({
paste(event) {
const clipboard = event.clipboardData as DataTransfer;
const htmlStr = clipboard.getData('text/html');
const imgRegex =
/<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
return Boolean(htmlStr.match(imgRegex));
},
}),
],
});
const view = new EditorView({
parent: editorRef.current,
state: startState,
});
const cm = createCodeMirrorAdapter(view as Editor);
cm.setReadOnly = (readOnly: boolean) => {
cm.dispatch({
effects: editableCompartment.reconfigure(
EditorView.editable.of(!readOnly),
),
});
};
if (autoFocus) {
setTimeout(() => {
cm.focus();
}, 10);
}
const originalSetValue = cm.setValue;
cm.setValue = (newValue: string) => {
isInternalUpdateRef.current = true;
originalSetValue.call(cm, newValue);
setTimeout(() => {
isInternalUpdateRef.current = false;
}, 0);
};
cm.on('change', () => {
if (!isInternalUpdateRef.current && onChange) {
const newValue = cm.getValue();
onChange(newValue);
}
});
cm.on('focus', () => {
onFocus?.();
});
cm.on('blur', () => {
onBlur?.();
});
setEditor(cm);
return cm;
};
useEffect(() => {
if (!editorRef.current) {
return;
}
if (editorRef.current.children.length > 0 || editor) {
return;
}
init();
}, [editor]);
return editor;
};
+36
View File
@@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo, ReactNode } from 'react';
import { Trans } from 'react-i18next';
const Index: FC<{ children?: ReactNode }> = ({ children }) => {
return (
<div className="text-center py-5">
{children || (
<Trans i18nKey="personal.list_empty">
We couldn't find anything. <br /> Try different or less specific
keywords.
</Trans>
)}
</div>
);
};
export default memo(Index);
+113
View File
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo, useState } from 'react';
import { Card, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { TagSelector, Tag } from '@/components';
import { tryLoggedAndActivated } from '@/utils/guard';
import { useFollowingTags, followTags } from '@/services';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const [isEdit, setEditState] = useState(false);
const { data: followingTags, mutate } = useFollowingTags();
const newTags: any = followingTags?.map((item) => {
if (item.slug_name) {
return item.slug_name;
}
return item;
});
const handleFollowTags = () => {
followTags({
slug_name_list: newTags,
});
setEditState(false);
};
const handleTagsChange = (value) => {
mutate([...value], {
revalidate: false,
});
};
if (!tryLoggedAndActivated().ok) {
return null;
}
return isEdit ? (
<Card className="mb-4">
<Card.Header className="text-nowrap d-flex justify-content-between">
{t('following_tags')}
<Button
variant="link"
className="p-0 m-0 btn-no-border"
onClick={handleFollowTags}>
{t('save')}
</Button>
</Card.Header>
<Card.Body>
<TagSelector
value={followingTags}
onChange={handleTagsChange}
hiddenDescription
hiddenCreateBtn
autoFocus
/>
</Card.Body>
</Card>
) : (
<Card className="mb-4">
<Card.Header className="text-nowrap d-flex justify-content-between text-capitalize">
{t('following_tags')}
<Button
variant="link"
className="p-0 btn-no-border text-capitalize"
onClick={() => setEditState(true)}>
{t('edit')}
</Button>
</Card.Header>
<Card.Body>
{followingTags?.length ? (
<div className="m-n1">
{followingTags.map((item) => {
const slugName = item?.slug_name;
return <Tag key={slugName} className="m-1" data={item} />;
})}
</div>
) : (
<>
<div className="text-muted">{t('follow_tag_tip')}</div>
<Button
size="sm"
className="mt-3"
variant="outline-primary"
onClick={() => setEditState(true)}>
{t('follow_a_tag')}
</Button>
</>
)}
</Card.Body>
</Card>
);
};
export default memo(Index);
+67
View File
@@ -0,0 +1,67 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import Row from 'react-bootstrap/Row';
import dayjs from 'dayjs';
import { siteInfoStore } from '@/stores';
const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'footer' }); // Scoped translations for footer
const fullYear = dayjs().format('YYYY');
const siteName = siteInfoStore((state) => state.siteInfo.name);
const cc = `${siteName} © ${fullYear}`;
return (
<Row>
<footer className="py-3 d-flex flex-wrap align-items-center justify-content-between text-secondary small">
<div className="d-flex align-items-center">
<div className="me-3">{cc}</div>
<Link to="/tos" className="me-3 link-secondary">
{t('terms', { keyPrefix: 'nav_menus' })}
</Link>
{/* Link to Privacy Policy with right margin for spacing */}
<Link to="/privacy" className="link-secondary">
{t('privacy', { keyPrefix: 'nav_menus' })}
</Link>
</div>
<div>
<Trans i18nKey="footer.build_on" values={{ cc }}>
Powered by
<a
href="https://answer.apache.org"
target="_blank"
className="link-secondary"
rel="noreferrer">
Apache Answer
</a>
</Trans>
</div>
</footer>
</Row>
);
};
export default React.memo(Index);
+80
View File
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import dayjs from 'dayjs';
interface Props {
time: number;
className?: string;
preFix?: string;
}
const Index: FC<Props> = ({ time, preFix, className }) => {
const { t } = useTranslation();
const formatTime = (from) => {
const now = Math.floor(dayjs().valueOf() / 1000);
const between = now > from ? now - from : 0;
if (between <= 1) {
return t('dates.now');
}
if (between > 1 && between < 60) {
return t('dates.x_seconds_ago', { count: between });
}
if (between >= 60 && between < 3600) {
const min = Math.floor(between / 60);
return t('dates.x_minutes_ago', { count: min });
}
if (between >= 3600 && between < 3600 * 24) {
const h = Math.floor(between / 3600);
return t('dates.x_hours_ago', { count: h });
}
if (
between >= 3600 * 24 &&
between < 3600 * 24 * 366 &&
dayjs.unix(from).format('YYYY') === dayjs.unix(now).format('YYYY')
) {
return dayjs.unix(from).tz().format(t('dates.long_date'));
}
return dayjs.unix(from).tz().format(t('dates.long_date_with_year'));
};
if (!time) {
return null;
}
return (
<time
className={classNames('', className)}
dateTime={dayjs.unix(time).tz().toISOString()}
title={dayjs.unix(time).tz().format(t('dates.long_date_with_time'))}>
{preFix ? `${preFix} ` : ''}
{formatTime(time)}
</time>
);
};
export default memo(Index);
@@ -0,0 +1,164 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
import { floppyNavigation, isDarkTheme } from '@/utils';
import { userCenterStore } from '@/stores';
import { REACT_BASE_PATH } from '@/router/alias';
interface Props {
redDot: Type.NotificationStatus | undefined;
userInfo: Type.UserInfoRes;
logOut: (e) => void;
}
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
const navigate = useNavigate();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { agent: ucAgent } = userCenterStore();
const handleLinkClick = (evt) => {
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
const href = evt.currentTarget.getAttribute('href');
floppyNavigation.navigate(href, {
handler: navigate,
});
}
};
return (
<>
<Nav className="flex-row">
<NavLink
to="/users/notifications/inbox"
title={t('inbox', { keyPrefix: 'notifications' })}
className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
<Icon name="bell-fill" className="fs-4" />
{(redDot?.inbox || 0) > 0 && (
<div className="unread-dot bg-danger">
<span className="visually-hidden">
{t('new_alerts', { keyPrefix: 'notifications' })}
</span>
</div>
)}
</NavLink>
<NavLink
to="/users/notifications/achievement"
title={t('achievement', { keyPrefix: 'notifications' })}
className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
<Icon name="trophy-fill" className="fs-4" />
{(redDot?.achievement || 0) > 0 && (
<div className="unread-dot bg-danger">
<span className="visually-hidden">
{t('new_alerts', { keyPrefix: 'notifications' })}
</span>
</div>
)}
</NavLink>
</Nav>
<Dropdown align="end" data-bs-theme={isDarkTheme() ? 'dark' : 'light'}>
<Dropdown.Toggle
variant="success"
id="dropdown-basic"
as="a"
role="button"
className="no-toggle pointer">
<Avatar
size="36px"
avatar={userInfo?.avatar}
alt={userInfo?.display_name}
searchStr="s=96"
/>
</Dropdown.Toggle>
<Dropdown.Menu className="position-absolute">
<Dropdown.Item
href={`${REACT_BASE_PATH}/users/${userInfo.username}`}
onClick={handleLinkClick}>
{t('header.nav.profile')}
</Dropdown.Item>
<Dropdown.Item
href={`${REACT_BASE_PATH}/users/${userInfo.username}/bookmarks`}
onClick={handleLinkClick}>
{t('header.nav.bookmark')}
</Dropdown.Item>
<Dropdown.Item
href={`${REACT_BASE_PATH}/users/settings/profile`}
onClick={handleLinkClick}>
{t('header.nav.setting')}
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item
href={`${REACT_BASE_PATH}/users/logout`}
onClick={(e) => logOut(e)}>
{t('header.nav.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
{/* Dropdown for user center agent info */}
{ucAgent?.enabled &&
(ucAgent?.agent_info?.url ||
ucAgent?.agent_info?.control_center?.length) ? (
<Dropdown align="end" data-bs-theme={isDarkTheme() ? 'dark' : 'light'}>
<Dropdown.Toggle
variant="success"
id="dropdown-uca"
as="span"
className="no-toggle">
<Nav>
<Icon
name="grid-3x3-gap-fill"
className="nav-link pointer p-0 fs-4 ms-3"
/>
</Nav>
</Dropdown.Toggle>
<Dropdown.Menu className="position-absolute">
{ucAgent.agent_info.url ? (
<Dropdown.Item href={ucAgent.agent_info.url}>
{ucAgent.agent_info.name}
</Dropdown.Item>
) : null}
{ucAgent.agent_info.url &&
ucAgent.agent_info.control_center?.length ? (
<Dropdown.Divider />
) : null}
{ucAgent.agent_info.control_center?.map((ctrl) => {
return (
<Dropdown.Item key={ctrl.name} href={ctrl.url}>
{ctrl.label}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
) : null}
</>
);
};
export default memo(Index);
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useState, useEffect } from 'react';
import { Form, FormControl } from 'react-bootstrap';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
const SearchInput: FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'header' });
const navigate = useNavigate();
const location = useLocation();
const [urlSearch] = useSearchParams();
const q = urlSearch.get('q');
const [searchStr, setSearch] = useState('');
const handleInput = (val) => {
setSearch(val);
};
const handleSearch = (evt) => {
evt.preventDefault();
if (!searchStr) {
return;
}
const searchUrl = `/search?q=${encodeURIComponent(searchStr)}`;
navigate(searchUrl);
};
useEffect(() => {
if (q && location.pathname === '/search') {
handleInput(q);
}
}, [q]);
useEffect(() => {
// clear search input when navigate to other page
if (location.pathname !== '/search' && searchStr) {
setSearch('');
}
}, [location.pathname]);
return (
<Form
action="/search"
className={`w-100 position-relative mx-auto ${className}`}
onSubmit={handleSearch}>
<div className="search-wrap" onClick={handleSearch}>
<Icon name="search" className="search-icon" />
</div>
<FormControl
type="search"
placeholder={t('search.placeholder')}
className="placeholder-search"
value={searchStr}
name="q"
onChange={(e) => handleInput(e.target.value)}
/>
</Form>
);
};
export default SearchInput;
+168
View File
@@ -0,0 +1,168 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
#header {
z-index: 1041;
--bs-navbar-padding-y: 0.75rem;
background: var(--bs-primary);
box-shadow:
inset 0 -1px 0 rgba(0, 0, 0, 0.15),
0 0.125rem 0.25rem rgb(0 0 0 / 8%);
.logo {
max-height: 2rem;
}
.fixed-width {
padding-left: 28px;
padding-right: 36px;
}
.create-icon {
color: var(--bs-nav-link-color);
}
.nav-link {
&.icon-link {
width: 38px;
height: 38px;
}
}
.search-mobile {
color: var(--bs-nav-link-color);
}
.answer-navBar {
font-size: 1rem;
padding: 0.25rem 0.5rem;
border: none;
}
.answer-navBar:focus {
box-shadow: none;
}
.xl-none {
display: none !important;
}
.hr {
color: var(--bs-navbar-color);
}
// style for colored navbar
&.theme-dark {
.placeholder-search {
padding-left: 42px;
box-shadow: none;
color: #fff;
background-color: rgba(255, 255, 255, 0.2);
border: $border-width $border-style rgba(255, 255, 255, 0.2);
&:focus {
border: $border-width $border-style $border-color;
}
&::placeholder {
color: rgba(255, 255, 255, 0.75);
}
}
.search-icon {
color: #fff;
}
}
// style for colored navbar
&.theme-light {
.nav-link {
color: rgba(0, 0, 0, 0.65);
}
.placeholder-search {
padding-left: 42px;
box-shadow: none;
color: var(--bs-body-color);
background-color: rgba(255, 255, 255, 0.2);
border: $border-width $border-style rgba(0, 0, 0, 0.05);
&:focus {
border: $border-width $border-style $border-color;
}
&::placeholder {
color: rgba(0, 0, 0, 0.65);
}
}
.search-icon {
color: var(--bs-body-color);
}
}
.maxw-560 {
max-width: 560px;
}
.search-wrap {
position: absolute;
top: 0;
left: 0;
padding: 10px 13px;
line-height: 1;
}
}
@media (max-width: 1199.9px) {
#header {
.fixed-width {
padding-right: 48px;
}
.nav-grow {
flex-grow: 1 !important;
}
.xl-none {
display: flex !important;
}
.maxw-400 {
max-width: 100%;
}
}
}
@media screen and (max-width: 991px) {
#header {
.fixed-width {
padding-left: 40px;
padding-right: 48px;
}
}
}
@media screen and (max-width: 767px) {
#header {
.fixed-width {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.nav-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
}
}
+224
View File
@@ -0,0 +1,224 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo, useState, useEffect } from 'react';
import { Navbar, Nav, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink, useLocation, useMatch } from 'react-router-dom';
import classnames from 'classnames';
import { userCenter, floppyNavigation, isLight } from '@/utils';
import {
loggedUserInfoStore,
siteInfoStore,
brandingStore,
loginSettingStore,
themeSettingStore,
sideNavStore,
} from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { Icon, MobileSideNav } from '@/components';
import NavItems from './components/NavItems';
import SearchInput from './components/SearchInput';
import './index.scss';
const Header: FC = () => {
const location = useLocation();
const { user, clear: clearUserStore } = loggedUserInfoStore();
const { t } = useTranslation();
const siteInfo = siteInfoStore((state) => state.siteInfo);
const brandingInfo = brandingStore((state) => state.branding);
const loginSetting = loginSettingStore((state) => state.login);
const { updateReview } = sideNavStore();
const { data: redDot } = useQueryNotificationStatus();
const [showMobileSideNav, setShowMobileSideNav] = useState(false);
const [showMobileSearchInput, setShowMobileSearchInput] = useState(false);
/**
* Automatically append `tag` information when creating a question
*/
const tagMatch = useMatch('/tags/:slugName');
let askUrl = '/questions/add';
if (tagMatch && tagMatch.params.slugName) {
askUrl = `${askUrl}?tags=${encodeURIComponent(tagMatch.params.slugName)}`;
}
useEffect(() => {
updateReview({
can_revision: Boolean(redDot?.can_revision),
revision: Number(redDot?.revision),
});
}, [redDot]);
const handleLogout = async (evt) => {
evt.preventDefault();
await logout();
clearUserStore();
window.location.replace(window.location.href);
};
useEffect(() => {
setShowMobileSearchInput(false);
setShowMobileSideNav(false);
}, [location.pathname]);
let navbarStyle = 'theme-light';
let themeMode = 'light';
const { theme, theme_config, layout } = themeSettingStore((_) => _);
if (theme_config?.[theme]?.navbar_style) {
// const color = theme_config[theme].navbar_style.startsWith('#')
themeMode = isLight(theme_config[theme].navbar_style) ? 'light' : 'dark';
navbarStyle = `theme-${themeMode}`;
}
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1199.9) {
setShowMobileSideNav(false);
setShowMobileSearchInput(false);
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<Navbar
data-bs-theme={themeMode}
expand="xl"
className={classnames('sticky-top', navbarStyle)}
style={{
backgroundColor: theme_config[theme].navbar_style,
}}
id="header">
<div
className={classnames(
'w-100 d-flex align-items-center',
layout === 'Fixed-width' ? 'container-xxl fixed-width' : 'px-3',
)}>
<Navbar.Toggle
className="answer-navBar me-2"
onClick={() => {
setShowMobileSideNav(!showMobileSideNav);
setShowMobileSearchInput(false);
}}
/>
<Navbar.Brand
to="/"
as={Link}
className="lh-1 me-0 me-sm-5 p-0 nav-text">
{brandingInfo.logo ? (
<>
<img
className="d-none d-xl-block logo me-0"
src={brandingInfo.logo}
alt={siteInfo.name}
/>
<img
className="xl-none logo me-0"
src={brandingInfo.mobile_logo || brandingInfo.logo}
alt={siteInfo.name}
/>
</>
) : (
<span>{siteInfo.name}</span>
)}
</Navbar.Brand>
<SearchInput className="d-none d-lg-block maxw-560" />
<Nav className="d-block d-lg-none me-2 ms-auto">
<Button
variant="link"
onClick={() => {
setShowMobileSideNav(false);
setShowMobileSearchInput(!showMobileSearchInput);
}}
className="p-0 btn-no-border icon-link nav-link d-flex align-items-center justify-content-center">
<Icon name="search" className="lh-1 fs-4" />
</Button>
</Nav>
{/* pc nav */}
{user?.username ? (
<Nav className="d-flex align-items-center flex-nowrap flex-row">
<Nav.Item className="me-2 d-block d-xl-none">
<NavLink
to={askUrl}
className="d-block icon-link nav-link text-center">
<Icon name="plus-lg" className="lh-1 fs-4" />
</NavLink>
</Nav.Item>
<Nav.Item className="me-2 d-none d-xl-block">
<NavLink
to={askUrl}
className="nav-link d-flex align-items-center text-capitalize text-nowrap">
<Icon name="plus-lg" className="me-2 lh-1 fs-4" />
<span>{t('btns.create')}</span>
</NavLink>
</Nav.Item>
<NavItems redDot={redDot} userInfo={user} logOut={handleLogout} />
</Nav>
) : (
<>
<Link
className={classnames('me-2 btn btn-link', {
'link-light': navbarStyle === 'theme-dark',
'link-primary': navbarStyle !== 'theme-dark',
})}
onClick={() => floppyNavigation.storageLoginRedirect()}
to={userCenter.getLoginUrl()}>
{t('btns.login')}
</Link>
{loginSetting.allow_new_registrations && (
<Link
className={classnames(
'btn',
navbarStyle === 'theme-dark' ? 'btn-light' : 'btn-primary',
)}
to={userCenter.getSignUpUrl()}>
{t('btns.signup')}
</Link>
)}
</>
)}
</div>
{showMobileSearchInput && (
<div className="w-100 px-3 mt-2 d-block d-lg-none">
<SearchInput />
</div>
)}
<MobileSideNav show={showMobileSideNav} onHide={setShowMobileSideNav} />
</Navbar>
);
};
export default memo(Header);
@@ -0,0 +1,22 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.highlight-text > mark {
padding: 0;
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, FC } from 'react';
import './index.scss';
interface IProps {
text: string;
keywords: string[];
}
const Index: FC<IProps> = ({ text = '', keywords = [] }) => {
const regex = new RegExp(`(${keywords.join('|')})`, 'gi');
return (
<span className="highlight-text">
{text.split(regex).map((piece: string, index: number) => {
const key = `${piece.substring(0, 6)}_${index}`;
return keywords.find(
(kw: string) => kw.toLocaleLowerCase() === piece.toLocaleLowerCase(),
) ? (
<mark key={key}>{piece}</mark>
) : (
piece
);
})}
</span>
);
};
export default memo(Index);
+73
View File
@@ -0,0 +1,73 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { Card, ListGroup, ListGroupItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { Icon } from '@/components';
import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { data: questionRes } = useHotQuestions();
if (!questionRes?.list?.length) {
return null;
}
return (
<Card>
<Card.Header className="text-nowrap text-capitalize">
{t('hot_questions')}
</Card.Header>
<ListGroup variant="flush">
{questionRes?.list?.map((li) => {
return (
<ListGroupItem
key={li.id}
as={Link}
to={pathFactory.questionLanding(li.id, li.url_title)}
action>
<div className="link-dark text-truncate-3">{li.title}</div>
{li.answer_count > 0 ? (
<div
className={`d-flex align-items-center small mt-1 ${
li.accepted_answer_id > 0
? 'link-success'
: 'link-secondary'
}`}>
{li.accepted_answer_id >= 1 ? (
<Icon name="check-circle-fill" />
) : (
<Icon name="chat-square-text-fill" />
)}
<span className="ms-1">
{t('x_answers', { count: li.answer_count })}
</span>
</div>
) : null}
</ListGroupItem>
);
})}
</ListGroup>
</Card>
);
};
export default HotQuestions;
@@ -0,0 +1,74 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
const Index = ({
httpCode = '',
title = '',
errMsg = '',
showErrorCode = true,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'page_error' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap') as HTMLElement;
if (pageWrap) {
pageWrap.style.display = 'contents';
}
return () => {
if (pageWrap) {
pageWrap.style.display = 'block';
}
};
}, []);
usePageTags({
title: t(`http_${httpCode}`, { keyPrefix: 'page_title' }),
});
return (
<div className="d-flex flex-column flex-shrink-1 flex-grow-1 justify-content-center align-items-center">
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
{showErrorCode && (
<h4 className="text-center">{t('http_error', { code: httpCode })}</h4>
)}
{title && <h4 className="text-center">{title}</h4>}
<div className="text-center mb-3 fs-5">
{errMsg || t(`desc_${httpCode}`)}
</div>
<div className="text-center">
<Link to="/" className="btn btn-link">
{t('back_home')}
</Link>
</div>
</div>
);
};
export default memo(Index);
+52
View File
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import classNames from 'classnames';
interface IProps {
type?: 'br' | 'bi';
/** icon name */
name: string;
className?: string;
size?: string;
title?: string;
onClick?: () => void;
}
const Icon: FC<IProps> = ({
type = 'br',
name,
className,
size,
onClick,
title,
}) => {
return (
<i
className={classNames(type, `bi-${name}`, className)}
style={{ ...(size && { fontSize: size }) }}
onClick={onClick}
onKeyDown={onClick}
title={title}
/>
);
};
export default Icon;
+38
View File
@@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { base64ToSvg } from '@/utils';
interface IProps {
svgClassName?: string;
base64: string | undefined;
}
const Icon: FC<IProps> = ({ base64 = '', svgClassName = '' }) => {
return base64 ? (
<span
dangerouslySetInnerHTML={{
__html: base64ToSvg(base64, svgClassName),
}}
/>
) : null;
};
export default Icon;
+26
View File
@@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.img-viewer .cursor-zoom-out {
cursor: zoom-out !important;
}
.img-viewer img:not(a img, img.broken, img.invisible) {
cursor: zoom-in;
}
+104
View File
@@ -0,0 +1,104 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, MouseEvent, ReactNode, useEffect, useState } from 'react';
import { Modal } from 'react-bootstrap';
import './index.css';
import classnames from 'classnames';
const Index: FC<{
children: ReactNode;
className?: classnames.Argument;
}> = ({ children, className }) => {
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState('');
const onClose = () => {
setVisible(false);
setImgSrc('');
};
const checkIfInLink = (target) => {
let ret = false;
let el = target.parentElement;
while (el) {
if (el.nodeName.toLowerCase() === 'a') {
ret = true;
break;
}
el = el.parentElement;
}
return ret;
};
const checkClickForImgView = (evt: MouseEvent<HTMLElement>) => {
const { target } = evt;
// @ts-ignore
if (target.nodeName.toLowerCase() !== 'img') {
return;
}
const img = target as HTMLImageElement;
if (!img.naturalWidth || !img.naturalHeight) {
img.classList.add('broken');
return;
}
const src = img.currentSrc || img.src;
if (src && checkIfInLink(img) === false) {
setImgSrc(src);
setVisible(true);
}
};
useEffect(() => {
return () => {
onClose();
};
}, []);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className={classnames('img-viewer', className)}
onClick={checkClickForImgView}>
{children}
<Modal
show={visible}
fullscreen
centered
scrollable
contentClassName="bg-transparent"
onHide={onClose}>
<Modal.Body onClick={onClose} className="img-viewer p-0 d-flex">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions */}
<img
className="cursor-zoom-out img-fluid m-auto"
onClick={(evt) => {
evt.stopPropagation();
onClose();
}}
src={imgSrc}
alt={imgSrc}
/>
</Modal.Body>
</Modal>
</div>
);
};
export default Index;
@@ -0,0 +1,56 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Same as spin in `public/index.html`
@keyframes _initial-loading-spin {
to {
transform: rotate(360deg);
}
}
.InitialLoadingPlaceholder {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: white;
z-index: 9999;
&-spinnerContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&-spinner {
box-sizing: border-box;
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: -0.125em;
border: 0.25rem solid currentColor;
border-right-color: transparent;
color: rgba(108, 117, 125, 0.75);
border-radius: 50%;
animation: 0.75s linear infinite _initial-loading-spin;
}
}
@@ -0,0 +1,34 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Same as spin in `public/index.html`
import './index.scss';
function InitialLoadingPlaceholder() {
return (
<div className="InitialLoadingPlaceholder">
<div className="InitialLoadingPlaceholder-spinnerContainer">
<div className="InitialLoadingPlaceholder-spinner" />
</div>
</div>
);
}
export default InitialLoadingPlaceholder;
+23
View File
@@ -0,0 +1,23 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.bg-gray-200 {
color: var(--bs-body-color);
background-color: var(--an-invite-answer-item-active);
}
+177
View File
@@ -0,0 +1,177 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useRef, useState, FC } from 'react';
import { Dropdown } from 'react-bootstrap';
import { useSearchUserStaff } from '@/services';
import * as Types from '@/common/interface';
import './index.scss';
interface IProps {
children: React.ReactNode;
pageUsers;
onSelected: (val: string) => void;
}
const MAX_RECODE = 5;
const Mentions: FC<IProps> = ({ children, pageUsers, onSelected }) => {
const menuRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [val, setValue] = useState('');
const [users, setUsers] = useState<Types.PageUser[]>([]);
const [cursor, setCursor] = useState(0);
const [isRequested, setRequestedState] = useState(false);
const { data: staffUserList = [] } = useSearchUserStaff(val);
const mapStaffUsers =
staffUserList
?.map((item) => ({
displayName: item.display_name,
userName: item.username,
}))
?.filter(
(item) =>
users.findIndex((user) => user.userName === item.userName) < 0,
) || [];
const searchUser = () => {
const element = dropdownRef.current?.children[0];
const { value, selectionStart = 0 } = element as HTMLTextAreaElement;
if (value.indexOf('@') < 0) {
setValue('');
}
if (!selectionStart) {
return;
}
const str = value.substring(
value.substring(0, selectionStart).lastIndexOf('@'),
selectionStart,
);
if (str.substring(str.lastIndexOf(' '), selectionStart).indexOf('@') < 0) {
return;
}
setValue(str.substring(1));
if (!str.substring(1)) {
return;
}
if (isRequested) {
return;
}
setRequestedState(true);
};
useEffect(() => {
const element = dropdownRef.current?.children[0] as HTMLTextAreaElement;
if (element) {
element.addEventListener('input', searchUser);
}
return () => {
element.removeEventListener('input', searchUser);
};
}, [dropdownRef]);
useEffect(() => {
setUsers(pageUsers);
}, [pageUsers, val]);
const handleClick = (item) => {
const element = dropdownRef.current?.children[0] as HTMLTextAreaElement;
const { value, selectionStart = 0 } = element;
if (!selectionStart) {
return;
}
const text = `@${item?.userName} `;
onSelected(
`${value.substring(
0,
value.substring(0, selectionStart).lastIndexOf('@'),
)}${text}${value.substring(selectionStart)}`,
);
setUsers([]);
setValue('');
};
const filterData = val
? [...users, ...mapStaffUsers].filter(
(item) =>
item.displayName?.indexOf(val) === 0 ||
item.userName?.indexOf(val) === 0,
)
: [];
const handleKeyDown = (e) => {
const { keyCode } = e;
if (keyCode === 38 && cursor > 0) {
e.preventDefault();
setCursor(cursor - 1);
}
if (keyCode === 40 && cursor < filterData.length - 1) {
e.preventDefault();
setCursor(cursor + 1);
}
if (keyCode === 13 && cursor > -1 && cursor <= filterData.length - 1) {
e.preventDefault();
const item = filterData[cursor];
handleClick(item);
setCursor(0);
}
};
return (
<Dropdown
ref={dropdownRef}
className="mentions-wrap"
show={filterData.length > 0}
onKeyDown={handleKeyDown}>
{children}
<Dropdown.Menu
className={filterData.length > 0 ? 'visible' : 'invisible'}
ref={menuRef}>
{filterData
.filter((_, index) => index < MAX_RECODE)
.map((item, index) => {
return (
<Dropdown.Item
className={`${cursor === index ? 'bg-gray-200' : ''}`}
key={item.displayName}
onClick={() => handleClick(item)}>
<span className="link-dark me-1">{item.displayName}</span>
<small className="link-secondary">@{item.userName}</small>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
);
};
export default Mentions;
@@ -0,0 +1,29 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#mobileSideNav {
top: 62px !important;
width: 240px !important;
height: calc(100vh - 62px) !important;
overflow-y: auto;
flex: none;
.navbar-nav {
--bs-nav-link-padding-x: 1rem;
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Offcanvas } from 'react-bootstrap';
import { useLocation } from 'react-router-dom';
import { SideNav, AdminSideNav } from '@/components';
import './index.scss';
const MobileSideNav = ({ show, onHide }) => {
const { pathname } = useLocation();
const isAdmin = pathname.includes('/admin');
return (
<Offcanvas
show={show}
onHide={() => {
onHide(false);
}}
id="mobileSideNav"
className="px-3 py-4">
<Offcanvas.Body className="p-0">
{isAdmin ? <AdminSideNav /> : <SideNav />}
</Offcanvas.Body>
</Offcanvas>
);
};
export default MobileSideNav;
+164
View File
@@ -0,0 +1,164 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import classNames from 'classnames';
import type * as Type from '@/common/interface';
import { loggedUserInfoStore } from '@/stores';
import { readNotification, useQueryNotificationStatus } from '@/services';
import AnimateGift from '@/utils/animateGift';
import Icon from '../Icon';
import Modal from './Modal';
interface BadgeModalProps {
badge?: Type.NotificationBadgeAward | null;
visible: boolean;
}
let bg1: AnimateGift;
let bg2: AnimateGift;
let timeout: NodeJS.Timeout;
const BadgeModal: FC<BadgeModalProps> = ({ badge, visible }) => {
const { t } = useTranslation('translation', { keyPrefix: 'badges.modal' });
const { user } = loggedUserInfoStore();
const navigate = useNavigate();
const { data, mutate } = useQueryNotificationStatus();
const handle = async () => {
if (!data) return;
await readNotification(badge?.notification_id);
await mutate({
...data,
badge_award: null,
});
clearTimeout(timeout);
bg1?.destroy();
bg2?.destroy();
};
const handleCancel = async () => {
await handle();
};
const handleConfirm = async () => {
await handle();
const url = `/badges/${badge?.badge_id}?username=${user.username}`;
navigate(url);
};
const initAnimation = () => {
const DURATION = 8000;
const LENGTH = 200;
const bgNode = document.documentElement || document.body;
const badgeModalNode = document.getElementById('badgeModal');
const parentNode = badgeModalNode?.parentNode;
badgeModalNode?.setAttribute('style', 'z-index: 1');
if (parentNode) {
bg1 = new AnimateGift({
elm: parentNode,
width: bgNode.clientWidth,
height: bgNode.clientHeight,
length: LENGTH,
duration: DURATION,
isLoop: true,
});
timeout = setTimeout(() => {
bg2 = new AnimateGift({
elm: parentNode,
width: window.innerWidth,
height: window.innerHeight,
length: LENGTH,
duration: DURATION,
});
}, DURATION / 2);
}
};
const destroyAnimation = () => {
clearTimeout(timeout);
bg1?.destroy();
bg2?.destroy();
};
useEffect(() => {
if (visible) {
initAnimation();
} else {
destroyAnimation();
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
initAnimation();
} else {
destroyAnimation();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
destroyAnimation();
};
}, [visible]);
return (
<Modal
id="badgeModal"
title={t('title')}
visible={visible}
onCancel={handleCancel}
onConfirm={handleConfirm}
cancelText={t('close')}
cancelBtnVariant="link"
confirmText={t('confirm')}
confirmBtnVariant="primary"
scrollable={false}>
{badge && (
<div className="text-center">
{badge.icon?.startsWith('http') ? (
<img src={badge.icon} width={96} height={96} alt={badge.name} />
) : (
<Icon
name={badge.icon}
size="96px"
className={classNames(
'lh-1',
badge.level === 1 && 'bronze',
badge.level === 2 && 'silver',
badge.level === 3 && 'gold',
)}
/>
)}
<h5 className="mt-3">{badge?.name}</h5>
<p>{t('content')}</p>
</div>
)}
</Modal>
);
};
export default BadgeModal;
+78
View File
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable @typescript-eslint/no-use-before-define */
import * as React from 'react';
import ReactDOM from 'react-dom/client';
import Modal from './Modal';
import type { Props } from './Modal';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
export interface Config extends Props {
content: string;
}
const Index = ({
title = '',
confirmText = '',
content,
onCancel: onClose,
onConfirm,
cancelBtnVariant = 'link',
confirmBtnVariant = 'primary',
...props
}: Config) => {
const onCancel = () => {
if (typeof onClose === 'function') {
onClose();
}
render({ visible: false });
div.remove();
};
const onOk = (e) => {
if (typeof onConfirm === 'function') {
onConfirm(e);
}
onCancel();
};
function render({ visible }: { visible: boolean }) {
root.render(
<Modal
visible={visible}
title={title}
centered={false}
onCancel={onCancel}
onConfirm={onOk}
confirmText={confirmText}
cancelBtnVariant={cancelBtnVariant}
confirmBtnVariant={confirmBtnVariant}
{...props}>
<p dangerouslySetInnerHTML={{ __html: content }} />
</Modal>,
);
}
render({ visible: true });
};
export default Index;
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { loginToContinueStore, siteInfoStore } from '@/stores';
import { floppyNavigation } from '@/utils';
import { WelcomeTitle } from '@/components';
import './login.scss';
interface IProps {
visible: boolean;
}
const Index: React.FC<IProps> = ({ visible = false }) => {
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { update: updateStore } = loginToContinueStore();
const { siteInfo } = siteInfoStore((_) => _);
const closeModal = () => {
updateStore({ show: false });
};
const linkClick = (evt) => {
evt.stopPropagation();
floppyNavigation.storageLoginRedirect();
closeModal();
};
return (
<Modal
show={visible}
onHide={closeModal}
centered
className="loginToContinueModal"
fullscreen="sm-down">
<Modal.Header closeButton>
<Modal.Title as="h5">{t('login_to_continue')}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-5">
<div className="d-flex flex-column align-items-center text-center text-body">
<WelcomeTitle className="mb-2" />
<p>{siteInfo.description}</p>
</div>
<div className="d-grid gap-2">
<Link
to="/users/login"
className="btn btn-primary"
onClick={linkClick}>
{t('login', { keyPrefix: 'btns' })}
</Link>
<Link
to="/users/register"
className="btn btn-link"
onClick={linkClick}>
{t('signup', { keyPrefix: 'btns' })}
</Link>
</div>
</Modal.Body>
</Modal>
);
};
export default Index;
+113
View File
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
export interface Props {
id?: string;
/** header title */
title?: string;
children?: React.ReactNode;
/** visible */
visible?: boolean;
centered?: boolean;
onCancel?: () => void;
onConfirm?: (event: any) => void;
cancelText?: string;
showCancel?: boolean;
cancelBtnVariant?: string;
confirmText?: string;
showConfirm?: boolean;
confirmBtnDisabled?: boolean;
confirmBtnVariant?: string;
/** body style */
bodyClass?: string;
scrollable?: boolean;
className?: string;
}
const Index: FC<Props> = ({
id = '',
title = 'title',
visible = false,
centered = true,
onCancel,
children,
onConfirm,
cancelText = '',
showCancel = true,
cancelBtnVariant = 'primary',
confirmText = '',
showConfirm = true,
confirmBtnVariant = 'link',
confirmBtnDisabled = false,
bodyClass = '',
scrollable = false,
className = '',
}) => {
const { t } = useTranslation();
return (
<Modal
id={id}
className={className}
scrollable={scrollable}
show={visible}
onHide={onCancel}
centered={centered}
fullscreen="sm-down">
<Modal.Header closeButton>
<Modal.Title as="h5">
{title || t('title', { keyPrefix: 'modal_confirm' })}
</Modal.Title>
</Modal.Header>
<Modal.Body className={classNames('text-break', bodyClass)}>
{children}
</Modal.Body>
{(showCancel || showConfirm) && (
<Modal.Footer>
{showCancel && (
<Button variant={cancelBtnVariant} onClick={onCancel}>
{cancelText === 'close'
? t('btns.close')
: cancelText || t('btns.cancel')}
</Button>
)}
{showConfirm && (
<Button
variant={confirmBtnVariant}
onClick={(event) => {
onConfirm?.(event);
}}
id="ok_button"
disabled={confirmBtnDisabled}>
{confirmText === 'OK'
? t('btns.ok')
: confirmText || t('btns.confirm')}
</Button>
)}
</Modal.Footer>
)}
</Modal>
);
};
export default React.memo(Index);
+36
View File
@@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import DefaultModal from './Modal';
import confirm, { Config } from './Confirm';
import LoginToContinueModal from './LoginToContinueModal';
import BadgeModal from './BadgeModal';
type ModalType = typeof DefaultModal & {
confirm: (config: Config) => void;
};
const Modal = DefaultModal as ModalType;
Modal.confirm = function (props: Config) {
return confirm(props);
};
export default Modal;
export { LoginToContinueModal, BadgeModal };
+26
View File
@@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.modal-backdrop {
z-index: 1081;
}
.loginToContinueModal.show {
z-index: 1082;
}
+431
View File
@@ -0,0 +1,431 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, FC } from 'react';
import { Button, Dropdown } from 'react-bootstrap';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Icon, Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks';
import { useCaptchaPlugin } from '@/utils/pluginKit';
import { QuestionOperationReq } from '@/common/interface';
import Share from '../Share';
import {
deleteQuestion,
deleteAnswer,
editCheck,
reopenQuestion,
questionOperation,
unDeleteAnswer,
unDeleteQuestion,
} from '@/services';
import { tryNormalLogged } from '@/utils/guard';
import { floppyNavigation } from '@/utils';
import { toastStore } from '@/stores';
import '@/components/QueryGroup/index.scss';
interface IProps {
type: 'answer' | 'question';
qid: string;
aid?: string;
title: string;
hasAnswer?: boolean;
isAccepted: boolean;
callback: (type: string) => void;
memberActions;
}
const Index: FC<IProps> = ({
type,
qid,
aid = '',
title,
isAccepted = false,
hasAnswer = false,
memberActions = [],
callback,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'delete' });
const toast = useToast();
const navigate = useNavigate();
const reportModal = useReportModal();
const dCaptcha = useCaptchaPlugin('delete');
const refreshQuestion = () => {
callback?.('default');
};
const closeModal = useReportModal(refreshQuestion);
const editUrl =
type === 'answer' ? `/posts/${qid}/${aid}/edit` : `/posts/${qid}/edit`;
const handleReport = () => {
reportModal.onShow({
type,
id: type === 'answer' ? aid : qid,
action: 'flag',
});
};
const handleClose = () => {
closeModal.onShow({
type,
id: qid,
action: 'close',
});
};
const submitDeleteQuestion = () => {
const req = {
id: qid,
captcha_code: undefined,
captcha_id: undefined,
};
dCaptcha?.resolveCaptchaReq(req);
deleteQuestion(req)
.then(async () => {
await dCaptcha?.close();
toast.onShow({
msg: t('post_deleted', { keyPrefix: 'messages' }),
variant: 'success',
});
callback?.('delete_question');
})
.catch((ex) => {
if (ex.isError) {
dCaptcha?.handleCaptchaError(ex.list);
}
});
};
const submitDeleteAnswer = () => {
const req = {
id: aid,
captcha_code: undefined,
captcha_id: undefined,
};
dCaptcha?.resolveCaptchaReq(req);
deleteAnswer(req)
.then(async () => {
await dCaptcha?.close();
// refresh page
toast.onShow({
msg: t('tip_answer_deleted'),
variant: 'success',
});
callback?.('delete_answer');
})
.catch((ex) => {
if (ex.isError) {
dCaptcha?.handleCaptchaError(ex.list);
}
});
};
const handleDelete = () => {
if (type === 'question') {
Modal.confirm({
title: t('title'),
content: hasAnswer ? t('question') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
onConfirm: () => {
if (!dCaptcha) {
submitDeleteQuestion();
return;
}
dCaptcha.check(() => {
submitDeleteQuestion();
});
},
});
}
if (type === 'answer' && aid) {
Modal.confirm({
title: t('title'),
content: isAccepted ? t('answer_accepted') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
onConfirm: () => {
if (!dCaptcha) {
submitDeleteAnswer();
return;
}
dCaptcha.check(() => {
submitDeleteAnswer();
});
},
});
}
};
const handleUndelete = () => {
Modal.confirm({
title: t('undelete_title'),
content: t('undelete_desc'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('undelete', { keyPrefix: 'btns' }),
onConfirm: () => {
if (type === 'question') {
unDeleteQuestion(qid).then(() => {
callback?.('default');
});
}
if (type === 'answer') {
unDeleteAnswer(aid).then(() => {
callback?.('all');
});
}
},
});
};
const handleEdit = (evt, targetUrl) => {
if (!floppyNavigation.shouldProcessLinkClick(evt)) {
return;
}
evt.preventDefault();
let checkObjectId = qid;
if (type === 'answer') {
checkObjectId = aid;
}
editCheck(checkObjectId).then(() => {
navigate(targetUrl);
});
};
const handleReopen = () => {
Modal.confirm({
title: t('title', { keyPrefix: 'question_detail.reopen' }),
content: t('content', { keyPrefix: 'question_detail.reopen' }),
cancelBtnVariant: 'link',
confirmText: t('confirm_btn', { keyPrefix: 'question_detail.reopen' }),
onConfirm: () => {
reopenQuestion({
question_id: qid,
}).then(() => {
toast.onShow({
msg: t('post_reopen', { keyPrefix: 'messages' }),
variant: 'success',
});
refreshQuestion();
});
},
});
};
const handleCommon = async (params) => {
await questionOperation(params);
let msg = '';
if (params.operation === 'pin') {
msg = t('post_pin', { keyPrefix: 'messages' });
}
if (params.operation === 'unpin') {
msg = t('post_unpin', { keyPrefix: 'messages' });
}
if (params.operation === 'hide') {
msg = t('post_hide_list', { keyPrefix: 'messages' });
}
if (params.operation === 'show') {
msg = t('post_show_list', { keyPrefix: 'messages' });
}
toastStore.getState().show({
msg,
variant: 'success',
});
setTimeout(() => {
refreshQuestion();
}, 100);
};
const handlOtherActions = (action) => {
const params: QuestionOperationReq = {
id: qid,
operation: action,
};
if (action === 'pin') {
Modal.confirm({
title: t('title', { keyPrefix: 'question_detail.pin' }),
content: t('content', { keyPrefix: 'question_detail.pin' }),
cancelBtnVariant: 'link',
confirmText: t('confirm_btn', { keyPrefix: 'question_detail.pin' }),
onConfirm: () => {
handleCommon(params);
},
});
} else {
handleCommon(params);
}
};
const handleAction = (action) => {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'delete') {
handleDelete();
}
if (action === 'undelete') {
handleUndelete();
}
if (action === 'report') {
handleReport();
}
if (action === 'close') {
handleClose();
}
if (action === 'reopen') {
handleReopen();
}
if (
action === 'pin' ||
action === 'unpin' ||
action === 'hide' ||
action === 'show'
) {
handlOtherActions(action);
}
};
const firstAction =
memberActions?.filter(
(v) =>
v.action === 'report' ||
v.action === 'edit' ||
v.action === 'delete' ||
v.action === 'undelete',
) || [];
const secondAction =
memberActions?.filter(
(v) =>
v.action === 'close' ||
v.action === 'reopen' ||
v.action === 'pin' ||
v.action === 'unpin' ||
v.action === 'hide' ||
v.action === 'show',
) || [];
return (
<>
<div className="md-show align-items-center">
<Share
type={type}
qid={qid}
aid={aid}
title={title}
className="link-secondary small"
/>
{firstAction?.map((item) => {
if (item.action === 'edit') {
return (
<Link
key={item.action}
to={editUrl}
className="link-secondary p-0 small ms-3"
onClick={(evt) => handleEdit(evt, editUrl)}
style={{ lineHeight: '23px' }}>
{item.name}
</Link>
);
}
return (
<Button
key={item.action}
variant="link"
size="sm"
className="link-secondary p-0 ms-3"
onClick={() => handleAction(item.action)}>
{item.name}
</Button>
);
})}
{secondAction.length > 0 && (
<Dropdown className="ms-3 d-flex">
<Dropdown.Toggle
variant="link"
size="sm"
title={t('action', { keyPrefix: 'question_detail' })}
className="link-secondary p-0 no-toggle">
<Icon name="three-dots" />
</Dropdown.Toggle>
<Dropdown.Menu>
{secondAction.map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
<div className="md-hide">
{memberActions.length > 0 && (
<Dropdown className="d-flex">
<Dropdown.Toggle
variant="link"
size="sm"
title={t('action', { keyPrefix: 'question_detail' })}
className="link-secondary no-toggle">
<Icon name="three-dots" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Share
type={type}
qid={qid}
aid={aid}
title={title}
className="inherit"
mode="mobile"
/>
{[...firstAction, ...secondAction].map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
</>
);
};
export default memo(Index);
+122
View File
@@ -0,0 +1,122 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useLayoutEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { REACT_BASE_PATH } from '@/router/alias';
import { brandingStore, pageTagStore, siteInfoStore } from '@/stores';
import { getCurrentLang } from '@/utils/localize';
const doInsertCustomCSS = !document.querySelector('link[href*="custom.css"]');
const Index: FC = () => {
const { favicon, square_icon } = brandingStore((state) => state.branding);
const { pageTitle, keywords, description } = pageTagStore(
(state) => state.items,
);
const appVersion = siteInfoStore((_) => _.version);
const hashVersion = siteInfoStore((_) => _.revision);
const siteName = siteInfoStore((_) => _.siteInfo).name;
const setAppGenerator = () => {
if (!appVersion) {
return;
}
const generatorMetaNode = document.querySelector('meta[name="generator"]');
if (generatorMetaNode) {
generatorMetaNode.setAttribute(
'content',
`Answer ${appVersion} - https://github.com/apache/answer version ${hashVersion}`,
);
}
};
const setDocTitle = () => {
try {
if (pageTitle) {
document.title = pageTitle;
}
// eslint-disable-next-line no-empty
} catch (ex) {}
};
const currentLang = getCurrentLang();
const setDocLang = () => {
if (currentLang) {
document.documentElement.setAttribute(
'lang',
currentLang.replace('_', '-'),
);
}
};
// properties used for social media tags
const openGraphType = 'website';
const twitterType = 'summary';
const { href } = window.location;
const { hostname } = new URL(href);
useEffect(() => {
setDocLang();
}, [currentLang]);
useEffect(() => {
setAppGenerator();
}, [appVersion]);
useLayoutEffect(() => {
setDocTitle();
}, [pageTitle]);
return (
<Helmet>
<link
rel="icon"
type="image/png"
href={favicon || square_icon || `${REACT_BASE_PATH}/favicon.ico`}
/>
<link rel="icon" type="image/png" sizes="192x192" href={square_icon} />
<link rel="apple-touch-icon" type="image/png" href={square_icon} />
<title>{pageTitle}</title>
{keywords && <meta name="keywords" content={keywords} />}
{description && <meta name="description" content={description} />}
{doInsertCustomCSS && (
<link
rel="stylesheet"
href={`${process.env.PUBLIC_URL}${REACT_BASE_PATH}/custom.css`}
/>
)}
{/* Social media meta share tags start here */}
<meta property="og:type" content={openGraphType} />
<meta property="og:title" name="twitter:title" content={pageTitle} />
<meta property="og:site_name" content={siteName} />
<meta property="og:url" content={href} />
{description && <meta property="og:description" content={description} />}
<meta
property="og:image"
itemProp="image primaryImageOfPage"
content={square_icon || favicon || '/favicon.ico'}
/>
<meta name="twitter:card" content={twitterType} />
<meta name="twitter:domain" content={hostname} />
{description && <meta name="twitter:description" content={description} />}
<meta
name="twitter:image"
content={square_icon || favicon || '/favicon.ico'}
/>
{/* Social media meta share tags end here */}
</Helmet>
);
};
export default Index;
+222
View File
@@ -0,0 +1,222 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { Pagination } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
interface Props {
currentPage: number;
pageSize: number;
totalSize: number;
pathname?: string;
}
interface PageItemProps {
page: number;
currentPage: number;
path: string;
}
const pageArr = [
{
href: '1',
page: 1,
},
{
href: '#!',
page: 2,
},
{
href: '#!',
page: 3,
},
{
href: '#!',
page: 4,
},
{
href: '#!',
page: 5,
},
];
const PageItem = ({ page, currentPage, path }: PageItemProps) => {
const navigate = useNavigate();
return (
<Pagination.Item
active={currentPage === page}
href={path}
onClick={(e) => {
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
e.stopPropagation();
navigate(path);
}
}}>
{page}
</Pagination.Item>
);
};
const Index: FC<Props> = ({
currentPage = 1,
pageSize = 15,
totalSize = 0,
pathname = '',
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'pagination' });
const location = useLocation();
if (!pathname) {
pathname = location.pathname;
}
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const totalPage = Math.ceil(totalSize / pageSize);
const realPage = currentPage > totalPage ? totalPage : currentPage;
const mapPage = pageArr.filter((i) => i.page <= totalPage);
if (totalPage <= 1) {
return null;
}
const handleParams = (pageNum): string => {
searchParams.set('page', String(pageNum));
const searchStr = searchParams.toString();
return `${pathname}?${searchStr}`;
};
return (
<Pagination size="sm" className="d-inline-flex mb-0">
{currentPage > 1 && (
<Pagination.Prev
href={handleParams(currentPage - 1)}
onClick={(e) => {
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
navigate(handleParams(currentPage - 1));
}
}}>
{t('prev')}
</Pagination.Prev>
)}
{currentPage >= 1 && currentPage <= 4 && (
<>
{mapPage.map((item) => {
return (
<PageItem
key={item.page}
page={item.page}
currentPage={currentPage}
path={handleParams(item.page)}
/>
);
})}
</>
)}
{currentPage === 4 && totalPage > 6 && (
<PageItem
key="page6"
page={6}
currentPage={currentPage}
path={handleParams(6)}
/>
)}
{currentPage > 4 && (
<>
<PageItem
key="first"
page={1}
currentPage={currentPage}
path={handleParams(1)}
/>
<Pagination.Ellipsis className="pe-none" />
</>
)}
{currentPage >= 5 && (
<>
<PageItem
key={realPage - 2}
page={realPage - 2}
currentPage={currentPage}
path={handleParams(realPage - 2)}
/>
<PageItem
key={realPage - 1}
page={realPage - 1}
currentPage={currentPage}
path={handleParams(realPage - 1)}
/>
</>
)}
{currentPage > totalPage && (
<PageItem
key={realPage}
page={realPage}
currentPage={currentPage}
path={handleParams(realPage)}
/>
)}
{currentPage >= 5 &&
totalPage >= currentPage &&
new Array(
totalPage <= 3
? totalPage - currentPage + 1
: Math.min(totalPage - currentPage + 1, 3),
)
.fill('')
.map((v, i) => {
return (
<PageItem
key={`${currentPage + i}`}
page={currentPage + i}
currentPage={currentPage}
path={handleParams(currentPage + i)}
/>
);
})}
{totalPage > 5 && realPage + 2 < totalPage && (
<Pagination.Ellipsis className="pe-none" />
)}
{totalPage > 0 && currentPage < totalPage && (
<Pagination.Next
disabled={currentPage === totalPage}
href={handleParams(currentPage + 1)}
onClick={(e) => {
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
navigate(handleParams(currentPage + 1));
}
}}>
{t('next')}
</Pagination.Next>
)}
</Pagination>
);
};
export default Index;
+80
View File
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { ListGroup, Stack } from 'react-bootstrap';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Counts } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface IProps {
data: any[];
}
const PinList: FC<IProps> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
if (!data?.length) return null;
return (
<ListGroup.Item className="py-3 px-0 border-start-0 border-end-0">
<Stack
direction="horizontal"
gap={3}
className="overflow-x-auto align-items-stretch">
{data.map((item) => {
return (
<ListGroup.Item
action
as="li"
key={item.id}
className="border-0 p-0"
style={{
minWidth: '238px',
width: `${100 / data.length}%`,
}}>
<NavLink
to={pathFactory.questionLanding(item.id, item.url_title)}
className="border rounded h-100 d-flex flex-column justify-content-between p-3">
<h6 className="text-wrap link-dark text-break text-truncate-2">
{item.title}
{item.status === 2 ? ` [${t('closed')}]` : ''}
</h6>
<Counts
data={{
votes: item.vote_count,
answers: item.answer_count,
views: item.view_count,
}}
isAccepted={item.accepted_answer_id >= 1}
showViews={false}
className="mt-2 mt-md-0 small text-secondary"
/>
</NavLink>
</ListGroup.Item>
);
})}
</Stack>
</ListGroup.Item>
);
};
export default PinList;
+160
View File
@@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC, ReactNode, useEffect, useState } from 'react';
import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit';
// Marker component for plugin insertion point
export const PluginSlot: FC = () => null;
/**
* NotePlease set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered.
*
* @field slug_name: The `slug_name` of the plugin needs to be rendered.
* If this property is set, `PluginRender` will use it first (regardless of whether `type` is set)
* to find the corresponding plugin and render it.
* @field type: Used to formulate the rendering of all plugins of this type.
* (if the `slug_name` attribute is set, it will be ignored)
* @field prop: Any attribute you want to configure, e.g. `className`
*
* For editor type plugins, use <PluginSlot /> component as a marker to indicate where plugins should be inserted.
*/
interface Props {
slug_name?: string;
type: PluginType;
children?: ReactNode;
className?: string;
[key: string]: unknown;
}
const Index: FC<Props> = ({
slug_name,
type,
children = null,
className,
...props
}) => {
const [pluginSlice, setPluginSlice] = useState<Plugin[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let mounted = true;
const loadPlugins = async () => {
await PluginKit.initialization;
if (!mounted) return;
const plugins = PluginKit.getPlugins().filter(
(plugin) => plugin.activated,
);
console.log(
'[PluginRender] Loaded plugins:',
plugins.map((p) => p.info.slug_name),
);
const filtered: Plugin[] = [];
plugins.forEach((plugin) => {
if (type && slug_name) {
if (
plugin.info.slug_name === slug_name &&
plugin.info.type === type
) {
filtered.push(plugin);
}
} else if (type) {
if (plugin.info.type === type) {
filtered.push(plugin);
}
} else if (slug_name) {
if (plugin.info.slug_name === slug_name) {
filtered.push(plugin);
}
}
});
if (mounted) {
setPluginSlice(filtered);
setIsLoading(false);
}
};
loadPlugins();
return () => {
mounted = false;
};
}, [slug_name, type]);
/**
* TODO: Rendering control for non-builtin plug-ins
* ps: Logic such as version compatibility determination can be placed here
*/
if (isLoading) {
// Don't render anything while loading to avoid flashing
if (type === 'editor') {
return <div className={className}>{children}</div>;
}
return null;
}
if (pluginSlice.length === 0) {
if (type === 'editor') {
return <div className={className}>{children}</div>;
}
return null;
}
if (type === 'editor') {
// Use PluginSlot marker to insert plugins at the correct position
const nodes = React.Children.map(children, (child) => {
// Check if this is the PluginSlot marker
if (React.isValidElement(child) && child.type === PluginSlot) {
return (
<>
{pluginSlice.map((ps) => {
const PluginFC = ps.component as FC<typeof props>;
return <PluginFC key={ps.info.slug_name} {...props} />;
})}
{pluginSlice.length > 0 && <div className="toolbar-divider" />}
</>
);
}
return child;
});
return <div className={className}>{nodes}</div>;
}
return (
<>
{pluginSlice.map((ps) => {
const PluginFC = ps.component as FC<
{ className?: string } & typeof props
>;
return (
<PluginFC key={ps.info.slug_name} className={className} {...props} />
);
})}
</>
);
};
export default Index;
+34
View File
@@ -0,0 +1,34 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.md-show {
display: flex !important;
}
.md-hide {
display: none;
}
@media screen and (max-width: 768px) {
.md-show {
display: none !important;
}
.md-hide {
display: block;
}
}
+162
View File
@@ -0,0 +1,162 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import { ButtonGroup, Button, DropdownButton, Dropdown } from 'react-bootstrap';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { REACT_BASE_PATH } from '@/router/alias';
import { floppyNavigation } from '@/utils';
import './index.scss';
interface Props {
data;
i18nKeyPrefix: string;
currentSort: string;
sortKey?: string;
className?: string;
pathname?: string;
wrapClassName?: string;
maxBtnCount?: number;
}
const Index: FC<Props> = ({
data = [],
currentSort = '',
sortKey = 'order',
i18nKeyPrefix = '',
className = '',
pathname = '',
wrapClassName = '',
maxBtnCount = 3,
}) => {
const [searchParams, setUrlSearchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation('translation', {
keyPrefix: i18nKeyPrefix,
});
const handleParams = (order): string => {
searchParams.delete('page');
searchParams.set(sortKey, order);
const searchStr = searchParams.toString();
return `?${searchStr}`;
};
const handleClick = (e, type) => {
const str = handleParams(type);
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
if (pathname) {
navigate(`${pathname}${str}`);
} else {
setUrlSearchParams(str);
}
}
};
const moreBtnData = data.length > 4 ? data.slice(maxBtnCount) : [];
const normalBtnData = data.length > 4 ? data.slice(0, maxBtnCount) : data;
const currentBtn = moreBtnData.find((btn) => {
return (typeof btn === 'string' ? btn : btn.name) === currentSort;
});
return (
<>
<ButtonGroup size="sm" className={classNames('md-show', wrapClassName)}>
{normalBtnData.map((btn) => {
const key = typeof btn === 'string' ? btn : btn.sort;
const name = typeof btn === 'string' ? btn : btn.name;
return (
<Button
key={key}
variant="outline-secondary"
active={currentSort === name}
className={classNames('text-capitalize fit-content', className)}
href={
pathname
? `${REACT_BASE_PATH}${pathname}${handleParams(key)}`
: handleParams(key)
}
onClick={(evt) => handleClick(evt, key)}>
{t(name)}
</Button>
);
})}
{moreBtnData.length > 0 && (
<DropdownButton
size="sm"
variant={currentBtn ? 'secondary' : 'outline-secondary'}
as={ButtonGroup}
title={currentBtn ? t(currentSort) : t('more')}>
{moreBtnData.map((btn) => {
const key = typeof btn === 'string' ? btn : btn.sort;
const name = typeof btn === 'string' ? btn : btn.name;
return (
<Dropdown.Item
as="a"
key={key}
active={currentSort === name}
className={classNames('text-capitalize', className)}
href={
pathname
? `${REACT_BASE_PATH}${pathname}${handleParams(key)}`
: handleParams(key)
}
onClick={(evt) => handleClick(evt, key)}>
{t(name)}
</Dropdown.Item>
);
})}
</DropdownButton>
)}
</ButtonGroup>
<DropdownButton
size="sm"
variant="outline-secondary"
className={classNames('md-hide', wrapClassName)}
title={t(currentSort)}>
{data.map((btn) => {
const key = typeof btn === 'string' ? btn : btn.sort;
const name = typeof btn === 'string' ? btn : btn.name;
return (
<Dropdown.Item
as="a"
key={key}
active={currentSort === name}
className={classNames('text-capitalize', className)}
href={
pathname
? `${REACT_BASE_PATH}${pathname}${handleParams(key)}`
: handleParams(key)
}
onClick={(evt) => handleClick(evt, key)}>
{t(name)}
</Dropdown.Item>
);
})}
</DropdownButton>
</>
);
};
export default memo(Index);
+234
View File
@@ -0,0 +1,234 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState } from 'react';
import { ListGroup, Dropdown } from 'react-bootstrap';
import { NavLink, useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import {
Tag,
Pagination,
FormatTime,
Empty,
BaseUserCard,
QueryGroup,
QuestionListLoader,
Counts,
PinList,
Icon,
} from '@/components';
import * as Type from '@/common/interface';
import { useSkeletonControl } from '@/hooks';
import Storage from '@/utils/storage';
import { LIST_VIEW_STORAGE_KEY } from '@/common/constants';
export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [
'newest',
'active',
'unanswered',
'recommend',
'frequent',
'score',
];
interface Props {
source: 'questions' | 'tag' | 'linked';
order?: Type.QuestionOrderBy;
data;
orderList?: Type.QuestionOrderBy[];
isLoading: boolean;
}
const QuestionList: FC<Props> = ({
source,
order,
data,
orderList,
isLoading = false,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const navigate = useNavigate();
const [urlSearchParams] = useSearchParams();
const { isSkeletonShow } = useSkeletonControl(isLoading);
const curOrder =
order || urlSearchParams.get('order') || QUESTION_ORDER_KEYS[0];
const curPage = Number(urlSearchParams.get('page')) || 1;
const pageSize = 20;
const count = data?.count || 0;
const orderKeys = orderList || QUESTION_ORDER_KEYS;
const pinData =
source === 'questions'
? data?.list?.filter((v) => v.pin === 2).slice(0, 3)
: [];
const renderData = data?.list?.filter(
(v) => pinData.findIndex((p) => p.id === v.id) === -1,
);
const [viewType, setViewType] = useState('card');
const handleViewMode = (key) => {
Storage.set(LIST_VIEW_STORAGE_KEY, key);
setViewType(key);
};
const handleNavigate = (href) => {
navigate(href);
};
useEffect(() => {
const type = Storage.get(LIST_VIEW_STORAGE_KEY) || 'card';
setViewType(type);
}, []);
return (
<div>
<div className="mb-3 d-flex flex-wrap justify-content-between">
<h5 className="fs-5 text-nowrap mb-3 mb-md-0">
{source === 'questions'
? t('all_questions')
: source === 'linked'
? t('x_posts', { count })
: t('x_questions', { count })}
</h5>
<div className="d-flex flex-wrap">
<QueryGroup
data={orderKeys}
currentSort={curOrder}
pathname={source === 'questions' ? '/questions' : ''}
i18nKeyPrefix="question"
maxBtnCount={source === 'tag' ? 3 : 4}
wrapClassName="me-2"
/>
<Dropdown align="end" onSelect={handleViewMode}>
<Dropdown.Toggle variant="outline-secondary" size="sm">
<Icon name={viewType === 'card' ? 'view-stacked' : 'list'} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Header as="h6">
{t('view', { keyPrefix: 'btns' })}
</Dropdown.Header>
<Dropdown.Item eventKey="card" active={viewType === 'card'}>
{t('card', { keyPrefix: 'btns' })}
</Dropdown.Item>
<Dropdown.Item eventKey="compact" active={viewType === 'compact'}>
{t('compact', { keyPrefix: 'btns' })}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</div>
<ListGroup className="rounded-0">
{isSkeletonShow ? (
<QuestionListLoader />
) : (
<>
<PinList data={pinData} />
{renderData?.map((li) => {
return (
<ListGroup.Item
key={li.id}
action
as="li"
onClick={() =>
handleNavigate(
pathFactory.questionLanding(li.id, li.url_title),
)
}
className="py-3 px-2 border-start-0 border-end-0 position-relative pointer">
<div className="d-flex flex-wrap text-secondary small mb-12">
<BaseUserCard
data={li.operator}
className="me-1"
avatarClass="me-1"
/>
<FormatTime
time={
curOrder === 'active' ? li.operated_at : li.created_at
}
className="text-secondary ms-1 flex-shrink-0"
/>
</div>
<h5 className="text-wrap text-break">
<NavLink
className="link-dark d-block"
onClick={(e) => e.stopPropagation()}
to={pathFactory.questionLanding(li.id, li.url_title)}>
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>
</h5>
{viewType === 'card' && (
<div className="text-truncate-2 mb-2">
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="d-block small text-body"
dangerouslySetInnerHTML={{ __html: li.description }}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<div className="question-tags mb-12">
{Array.isArray(li.tags)
? li.tags.map((tag, index) => {
return (
<Tag
key={tag.slug_name}
className={`${
li.tags.length - 1 === index ? '' : 'me-1'
}`}
data={tag}
/>
);
})
: null}
</div>
<div className="small text-secondary">
<Counts
data={{
votes: li.vote_count,
answers: li.answer_count,
views: li.view_count,
}}
isAccepted={li.accepted_answer_id >= 1}
className="mt-2 mt-md-0"
/>
</div>
</ListGroup.Item>
);
})}
</>
)}
</ListGroup>
{count <= 0 && !isLoading && <Empty />}
<div className="mt-4 mb-2 d-flex justify-content-center">
<Pagination
currentPage={curPage}
totalSize={count}
pageSize={pageSize}
pathname={source === 'questions' ? '/questions' : ''}
/>
</div>
</div>
);
};
export default QuestionList;
@@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo } from 'react';
import { ListGroupItem } from 'react-bootstrap';
interface Props {
count?: number;
}
const Index: FC<Props> = ({ count = 10 }) => {
const list = new Array(count).fill(0).map((v, i) => v + i);
return (
<>
{list.map((v) => (
<ListGroupItem
className="bg-transparent py-3 px-2 border-start-0 border-end-0 placeholder-glow"
key={v}>
<div
className="placeholder h5 align-top d-block"
style={{ height: '21px', width: '35%' }}
/>
<div
className="placeholder w-75 h5 align-top"
style={{ height: '24px' }}
/>
<div
className="placeholder w-100 d-block align-top mb-2"
style={{ height: '21px' }}
/>
<div
className="placeholder w-100 d-block align-top mb-2"
style={{ height: '21px' }}
/>
<div
className="placeholder w-50 align-top mb-12"
style={{ height: '24px' }}
/>
<div
className="placeholder w-25 align-top d-block"
style={{ height: '21px' }}
/>
</ListGroupItem>
))}
</>
);
};
export default memo(Index);
+380
View File
@@ -0,0 +1,380 @@
# Schema Form
## Introduction
A React component capable of building HTML forms out of a [JSON schema](https://json-schema.org/understanding-json-schema/index.html).
## Usage
```tsx
import React, { useState } from 'react';
import {
SchemaForm,
initFormData,
JSONSchema,
UISchema,
FormKit,
} from '@/components';
const schema: JSONSchema = {
title: 'General',
properties: {
name: {
type: 'string',
title: 'Name',
},
age: {
type: 'number',
title: 'Age',
},
sex: {
type: 'string',
title: 'sex',
enum: [1, 2],
enumNames: ['male', 'female'],
},
},
};
const uiSchema: UISchema = {
name: {
'ui:widget': 'input',
},
age: {
'ui:widget': 'input',
'ui:options': {
type: 'number',
},
},
sex: {
'ui:widget': 'radio',
},
};
const Form = () => {
const [formData, setFormData] = useState(initFormData(schema));
const formRef = useRef<{
validator: () => Promise<boolean>;
}>(null);
const refreshConfig: FormKit['refreshConfig'] = async () => {
// refreshFormConfig();
};
const handleChange = (data) => {
setFormData(data);
};
return (
<SchemaForm
ref={formRef}
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={handleChange}
refreshConfig={refreshConfig}
/>
);
};
export default Form;
```
---
## Form Props
```ts
interface FormProps {
// Describe the form structure with schema
schema: JSONSchema | null;
// Describe the properties of the field
uiSchema?: UISchema;
// Describe form data
formData: Type.FormDataType | null;
// Callback function when form data changes
onChange?: (data: Type.FormDataType) => void;
// Handler for when a form fires a `submit` event
onSubmit?: (e: React.FormEvent) => void;
/**
* Callback method for updating form configuration
* information (schema/uiSchema) in UIAction
*/
refreshConfig?: FormKit['refreshConfig'];
}
```
## Form Ref
```ts
export interface FormRef {
validator: () => Promise<boolean>;
}
```
When you need to validate a form and get the result outside the form, you can create a `FormRef` with `useRef` and pass it to the form using the `ref` property.
This allows you to validate the form and get the result outside the form using `formRef.current.validator()`.
---
## Types Definition
### JSONSchema
```ts
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
label?: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
```
### UISchema
```ts
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
```
### UIWidget
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
```
---
### UIOptions
```ts
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
```
#### BaseUIOptions
```ts
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
class_name?: classnames.Argument;
// The className that will be attached to a form field container
field_class_name?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
```
#### InputOptions
```ts
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
```
#### SelectOptions
```ts
export interface SelectOptions extends UIOptions {}
```
#### UploadOptions
```ts
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
```
#### SwitchOptions
```ts
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
```
#### TimezoneOptions
```ts
export interface TimezoneOptions extends UIOptions {
placeholder?: string;
}
```
#### CheckboxOptions
```ts
export interface CheckboxOptions extends UIOptions {}
```
#### RadioOptions
```ts
export interface RadioOptions extends UIOptions {}
```
#### TextareaOptions
```ts
export interface TextareaOptions extends UIOptions {
placeholder?: string;
rows?: number;
}
```
#### ButtonOptions
```ts
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
```
#### UIAction
```ts
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
loading?: {
text: string;
state?: 'none' | 'pending' | 'completed';
};
on_complete?: {
toast_return_message?: boolean;
refresh_form_config?: boolean;
};
}
```
#### FormKit
```ts
export interface FormKit {
refreshConfig(): void;
}
```
---
### FormData
```ts
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
errorMsg: string;
[prop: string]: any;
}
export interface FormDataType {
[prop: string]: FormValue;
}
```
---
## Backend API
For backend generating modal form you can return json like this.
### Response
```json
{
"name": "string",
"slug_name": "string",
"description": "string",
"version": "string",
"config_fields": [
{
"name": "string",
"type": "textarea",
"title": "string",
"description": "string",
"required": true,
"value": "string",
"ui_options": {
"placeholder": "placeholder",
"rows": 4
},
"options": [
{
"value": "string",
"label": "string"
}
]
}
]
}
```
## reference
- [json schema](https://json-schema.org/understanding-json-schema/index.html)
- [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form)
- [vue-json-schema-form](https://github.com/lljj-x/vue-json-schema-form/)
@@ -0,0 +1,169 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState } from 'react';
import { Button, ButtonProps, Spinner } from 'react-bootstrap';
import { request } from '@/utils';
import type { UIAction, FormKit } from '../types';
import { useToast } from '@/hooks';
import { Icon } from '@/components';
interface Props {
fieldName: string;
text: string;
action: UIAction | undefined;
actionType?: 'submit' | 'click';
clickCallback?: () => void;
formKit: FormKit;
readOnly: boolean;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
iconName?: string;
nowrap?: boolean;
title?: string;
}
const Index: FC<Props> = ({
fieldName,
action,
actionType = 'submit',
formKit,
text = '',
readOnly = false,
variant = 'primary',
size,
iconName = '',
nowrap = false,
clickCallback,
title,
}) => {
const Toast = useToast();
const [isLoading, setLoading] = useState(false);
const handleToast = (msg, type: 'success' | 'danger' = 'success') => {
const tm = action?.on_complete?.toast_return_message;
if (tm === false || !msg) {
return;
}
Toast.onShow({
msg,
variant: type,
});
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleCallback = (resp) => {
const callback = action?.on_complete;
if (callback?.refresh_form_config) {
formKit.refreshConfig();
}
};
const handleAction = () => {
if (actionType === 'click') {
if (typeof clickCallback === 'function') {
clickCallback();
}
return;
}
if (!action) {
return;
}
setLoading(true);
request
.request({
method: action.method,
url: action.url,
timeout: 0,
})
.then((resp) => {
if ('message' in resp) {
handleToast(resp.message, 'success');
}
handleCallback(resp);
})
.catch((ex) => {
if (ex && 'msg' in ex) {
handleToast(ex.msg, 'danger');
}
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (action?.loading?.state === 'pending') {
setLoading(true);
}
}, []);
const loadingText = action?.loading?.text || text;
const disabled = isLoading || readOnly;
if (nowrap) {
return (
<Button
name={fieldName}
onClick={handleAction}
disabled={disabled}
size={size}
title={title}
variant={variant}>
{isLoading ? (
<>
<Spinner
className="align-middle me-2"
animation="border"
size="sm"
variant={variant}
/>
{loadingText}
</>
) : (
text
)}
{iconName && <Icon name={iconName} />}
</Button>
);
}
return (
<div className="d-flex">
<Button
name={fieldName}
onClick={handleAction}
disabled={disabled}
size={size}
title={title}
variant={variant}>
{isLoading ? (
<>
<Spinner
className="align-middle me-2"
animation="border"
size="sm"
variant={variant}
/>
{loadingText}
</>
) : (
text
)}
{iconName && <Icon name={iconName} />}
</Button>
</div>
);
};
export default Index;
@@ -0,0 +1,87 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Form, Stack } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
type: 'radio' | 'checkbox';
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
enumValues: (string | boolean | number)[];
enumNames: string[];
formData: Type.FormDataType;
readOnly?: boolean;
}
const Index: FC<Props> = ({
type = 'radio',
fieldName,
onChange,
enumValues,
enumNames,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
const handleCheck = (
evt: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const { name, checked } = evt.currentTarget;
enumValues[index] = checked;
const state = {
...formData,
[name]: {
...formData[name],
value: enumValues,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<Stack direction="horizontal">
{enumValues?.map((item, index) => {
return (
<Form.Check
key={String(item)}
inline
type={type}
name={fieldName}
id={`${fieldName}-${enumNames?.[index]}`}
label={enumNames?.[index]}
checked={fieldObject?.value?.[index] || false}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject?.isInvalid}
disabled={readOnly}
onChange={(evt) => handleCheck(evt, index)}
/>
);
})}
</Stack>
);
};
export default Index;
@@ -0,0 +1,96 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
type: string | undefined;
placeholder: string | undefined;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly: boolean;
min?: number;
max?: number;
inputMode?:
| 'text'
| 'search'
| 'none'
| 'tel'
| 'url'
| 'email'
| 'numeric'
| 'decimal'
| undefined;
}
const Index: FC<Props> = ({
type = 'text',
placeholder = '',
fieldName,
onChange,
formData,
readOnly = false,
min = 0,
max,
inputMode = 'text',
}) => {
const fieldObject = formData[fieldName];
const numberInputProps =
type === 'number'
? { min, ...(max != null && max > 0 ? { max } : {}) }
: {};
const handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = evt.currentTarget;
const state = {
...formData,
[name]: {
...formData[name],
value: type === 'number' ? Number(value) : value,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
// For number type, use ?? to preserve 0 value; for other types, use || for backward compatibility
const inputValue =
type === 'number' ? (fieldObject?.value ?? '') : fieldObject?.value || '';
return (
<Form.Control
name={fieldName}
placeholder={placeholder}
type={type}
value={inputValue}
{...numberInputProps}
inputMode={inputMode}
onChange={handleChange}
disabled={readOnly}
isInvalid={fieldObject?.isInvalid}
style={type === 'color' ? { width: '100px', flex: 'none' } : {}}
/>
);
};
export default Index;
@@ -0,0 +1,90 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { InputGroup } from 'react-bootstrap';
import type { FormKit, InputGroupOptions } from '../types';
import Button from './Button';
interface Props {
formKitWithContext: FormKit;
uiOpt: InputGroupOptions;
prefixText?: string;
suffixText?: string;
children: React.ReactNode;
}
const InputGroupBtn = ({
formKitWithContext,
uiOpt,
}: {
formKitWithContext: FormKit;
uiOpt:
| InputGroupOptions['prefixBtnOptions']
| InputGroupOptions['suffixBtnOptions'];
}) => {
return (
<Button
fieldName="1"
text={String(uiOpt?.text)}
iconName={uiOpt?.iconName ? uiOpt?.iconName : ''}
action={uiOpt?.action ? uiOpt?.action : undefined}
actionType="click"
clickCallback={uiOpt?.clickCallback ? uiOpt?.clickCallback : undefined}
formKit={formKitWithContext}
variant={uiOpt?.variant ? uiOpt.variant : undefined}
size={uiOpt?.size ? uiOpt?.size : undefined}
title={uiOpt?.title ? uiOpt?.title : ''}
nowrap
readOnly={false}
/>
);
};
const Index: FC<Props> = ({
formKitWithContext,
uiOpt,
prefixText = null,
suffixText = null,
children,
}) => {
return (
<InputGroup>
{prefixText && <InputGroup.Text>{prefixText}</InputGroup.Text>}
{uiOpt && 'prefixBtnOptions' in uiOpt && (
<InputGroupBtn
uiOpt={uiOpt.prefixBtnOptions}
formKitWithContext={formKitWithContext}
/>
)}
{children}
{uiOpt && 'suffixBtnOptions' in uiOpt && (
<InputGroupBtn
uiOpt={uiOpt.suffixBtnOptions}
formKitWithContext={formKitWithContext}
/>
)}
{suffixText ? <InputGroup.Text>{suffixText}</InputGroup.Text> : null}
</InputGroup>
);
};
export default Index;
@@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { Form } from 'react-bootstrap';
interface Props {
title: string;
className?: string | undefined;
}
const Index: FC<Props> = ({ title, className }) => {
return <Form.Label className={className}>{title}</Form.Label>;
};
export default Index;
@@ -0,0 +1,77 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
desc: string | undefined;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
enumValues: (string | boolean | number)[];
enumNames: string[];
formData: Type.FormDataType;
readOnly: boolean;
}
const Index: FC<Props> = ({
desc,
fieldName,
onChange,
enumValues,
enumNames,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = evt.currentTarget;
const state = {
...formData,
[name]: {
...formData[name],
value,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<Form.Select
aria-label={desc}
name={fieldName}
value={fieldObject?.value || ''}
onChange={handleChange}
disabled={readOnly}
isInvalid={fieldObject?.isInvalid}>
{enumValues?.map((item, index) => {
return (
<option value={String(item)} key={String(item)}>
{enumNames?.[index]}
</option>
);
})}
</Form.Select>
);
};
export default Index;
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
label: string | undefined;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly?: boolean;
}
const Index: FC<Props> = ({
fieldName,
onChange,
label,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = evt.currentTarget;
const state = {
...formData,
[name]: {
...formData[name],
value: checked,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<Form.Check
name={fieldName}
type="switch"
label={label}
checked={fieldObject?.value || ''}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject?.isInvalid}
disabled={readOnly}
onChange={handleChange}
/>
);
};
export default Index;
@@ -0,0 +1,66 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { TagSelector } from '@/components';
import type * as Type from '@/common/interface';
interface Props {
maxTagLength?: number;
description?: string;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
description,
maxTagLength,
fieldName,
onChange,
formData,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (data: Type.Tag[]) => {
const state = {
...formData,
[fieldName]: {
...formData[fieldName],
value: data,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<TagSelector
value={fieldObject?.value || []}
onChange={handleChange}
maxTagLength={maxTagLength || 0}
isInvalid={fieldObject?.isInvalid}
formText={description}
errMsg={fieldObject?.errorMsg}
/>
);
};
export default Index;
@@ -0,0 +1,76 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import classnames from 'classnames';
import type * as Type from '@/common/interface';
interface Props {
placeholder: string | undefined;
rows: number | undefined;
className: classnames.Argument;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly: boolean;
}
const Index: FC<Props> = ({
placeholder = '',
rows = 3,
className,
fieldName,
onChange,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = evt.currentTarget;
const state = {
...formData,
[name]: {
...formData[name],
value,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<Form.Control
as="textarea"
name={fieldName}
placeholder={placeholder}
value={fieldObject?.value || ''}
onChange={handleChange}
isInvalid={fieldObject?.isInvalid}
rows={rows}
disabled={readOnly}
className={classnames(className)}
/>
);
};
export default Index;

Some files were not shown because too many files have changed in this diff Show More