Merge branch 'feat/1.1.2/ui' into beta.2/1.1.0

This commit is contained in:
haitaoo 2023-05-09 10:31:41 +08:00
commit d61e0dab9e
115 changed files with 3383 additions and 1331 deletions

View File

@ -340,6 +340,7 @@ ui:
until_time: "Your account was suspended until {{ time }}."
forever: This user was suspended forever.
end: You don't meet a community guideline.
contact_us: Contact us
editor:
blockquote:
text: Blockquote
@ -733,6 +734,7 @@ ui:
label: Confirm New Password
settings:
page_title: Settings
goto_modify: Go to Modify
nav:
profile: Profile
notification: Notifications
@ -897,6 +899,10 @@ ui:
skip: Skip
discard_draft: Discard draft
pinned: Pinned
all: All
question: Question
answer: Answer
comment: Comment
search:
title: Search Results
keywords: Keywords
@ -1119,12 +1125,16 @@ ui:
seo: SEO
customize: Customize
themes: Themes
css-html: CSS/HTML
css_html: CSS/HTML
login: Login
privileges: Privileges
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
@ -1171,6 +1181,7 @@ ui:
pending: Pending
completed: Completed
flagged: Flagged
flagged_type: Flagged {{ type }}
created: Created
action: Action
review: Review
@ -1321,9 +1332,6 @@ ui:
label: Timezone
msg: Timezone cannot be empty.
text: Choose a city in the same timezone as you.
avatar:
label: Default Avatar
text: For users without a custom avatar of their own.
smtp:
page_title: SMTP
from_email:
@ -1433,12 +1441,22 @@ ui:
footer:
label: Footer
text: This will insert before </html>.
sidebar:
label: Sidebar
text: This will insert in sidebar.
login:
page_title: Login
membership:
title: Membership
label: Allow new registrations
text: Turn off to prevent anyone from creating a new account.
email_registration:
title: Email registration
label: Allow email registration
text: Turn off to prevent anyone creating new account through email.
allowed_email_domains:
title: Allowed email domains
text: Email domains that users must register accounts with. One domain per line. Ignored when empty.
private:
title: Private
label: Login required
@ -1460,7 +1478,30 @@ ui:
deactivate: Deactivate
activate: Activate
settings: Settings
settings_users:
title: Users
avatar:
label: Default Avatar
text: For users without a custom avatar of their own.
profile_editable:
title: Profile Editable
allow_update_display_name:
label: Allow users to change their display name
allow_update_username:
label: Allow users to change their username
allow_update_avatar:
label: Allow users to change their profile image
allow_update_bio:
label: Allow users to change their about me
allow_update_website:
label: Allow users to change their website
allow_update_location:
label: Allow users to change their location
privilege:
title: Privileges
level:
label: Reputation required level
text: Choose the reputation required for the privileges
form:
optional: (optional)

View File

@ -837,6 +837,10 @@ ui:
skip: 略过
discard_draft: 丢弃草稿
pinned: 已置顶
all: 所有
question: 问题
answer: 回答
comment: 评论
search:
title: 搜索结果
keywords: 关键词
@ -1103,6 +1107,7 @@ ui:
pending: 等待处理
completed: 已完成
flagged: 被举报内容
flagged_type: 被举报的{{ type }}
created: 创建于
action: 操作
review: 审查

View File

@ -36,6 +36,7 @@ module.exports = {
'react/no-unescaped-entities': 'off',
'react/require-default-props': 'off',
'arrow-body-style': 'off',
"global-require": "off",
'react/prop-types': 0,
'react/no-danger': 'off',
'jsx-a11y/no-static-element-interactions': 'off',

View File

@ -15,14 +15,13 @@
"dependencies": {
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"bootstrap-icons": "1.10.2",
"bootstrap-icons": "1.10.4",
"classnames": "^2.3.1",
"codemirror": "5.65.0",
"color": "^4.2.3",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"diff": "^5.1.0",
"emoji-regex": "^10.2.1",
"i18next": "^21.9.0",
"katex": "^0.16.2",
"lodash": "^4.17.21",
@ -30,6 +29,7 @@
"md5": "^2.3.0",
"mermaid": "^9.1.7",
"next-share": "^0.18.1",
"qrcode": "^1.5.1",
"qs": "^6.11.0",
"react": "^18.2.0",
"react-bootstrap": "^2.5.0",

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0033FF" />
<meta name="generator" content="Answer - https://github.com/answerdev/answer">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
</head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -2,25 +2,18 @@ export const DEFAULT_SITE_NAME = 'Answer';
export const DEFAULT_LANG = 'en_US';
export const CURRENT_LANG_STORAGE_KEY = '_a_lang_';
export const LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_';
export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
export const DRAFT_QUESTION_STORAGE_KEY = '_a_dq_';
export const DRAFT_ANSWER_STORAGE_KEY = '_a_da_';
export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|';
export const IGNORE_PATH_LIST = [
'/users/login',
'/users/register',
'/users/account-recovery',
'/users/change-email',
'/users/password-reset',
'/users/account-activation',
'/users/account-activation/success',
'/users/account-activation/failed',
'/users/confirm-new-email',
];
export const USER_AGENT_NAMES = {
SegmentFault: 'SegmentFault',
WeChat: 'WeChat',
WeCom: 'WeCom',
DingTalk: 'DingTalk',
};
export const ADMIN_LIST_STATUS = {
// normal;
@ -75,7 +68,8 @@ export const ADMIN_NAV_MENUS = [
name: 'themes',
},
{
name: 'css-html',
name: 'css_html',
path: 'css-html',
},
],
},
@ -90,6 +84,8 @@ export const ADMIN_NAV_MENUS = [
{ name: 'write' },
{ name: 'seo' },
{ name: 'login' },
{ name: 'users', path: 'settings-users' },
{ name: 'privileges' },
],
},
{
@ -97,6 +93,7 @@ export const ADMIN_NAV_MENUS = [
children: [
{
name: 'installed_plugins',
path: 'installed-plugins',
},
],
},

View File

@ -1,5 +1,3 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
@ -246,6 +244,7 @@ export type QuestionOrderBy =
export interface QueryQuestionsReq extends Paging {
order: QuestionOrderBy;
tag?: string;
in_days?: number;
}
export type AdminQuestionStatus = 'available' | 'closed' | 'deleted';
@ -312,7 +311,6 @@ export interface HelmetUpdate extends Omit<HelmetBase, 'pageTitle'> {
export interface AdminSettingsInterface {
language: string;
time_zone?: string;
default_avatar?: string;
}
export interface AdminSettingsSmtp {
@ -327,6 +325,16 @@ export interface AdminSettingsSmtp {
test_email_recipient?: string;
}
export interface AdminSettingsUsers {
allow_update_avatar: boolean;
allow_update_bio: boolean;
allow_update_display_name: boolean;
allow_update_location: boolean;
allow_update_username: boolean;
allow_update_website: boolean;
default_avatar: string;
}
export interface SiteSettings {
branding: AdminSettingBranding;
general: AdminSettingsGeneral;
@ -335,6 +343,7 @@ export interface SiteSettings {
custom_css_html: AdminSettingsCustom;
theme: AdminSettingsTheme;
site_seo: AdminSettingsSeo;
site_users: AdminSettingsUsers;
version: string;
revision: string;
}
@ -385,11 +394,14 @@ export interface AdminSettingsCustom {
custom_head: string;
custom_header: string;
custom_footer: string;
custom_sidebar: string;
}
export interface AdminSettingsLogin {
allow_new_registrations: boolean;
login_required: boolean;
allow_email_registrations: boolean;
allow_email_domains: string[];
}
/**
@ -556,27 +568,6 @@ export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export interface QuestionOperationReq {
id: string;

View File

@ -1,9 +1,9 @@
import emojiRegex from 'emoji-regex';
const pattern = {
emoji: emojiRegex(),
email:
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/,
uaWeChat: /micromessenger/i,
uaWeCom: /wxwork/i,
uaDingTalk: /dingtalk/i,
};
export default pattern;

View File

@ -18,12 +18,12 @@ function MenuNode({
}) {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
const isLeaf = !menu.children.length;
const href = isLeaf ? `${path}${menu.name}` : '#';
const href = isLeaf ? `${path}${menu.path}` : '#';
return (
<Nav.Item key={menu.name} className="w-100">
<Nav.Item key={menu.path} className="w-100">
<Nav.Link
eventKey={menu.name}
eventKey={menu.path}
as={isLeaf ? 'a' : 'button'}
onClick={(evt) => {
callback(evt, menu, href, isLeaf);
@ -31,7 +31,7 @@ function MenuNode({
href={href}
className={classNames(
'text-nowrap d-flex flex-nowrap align-items-center w-100',
{ expanding, 'link-dark': activeKey !== menu.name },
{ expanding, 'link-dark': activeKey !== menu.path },
)}>
<span className="me-auto text-truncate">
{menu.displayName ? menu.displayName : t(menu.name)}
@ -44,7 +44,7 @@ function MenuNode({
)}
</Nav.Link>
{menu.children.length ? (
<Accordion.Collapse eventKey={menu.name} className="ms-3">
<Accordion.Collapse eventKey={menu.path} className="ms-3">
<>
{menu.children.map((leaf) => {
return (
@ -53,7 +53,7 @@ function MenuNode({
callback={callback}
activeKey={activeKey}
path={path}
key={leaf.name}
key={leaf.path}
/>
);
})}
@ -73,17 +73,24 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
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 = menus[0].name;
let activeKey = menus[0].path;
if (splat) {
activeKey = splat;
}
@ -92,10 +99,10 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
menus.forEach((li) => {
if (li.children.length) {
const matchedChild = li.children.find((el) => {
return el.name === activeKey;
return el.path === activeKey;
});
if (matchedChild) {
openKey = li.name;
openKey = li.path;
}
}
});
@ -111,7 +118,7 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
navigate(href);
}
} else {
setOpenKey(openKey === menu.name ? '' : menu.name);
setOpenKey(openKey === menu.path ? '' : menu.path);
}
};
useEffect(() => {
@ -127,8 +134,8 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
path={path}
callback={menuClick}
activeKey={activeKey}
expanding={openKey === li.name}
key={li.name}
expanding={openKey === li.path}
key={li.path}
/>
);
})}

View File

@ -9,9 +9,16 @@ interface Props {
value: string;
onChange: (value: string) => void;
acceptType?: string;
readOnly?: boolean;
}
const Index: FC<Props> = ({ type = 'post', value, onChange, acceptType }) => {
const Index: FC<Props> = ({
type = 'post',
value,
onChange,
acceptType,
readOnly = false,
}) => {
const onUpload = (imgPath: string) => {
onChange(imgPath);
};
@ -29,11 +36,15 @@ const Index: FC<Props> = ({ type = 'post', value, onChange, acceptType }) => {
type={type}
uploadCallback={onUpload}
className="mb-0"
disabled={readOnly}
acceptType={acceptType}>
<Icon name="cloud-upload" />
</UploadImg>
<Button variant="outline-secondary" onClick={onRemove}>
<Button
disabled={readOnly}
variant="outline-secondary"
onClick={onRemove}>
<Icon name="trash" />
</Button>
</ButtonGroup>

View File

@ -0,0 +1,11 @@
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);

View File

@ -11,6 +11,9 @@ const Index: FC = () => {
let primaryColor;
if (theme_config?.[theme]?.primary_color) {
primaryColor = Color(theme_config[theme].primary_color);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', primaryColor.hex());
}
return (
@ -55,9 +58,12 @@ const Index: FC = () => {
--bs-pagination-active-border-color: ${primaryColor.hex()};
}
.form-select:focus,
.form-control:focus {
box-shadow: 0 0 0 0.25rem ${primaryColor.fade(0.75).string()};
border-color: ${tintColor(primaryColor, 0.5)};
.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()};
@ -80,7 +86,7 @@ const Index: FC = () => {
color: ${primaryColor.hex()}!important;
}
.link-primary:hover, .link-primary:focus {
color: ${shadeColor(primaryColor, 0.8).hex()}!important
color: ${shadeColor(primaryColor, 0.8).hex()}!important;
}
`}
</style>

View File

@ -94,7 +94,7 @@ const Editor = ({
return;
}
if (editor.getValue() !== value) {
editor.setValue(value);
editor.setValue(value || '');
}
}, [editor, value]);

View File

@ -8,6 +8,7 @@ import {
} from 'react';
import { markdownToHtml } from '@/services';
import ImgViewer from '@/components/ImgViewer';
import { htmlRender } from './utils';
@ -48,11 +49,13 @@ const Index = ({ value }, ref) => {
});
return (
<div
ref={previewRef}
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt"
dangerouslySetInnerHTML={{ __html: html }}
/>
<ImgViewer>
<div
ref={previewRef}
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt"
dangerouslySetInnerHTML={{ __html: html }}
/>
</ImgViewer>
);
};

View File

@ -11,8 +11,8 @@ const Index = () => {
const siteName = siteInfoStore((state) => state.siteInfo.name);
const cc = `${fullYear} ${siteName}`;
return (
<footer className="bg-light py-3">
<Container>
<footer className="bg-light">
<Container className="py-3">
<p className="text-center mb-0 fs-14 text-secondary">
<Trans i18nKey="footer.build_on" values={{ cc }}>
Built on

View File

@ -6,6 +6,7 @@ import { NavLink, useNavigate } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
import { floppyNavigation } from '@/utils';
import { userCenterStore } from '@/stores';
interface Props {
redDot: Type.NotificationStatus | undefined;
@ -15,13 +16,14 @@ interface Props {
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const navigate = useNavigate();
const { agent: ucAgent } = userCenterStore();
const handleLinkClick = (evt) => {
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
const { href } = evt.currentTarget;
const { pathname } = new URL(href);
navigate(pathname);
const href = evt.currentTarget.getAttribute('href');
navigate(href);
}
};
return (
@ -90,6 +92,44 @@ const Index: FC<Props> = ({ redDot, userInfo, 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">
<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>
{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}
</>
);
};

View File

@ -5,7 +5,7 @@
background: linear-gradient(180deg, rgb(var(--bs-primary-rgb)) 0%, rgba(var(--bs-primary-rgb), 0.95) 100%);
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: 1.75rem;
max-height: 2rem;
}
.nav-link {
@ -57,9 +57,6 @@
@media (max-width: 992.9px) {
#header {
.logo {
max-width: 93px;
}
.nav-grow {
flex-grow: 1!important;
}
@ -75,12 +72,3 @@
}
@media (max-width: 576px) {
#header {
.logo {
max-width: 93px;
}
}
}

View File

@ -20,7 +20,7 @@ import {
import classnames from 'classnames';
import { floppyNavigation } from '@/utils';
import { floppyNavigation, userCenter } from '@/utils';
import {
loggedUserInfoStore,
siteInfoStore,
@ -29,6 +29,7 @@ import {
themeSettingStore,
} from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import NavItems from './components/NavItems';
@ -46,6 +47,9 @@ const Header: FC = () => {
const brandingInfo = brandingStore((state) => state.branding);
const loginSetting = loginSettingStore((state) => state.login);
const { data: redDot } = useQueryNotificationStatus();
/**
* Automatically append `tag` information when creating a question
*/
const tagMatch = useMatch('/tags/:slugName');
let askUrl = '/questions/ask';
if (tagMatch && tagMatch.params.slugName) {
@ -70,15 +74,15 @@ const Header: FC = () => {
window.location.replace(window.location.href);
};
const onLoginClick = (evt) => {
if (location.pathname === '/users/login') {
if (location.pathname === RouteAlias.login) {
evt.preventDefault();
window.location.reload();
return;
}
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
floppyNavigation.navigateToLogin({
handler: navigate,
});
}
};
@ -119,17 +123,17 @@ const Header: FC = () => {
/>
<div className="d-flex justify-content-between align-items-center nav-grow flex-nowrap">
<Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3">
<Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3 p-0">
{brandingInfo.logo ? (
<>
<img
className="d-none d-lg-block logo rounded-1 me-0"
className="d-none d-lg-block logo me-0"
src={brandingInfo.logo}
alt=""
/>
<img
className="lg-none logo rounded-1 me-0"
className="lg-none logo me-0"
src={brandingInfo.mobile_logo || brandingInfo.logo}
alt=""
/>
@ -152,7 +156,7 @@ const Header: FC = () => {
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
href={userCenter.getLoginUrl()}>
{t('btns.login')}
</Button>
{loginSetting.allow_new_registrations && (
@ -160,7 +164,7 @@ const Header: FC = () => {
variant={
navbarStyle === 'theme-colored' ? 'light' : 'primary'
}
href="/users/register">
href={userCenter.getSignUpUrl()}>
{t('btns.signup')}
</Button>
)}
@ -240,7 +244,7 @@ const Header: FC = () => {
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
href={userCenter.getLoginUrl()}>
{t('btns.login')}
</Button>
{loginSetting.allow_new_registrations && (
@ -248,7 +252,7 @@ const Header: FC = () => {
variant={
navbarStyle === 'theme-colored' ? 'light' : 'primary'
}
href="/users/register">
href={userCenter.getSignUpUrl()}>
{t('btns.signup')}
</Button>
)}

View File

@ -1,4 +1,4 @@
import { FC, useEffect, useState } from 'react';
import { FC } from 'react';
import { Card, ListGroup, ListGroupItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -9,23 +9,17 @@ import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const [questions, setQuestions] = useState<any>([]);
const { data: questionRes } = useHotQuestions();
useEffect(() => {
const questionResp = questionRes?.list;
if (Array.isArray(questionResp)) {
setQuestions(questionResp);
}
}, [questionRes]);
if (!questionRes?.list?.length) {
return null;
}
return (
<Card>
<Card.Header className="text-nowrap text-capitalize">
{t('hot_questions')}
</Card.Header>
<ListGroup variant="flush">
{questions.map((li) => {
{questionRes?.list?.map((li) => {
return (
<ListGroupItem
key={li.id}

View File

@ -4,7 +4,12 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
const Index = ({ httpCode = '', errMsg = '' }) => {
const Index = ({
httpCode = '',
title = '',
errMsg = '',
showErrorCode = true,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'page_error' });
useEffect(() => {
// auto height of container
@ -31,7 +36,10 @@ const Index = ({ httpCode = '', errMsg = '' }) => {
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<h4 className="text-center">{t('http_error', { code: httpCode })}</h4>
{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>

View File

@ -0,0 +1,18 @@
import { FC } from 'react';
import { base64ToSvg } from '@/utils';
interface IProps {
base64: string | undefined;
}
const Icon: FC<IProps> = ({ base64 = '' }) => {
return base64 ? (
<span
dangerouslySetInnerHTML={{
__html: base64ToSvg(base64),
}}
/>
) : null;
};
export default Icon;

View File

@ -0,0 +1,7 @@
.img-viewer .cursor-zoom-out {
cursor: zoom-out !important;
}
.img-viewer img:not(a img, img.broken) {
cursor: zoom-in;
}

View File

@ -1,14 +1,13 @@
import { useLayoutEffect, useState, MouseEvent, useEffect } from 'react';
import { FC, MouseEvent, ReactNode, useEffect, useState } from 'react';
import { Modal } from 'react-bootstrap';
import { useLocation } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
import './index.css';
import classnames from 'classnames';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
const useImgViewer = () => {
const location = useLocation();
const Index: FC<{
children: ReactNode;
className?: classnames.Argument;
}> = ({ children, className }) => {
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState('');
const onClose = () => {
@ -47,8 +46,18 @@ const useImgViewer = () => {
}
};
useLayoutEffect(() => {
root.render(
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
@ -56,23 +65,21 @@ const useImgViewer = () => {
scrollable
contentClassName="bg-transparent"
onHide={onClose}>
<Modal.Body onClick={onClose} className="p-0 d-flex">
<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>,
);
});
useEffect(() => {
onClose();
}, [location]);
return {
onClose,
checkClickForImgView,
};
</Modal>
</div>
);
};
export default useImgViewer;
export default Index;

View File

@ -0,0 +1,105 @@
import React, { FC, useLayoutEffect, useState } from 'react';
import { Button, ButtonProps, Spinner } from 'react-bootstrap';
import { request } from '@/utils';
import type { UIAction, FormKit } from '../types';
import { useToast } from '@/hooks';
interface Props {
fieldName: string;
text: string;
action: UIAction | undefined;
formKit: FormKit;
readOnly: boolean;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
const Index: FC<Props> = ({
fieldName,
action,
formKit,
text = '',
readOnly = false,
variant = 'primary',
size,
}) => {
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 (!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);
});
};
useLayoutEffect(() => {
if (action?.loading?.state === 'pending') {
setLoading(true);
}
}, []);
const loadingText = action?.loading?.text || text;
const disabled = isLoading || readOnly;
return (
<div className="d-flex">
<Button
name={fieldName}
onClick={handleAction}
disabled={disabled}
size={size}
variant={variant}>
{isLoading ? (
<>
<Spinner
className="align-middle me-2"
animation="border"
size="sm"
variant={variant}
/>
{loadingText}
</>
) : (
text
)}
</Button>
</div>
);
};
export default Index;

View File

@ -0,0 +1,67 @@
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;
const freshVal = checked ? enumValues?.[index] : '';
const state = {
...formData,
[name]: {
...formData[name],
value: freshVal,
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={`form-${String(item)}`}
label={enumNames?.[index]}
checked={(fieldObject?.value || '') === item}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject?.isInvalid}
disabled={readOnly}
onChange={(evt) => handleCheck(evt, index)}
/>
);
})}
</Stack>
);
};
export default Index;

View File

@ -0,0 +1,52 @@
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;
}
const Index: FC<Props> = ({
type = 'text',
placeholder = '',
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
name={fieldName}
placeholder={placeholder}
type={type}
value={fieldObject?.value || ''}
onChange={handleChange}
disabled={readOnly}
isInvalid={fieldObject?.isInvalid}
style={type === 'color' ? { width: '6rem' } : {}}
/>
);
};
export default Index;

View File

@ -0,0 +1,11 @@
import { FC } from 'react';
import { Form } from 'react-bootstrap';
interface Props {
title: string;
}
const Index: FC<Props> = ({ title }) => {
return <Form.Label>{title}</Form.Label>;
};
export default Index;

View File

@ -0,0 +1,58 @@
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;

View File

@ -0,0 +1,53 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
title: string;
label: string | undefined;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly?: boolean;
}
const Index: FC<Props> = ({
title,
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
id={`switch-${title}`}
name={fieldName}
type="switch"
label={label}
checked={fieldObject?.value || ''}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject.isInvalid}
disabled={readOnly}
onChange={handleChange}
/>
);
};
export default Index;

View File

@ -0,0 +1,57 @@
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;

View File

@ -0,0 +1,44 @@
import React, { FC } from 'react';
import type * as Type from '@/common/interface';
import TimeZonePicker from '@/components/TimeZonePicker';
interface Props {
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly?: boolean;
}
const Index: FC<Props> = ({
fieldName,
onChange,
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 (
<TimeZonePicker
value={fieldObject?.value || ''}
isInvalid={fieldObject?.isInvalid}
name={fieldName}
disabled={readOnly}
onChange={handleChange}
/>
);
};
export default Index;

View File

@ -0,0 +1,54 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
import BrandUpload from '@/components/BrandUpload';
interface Props {
type: Type.UploadType | undefined;
acceptType: string | undefined;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
readOnly?: boolean;
}
const Index: FC<Props> = ({
type = 'avatar',
acceptType = '',
fieldName,
onChange,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (name: string, value: string) => {
const state = {
...formData,
[name]: {
...formData[name],
value,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<>
<BrandUpload
type={type}
acceptType={acceptType}
value={fieldObject?.value}
readOnly={readOnly}
onChange={(value) => handleChange(fieldName, value)}
/>
<Form.Control
name={fieldName}
className="d-none"
isInvalid={fieldObject?.isInvalid}
/>
</>
);
};
export default Index;

View File

@ -0,0 +1,21 @@
import Legend from './Legend';
import Select from './Select';
import Check from './Check';
import Switch from './Switch';
import Timezone from './Timezone';
import Upload from './Upload';
import Textarea from './Textarea';
import Input from './Input';
import Button from './Button';
export {
Legend,
Select,
Check,
Switch,
Timezone,
Upload,
Textarea,
Input,
Button,
};

View File

@ -1,113 +1,37 @@
import {
import React, {
ForwardRefRenderFunction,
forwardRef,
useImperativeHandle,
useEffect,
} from 'react';
import { Form, Button, Stack } from 'react-bootstrap';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { isEmpty } from 'lodash';
import classnames from 'classnames';
import BrandUpload from '../BrandUpload';
import TimeZonePicker from '../TimeZonePicker';
import type * as Type from '@/common/interface';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
import type { JSONSchema, UISchema, BaseUIOptions, FormKit } from './types';
import {
Legend,
Select,
Check,
Switch,
Timezone,
Upload,
Textarea,
Input,
Button as SfButton,
} from './components';
export interface BaseUIOptions {
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
export * from './types';
interface IProps {
schema: JSONSchema;
schema: JSONSchema | null;
formData: Type.FormDataType | null;
uiSchema?: UISchema;
formData?: Type.FormDataType;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
@ -117,6 +41,17 @@ interface IRef {
validator: () => Promise<boolean>;
}
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
* - Normalize and document `formData[key].hidden && 'd-none'`
* - Normalize and document `hiddenSubmit`
* - Improving field hints for `formData`
* - Optimise form data updates
* * Automatic field type conversion
* * Dynamic field generation
*/
/**
* json schema form
* @param schema json schema
@ -129,7 +64,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
{
schema,
uiSchema = {},
formData = {},
refreshConfig,
formData,
onChange,
onSubmit,
hiddenSubmit = false,
@ -139,16 +75,13 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const { t } = useTranslation('translation', {
keyPrefix: 'form',
});
const { required = [], properties } = schema;
const { required = [], properties = {} } = schema || {};
// check required field
const excludes = required.filter((key) => !properties[key]);
if (excludes.length > 0) {
console.error(t('not_found_props', { key: excludes.join(', ') }));
}
formData ||= {};
const keys = Object.keys(properties);
/**
* Prevent components such as `select` from having default values,
@ -156,14 +89,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
*/
const setDefaultValueAsDomBehaviour = () => {
keys.forEach((k) => {
const fieldVal = formData[k]?.value;
const fieldVal = formData![k]?.value;
const metaProp = properties[k];
const uiCtrl = uiSchema[k]?.['ui:widget'];
if (!metaProp || !uiCtrl || fieldVal !== undefined) {
return;
}
if (uiCtrl === 'select' && metaProp.enum?.[0] !== undefined) {
formData[k] = {
formData![k] = {
errorMsg: '',
isInvalid: false,
value: metaProp.enum?.[0],
@ -175,43 +108,22 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
setDefaultValueAsDomBehaviour();
}, [formData]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const data = {
...formData,
[name]: { ...formData[name], value, isInvalid: false },
};
if (onChange instanceof Function) {
onChange(data);
}
};
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = e.target;
const data = {
...formData,
[name]: { ...formData[name], value, isInvalid: false },
};
if (onChange instanceof Function) {
onChange(data);
}
};
const handleSwitchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
const data = {
...formData,
[name]: { ...formData[name], value: checked, isInvalid: false },
};
if (onChange instanceof Function) {
onChange(data);
}
const formKitWithContext: FormKit = {
refreshConfig() {
if (typeof refreshConfig === 'function') {
refreshConfig();
}
},
};
/**
* Form validation
* - Currently only dynamic forms are in use, the business form validation has been handed over to the server
*/
const requiredValidator = () => {
const errors: string[] = [];
required.forEach((key) => {
if (!formData[key] || !formData[key].value) {
if (!formData![key] || !formData![key].value) {
errors.push(key);
}
});
@ -227,7 +139,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
keys.forEach((key) => {
const { validator } = uiSchema[key]?.['ui:options'] || {};
if (validator instanceof Function) {
const value = formData[key]?.value;
const value = formData![key]?.value;
promises.push({
key,
promise: validator(value, formData),
@ -265,14 +177,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
acc[cur] = {
...formData[cur],
...formData![cur],
isInvalid: true,
errorMsg:
uiSchema[cur]?.['ui:options']?.empty ||
`${schema.properties[cur]?.title} ${t('empty')}`,
`${properties[cur]?.title} ${t('empty')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -282,13 +194,12 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (syncErrors.length > 0) {
formData = syncErrors.reduce((acc, cur) => {
acc[cur.key] = {
...formData[cur.key],
...formData![cur.key],
isInvalid: true,
errorMsg:
cur.msg || `${schema.properties[cur.key].title} ${t('invalid')}`,
errorMsg: cur.msg || `${properties[cur.key].title} ${t('invalid')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -304,259 +215,160 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
return;
}
Object.keys(formData).forEach((key) => {
formData[key].isInvalid = false;
formData[key].errorMsg = '';
Object.keys(formData!).forEach((key) => {
formData![key].isInvalid = false;
formData![key].errorMsg = '';
});
if (onChange instanceof Function) {
onChange(formData);
onChange(formData!);
}
if (onSubmit instanceof Function) {
onSubmit(e);
}
};
const handleUploadChange = (name: string, value: string) => {
const data = { ...formData, [name]: { ...formData[name], value } };
if (onChange instanceof Function) {
onChange(data);
}
};
const handleInputCheck = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const { name, checked } = e.currentTarget;
const freshVal = checked ? schema.properties[name]?.enum?.[index] : '';
const data = {
...formData,
[name]: {
...formData[name],
value: freshVal,
isInvalid: false,
},
};
if (onChange instanceof Function) {
onChange(data);
}
};
useImperativeHandle(ref, () => ({
validator,
}));
if (!formData || !schema || isEmpty(schema.properties)) {
return null;
}
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
const { title, description } = properties[key];
const {
title,
description,
enum: enumValues = [],
enumNames = [],
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {};
if (widget === 'select') {
return (
<Form.Group
key={title}
controlId={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
<Form.Select
aria-label={description}
name={key}
value={formData[key]?.value || ''}
onChange={handleSelectChange}
isInvalid={formData[key].isInvalid}>
{properties[key].enum?.map((item, index) => {
return (
<option value={String(item)} key={String(item)}>
{properties[key].enumNames?.[index]}
</option>
);
})}
</Form.Select>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
uiSchema?.[key] || {};
formData ||= {};
const fieldState = formData[key];
const uiSimplify = widget === 'legend' || uiOpt?.simplify;
let groupClassName: BaseUIOptions['fieldClassName'] = uiOpt?.simplify
? 'mb-2'
: 'mb-3';
if (widget === 'legend') {
groupClassName = 'mb-0';
}
if (widget === 'checkbox' || widget === 'radio') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<Stack direction="horizontal">
{properties[key].enum?.map((item, index) => {
return (
<Form.Check
key={String(item)}
inline
required
type={widget}
name={key}
id={`form-${String(item)}`}
label={properties[key].enumNames?.[index]}
checked={(formData[key]?.value || '') === item}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
onChange={(e) => handleInputCheck(e, index)}
/>
);
})}
</Stack>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
if (uiOpt?.fieldClassName) {
groupClassName = uiOpt.fieldClassName;
}
if (widget === 'switch') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<Form.Check
required
id={`switch-${title}`}
name={key}
type="switch"
label={(uiOpt as SwitchOptions)?.label}
checked={formData[key]?.value || ''}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
onChange={handleSwitchChange}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'timezone') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<TimeZonePicker
value={formData[key]?.value || ''}
name={key}
onChange={handleSelectChange}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'upload') {
const options: UploadOptions = uiSchema[key]?.['ui:options'] || {};
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<BrandUpload
type={options.imageType || 'avatar'}
acceptType={options.acceptType || ''}
value={formData[key]?.value}
onChange={(value) => handleUploadChange(key, value)}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'textarea') {
const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {};
return (
<Form.Group
controlId={`form-${key}`}
key={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
<Form.Control
as="textarea"
name={key}
placeholder={options?.placeholder || ''}
value={formData[key]?.value || ''}
onChange={handleInputChange}
isInvalid={formData[key].isInvalid}
rows={options?.rows || 3}
className={classnames(options.className)}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
const options: InputOptions = uiSchema[key]?.['ui:options'] || {};
const readOnly = uiOpt?.readOnly || false;
return (
<Form.Group
key={title}
controlId={key}
key={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
<Form.Control
name={key}
placeholder={options?.placeholder || ''}
type={options?.inputType || 'text'}
value={formData[key]?.value || ''}
onChange={handleInputChange}
style={options?.inputType === 'color' ? { width: '6rem' } : {}}
isInvalid={formData[key].isInvalid}
/>
className={classnames(
groupClassName,
formData[key].hidden ? 'd-none' : null,
)}>
{/* Uniform processing `label` */}
{title && !uiSimplify ? <Form.Label>{title}</Form.Label> : null}
{/* Handling of individual specific controls */}
{widget === 'legend' ? <Legend title={title} /> : null}
{widget === 'select' ? (
<Select
desc={description}
fieldName={key}
onChange={onChange}
enumValues={enumValues}
enumNames={enumNames}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'radio' || widget === 'checkbox' ? (
<Check
type={widget}
fieldName={key}
onChange={onChange}
enumValues={enumValues}
enumNames={enumNames}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'switch' ? (
<Switch
title={title}
label={uiOpt && 'label' in uiOpt ? uiOpt.label : ''}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'timezone' ? (
<Timezone
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'upload' ? (
<Upload
type={
uiOpt && 'imageType' in uiOpt ? uiOpt.imageType : undefined
}
acceptType={
uiOpt && 'acceptType' in uiOpt ? uiOpt.acceptType : ''
}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'textarea' ? (
<Textarea
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
rows={uiOpt && 'rows' in uiOpt ? uiOpt.rows : 3}
className={uiOpt && 'className' in uiOpt ? uiOpt.className : ''}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'input' ? (
<Input
type={uiOpt && 'inputType' in uiOpt ? uiOpt.inputType : 'text'}
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'button' ? (
<SfButton
fieldName={key}
text={uiOpt && 'text' in uiOpt ? uiOpt.text : ''}
action={uiOpt && 'action' in uiOpt ? uiOpt.action : undefined}
formKit={formKitWithContext}
readOnly={readOnly}
variant={
uiOpt && 'variant' in uiOpt ? uiOpt.variant : undefined
}
size={uiOpt && 'size' in uiOpt ? uiOpt.size : undefined}
/>
) : null}
{/* Unified handling of `Feedback` and `Text` */}
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
{fieldState?.errorMsg}
</Form.Control.Feedback>
{description && (
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>
)}
) : null}
</Form.Group>
);
})}
@ -570,9 +382,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
};
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => {
const prop = schema.properties[key];
const props: JSONSchema['properties'] = schema?.properties || {};
Object.keys(props).forEach((key) => {
const prop = props[key];
const defaultVal = prop?.default;
formData[key] = {
value: defaultVal,
isInvalid: false,
@ -582,4 +396,27 @@ export const initFormData = (schema: JSONSchema): Type.FormDataType => {
return formData;
};
export const mergeFormData = (
target: Type.FormDataType | null,
origin: Type.FormDataType | null,
) => {
if (!target) {
return origin;
}
if (!origin) {
return target;
}
Object.keys(target).forEach((k) => {
const oi = origin[k];
if (oi && oi.value !== undefined) {
target[k] = {
value: oi.value,
isInvalid: false,
errorMsg: '',
};
}
});
return target;
};
export default forwardRef(SchemaForm);

View File

@ -0,0 +1,152 @@
import { ButtonProps } from 'react-bootstrap';
import classnames from 'classnames';
import * as Type from '@/common/interface';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
/**
* A few notes on button control
* - Mainly used to send a request and notify the result of the request, and to update the data as required
* - A scenario where a message notification is displayed directly after a click without sending a request, implementing a dedicated control
* - Scenarios where the page jumps directly after a click without sending a request, implementing a dedicated control
*
* @field url : Target address for sending requests
* @field method : Method for sending requests, default `get`
* @field callback: Button event handler function that will fully take over the button events when this field is configured
* *** Incomplete, DO NOT USE ***
* @field loading: Set button loading information
* @field on_complete: What needs to be done when the `Action` completes
* @field on_complete.toast_return_message: Does toast show the returned message
* @field on_complete.refresh_form_config: Whether to refresh the form configuration (configuration only, no data included)
*/
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;
};
}
/**
* Form tools
* - Used to get or set the configuration of forms and form items, the value of a form item
* * @method refreshConfig(): void
*/
export interface FormKit {
refreshConfig(): void;
}

View File

@ -1,14 +1,17 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { uploadImage } from '@/services';
import * as Type from '@/common/interface';
interface IProps {
type: Type.UploadType;
className?: string;
className?: classnames.Argument;
children?: React.ReactNode;
acceptType?: string;
disabled?: boolean;
uploadCallback: (img: string) => void;
}
@ -18,6 +21,7 @@ const Index: React.FC<IProps> = ({
children,
acceptType = '',
className,
disabled = false,
}) => {
const { t } = useTranslation();
const [status, setStatus] = useState(false);
@ -47,11 +51,15 @@ const Index: React.FC<IProps> = ({
};
return (
<label className={`btn btn-outline-secondary uploadBtn ${className}`}>
<label
className={classnames('btn btn-outline-secondary', className, {
disabled: !!disabled,
})}>
{children || (status ? t('upload_img.loading') : t('upload_img.name'))}
<input
type="file"
className="d-none"
disabled={disabled}
accept={`image/jpeg,image/jpg,image/png,image/webp${acceptType}`}
onChange={onChange}
/>

View File

@ -3,6 +3,7 @@ import Editor, { EditorRef, htmlRender } from './Editor';
import Header from './Header';
import Footer from './Footer';
import Icon from './Icon';
import SvgIcon from './Icon/svg';
import Modal from './Modal';
import TagSelector from './TagSelector';
import Unactivate from './Unactivate';
@ -37,12 +38,15 @@ import Counts from './Counts';
import QuestionList from './QuestionList';
import HotQuestions from './HotQuestions';
import HttpErrorContent from './HttpErrorContent';
import CustomSidebar from './CustomSidebar';
import ImgViewer from './ImgViewer';
export {
Avatar,
Header,
Footer,
Icon,
SvgIcon,
Modal,
Unactivate,
UploadImg,
@ -80,5 +84,7 @@ export {
QuestionList,
HotQuestions,
HttpErrorContent,
CustomSidebar,
ImgViewer,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -10,7 +10,6 @@ import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags';
import useLoginRedirect from './useLoginRedirect';
import usePromptWithUnload from './usePrompt';
import useImgViewer from './useImgViewer';
export {
useTagModal,
@ -25,5 +24,4 @@ export {
usePageTags,
useLoginRedirect,
usePromptWithUnload,
useImgViewer,
};

View File

@ -11,8 +11,11 @@ const Index = () => {
const loginRedirect = () => {
const redirect = Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
floppyNavigation.navigate(redirect, () => {
navigate(redirect, { replace: true });
floppyNavigation.navigate(redirect, {
handler: navigate,
options: {
replace: true,
},
});
};

View File

@ -102,6 +102,8 @@ a {
}
#root > footer {
margin-top: auto !important;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.bg-f5 {
@ -120,14 +122,6 @@ a {
cursor: pointer;
}
.cursor-zoom-out {
cursor: zoom-out !important;
}
img:not(a img, img.broken) {
cursor: zoom-in;
}
.resize-none {
resize: none;
}

View File

@ -1,7 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import { HttpErrorContent } from '@/components';
const Index = () => {
return <HttpErrorContent httpCode="50X" />;
const [searchParams] = useSearchParams();
const errMsg = searchParams.get('msg') || '';
const title = searchParams.get('title') || '';
return (
<HttpErrorContent
httpCode="50X"
title={title}
errMsg={errMsg}
showErrorCode={!errMsg}
/>
);
};
export default Index;

View File

@ -1,7 +1,7 @@
import { FC, memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { JSONSchema, SchemaForm, UISchema, ImgViewer } from '@/components';
import { FormDataType } from '@/common/interface';
import { brandSetting, getBrandSetting } from '@/services';
import { brandingStore } from '@/stores';
@ -142,7 +142,7 @@ const Index: FC = () => {
}, []);
return (
<div>
<ImgViewer>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
@ -151,7 +151,7 @@ const Index: FC = () => {
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</div>
</ImgViewer>
);
};

View File

@ -31,6 +31,11 @@ const Index: FC = () => {
title: t('header.label'),
description: t('header.text'),
},
custom_sidebar: {
type: 'string',
title: t('sidebar.label'),
description: t('sidebar.text'),
},
custom_footer: {
type: 'string',
title: t('footer.label'),
@ -60,6 +65,13 @@ const Index: FC = () => {
className: ['fs-14', 'font-monospace'],
},
},
custom_sidebar: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
className: ['fs-14', 'font-monospace'],
},
},
custom_footer: {
'ui:widget': 'textarea',
'ui:options': {
@ -77,6 +89,7 @@ const Index: FC = () => {
custom_css: formData.custom_css.value,
custom_head: formData.custom_head.value,
custom_header: formData.custom_header.value,
custom_sidebar: formData.custom_sidebar.value,
custom_footer: formData.custom_footer.value,
};
@ -103,6 +116,7 @@ const Index: FC = () => {
formMeta.custom_css.value = setting.custom_css;
formMeta.custom_head.value = setting.custom_head;
formMeta.custom_header.value = setting.custom_header;
formMeta.custom_sidebar.value = setting.custom_sidebar;
formMeta.custom_footer.value = setting.custom_footer;
setFormData(formMeta);
}

View File

@ -73,7 +73,7 @@ const Flags: FC = () => {
{flagTypeKeys.map((li) => {
return (
<option value={li} key={li}>
{li}
{t(li, { keyPrefix: 'btns' })}
</option>
);
})}
@ -96,7 +96,9 @@ const Flags: FC = () => {
<td>
<Stack>
<small className="text-secondary">
Flagged {li.object_type}
{t('flagged_type', {
type: t(li.object_type, { keyPrefix: 'btns' }),
})}
</small>
<BaseUserCard
data={li.reported_user}

View File

@ -9,7 +9,7 @@ import {
} from '@/common/interface';
import { interfaceStore, loggedUserInfoStore } from '@/stores';
import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants';
import { DEFAULT_TIMEZONE } from '@/common/constants';
import {
updateInterfaceSetting,
useInterfaceSetting,
@ -48,13 +48,6 @@ const Interface: FC = () => {
description: t('time_zone.text'),
default: setting?.time_zone || DEFAULT_TIMEZONE,
},
default_avatar: {
type: 'string',
title: t('avatar.label'),
description: t('avatar.text'),
enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value),
enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label),
},
},
};
@ -69,11 +62,6 @@ const Interface: FC = () => {
isInvalid: false,
errorMsg: '',
},
default_avatar: {
value: setting?.default_avatar || 'system',
isInvalid: false,
errorMsg: '',
},
});
const uiSchema: UISchema = {
@ -83,9 +71,6 @@ const Interface: FC = () => {
time_zone: {
'ui:widget': 'timezone',
},
default_avatar: {
'ui:widget': 'select',
},
};
const getLangs = async () => {
const res: LangsType[] = await loadLanguageOptions(true);
@ -118,7 +103,6 @@ const Interface: FC = () => {
const reqParams: AdminSettingsInterface = {
language: formData.language.value,
time_zone: formData.time_zone.value,
default_avatar: formData.default_avatar.value,
};
updateInterfaceSetting(reqParams)
@ -147,9 +131,6 @@ const Interface: FC = () => {
const formMeta = {};
Object.keys(setting).forEach((k) => {
formMeta[k] = { ...formData[k], value: setting[k] };
if (k === 'default_avatar') {
formMeta[k].value = setting[k] || 'system';
}
});
setFormData({ ...formData, ...formMeta });
}

View File

@ -22,6 +22,17 @@ const Index: FC = () => {
description: t('membership.text'),
default: false,
},
allow_email_registrations: {
type: 'boolean',
title: t('email_registration.title'),
description: t('email_registration.text'),
default: true,
},
allow_email_domains: {
type: 'string',
title: t('allowed_email_domains.title'),
description: t('allowed_email_domains.text'),
},
login_required: {
type: 'boolean',
title: t('private.title'),
@ -37,6 +48,15 @@ const Index: FC = () => {
label: t('membership.label'),
},
},
allow_email_registrations: {
'ui:widget': 'switch',
'ui:options': {
label: t('email_registration.label'),
},
},
allow_email_domains: {
'ui:widget': 'textarea',
},
login_required: {
'ui:widget': 'switch',
'ui:options': {
@ -51,8 +71,20 @@ const Index: FC = () => {
evt.preventDefault();
evt.stopPropagation();
const allowedEmailDomains: string[] = [];
if (formData.allow_email_domains.value) {
const domainList = formData.allow_email_domains.value.split('\n');
domainList.forEach((li) => {
li = li.trim();
if (li) {
allowedEmailDomains.push(li);
}
});
}
const reqParams: Type.AdminSettingsLogin = {
allow_new_registrations: formData.allow_new_registrations.value,
allow_email_registrations: formData.allow_email_registrations.value,
allow_email_domains: allowedEmailDomains,
login_required: formData.login_required.value,
};
@ -78,6 +110,13 @@ const Index: FC = () => {
const formMeta = { ...formData };
formMeta.allow_new_registrations.value =
setting.allow_new_registrations;
formMeta.allow_email_registrations.value =
setting.allow_email_registrations;
formMeta.allow_email_domains.value = '';
if (Array.isArray(setting.allow_email_domains)) {
formMeta.allow_email_domains.value =
setting.allow_email_domains.join('\n');
}
formMeta.login_required.value = setting.login_required;
setFormData({ ...formMeta });
}

View File

@ -2,18 +2,23 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { useToast } from '@/hooks';
import type * as Types from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { SchemaForm, JSONSchema, UISchema } from '@/components';
import { useQueryPluginConfig, updatePluginConfig } from '@/services';
import { InputOptions } from '@/components/SchemaForm';
import {
InputOptions,
FormKit,
initFormData,
mergeFormData,
} from '@/components/SchemaForm';
const Config = () => {
const { t } = useTranslation('translation');
const { slug_name } = useParams<{ slug_name: string }>();
const { data } = useQueryPluginConfig({ plugin_slug_name: slug_name });
const { data, mutate: refreshPluginConfig } = useQueryPluginConfig({
plugin_slug_name: slug_name,
});
const Toast = useToast();
const [schema, setSchema] = useState<JSONSchema | null>(null);
const [uiSchema, setUISchema] = useState<UISchema>();
@ -62,7 +67,7 @@ const Config = () => {
};
setSchema(result);
setUISchema(uiConf);
setFormData(initFormData(result));
setFormData(mergeFormData(initFormData(result), formData));
}, [data?.config_fields]);
const onSubmit = (evt) => {
@ -86,24 +91,19 @@ const Config = () => {
});
});
};
const refreshConfig: FormKit['refreshConfig'] = async () => {
refreshPluginConfig();
};
const handleOnChange = (form) => {
setFormData(form);
};
if (!data || !schema || !formData) {
return null;
}
if (isEmpty(schema.properties)) {
return <h3 className="mb-4">{data?.name}</h3>;
}
return (
<>
<h3 className="mb-4">{data?.name}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
refreshConfig={refreshConfig}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}

View File

@ -0,0 +1,130 @@
import { FC, FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks';
import { FormDataType } from '@/common/interface';
import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components';
import {
getPrivilegeSetting,
putPrivilegeSetting,
AdminSettingsPrivilege,
} from '@/services';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.privilege',
});
const Toast = useToast();
const [privilege, setPrivilege] = useState<AdminSettingsPrivilege>();
const [schema, setSchema] = useState<JSONSchema>({
title: t('title'),
properties: {},
});
const [uiSchema, setUiSchema] = useState<UISchema>({
level: {
'ui:widget': 'select',
},
});
const [formData, setFormData] = useState<FormDataType>(initFormData(schema));
const setFormConfig = (selectedLevel: number = 1) => {
selectedLevel = Number(selectedLevel);
const levelOptions = privilege?.options;
const curLevel = levelOptions?.find((li) => {
return li.level === selectedLevel;
});
if (!levelOptions || !curLevel) {
return;
}
const uiState = {
level: uiSchema.level,
};
const props: JSONSchema['properties'] = {
level: {
type: 'number',
title: t('level.label'),
description: t('level.text'),
enum: levelOptions.map((_) => _.level),
enumNames: levelOptions.map((_) => _.level_desc),
default: selectedLevel,
},
};
curLevel.privileges.forEach((li) => {
props[li.key] = {
type: 'number',
title: li.label,
default: li.value,
};
uiState[li.key] = {
'ui:options': {
readOnly: true,
},
};
});
const schemaState = {
...schema,
properties: props,
};
const formState = initFormData(schemaState);
curLevel.privileges.forEach((li) => {
formState[li.key] = {
value: li.value,
isInvalid: false,
errorMsg: '',
};
});
setSchema(schemaState);
setUiSchema(uiState);
setFormData(formState);
};
const onSubmit = (evt: FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
const lv = Number(formData.level.value);
putPrivilegeSetting(lv)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
if (!privilege) {
return;
}
setFormConfig(privilege.selected_level);
}, [privilege]);
useEffect(() => {
getPrivilegeSetting().then((resp) => {
setPrivilege(resp);
});
}, []);
const handleOnChange = (state) => {
setFormConfig(state.level.value);
};
return (
<>
<h3 className="mb-4">{t('title')}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -80,7 +80,7 @@ const Index: FC = () => {
formMeta.robots.value = setting.robots;
formMeta.permalink.value = setting.permalink;
if (!/[1234]/.test(formMeta.permalink.value)) {
formMeta.permalink.value = 1;
formMeta.permalink.value = 4;
}
setFormData(formMeta);
}

View File

@ -0,0 +1,182 @@
import { FC, FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks';
import { FormDataType } from '@/common/interface';
import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components';
import { SYSTEM_AVATAR_OPTIONS } from '@/common/constants';
import {
getUsersSetting,
putUsersSetting,
AdminSettingsUsers,
} from '@/services';
import { handleFormError } from '@/utils';
import * as Type from '@/common/interface';
import { siteInfoStore } from '@/stores';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.settings_users',
});
const Toast = useToast();
const { updateUsers: updateUsersStore } = siteInfoStore();
const schema: JSONSchema = {
title: t('title'),
properties: {
default_avatar: {
type: 'string',
title: t('avatar.label'),
description: t('avatar.text'),
enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value),
enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label),
default: 'system',
},
profile_editable: {
type: 'string',
title: t('profile_editable.title'),
},
allow_update_display_name: {
type: 'boolean',
title: 'allow_update_display_name',
},
allow_update_username: {
type: 'boolean',
title: 'allow_update_username',
},
allow_update_avatar: {
type: 'boolean',
title: 'allow_update_avatar',
},
allow_update_bio: {
type: 'boolean',
title: 'allow_update_bio',
},
allow_update_website: {
type: 'boolean',
title: 'allow_update_website',
},
allow_update_location: {
type: 'boolean',
title: 'allow_update_location',
},
},
};
const [formData, setFormData] = useState<FormDataType>(initFormData(schema));
const uiSchema: UISchema = {
default_avatar: {
'ui:widget': 'select',
},
profile_editable: {
'ui:widget': 'legend',
},
allow_update_display_name: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_display_name.label'),
simplify: true,
},
},
allow_update_username: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_username.label'),
simplify: true,
},
},
allow_update_avatar: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_avatar.label'),
simplify: true,
},
},
allow_update_bio: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_bio.label'),
simplify: true,
},
},
allow_update_website: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_website.label'),
simplify: true,
},
},
allow_update_location: {
'ui:widget': 'switch',
'ui:options': {
label: t('allow_update_location.label'),
fieldClassName: 'mb-3',
simplify: true,
},
},
};
const onSubmit = (evt: FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: AdminSettingsUsers = {
allow_update_avatar: formData.allow_update_avatar.value,
allow_update_bio: formData.allow_update_bio.value,
allow_update_display_name: formData.allow_update_display_name.value,
allow_update_location: formData.allow_update_location.value,
allow_update_username: formData.allow_update_username.value,
allow_update_website: formData.allow_update_website.value,
default_avatar: formData.default_avatar.value,
};
putUsersSetting(reqParams)
.then(() => {
updateUsersStore(reqParams);
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getUsersSetting().then((resp) => {
if (!resp) {
return;
}
const formMeta: Type.FormDataType = {};
Object.keys(formData).forEach((k) => {
let v = resp[k];
if (k === 'default_avatar' && !v) {
v = 'system';
}
formMeta[k] = { ...formData[k], value: v };
});
setFormData({ ...formData, ...formMeta });
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('title')}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -1,4 +1,4 @@
import { FC } from 'react';
import { FC, useEffect, useState } from 'react';
import { Form, Table, Dropdown, Button, Stack } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -21,8 +21,14 @@ import {
useChangePasswordModal,
useToast,
} from '@/hooks';
import { useQueryUsers, addUser, updateUserPassword } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import {
useQueryUsers,
addUser,
updateUserPassword,
getAdminUcAgent,
AdminUcAgent,
} from '@/services';
import { loggedUserInfoStore, userCenterStore } from '@/stores';
import { formatCount } from '@/utils';
const UserFilterKeys: Type.UserFilterBy[] = [
@ -49,6 +55,13 @@ const Users: FC = () => {
const curPage = Number(urlSearchParams.get('page') || '1');
const curQuery = urlSearchParams.get('query') || '';
const currentUser = loggedUserInfoStore((state) => state.user);
const { agent: ucAgent } = userCenterStore();
const [adminUcAgent, setAdminUcAgent] = useState<AdminUcAgent>({
allow_create_user: true,
allow_update_user_status: true,
allow_update_user_password: true,
allow_update_user_role: true,
});
const Toast = useToast();
const {
data,
@ -138,6 +151,25 @@ const Users: FC = () => {
urlSearchParams.delete('page');
setUrlSearchParams(urlSearchParams);
};
useEffect(() => {
if (ucAgent?.enabled) {
getAdminUcAgent().then((resp) => {
setAdminUcAgent(resp);
});
}
}, [ucAgent]);
const showAddUser =
!ucAgent?.enabled || (ucAgent?.enabled && adminUcAgent?.allow_create_user);
const showActionPassword =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_password);
const showActionRole =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_role);
const showActionStatus =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_status);
const showAction = showActionPassword || showActionRole || showActionStatus;
return (
<>
<h3 className="mb-4">{t('title')}</h3>
@ -149,12 +181,14 @@ const Users: FC = () => {
sortKey="filter"
i18nKeyPrefix="admin.users"
/>
<Button
variant="outline-primary"
size="sm"
onClick={() => userModal.onShow()}>
{t('add_user')}
</Button>
{showAddUser ? (
<Button
variant="outline-primary"
size="sm"
onClick={() => userModal.onShow()}>
{t('add_user')}
</Button>
) : null}
</Stack>
<Form.Control
@ -232,25 +266,31 @@ const Users: FC = () => {
</span>
</td>
)}
{curFilter !== 'deleted' ? (
{curFilter !== 'deleted' && showAction ? (
<td className="text-end">
<Dropdown>
<Dropdown.Toggle variant="link" className="no-toggle">
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={() => handleAction('password', user)}>
{t('set_new_password')}
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleAction('role', user)}>
{t('change_role')}
</Dropdown.Item>
{showActionPassword ? (
<Dropdown.Item
onClick={() => handleAction('password', user)}>
{t('set_new_password')}
</Dropdown.Item>
) : null}
{showActionStatus ? (
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}
</Dropdown.Item>
) : null}
{showActionRole ? (
<Dropdown.Item
onClick={() => handleAction('role', user)}>
{t('change_role')}
</Dropdown.Item>
) : null}
</Dropdown.Menu>
</Dropdown>
</td>

View File

@ -19,7 +19,7 @@ const g10Paths = [
'answers',
'users',
'flags',
'installed_plugins',
'installed-plugins',
];
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
@ -80,7 +80,7 @@ const Index: FC = () => {
<Col lg={2}>
<AccordionNav menus={menus} path="/admin/" />
</Col>
<Col lg={g10Paths.find((v) => curPath.includes(v)) ? 10 : 6}>
<Col lg={g10Paths.find((v) => curPath === v) ? 10 : 6}>
<Outlet />
</Col>
</Row>

View File

@ -15,7 +15,6 @@ import {
HttpErrorContent,
} from '@/components';
import { LoginToContinueModal } from '@/components/Modal';
import { useImgViewer } from '@/hooks';
const Layout: FC = () => {
const location = useLocation();
@ -24,7 +23,6 @@ const Layout: FC = () => {
toastClear();
};
const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore();
const imgViewer = useImgViewer();
const { show: showLoginToContinueModal } = loginToContinueStore();
useEffect(() => {
@ -40,9 +38,7 @@ const Layout: FC = () => {
}}>
<Header />
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="position-relative page-wrap"
onClick={imgViewer.checkClickForImgView}>
<div className="position-relative page-wrap">
{httpStatusCode ? (
<HttpErrorContent httpCode={httpStatusCode} />
) : (

View File

@ -307,7 +307,7 @@ const Ask = () => {
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
formData.content.value = revision.content.content;
formData.content.value = revision.content?.content || '';
setImmData({ ...formData });
setFormData({ ...formData });
};

View File

@ -11,6 +11,7 @@ import {
Comment,
FormatTime,
htmlRender,
ImgViewer,
} from '@/components';
import { scrollToElementTop, bgFadeOut } from '@/utils';
import { AnswerItem } from '@/common/interface';
@ -84,10 +85,12 @@ const Index: FC<Props> = ({
</Badge>
</div>
)}
<article
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<ImgViewer>
<article
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
</ImgViewer>
<div className="d-flex align-items-center mt-4">
<Actions
source="answer"

View File

@ -12,6 +12,7 @@ import {
FormatTime,
htmlRender,
Icon,
ImgViewer,
} from '@/components';
import { formatCount, guard } from '@/utils';
import { following } from '@/services';
@ -114,11 +115,13 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
return <Tag className="m-1" key={item.slug_name} data={item} />;
})}
</div>
<article
ref={ref}
className="fmt text-break text-wrap mt-4"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<ImgViewer>
<article
ref={ref}
className="fmt text-break text-wrap mt-4"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
</ImgViewer>
<Actions
className="mt-4"

View File

@ -8,7 +8,7 @@ import {
} from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Pagination } from '@/components';
import { Pagination, CustomSidebar } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores';
import { scrollToElementTop } from '@/utils';
import { usePageTags, usePageUsers } from '@/hooks';
@ -83,7 +83,7 @@ const Index = () => {
// delete answers only show to author and admin and has search params aid
if (v.status === 10) {
if (
(v?.user_info.username === userInfo?.username || isAdmin) &&
(v?.user_info?.username === userInfo?.username || isAdmin) &&
aid === v.id
) {
return v;
@ -104,8 +104,8 @@ const Index = () => {
res.list.forEach((item) => {
setUsers([
{
displayName: item.user_info.display_name,
userName: item.user_info.username,
displayName: item.user_info?.display_name,
userName: item.user_info?.username,
},
{
displayName: item?.update_user_info?.display_name,
@ -123,10 +123,10 @@ const Index = () => {
if (res) {
setUsers([
{
id: res.user_info.id,
displayName: res.user_info.display_name,
userName: res.user_info.username,
avatar_url: res.user_info.avatar,
id: res.user_info?.id,
displayName: res.user_info?.display_name,
userName: res.user_info?.username,
avatar_url: res.user_info?.avatar,
},
{
id: res?.update_user_info?.id,
@ -256,6 +256,7 @@ const Index = () => {
)}
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<CustomSidebar />
<RelatedQuestions id={question?.id || ''} />
</Col>
</Row>

View File

@ -4,10 +4,20 @@ import { useMatch, Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { FollowingTags, QuestionList, HotQuestions } from '@/components';
import { siteInfoStore, loggedUserInfoStore } from '@/stores';
import {
FollowingTags,
QuestionList,
HotQuestions,
CustomSidebar,
} from '@/components';
import {
siteInfoStore,
loggedUserInfoStore,
loginSettingStore,
} from '@/stores';
import { useQuestionList } from '@/services';
import * as Type from '@/common/interface';
import { userCenter, floppyNavigation } from '@/utils';
const Questions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
@ -30,6 +40,7 @@ const Questions: FC = () => {
pageTitle = `${siteInfo.name}`;
slogan = `${siteInfo.short_description}`;
}
const { login: loginSetting } = loginSettingStore();
usePageTags({ title: pageTitle, subtitle: slogan });
return (
@ -43,7 +54,8 @@ const Questions: FC = () => {
/>
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
{!loggedUser.access_token && (
<CustomSidebar />
{!loggedUser.username && (
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">
@ -52,12 +64,20 @@ const Questions: FC = () => {
})}
</h5>
<p className="card-text">{siteInfo.description}</p>
<Link to="/users/login" className="btn btn-primary">
<Link
to={userCenter.getLoginUrl()}
className="btn btn-primary"
onClick={floppyNavigation.handleRouteLinkClick}>
{t('login', { keyPrefix: 'btns' })}
</Link>
<Link to="/users/register" className="btn btn-link ms-2">
{t('signup', { keyPrefix: 'btns' })}
</Link>
{loginSetting.allow_new_registrations ? (
<Link
to={userCenter.getSignUpUrl()}
className="btn btn-link ms-2"
onClick={floppyNavigation.handleRouteLinkClick}>
{t('signup', { keyPrefix: 'btns' })}
</Link>
) : null}
</div>
</div>
)}

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import * as Type from '@/common/interface';
import { FollowingTags } from '@/components';
import { FollowingTags, CustomSidebar } from '@/components';
import {
useTagInfo,
useFollow,
@ -152,6 +152,7 @@ const Questions: FC = () => {
<QuestionList source="tag" data={listData} isLoading={listLoading} />
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<CustomSidebar />
<FollowingTags />
<HotQuestions />
</Col>

View File

@ -64,8 +64,8 @@ const Tags = () => {
<Row className="mb-4 d-flex justify-content-center">
<Col xxl={10} sm={12}>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center flex-wrap">
<Stack direction="horizontal" gap={3}>
<div className="d-block d-sm-flex justify-content-between align-items-center flex-wrap">
<Stack direction="horizontal" gap={3} className="mb-3 mb-sm-0">
<Form>
<Form.Group controlId="formBasicEmail">
<Form.Control

View File

@ -0,0 +1,97 @@
import React, { memo, FC, useState, useEffect } from 'react';
import { Card } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import QrCode from 'qrcode';
import { userCenterStore } from '@/stores';
import { guard, getUaType, floppyNavigation } from '@/utils';
import { USER_AGENT_NAMES } from '@/common/constants';
import { getLoginConf, checkLoginResult } from './service';
let checkTimer: NodeJS.Timeout;
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const navigate = useNavigate();
const ucAgent = userCenterStore().agent;
const agentName = ucAgent?.agent_info?.name || '';
const [qrcodeDataUrl, setQrCodeDataUrl] = useState('');
const handleLoginResult = (key: string) => {
if (!key) {
return;
}
checkLoginResult(key).then((res) => {
if (res.is_login) {
guard.handleLoginWithToken(res.token, navigate);
return;
}
clearTimeout(checkTimer);
checkTimer = setTimeout(() => {
handleLoginResult(key);
}, 2000);
});
};
const handleQrCode = (targetUrl: string) => {
if (!targetUrl) {
return;
}
QrCode.toDataURL(targetUrl, { width: 240, margin: 0 }, (err, url) => {
if (err) {
return;
}
setQrCodeDataUrl(url);
});
};
useEffect(() => {
if (!agentName) {
return;
}
getLoginConf().then((res) => {
if (getUaType() === USER_AGENT_NAMES.WeCom) {
floppyNavigation.navigate(res?.redirect_url, {
handler: 'replace',
});
} else {
handleQrCode(res?.redirect_url);
handleLoginResult(res?.key);
}
});
}, [agentName]);
useEffect(() => {
return () => {
clearTimeout(checkTimer);
};
}, []);
if (getUaType() !== USER_AGENT_NAMES.WeCom) {
return (
<Card className="text-center">
<Card.Body>
<Card.Title as="h3" className="mb-3">
{ucAgent?.agent_info.display_name} {t('login')}
</Card.Title>
{qrcodeDataUrl ? (
<>
<img
className="w-100"
style={{ maxWidth: '240px' }}
src={qrcodeDataUrl}
alt={agentName}
/>
<div className="text-secondary mt-3">
{t('qrcode_login_tip', {
agentName: ucAgent?.agent_info.display_name,
})}
</div>
</>
) : null}
</Card.Body>
</Card>
);
}
return null;
};
export default memo(Index);

View File

@ -0,0 +1,21 @@
import request from '@/utils/request';
type loginConf = {
key: string;
redirect_url: string;
};
type loginResult = {
is_login: boolean;
token: string;
};
export const getLoginConf = () => {
const apiUrl = `/answer/api/v1/wecom/login/url`;
return request.get<loginConf>(apiUrl);
};
export const checkLoginResult = (key: loginConf['key']) => {
const apiUrl = `/answer/api/v1/wecom/login/check?key=${key}`;
return request.get<loginResult>(apiUrl);
};

View File

@ -0,0 +1,34 @@
import { memo } from 'react';
import { Container, Col } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { userCenterStore } from '@/stores';
import { USER_AGENT_NAMES } from '@/common/constants';
import { usePageTags } from '@/hooks';
import WeComAuth from './components/WeCom';
const Index = () => {
const { t } = useTranslation('translation');
const [searchParam] = useSearchParams();
const { agent: ucAgent } = userCenterStore();
let agentName = ucAgent?.agent_info.name || '';
if (searchParam.get('agent_name')) {
agentName = searchParam.get('agent_name') || '';
}
usePageTags({
title: t('login', { keyPrefix: 'page_title' }),
});
return (
<Container style={{ paddingTop: '5rem', paddingBottom: '5rem' }}>
<Col md={4} className="mx-auto">
{USER_AGENT_NAMES.WeCom.toLowerCase() === agentName.toLowerCase() ? (
<WeComAuth />
) : null}
</Col>
</Container>
);
};
export default memo(Index);

View File

@ -0,0 +1,61 @@
import React, { FC } from 'react';
import { Card, Col, Carousel } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { userCenterStore } from '@/stores';
const data = [
{
id: 1,
url: require('@/assets/images/carousel-wecom-1.jpg'),
},
{
id: 2,
url: require('@/assets/images/carousel-wecom-2.jpg'),
},
{
id: 3,
url: require('@/assets/images/carousel-wecom-3.jpg'),
},
{
id: 4,
url: require('@/assets/images/carousel-wecom-4.jpg'),
},
{
id: 5,
url: require('@/assets/images/carousel-wecom-5.jpg'),
},
];
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const ucAgent = userCenterStore().agent;
return (
<Col lg={4} className="mx-auto mt-3 py-5">
<Card>
<Card.Body>
<h3 className="text-center pt-3 mb-3">
{ucAgent?.agent_info.display_name} {t('login')}
</h3>
<p className="text-danger text-center">
{t('login_failed_email_tip')}
</p>
<Carousel controls={false}>
{data.map((item) => (
<Carousel.Item key={item.id}>
<img
className="d-block w-100"
src={item.url}
alt="First slide"
/>
</Carousel.Item>
))}
</Carousel>
</Card.Body>
</Card>
</Col>
);
};
export default Index;

View File

@ -0,0 +1,32 @@
import { memo } from 'react';
import { Container } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { userCenterStore } from '@/stores';
import { USER_AGENT_NAMES } from '@/common/constants';
import { usePageTags } from '@/hooks';
import WeCom from './components/WeCom';
const Index = () => {
const { t } = useTranslation('translation');
const [searchParam] = useSearchParams();
const { agent: ucAgent } = userCenterStore();
let agentName = ucAgent?.agent_info.name || '';
if (searchParam.get('agent_name')) {
agentName = searchParam.get('agent_name') || '';
}
usePageTags({
title: t('login', { keyPrefix: 'page_title' }),
});
return (
<Container>
{USER_AGENT_NAMES.WeCom.toLowerCase() === agentName.toLowerCase() ? (
<WeCom />
) : null}
</Container>
);
};
export default memo(Index);

View File

@ -22,7 +22,7 @@ const Index: React.FC = () => {
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
{step === 1 && (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<SendEmail visible={step === 1} callback={callback} />
</Col>
)}

View File

@ -0,0 +1,22 @@
import { FC, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { usePageTags } from '@/hooks';
import { guard } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('access_token');
guard.handleLoginWithToken(token, navigate);
}, []);
usePageTags({
title: t('oauth_callback'),
});
return null;
};
export default memo(Index);

View File

@ -15,7 +15,7 @@ const Index: FC = () => {
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<WelcomeTitle />
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<SendEmail />
</Col>
</Container>

View File

@ -4,8 +4,9 @@ import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { loggedUserInfoStore, siteInfoStore } from '@/stores';
import { loggedUserInfoStore } from '@/stores';
import { changeEmailVerify } from '@/services';
import { WelcomeTitle } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'account_result' });
@ -13,7 +14,6 @@ const Index: FC = () => {
const [step, setStep] = useState('loading');
const updateUser = loggedUserInfoStore((state) => state.update);
const siteName = siteInfoStore((state) => state.siteInfo.name);
useEffect(() => {
const code = searchParams.get('code');
@ -39,9 +39,7 @@ const Index: FC = () => {
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
<Col lg={6}>
<h3 className="text-center mt-3 mb-5">
{t('page_title', { site_name: siteName })}
</h3>
<WelcomeTitle className="mt-3 mb-5" />
{step === 'success' && (
<>
<p className="text-center">{t('confirm_new_email')}</p>

View File

@ -3,8 +3,6 @@ import { Container, Form, Button, Col } from 'react-bootstrap';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { RouteAlias } from '@/router/alias';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { usePageTags } from '@/hooks';
import type {
LoginReqParams,
@ -12,12 +10,15 @@ import type {
FormDataType,
} from '@/common/interface';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth } from '@/plugins';
import { loggedUserInfoStore, loginSettingStore } from '@/stores';
import { guard, floppyNavigation, handleFormError } from '@/utils';
import { login, checkImgCode } from '@/services';
import { PluginOauth, PluginUcLogin } from '@/plugins';
import {
loggedUserInfoStore,
loginSettingStore,
userCenterStore,
} from '@/stores';
import { floppyNavigation, guard, handleFormError, userCenter } from '@/utils';
import { login, checkImgCode, UcAgent } from '@/services';
import { PicAuthCodeModal } from '@/components/Modal';
import Storage from '@/utils/storage';
const Index: React.FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'login' });
@ -26,6 +27,14 @@ const Index: React.FC = () => {
const [refresh, setRefresh] = useState(0);
const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _);
const loginSetting = loginSettingStore((state) => state.login);
const ucAgent = userCenterStore().agent;
let ucAgentInfo: UcAgent['agent_info'] | undefined;
if (ucAgent?.enabled && ucAgent?.agent_info) {
ucAgentInfo = ucAgent.agent_info;
}
const canOriginalLogin =
!ucAgentInfo || ucAgentInfo.enabled_original_user_system;
const [formData, setFormData] = useState<FormDataType>({
e_mail: {
value: '',
@ -56,6 +65,9 @@ const Index: React.FC = () => {
};
const getImgCode = () => {
if (!canOriginalLogin) {
return;
}
checkImgCode({
action: 'login',
}).then((res) => {
@ -91,14 +103,6 @@ const Index: React.FC = () => {
return bol;
};
const handleLoginRedirect = () => {
const redirect = Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
floppyNavigation.navigate(redirect, () => {
navigate(redirect, { replace: true });
});
};
const handleLogin = (event?: any) => {
if (event) {
event.preventDefault();
@ -121,7 +125,7 @@ const Index: React.FC = () => {
setStep(2);
setRefresh((pre) => pre + 1);
} else {
handleLoginRedirect();
guard.handleLoginRedirect(navigate);
}
setModalState(false);
@ -171,9 +175,13 @@ const Index: React.FC = () => {
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<WelcomeTitle />
{step === 1 && (
<Col className="mx-auto" md={3}>
<PluginOauth className="mb-5" />
{step === 1 && canOriginalLogin ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
{ucAgentInfo ? (
<PluginUcLogin className="mb-5" />
) : (
<PluginOauth className="mb-5" />
)}
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label>
@ -238,14 +246,17 @@ const Index: React.FC = () => {
<div className="text-center mt-5">
<Trans i18nKey="login.info_sign" ns="translation">
Dont have an account?
<Link to="/users/register" tabIndex={2}>
<Link
to={userCenter.getSignUpUrl()}
tabIndex={2}
onClick={floppyNavigation.handleRouteLinkClick}>
Sign up
</Link>
</Trans>
</div>
)}
</Col>
)}
) : null}
{step === 2 && <Unactivate visible={step === 2} />}

View File

@ -148,7 +148,7 @@ const Index: FC = () => {
<p>{t('info', { keyPrefix: 'inactive' })}</p>
</Col>
) : (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<div className="text-center mb-5">{t('subtitle')}</div>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="email" className="mb-3">

View File

@ -1,46 +0,0 @@
import { FC, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { usePageTags, useLoginRedirect } from '@/hooks';
import { loggedUserInfoStore } from '@/stores';
import { getLoggedUserInfo } from '@/services';
import Storage from '@/utils/storage';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { guard } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const [searchParams] = useSearchParams();
const { loginRedirect } = useLoginRedirect();
const updateUser = loggedUserInfoStore((state) => state.update);
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('access_token');
if (token) {
Storage.set(LOGGED_TOKEN_STORAGE_KEY, token);
getLoggedUserInfo().then((res) => {
updateUser(res);
const userStat = guard.deriveLoginState();
if (userStat.isNotActivated) {
// inactive
navigate('/users/login?status=inactive', { replace: true });
} else {
setTimeout(() => {
loginRedirect();
}, 0);
}
});
} else {
navigate('/', { replace: true });
}
}, []);
usePageTags({
title: t('oauth_callback'),
});
return null;
};
export default memo(Index);

View File

@ -119,7 +119,7 @@ const Index: React.FC = () => {
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
{step === 1 && (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('password.label')}</Form.Label>

View File

@ -1,10 +1,14 @@
import { FC, memo } from 'react';
import { FC, memo, useEffect, useState } from 'react';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Avatar, Icon } from '@/components';
import classnames from 'classnames';
import { Avatar, Icon, SvgIcon } from '@/components';
import type { UserInfoRes } from '@/common/interface';
import { getUcBranding, UcBrandingEntry } from '@/services';
import { userCenterStore } from '@/stores';
interface Props {
data: UserInfoRes;
@ -12,6 +16,22 @@ interface Props {
const Index: FC<Props> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'personal' });
const { agent: ucAgent } = userCenterStore();
const [ucBranding, setUcBranding] = useState<UcBrandingEntry[]>([]);
const initData = () => {
if (ucAgent?.enabled && data?.username) {
getUcBranding(data.username).then((resp) => {
if (resp.enabled && Array.isArray(resp.personal_branding)) {
setUcBranding(resp.personal_branding);
}
});
}
};
useEffect(() => {
initData();
}, [data?.username]);
if (!data?.username) {
return null;
}
@ -65,27 +85,55 @@ const Index: FC<Props> = ({ data }) => {
</div>
<div className="d-flex text-secondary">
{data.location && (
<div className="d-flex align-items-center me-3">
<Icon name="geo-alt-fill" className="me-2" />
<span>{data.location}</span>
</div>
)}
{data.website && (
<div className="d-flex align-items-center">
<Icon name="house-door-fill" className="me-2" />
<a
className="link-secondary"
href={
data.website?.includes('http')
? data.website
: `http://${data.website}`
}>
{data?.website.replace(/(http|https):\/\//, '').split('/')?.[0]}
</a>
</div>
)}
{!ucAgent?.enabled ? (
<>
{data.location && (
<div className="d-flex align-items-center me-3">
<Icon name="geo-alt-fill" className="me-2" />
<span>{data.location}</span>
</div>
)}
{data.website && (
<div className="d-flex align-items-center">
<Icon name="house-door-fill" className="me-2" />
<a
className="link-secondary"
href={
data.website?.includes('http')
? data.website
: `http://${data.website}`
}>
{
data?.website
.replace(/(http|https):\/\//, '')
.split('/')?.[0]
}
</a>
</div>
)}
</>
) : null}
{ucBranding.map((b, i, a) => {
if (!b.label) {
return null;
}
return (
<div
key={b.name}
className={classnames('d-flex', 'align-items-center', {
'me-3': i < a.length - 1,
})}>
{b.icon ? <SvgIcon base64={b.icon} /> : null}
{b.url ? (
<a className="link-secondary" href={b.url}>
{b.label}
</a>
) : (
<span>{b.label}</span>
)}
</div>
);
})}
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { FC } from 'react';
import { Container, Row, Col, Button } from 'react-bootstrap';
import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router-dom';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import { usePageTags } from '@/hooks';
import { Pagination, FormatTime, Empty } from '@/components';
@ -47,14 +47,16 @@ const Personal: FC = () => {
},
tabName,
);
const { count = 0, list = [] } = listData?.[tabName] || {};
let pageTitle = '';
if (userInfo?.username) {
pageTitle = `${userInfo?.display_name} (${userInfo?.username})`;
}
const { count = 0, list = [] } = listData?.[tabName] || {};
usePageTags({
title: pageTitle,
});
return (
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
@ -71,12 +73,11 @@ const Personal: FC = () => {
className="d-flex justify-content-start justify-content-md-end">
{isSelf && (
<div className="mb-3">
<Button
variant="outline-secondary"
href="/users/settings/profile"
className="btn">
<Link
className="btn btn-outline-secondary"
to="/users/settings/profile">
{t('edit_profile')}
</Button>
</Link>
</div>
)}
</Col>

View File

@ -12,7 +12,7 @@ import {
useLegalTos,
useLegalPrivacy,
} from '@/services';
import userStore from '@/stores/loggedUserInfoStore';
import userStore from '@/stores/loggedUserInfo';
import { handleFormError } from '@/utils';
interface Props {

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth } from '@/plugins';
import { guard } from '@/utils';
import SignUpForm from './components/SignUpForm';
@ -17,12 +18,15 @@ const Index: React.FC = () => {
usePageTags({
title: t('sign_up', { keyPrefix: 'page_title' }),
});
if (!guard.singUpAgent().ok) {
return null;
}
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<WelcomeTitle />
{showForm ? (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<PluginOauth className="mb-5" />
<SignUpForm callback={onStep} />
</Col>

View File

@ -1,18 +1,43 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { userCenterStore } from '@/stores';
import { getUcSettings, UcSettingAgent } from '@/services';
import { ModifyEmail, ModifyPassword, MyLogins } from './components';
const Index = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'settings.account',
});
const { agent: ucAgent } = userCenterStore();
const [accountAgent, setAccountAgent] = useState<UcSettingAgent>();
const initData = () => {
if (ucAgent?.enabled) {
getUcSettings().then((resp) => {
setAccountAgent(resp.account_setting_agent);
});
}
};
useEffect(() => {
initData();
}, []);
return (
<>
<h3 className="mb-4">{t('heading')}</h3>
<ModifyEmail />
<ModifyPassword />
<MyLogins />
{accountAgent?.enabled && accountAgent?.redirect_url ? (
<a href={accountAgent.redirect_url}>
{t('goto_modify', { keyPrefix: 'settings' })}
</a>
) : null}
{!ucAgent?.enabled || accountAgent?.enabled === false ? (
<>
<ModifyEmail />
<ModifyPassword />
<MyLogins />
</>
) : null}
</>
);
};

View File

@ -5,10 +5,15 @@ import { Trans, useTranslation } from 'react-i18next';
import MD5 from 'md5';
import type { FormDataType } from '@/common/interface';
import { UploadImg, Avatar, Icon } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { UploadImg, Avatar, Icon, ImgViewer } from '@/components';
import { loggedUserInfoStore, userCenterStore, siteInfoStore } from '@/stores';
import { useToast } from '@/hooks';
import { modifyUserInfo, getLoggedUserInfo } from '@/services';
import {
modifyUserInfo,
getLoggedUserInfo,
getUcSettings,
UcSettingAgent,
} from '@/services';
import { handleFormError } from '@/utils';
const Index: React.FC = () => {
@ -17,9 +22,11 @@ const Index: React.FC = () => {
});
const toast = useToast();
const { user, update } = loggedUserInfoStore();
const { agent: ucAgent } = userCenterStore();
const { users: usersSetting } = siteInfoStore();
const [mailHash, setMailHash] = useState('');
const [count] = useState(0);
const [profileAgent, setProfileAgent] = useState<UcSettingAgent>();
const [formData, setFormData] = useState<FormDataType>({
display_name: {
value: '',
@ -243,228 +250,255 @@ const Index: React.FC = () => {
}
});
};
// const refreshGravatar = () => {
// setCount((pre) => pre + 1);
// };
const initData = () => {
if (ucAgent?.enabled) {
getUcSettings().then((resp) => {
setProfileAgent(resp.profile_setting_agent);
if (resp.profile_setting_agent?.enabled === false) {
getProfile();
}
});
} else {
getProfile();
}
};
useEffect(() => {
getProfile();
initData();
}, []);
return (
<>
<h3 className="mb-4">{t('heading')}</h3>
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="displayName" className="mb-3">
<Form.Label>{t('display_name.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.display_name.value}
isInvalid={formData.display_name.isInvalid}
onChange={(e) =>
handleChange({
display_name: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.display_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
{profileAgent?.enabled && profileAgent?.redirect_url ? (
<a href={profileAgent.redirect_url}>
{t('goto_modify', { keyPrefix: 'settings' })}
</a>
) : null}
{!ucAgent?.enabled || profileAgent?.enabled === false ? (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="displayName" className="mb-3">
<Form.Label>{t('display_name.label')}</Form.Label>
<Form.Control
required
type="text"
disabled={!usersSetting.allow_update_display_name}
value={formData.display_name.value}
isInvalid={formData.display_name.isInvalid}
onChange={(e) =>
handleChange({
display_name: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.display_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="userName" className="mb-3">
<Form.Label>{t('username.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.username.value}
isInvalid={formData.username.isInvalid}
onChange={(e) =>
handleChange({
username: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Text as="div">{t('username.caption')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.username.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="userName" className="mb-3">
<Form.Label>{t('username.label')}</Form.Label>
<Form.Control
required
type="text"
disabled={!usersSetting.allow_update_username}
value={formData.username.value}
isInvalid={formData.username.isInvalid}
onChange={(e) =>
handleChange({
username: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Text as="div">{t('username.caption')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.username.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="avatar" className="mb-3">
<Form.Label>{t('avatar.label')}</Form.Label>
<div className="mb-3">
<Form.Select
name="avatar.type"
value={formData.avatar.type}
onChange={handleAvatarChange}>
<option value="gravatar" key="gravatar">
{t('avatar.gravatar')}
</option>
<option value="default" key="default">
{t('avatar.default')}
</option>
<option value="custom" key="custom">
{t('avatar.custom')}
</option>
</Form.Select>
</div>
<div className="d-flex">
{formData.avatar.type === 'gravatar' && (
<Stack>
<Avatar
size="160px"
avatar={formData.avatar.gravatar}
searchStr={`s=256&d=identicon${
count > 0 ? `&t=${new Date().valueOf()}` : ''
}`}
className="me-3 rounded"
/>
<Form.Text className="text-muted mt-1">
<Trans i18nKey="settings.profile.avatar.gravatar_text">
You can change image on
<a
href="https://gravatar.com"
target="_blank"
rel="noreferrer">
gravatar.com
</a>
</Trans>
</Form.Text>
</Stack>
)}
<Form.Group controlId="avatar" className="mb-3">
<Form.Label>{t('avatar.label')}</Form.Label>
<div className="mb-3">
<Form.Select
name="avatar.type"
disabled={!usersSetting.allow_update_avatar}
value={formData.avatar.type}
onChange={handleAvatarChange}>
<option value="gravatar" key="gravatar">
{t('avatar.gravatar')}
</option>
<option value="default" key="default">
{t('avatar.default')}
</option>
<option value="custom" key="custom">
{t('avatar.custom')}
</option>
</Form.Select>
</div>
<ImgViewer>
<div className="d-flex">
{formData.avatar.type === 'gravatar' && (
<Stack>
<Avatar
size="160px"
avatar={formData.avatar.gravatar}
searchStr={`s=256&d=identicon${
count > 0 ? `&t=${new Date().valueOf()}` : ''
}`}
className="me-3 rounded"
/>
<Form.Text className="text-muted mt-1">
<Trans i18nKey="settings.profile.avatar.gravatar_text">
You can change image on
<a
href="https://gravatar.com"
target="_blank"
rel="noreferrer">
gravatar.com
</a>
</Trans>
</Form.Text>
</Stack>
)}
{formData.avatar.type === 'custom' && (
<Stack>
<Stack direction="horizontal" className="align-items-start">
<Avatar
size="160px"
searchStr="s=256"
avatar={formData.avatar.custom}
className="me-2 bg-gray-300 "
/>
<ButtonGroup vertical className="fit-content">
<UploadImg type="avatar" uploadCallback={avatarUpload}>
<Icon name="cloud-upload" />
</UploadImg>
<Button
variant="outline-secondary"
onClick={removeCustomAvatar}>
<Icon name="trash" />
</Button>
</ButtonGroup>
</Stack>
<Form.Text className="text-muted mt-1">
<Trans i18nKey="settings.profile.avatar.text">
You can upload your image.
</Trans>
</Form.Text>
</Stack>
)}
{formData.avatar.type === 'default' && (
<Avatar size="160px" avatar="" />
)}
</div>
<Form.Control
isInvalid={formData.avatar.isInvalid}
className="d-none"
/>
<Form.Control.Feedback type="invalid">
{formData.avatar.errorMsg}
</Form.Control.Feedback>
</Form.Group>
{formData.avatar.type === 'custom' && (
<Stack>
<Stack direction="horizontal" className="align-items-start">
<Avatar
size="160px"
searchStr="s=256"
avatar={formData.avatar.custom}
className="me-2 bg-gray-300 "
/>
<ButtonGroup vertical className="fit-content">
<UploadImg
type="avatar"
disabled={!usersSetting.allow_update_avatar}
uploadCallback={avatarUpload}>
<Icon name="cloud-upload" />
</UploadImg>
<Button
variant="outline-secondary"
disabled={!usersSetting.allow_update_avatar}
onClick={removeCustomAvatar}>
<Icon name="trash" />
</Button>
</ButtonGroup>
</Stack>
<Form.Text className="text-muted mt-1">
<Trans i18nKey="settings.profile.avatar.text">
You can upload your image.
</Trans>
</Form.Text>
</Stack>
)}
{formData.avatar.type === 'default' && (
<Avatar size="160px" avatar="" />
)}
</div>
</ImgViewer>
<Form.Control
isInvalid={formData.avatar.isInvalid}
className="d-none"
/>
<Form.Control.Feedback type="invalid">
{formData.avatar.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="bio" className="mb-3">
<Form.Label>
{`${t('bio.label')} ${t('optional', {
<Form.Group controlId="bio" className="mb-3">
<Form.Label>
{`${t('bio.label')} ${t('optional', {
keyPrefix: 'form',
})}`}
</Form.Label>
<Form.Control
className="font-monospace"
required
as="textarea"
rows={5}
disabled={!usersSetting.allow_update_bio}
value={formData.bio.value}
isInvalid={formData.bio.isInvalid}
onChange={(e) =>
handleChange({
bio: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.bio.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="website" className="mb-3">
<Form.Label>{`${t('website.label')} ${t('optional', {
keyPrefix: 'form',
})}`}
</Form.Label>
<Form.Control
className="font-monospace"
required
as="textarea"
rows={5}
value={formData.bio.value}
isInvalid={formData.bio.isInvalid}
onChange={(e) =>
handleChange({
bio: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.bio.errorMsg}
</Form.Control.Feedback>
</Form.Group>
})}`}</Form.Label>
<Form.Control
required
type="url"
placeholder={t('website.placeholder')}
disabled={!usersSetting.allow_update_website}
value={formData.website.value}
isInvalid={formData.website.isInvalid}
onChange={(e) =>
handleChange({
website: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.website.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="website" className="mb-3">
<Form.Label>{`${t('website.label')} ${t('optional', {
keyPrefix: 'form',
})}`}</Form.Label>
<Form.Control
required
type="url"
placeholder={t('website.placeholder')}
value={formData.website.value}
isInvalid={formData.website.isInvalid}
onChange={(e) =>
handleChange({
website: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.website.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="email" className="mb-3">
<Form.Label>{`${t('location.label')} ${t('optional', {
keyPrefix: 'form',
})}`}</Form.Label>
<Form.Control
required
type="text"
placeholder={t('location.placeholder')}
disabled={!usersSetting.allow_update_location}
value={formData.location.value}
isInvalid={formData.location.isInvalid}
onChange={(e) =>
handleChange({
location: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.location.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="email" className="mb-3">
<Form.Label>{`${t('location.label')} ${t('optional', {
keyPrefix: 'form',
})}`}</Form.Label>
<Form.Control
required
type="text"
placeholder={t('location.placeholder')}
value={formData.location.value}
isInvalid={formData.location.isInvalid}
onChange={(e) =>
handleChange({
location: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.location.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Button variant="primary" type="submit">
{t('btn_name')}
</Button>
</Form>
<Button variant="primary" type="submit">
{t('btn_name')}
</Button>
</Form>
) : null}
</>
);
};

View File

@ -1,13 +1,18 @@
import React, { FC } from 'react';
import { Nav } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { NavLink, useMatch } from 'react-router-dom';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'settings.nav' });
const settingMatch = useMatch('/users/settings/:setting');
return (
<Nav variant="pills" className="flex-column">
<NavLink className="nav-link" to="/users/settings/profile">
<NavLink
className={({ isActive }) =>
isActive || !settingMatch ? 'nav-link active' : 'nav-link'
}
to="/users/settings/profile">
{t('profile')}
</NavLink>
<NavLink className="nav-link" to="/users/settings/notify">

View File

@ -1,62 +1,17 @@
import React, { useState, useEffect } from 'react';
import { FC, memo } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { usePageTags } from '@/hooks';
import type { FormDataType } from '@/common/interface';
import { getLoggedUserInfo } from '@/services';
import Nav from './components/Nav';
const Index: React.FC = () => {
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'settings.profile',
});
const [formData, setFormData] = useState<FormDataType>({
display_name: {
value: '',
isInvalid: false,
errorMsg: '',
},
avatar: {
value: '',
isInvalid: false,
errorMsg: '',
},
bio: {
value: '',
isInvalid: false,
errorMsg: '',
},
website: {
value: '',
isInvalid: false,
errorMsg: '',
},
location: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const getProfile = () => {
getLoggedUserInfo().then((res) => {
if (res) {
formData.display_name.value = res.display_name;
formData.bio.value = res.bio;
formData.avatar.value = res.avatar;
formData.location.value = res.location;
formData.website.value = res.website;
setFormData({ ...formData });
}
});
};
useEffect(() => {
getProfile();
}, []);
usePageTags({
title: t('settings', { keyPrefix: 'page_title' }),
});
@ -81,4 +36,4 @@ const Index: React.FC = () => {
);
};
export default React.memo(Index);
export default memo(Index);

View File

@ -1,8 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Button } from 'react-bootstrap';
import { siteInfoStore } from '@/stores';
import { usePageTags } from '@/hooks';
const Suspended = () => {
const { contact_email = '' } = siteInfoStore((state) => state.siteInfo);
const { t } = useTranslation('translation', { keyPrefix: 'suspended' });
usePageTags({
title: t('account_suspended', { keyPrefix: 'page_title' }),
@ -16,6 +19,9 @@ const Suspended = () => {
<br />
{t('end')}
</p>
<Button href={`mailto:${contact_email}`} variant="link">
{t('contact_us')}
</Button>
</div>
);
};

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { useGetStartUseOauthConnector } from '@/services';
import { base64ToSvg } from '@/utils';
import { SvgIcon } from '@/components';
interface Props {
className?: string;
@ -20,12 +20,7 @@ const Index: FC<Props> = ({ className }) => {
{data?.map((item) => {
return (
<Button variant="outline-secondary" href={item.link} key={item.name}>
<span
dangerouslySetInnerHTML={{
__html: base64ToSvg(item.icon),
}}
/>
<SvgIcon base64={item.icon} />
<span>{t('connect', { auth_name: item.name })}</span>
</Button>
);

View File

@ -0,0 +1,35 @@
import { memo, FC } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { SvgIcon } from '@/components';
import { userCenterStore } from '@/stores';
interface Props {
className?: classnames.Argument;
}
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const ucAgent = userCenterStore().agent;
const ucLoginRedirect =
ucAgent?.enabled && ucAgent?.agent_info?.login_redirect_url;
if (ucLoginRedirect) {
return (
<Button
className={classnames('w-100', className)}
variant="outline-secondary"
href={ucAgent?.agent_info.login_redirect_url}>
<SvgIcon base64={ucAgent?.agent_info.icon} />
<span>
{t('connect', { auth_name: ucAgent?.agent_info.display_name })}
</span>
</Button>
);
}
return null;
};
export default memo(Index);

View File

@ -1,3 +1,4 @@
import PluginOauth from './PluginOauth';
import PluginUcLogin from './PluginUcLogin';
export { PluginOauth };
export { PluginOauth, PluginUcLogin };

23
ui/src/plugins/types.ts Normal file
View File

@ -0,0 +1,23 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -1,8 +1,8 @@
import { FC, ReactNode, useEffect } from 'react';
import { useLocation, useNavigate, useLoaderData } from 'react-router-dom';
import { FC, ReactNode, useEffect, useState } from 'react';
import { useNavigate, useLoaderData } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import { TGuardFunc } from '@/utils/guard';
import { TGuardFunc, TGuardResult } from '@/utils/guard';
import RouteErrorBoundary from './RouteErrorBoundary';
@ -13,39 +13,59 @@ const RouteGuard: FC<{
page?: string;
}> = ({ children, onEnter, path, page }) => {
const navigate = useNavigate();
const location = useLocation();
const loaderData = useLoaderData();
const gr = onEnter({
loaderData,
path,
page,
const [gk, setKeeper] = useState<TGuardResult>({
ok: true,
});
const [gkError, setGkError] = useState<TGuardResult['error']>();
const applyGuard = () => {
if (typeof onEnter !== 'function') {
return;
}
let guardError;
const errCode = gr.error?.code;
if (errCode === '403' || errCode === '404' || errCode === '50X') {
guardError = {
code: errCode,
msg: gr.error?.msg,
};
}
const handleGuardRedirect = () => {
const redirectUrl = gr.redirect;
if (redirectUrl) {
floppyNavigation.navigate(redirectUrl, () => {
navigate(redirectUrl, { replace: true });
const gr = onEnter({
loaderData,
path,
page,
});
setKeeper(gr);
if (
gk.ok === false &&
gk.error?.code &&
/403|404|50X/i.test(gk.error.code.toString())
) {
setGkError(gk.error);
return;
}
setGkError(undefined);
if (gr.redirect) {
floppyNavigation.navigate(gr.redirect, {
handler: navigate,
options: { replace: true },
});
}
};
useEffect(() => {
handleGuardRedirect();
}, [location]);
/**
* By detecting changes to location.href, many unnecessary tests can be avoided
*/
applyGuard();
}, [window.location.href]);
let asOK = gk.ok;
if (gk.ok === false && gk.redirect) {
/**
* It is possible that the route guard verification fails
* but the current page is already the target page for the route guard jump
* This should render `children`!
*/
asOK = floppyNavigation.equalToCurrentHref(gk.redirect);
}
return (
<>
{gr.ok ? children : null}
{!gr.ok && guardError ? (
<RouteErrorBoundary errCode={guardError.code} />
) : null}
{asOK ? children : null}
{gkError ? <RouteErrorBoundary errCode={gkError.code as string} /> : null}
</>
);
};

View File

@ -1,8 +1,16 @@
export const RouteAlias = {
home: '/',
login: '/users/login',
register: '/users/register',
activation: '/users/login?status=inactive',
signUp: '/users/register',
inactive: '/users/login?status=inactive',
accountRecovery: '/users/account-recovery',
changeEmail: '/users/change-email',
passwordReset: '/users/password-reset',
accountActivation: '/users/account-activation',
activationSuccess: '/users/account-activation/success',
activationFailed: '/users/account-activation/failed',
suspended: '/users/account-suspended',
confirmNewEmail: '/users/confirm-new-email',
confirmEmail: '/users/confirm-email',
authLanding: '/users/auth-landing',
};

View File

@ -169,6 +169,7 @@ const routes: RouteNode[] = [
if (notLogged.ok) {
return notLogged;
}
return guard.notActivated();
},
},
@ -180,7 +181,14 @@ const routes: RouteNode[] = [
if (!allowNew.ok) {
return allowNew;
}
return guard.notLogged();
const notLogged = guard.notLogged();
if (notLogged.ok) {
const sa = guard.singUpAgent();
if (!sa.ok) {
return sa;
}
}
return notLogged;
},
},
{
@ -232,8 +240,8 @@ const routes: RouteNode[] = [
page: 'pages/Users/OauthBindEmail',
},
{
path: '/users/oauth',
page: 'pages/Users/OauthCallback',
path: '/users/auth-landing',
page: 'pages/Users/AuthCallback',
},
{
path: '/posts/:qid/timeline',
@ -261,7 +269,7 @@ const routes: RouteNode[] = [
path: 'admin',
page: 'pages/Admin',
loader: async () => {
await guard.pullLoggedUser(true);
await guard.pullLoggedUser();
return null;
},
guard: () => {
@ -337,7 +345,15 @@ const routes: RouteNode[] = [
page: 'pages/Admin/Login',
},
{
path: 'installed_plugins',
path: 'settings-users',
page: 'pages/Admin/SettingsUsers',
},
{
path: 'privileges',
page: 'pages/Admin/Privileges',
},
{
path: 'installed-plugins',
page: 'pages/Admin/Plugins/Installed',
},
{
@ -346,6 +362,18 @@ const routes: RouteNode[] = [
},
],
},
{
path: '/user-center/auth',
page: 'pages/UserCenter/Auth',
guard: () => {
const notLogged = guard.notLogged();
return notLogged;
},
},
{
path: '/user-center/auth-failed',
page: 'pages/UserCenter/AuthFailed',
},
// for review
{
path: 'review',

View File

@ -1,8 +1,8 @@
import qs from 'qs';
import useSWR from 'swr';
import type * as Types from '@/common/interface';
import request from '@/utils/request';
import type { PluginConfig } from '@/plugins/types';
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;
@ -24,7 +24,7 @@ export const updatePluginStatus = (params) => {
export const useQueryPluginConfig = (params) => {
const apiUrl = `/answer/admin/api/plugin/config?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Types.PluginConfig, Error>(
const { data, error, mutate } = useSWR<PluginConfig, Error>(
apiUrl,
request.instance.get,
);

View File

@ -3,6 +3,30 @@ import useSWR from 'swr';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export interface AdminSettingsUsers {
allow_update_avatar: boolean;
allow_update_bio: boolean;
allow_update_display_name: boolean;
allow_update_location: boolean;
allow_update_username: boolean;
allow_update_website: boolean;
default_avatar: string;
}
interface PrivilegeLevel {
level: number;
level_desc: string;
privileges: {
label: string;
value: number;
key: string;
}[];
}
export interface AdminSettingsPrivilege {
selected_level: number;
options: PrivilegeLevel[];
}
export const useGeneralSetting = () => {
const apiUrl = `/answer/admin/api/siteinfo/general`;
const { data, error } = useSWR<Type.AdminSettingsGeneral, Error>(
@ -126,3 +150,23 @@ export const getLoginSetting = () => {
export const putLoginSetting = (params: Type.AdminSettingsLogin) => {
return request.put('/answer/admin/api/siteinfo/login', params);
};
export const getUsersSetting = () => {
return request.get<AdminSettingsUsers>('/answer/admin/api/siteinfo/users');
};
export const putUsersSetting = (params: AdminSettingsUsers) => {
return request.put('/answer/admin/api/siteinfo/users', params);
};
export const getPrivilegeSetting = () => {
return request.get<AdminSettingsPrivilege>(
'/answer/admin/api/setting/privileges',
);
};
export const putPrivilegeSetting = (level: number) => {
return request.put('/answer/admin/api/setting/privileges', {
level,
});
};

View File

@ -22,6 +22,7 @@ export const useHotQuestions = (
page: 1,
page_size: 6,
order: 'frequent',
in_days: 7,
},
) => {
const apiUrl = `/answer/api/v1/question/page?${qs.stringify(params)}`;

View File

@ -130,8 +130,8 @@ export const resendEmail = (params?: Type.ImgCodeReq) => {
* @description get login userinfo
* @returns {UserInfo}
*/
export const getLoggedUserInfo = () => {
return request.get<Type.UserInfoRes>('/answer/api/v1/user/info');
export const getLoggedUserInfo = (config = { passingError: false }) => {
return request.get<Type.UserInfoRes>('/answer/api/v1/user/info', config);
};
export const modifyPassword = (params: Type.ModifyPasswordReq) => {

View File

@ -2,3 +2,4 @@ export * from './admin';
export * from './common';
export * from './client';
export * from './install';
export * from './user-center';

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