Browse Source
* Add user-customizable theming. Closes #1915 * Prettified Code! * Add user-customizable theming. Closes #1915 * Add explicit color for page content background * Prettified Code! Co-authored-by: gabek <gabek@users.noreply.github.com>pull/2330/head
33 changed files with 499 additions and 65 deletions
@ -0,0 +1,35 @@ |
|||||||
|
package admin |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/owncast/owncast/controllers" |
||||||
|
"github.com/owncast/owncast/core/data" |
||||||
|
) |
||||||
|
|
||||||
|
// SetCustomColorVariableValues sets the custom color variables.
|
||||||
|
func SetCustomColorVariableValues(w http.ResponseWriter, r *http.Request) { |
||||||
|
if !requirePOST(w, r) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
type request struct { |
||||||
|
Value map[string]string `json:"value"` |
||||||
|
} |
||||||
|
|
||||||
|
decoder := json.NewDecoder(r.Body) |
||||||
|
var values request |
||||||
|
|
||||||
|
if err := decoder.Decode(&values); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, "unable to update appearance variable values") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := data.SetCustomColorVariableValues(values.Value); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
controllers.WriteSimpleResponse(w, true, "custom appearance variables updated") |
||||||
|
} |
@ -1,4 +1,5 @@ |
|||||||
.chatAction { |
.chatAction { |
||||||
padding: 5px; |
padding: 5px; |
||||||
text-align: center; |
text-align: center; |
||||||
|
color: var(--theme-color-components-chat-text); |
||||||
} |
} |
||||||
|
@ -1,6 +1,7 @@ |
|||||||
.root { |
.root { |
||||||
padding: 10px 0px; |
padding: 10px 0px; |
||||||
text-align: center; |
text-align: center; |
||||||
font-size: .8rem; |
font-size: 0.8rem; |
||||||
font-style: italic; |
font-style: italic; |
||||||
|
color: var(--theme-color-components-chat-text); |
||||||
} |
} |
||||||
|
@ -0,0 +1,27 @@ |
|||||||
|
/* eslint-disable react/no-danger */ |
||||||
|
import { FC } from 'react'; |
||||||
|
import { useRecoilValue } from 'recoil'; |
||||||
|
import { ClientConfig } from '../../interfaces/client-config.model'; |
||||||
|
import { clientConfigStateAtom } from '../stores/ClientConfigStore'; |
||||||
|
|
||||||
|
export const Theme: FC = () => { |
||||||
|
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom); |
||||||
|
const { appearanceVariables, customStyles } = clientConfig; |
||||||
|
|
||||||
|
const appearanceVars = Object.keys(appearanceVariables) |
||||||
|
.filter(variable => !!appearanceVariables[variable]) |
||||||
|
.map(variable => `--${variable}: ${appearanceVariables[variable]}`); |
||||||
|
|
||||||
|
return ( |
||||||
|
<style |
||||||
|
dangerouslySetInnerHTML={{ |
||||||
|
__html: ` |
||||||
|
:root { |
||||||
|
${appearanceVars.join(';\n')} |
||||||
|
} |
||||||
|
${customStyles} |
||||||
|
`,
|
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,4 @@ |
|||||||
|
.colorPicker { |
||||||
|
width: 100%; |
||||||
|
height: 50px; |
||||||
|
} |
@ -0,0 +1,256 @@ |
|||||||
|
import React, { useContext, useEffect, useState } from 'react'; |
||||||
|
|
||||||
|
import { Button, Col, Collapse, Row, Space } from 'antd'; |
||||||
|
import Paragraph from 'antd/lib/typography/Paragraph'; |
||||||
|
import Title from 'antd/lib/typography/Title'; |
||||||
|
import { EditCustomStyles } from '../../../../components/config/EditCustomStyles'; |
||||||
|
import s from './appearance.module.scss'; |
||||||
|
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../../../utils/config-constants'; |
||||||
|
import { |
||||||
|
createInputStatus, |
||||||
|
StatusState, |
||||||
|
STATUS_ERROR, |
||||||
|
STATUS_SUCCESS, |
||||||
|
} from '../../../../utils/input-statuses'; |
||||||
|
import { ServerStatusContext } from '../../../../utils/server-status-context'; |
||||||
|
import { FormStatusIndicator } from '../../../../components/config/FormStatusIndicator'; |
||||||
|
|
||||||
|
const { Panel } = Collapse; |
||||||
|
|
||||||
|
const ENDPOINT = '/appearance'; |
||||||
|
|
||||||
|
interface AppearanceVariable { |
||||||
|
value: string; |
||||||
|
description: string; |
||||||
|
} |
||||||
|
|
||||||
|
function ColorPicker({ |
||||||
|
value, |
||||||
|
name, |
||||||
|
description, |
||||||
|
onChange, |
||||||
|
}: { |
||||||
|
value: string; |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
onChange: (name: string, value: string, description: string) => void; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<Col span={3} key={name}> |
||||||
|
<input |
||||||
|
type="color" |
||||||
|
id={name} |
||||||
|
name={description} |
||||||
|
title={description} |
||||||
|
value={value} |
||||||
|
className={s.colorPicker} |
||||||
|
onChange={e => onChange(name, e.target.value, description)} |
||||||
|
/> |
||||||
|
<div style={{ padding: '2px' }}>{description}</div> |
||||||
|
</Col> |
||||||
|
); |
||||||
|
} |
||||||
|
export default function Appearance() { |
||||||
|
const serverStatusData = useContext(ServerStatusContext); |
||||||
|
const { serverConfig } = serverStatusData || {}; |
||||||
|
const { instanceDetails } = serverConfig; |
||||||
|
const { appearanceVariables } = instanceDetails; |
||||||
|
|
||||||
|
const chatColorVariables = [ |
||||||
|
{ name: 'theme-color-users-0', description: '' }, |
||||||
|
{ name: 'theme-color-users-1', description: '' }, |
||||||
|
{ name: 'theme-color-users-2', description: '' }, |
||||||
|
{ name: 'theme-color-users-3', description: '' }, |
||||||
|
{ name: 'theme-color-users-4', description: '' }, |
||||||
|
{ name: 'theme-color-users-5', description: '' }, |
||||||
|
{ name: 'theme-color-users-6', description: '' }, |
||||||
|
{ name: 'theme-color-users-7', description: '' }, |
||||||
|
]; |
||||||
|
|
||||||
|
const paletteVariables = [ |
||||||
|
{ name: 'theme-color-palette-0', description: '' }, |
||||||
|
{ name: 'theme-color-palette-1', description: '' }, |
||||||
|
{ name: 'theme-color-palette-2', description: '' }, |
||||||
|
{ name: 'theme-color-palette-3', description: '' }, |
||||||
|
{ name: 'theme-color-palette-4', description: '' }, |
||||||
|
{ name: 'theme-color-palette-5', description: '' }, |
||||||
|
{ name: 'theme-color-palette-6', description: '' }, |
||||||
|
{ name: 'theme-color-palette-7', description: '' }, |
||||||
|
{ name: 'theme-color-palette-8', description: '' }, |
||||||
|
{ name: 'theme-color-palette-9', description: '' }, |
||||||
|
{ name: 'theme-color-palette-10', description: '' }, |
||||||
|
{ name: 'theme-color-palette-11', description: '' }, |
||||||
|
{ name: 'theme-color-palette-12', description: '' }, |
||||||
|
]; |
||||||
|
|
||||||
|
const componentColorVariables = [ |
||||||
|
{ name: 'theme-color-background-main', description: 'Background' }, |
||||||
|
{ name: 'theme-color-action', description: 'Action' }, |
||||||
|
{ name: 'theme-color-action-hover', description: 'Action Hover' }, |
||||||
|
{ name: 'theme-color-components-chat-background', description: 'Chat Background' }, |
||||||
|
{ name: 'theme-color-components-chat-text', description: 'Text: Chat' }, |
||||||
|
{ name: 'theme-color-components-text-on-dark', description: 'Text: Light' }, |
||||||
|
{ name: 'theme-color-components-text-on-light', description: 'Text: Dark' }, |
||||||
|
{ name: 'theme-color-background-header', description: 'Header/Footer' }, |
||||||
|
{ name: 'theme-color-components-content-background', description: 'Page Content' }, |
||||||
|
]; |
||||||
|
|
||||||
|
const [colors, setColors] = useState<Record<string, AppearanceVariable>>(); |
||||||
|
const [submitStatus, setSubmitStatus] = useState<StatusState>(null); |
||||||
|
|
||||||
|
let resetTimer = null; |
||||||
|
const resetStates = () => { |
||||||
|
setSubmitStatus(null); |
||||||
|
resetTimer = null; |
||||||
|
clearTimeout(resetTimer); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const c = {}; |
||||||
|
[...paletteVariables, ...componentColorVariables, ...chatColorVariables].forEach(color => { |
||||||
|
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue( |
||||||
|
`--${color.name}`, |
||||||
|
); |
||||||
|
c[color.name] = { value: resolvedColor.trim(), description: color.description }; |
||||||
|
}); |
||||||
|
setColors(c); |
||||||
|
}, []); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!appearanceVariables || !colors) return; |
||||||
|
|
||||||
|
const c = colors; |
||||||
|
Object.keys(appearanceVariables).forEach(key => { |
||||||
|
c[key] = { value: appearanceVariables[key], description: colors[key]?.description || '' }; |
||||||
|
}); |
||||||
|
setColors(c); |
||||||
|
}, [appearanceVariables]); |
||||||
|
|
||||||
|
const updateColor = (variable: string, color: string, description: string) => { |
||||||
|
setColors({ |
||||||
|
...colors, |
||||||
|
[variable]: { value: color, description }, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const reset = async () => { |
||||||
|
setColors({}); |
||||||
|
await postConfigUpdateToAPI({ |
||||||
|
apiPath: ENDPOINT, |
||||||
|
data: { value: {} }, |
||||||
|
onSuccess: () => { |
||||||
|
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.')); |
||||||
|
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); |
||||||
|
}, |
||||||
|
onError: (message: string) => { |
||||||
|
setSubmitStatus(createInputStatus(STATUS_ERROR, message)); |
||||||
|
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); |
||||||
|
}, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const save = async () => { |
||||||
|
const c = {}; |
||||||
|
Object.keys(colors).forEach(color => { |
||||||
|
c[color] = colors[color].value; |
||||||
|
}); |
||||||
|
|
||||||
|
await postConfigUpdateToAPI({ |
||||||
|
apiPath: ENDPOINT, |
||||||
|
data: { value: c }, |
||||||
|
onSuccess: () => { |
||||||
|
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.')); |
||||||
|
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); |
||||||
|
}, |
||||||
|
onError: (message: string) => { |
||||||
|
setSubmitStatus(createInputStatus(STATUS_ERROR, message)); |
||||||
|
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); |
||||||
|
}, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
if (!colors) { |
||||||
|
return <div>Loading...</div>; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Space direction="vertical"> |
||||||
|
<Title>Customize Appearance</Title> |
||||||
|
<Paragraph> |
||||||
|
The following colors are used across the user interface. You can change them. |
||||||
|
</Paragraph> |
||||||
|
<div> |
||||||
|
<Collapse defaultActiveKey={['1']}> |
||||||
|
<Panel header={<Title level={3}>Section Colors</Title>} key="1"> |
||||||
|
<p> |
||||||
|
Certain specific sections of the interface changed by selecting new colors for them |
||||||
|
here. |
||||||
|
</p> |
||||||
|
<Row gutter={[16, 16]}> |
||||||
|
{componentColorVariables.map(colorVar => { |
||||||
|
const { name } = colorVar; |
||||||
|
const c = colors[name]; |
||||||
|
return ( |
||||||
|
<ColorPicker |
||||||
|
key={name} |
||||||
|
value={c.value} |
||||||
|
name={name} |
||||||
|
description={c.description} |
||||||
|
onChange={updateColor} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</Row> |
||||||
|
</Panel> |
||||||
|
<Panel header={<Title level={3}>Chat User Colors</Title>} key="2"> |
||||||
|
<Row gutter={[16, 16]}> |
||||||
|
{chatColorVariables.map(colorVar => { |
||||||
|
const { name } = colorVar; |
||||||
|
const c = colors[name]; |
||||||
|
return ( |
||||||
|
<ColorPicker |
||||||
|
key={name} |
||||||
|
value={c.value} |
||||||
|
name={name} |
||||||
|
description={c.description} |
||||||
|
onChange={updateColor} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</Row> |
||||||
|
</Panel> |
||||||
|
<Panel header={<Title level={3}>Theme Colors</Title>} key="3"> |
||||||
|
<Row gutter={[16, 16]}> |
||||||
|
{paletteVariables.map(colorVar => { |
||||||
|
const { name } = colorVar; |
||||||
|
const c = colors[name]; |
||||||
|
return ( |
||||||
|
<ColorPicker |
||||||
|
key={name} |
||||||
|
value={c.value} |
||||||
|
name={name} |
||||||
|
description={c.description} |
||||||
|
onChange={updateColor} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</Row> |
||||||
|
</Panel> |
||||||
|
</Collapse> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Space direction="horizontal"> |
||||||
|
<Button type="primary" onClick={save}> |
||||||
|
Save Colors |
||||||
|
</Button> |
||||||
|
<Button type="ghost" onClick={reset}> |
||||||
|
Reset to Defaults |
||||||
|
</Button> |
||||||
|
</Space> |
||||||
|
<FormStatusIndicator status={submitStatus} /> |
||||||
|
<div className="form-module page-content-module"> |
||||||
|
<EditCustomStyles /> |
||||||
|
</div> |
||||||
|
</Space> |
||||||
|
); |
||||||
|
} |
@ -1,4 +1,4 @@ |
|||||||
#!/bin/sh |
#!/bin/sh |
||||||
|
|
||||||
mv build/variables.css ../styles/variables.css |
mv build/variables.css ../styles/variables.css |
||||||
mv build/variables.less ../styles/theme.less |
mv build/variables.less ../styles/theme.less |
||||||
|
Loading…
Reference in new issue