feat: support customize theme (#1500)

* refactor: simplify functions and improve variable naming

* feat: add themeEditor component

* feat: support customize theme

* chore: resolve conflict and add LICENCE

* chore: format code

* refactor: use icon replace background url

* feat: improve organization and application theme editor
This commit is contained in:
Yaodong Yu
2023-02-01 22:06:40 +08:00
committed by GitHub
parent b47baa06e1
commit 95b32d5ebf
28 changed files with 13025 additions and 12126 deletions

View File

@ -0,0 +1,173 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** @jsxImportSource @emotion/react */
import {Input, Popover, Space, theme} from "antd";
import React, {useEffect, useMemo, useState} from "react";
import {css} from "@emotion/react";
import {TinyColor} from "@ctrl/tinycolor";
import ColorPanel from "antd-token-previewer/es/ColorPanel";
export const BLUE_COLOR = "#1677FF";
export const PINK_COLOR = "#ED4192";
export const GREEN_COLOR = "#00B96B";
export const COLORS = [
{
color: BLUE_COLOR,
},
{
color: "#5734d3",
},
{
color: "#9E339F",
},
{
color: PINK_COLOR,
},
{
color: "#E0282E",
},
{
color: "#F4801A",
},
{
color: "#F2BD27",
},
{
color: GREEN_COLOR,
},
];
export const PRESET_COLORS = COLORS.map(({color}) => color);
const {useToken} = theme;
const useStyle = () => {
const {token} = useToken();
return {
color: css `
width: ${token.controlHeightLG / 2}px;
height: ${token.controlHeightLG / 2}px;
border-radius: 100%;
cursor: pointer;
transition: all ${token.motionDurationFast};
display: inline-block;
& > input[type="radio"] {
width: 0;
height: 0;
opacity: 0;
}
`,
colorActive: css `
box-shadow: 0 0 0 1px ${token.colorBgContainer},
0 0 0 ${token.controlOutlineWidth * 2 + 1}px ${token.colorPrimary};
`,
};
};
const DebouncedColorPanel = ({color, onChange}) => {
const [value, setValue] = useState(color);
useEffect(() => {
const timeout = setTimeout(() => {
onChange?.(value);
}, 200);
return () => clearTimeout(timeout);
}, [value]);
useEffect(() => {
setValue(color);
}, [color]);
return <ColorPanel color={value} onChange={setValue} />;
};
export default function ColorPicker({value, onChange}) {
const style = useStyle();
const matchColors = useMemo(() => {
const valueStr = new TinyColor(value).toRgbString();
let existActive = false;
const colors = PRESET_COLORS.map((color) => {
const colorStr = new TinyColor(color).toRgbString();
const active = colorStr === valueStr;
existActive = existActive || active;
return {color, active, picker: false};
});
return [
...colors,
{
color: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",
picker: true,
active: !existActive,
},
];
}, [value]);
return (
<Space size="large">
<Input
value={value}
onChange={(event) => {
onChange?.(event.target.value);
}}
style={{width: 120}}
/>
<Space size="middle">
{matchColors.map(({color, active, picker}) => {
let colorNode = (
<label
key={color}
css={[style.color, active && style.colorActive]}
style={{
background: color,
}}
onClick={() => {
if (!picker) {
onChange?.(color);
}
}}
>
<input type="radio" name={picker ? "picker" : "color"} tabIndex={picker ? -1 : 0} />
</label>
);
if (picker) {
colorNode = (
<Popover
key={color}
overlayInnerStyle={{padding: 0}}
content={
<DebouncedColorPanel color={value || ""} onChange={(c) => onChange?.(c)} />
}
trigger="click"
showArrow={false}
>
{colorNode}
</Popover>
);
}
return colorNode;
})}
</Space>
</Space>
);
}

View File

@ -0,0 +1,38 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {InputNumber, Slider, Space} from "antd";
export default function RadiusPicker({value, onChange}) {
return (
<Space size="large">
<InputNumber
value={value}
onChange={onChange}
style={{width: 120}}
min={0}
formatter={(val) => `${val}px`}
parser={(str) => (str ? parseFloat(str) : str)}
/>
<Slider
tooltip={{open: false}}
style={{width: 128}}
min={0}
value={value}
max={20}
onChange={onChange}
/>
</Space>
);
}

View File

@ -0,0 +1,108 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Card, ConfigProvider, Form, Layout, Switch, theme} from "antd";
import ThemePicker from "./ThemePicker";
import ColorPicker from "./ColorPicker";
import RadiusPicker from "./RadiusPicker";
import * as React from "react";
import {GREEN_COLOR, PINK_COLOR} from "./ColorPicker";
import {Content} from "antd/es/layout/layout";
import i18next from "i18next";
import {useEffect} from "react";
import * as Setting from "../../Setting";
const ThemesInfo = {
default: {},
dark: {
borderRadius: 2,
},
lark: {
colorPrimary: GREEN_COLOR,
borderRadius: 4,
},
comic: {
colorPrimary: PINK_COLOR,
borderRadius: 16,
},
};
const onChange = () => {};
export default function ThemeEditor(props) {
const themeData = props.themeData ?? Setting.ThemeDefault;
const onThemeChange = props.onThemeChange ?? onChange;
const {isCompact, themeType, ...themeToken} = themeData;
const isLight = themeType !== "dark";
const [form] = Form.useForm();
const algorithmFn = React.useMemo(() => {
const algorithms = [isLight ? theme.defaultAlgorithm : theme.darkAlgorithm];
if (isCompact === true) {
algorithms.push(theme.compactAlgorithm);
}
return algorithms;
}, [isLight, isCompact]);
useEffect(() => {
const mergedData = Object.assign(Object.assign(Object.assign({}, Setting.ThemeDefault), {themeType}), ThemesInfo[themeType]);
onThemeChange(null, mergedData);
form.setFieldsValue(mergedData);
}, [themeType]);
return (
<ConfigProvider
theme={{
token: {
...themeToken,
},
hashed: true,
algorithm: algorithmFn,
}}
>
<Layout style={{width: "800px", backgroundColor: "white"}}>
<Content >
<Card
title={i18next.t("theme:Theme")}
>
<Form
form={form}
initialValues={themeData}
onValuesChange={onThemeChange}
labelCol={{span: 4}}
wrapperCol={{span: 20}}
style={{width: "800px"}}
>
<Form.Item label={i18next.t("theme:Theme")} name="themeType">
<ThemePicker />
</Form.Item>
<Form.Item label={i18next.t("theme:Primary color")} name="colorPrimary">
<ColorPicker />
</Form.Item>
<Form.Item label={i18next.t("theme:Border radius")} name="borderRadius">
<RadiusPicker />
</Form.Item>
<Form.Item label={i18next.t("theme:Is compact")} valuePropName="checked" name="isCompact">
<Switch />
</Form.Item>
</Form>
</Card>
</Content>
</Layout>
</ConfigProvider>
);
}

View File

@ -0,0 +1,104 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** @jsxImportSource @emotion/react */
import {css} from "@emotion/react";
import {Space, theme} from "antd";
import * as React from "react";
import i18next from "i18next";
import * as Setting from "../../Setting";
const {useToken} = theme;
export const THEMES = {
default: `${Setting.StaticBaseUrl}/img/theme_default.svg`,
dark: `${Setting.StaticBaseUrl}/img/theme_dark.svg`,
lark: `${Setting.StaticBaseUrl}/img/theme_lark.svg`,
comic: `${Setting.StaticBaseUrl}/img/theme_comic.svg`,
};
Object.values(THEMES).map(value => new Image().src = value);
const themeTypes = {
default: "Default", // i18next.t("theme:Default")
dark: "Dark", // i18next.t("theme:Dark")
lark: "Document", // i18next.t("theme:Document")
comic: "Blossom", // i18next.t("theme:Blossom")
};
const useStyle = () => {
const {token} = useToken();
return {
themeCard: css `
border-radius: ${token.borderRadius}px;
cursor: pointer;
transition: all ${token.motionDurationSlow};
overflow: hidden;
display: inline-block;
& > input[type="radio"] {
width: 0;
height: 0;
opacity: 0;
}
img {
vertical-align: top;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
&:focus-within,
&:hover {
transform: scale(1.04);
}
`,
themeCardActive: css `
box-shadow: 0 0 0 1px ${token.colorBgContainer},
0 0 0 ${token.controlOutlineWidth * 2 + 1}px ${token.colorPrimary};
&:hover:not(:focus-within) {
transform: scale(1);
}
`,
};
};
export default function ThemePicker({value, onChange}) {
const {token} = useToken();
const style = useStyle();
return (
<Space size={token.paddingLG}>
{Object.keys(THEMES).map((theme) => {
const url = THEMES[theme];
return (
<Space key={theme} direction="vertical" align="center">
<label
css={[style.themeCard, value === theme && style.themeCardActive]}
onClick={() => {
onChange?.(theme);
}}
>
<input type="radio" name="theme" />
<img src={url} alt={theme} />
</label>
<span>{i18next.t(`theme:${themeTypes[theme]}`)}</span>
</Space>
);
})}
</Space>
);
}