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 @@
@@ -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 @@
@@ -1,4 +1,5 @@
|
||||
.chatAction { |
||||
padding: 5px; |
||||
text-align: center; |
||||
color: var(--theme-color-components-chat-text); |
||||
} |
||||
|
@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
|
||||
.root { |
||||
padding: 10px 0px; |
||||
text-align: center; |
||||
font-size: .8rem; |
||||
font-size: 0.8rem; |
||||
font-style: italic; |
||||
color: var(--theme-color-components-chat-text); |
||||
} |
||||
|
@ -0,0 +1,27 @@
@@ -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 @@
@@ -0,0 +1,4 @@
|
||||
.colorPicker { |
||||
width: 100%; |
||||
height: 50px; |
||||
} |
@ -0,0 +1,256 @@
@@ -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> |
||||
); |
||||
} |
Loading…
Reference in new issue