chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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, '<'),
|
||||
oldData?.title?.replace(/</gi, '<'),
|
||||
),
|
||||
}}
|
||||
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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 += ``;
|
||||
} 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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('`, 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
/**
|
||||
* Note:Please 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user