mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-04 21:30:24 +08:00
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:
173
web/src/common/theme/ColorPicker.js
Normal file
173
web/src/common/theme/ColorPicker.js
Normal 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>
|
||||
);
|
||||
}
|
38
web/src/common/theme/RadiusPicker.js
Normal file
38
web/src/common/theme/RadiusPicker.js
Normal 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>
|
||||
);
|
||||
}
|
108
web/src/common/theme/ThemeEditor.js
Normal file
108
web/src/common/theme/ThemeEditor.js
Normal 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>
|
||||
);
|
||||
}
|
104
web/src/common/theme/ThemePicker.js
Normal file
104
web/src/common/theme/ThemePicker.js
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user