Browse Source

Support color customization from the admin (#2338)

* 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
Gabe Kangas 3 years ago committed by GitHub
parent
commit
813f8692f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 35
      controllers/admin/appearance.go
  2. 48
      controllers/admin/serverConfig.go
  3. 2
      controllers/config.go
  4. 12
      core/data/config.go
  5. 7
      core/data/configEntry.go
  6. 34
      core/data/data_test.go
  7. 15
      core/data/types.go
  8. 3
      router/router.go
  9. 10
      test/automated/api/configmanagement.test.js
  10. 3
      web/.storybook/stories-category-doc-pages/Colors.stories.mdx
  11. 4
      web/components/MainLayout.tsx
  12. 1
      web/components/chat/ChatActionMessage/ChatActionMessage.module.scss
  13. 2
      web/components/chat/ChatContainer/ChatContainer.module.scss
  14. 3
      web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss
  15. 4
      web/components/chat/ChatUserMessage/ChatUserMessage.module.scss
  16. 2
      web/components/layouts/Main.tsx
  17. 27
      web/components/theme/Theme.tsx
  18. 6
      web/components/ui/CustomPageContent/CustomPageContent.module.scss
  19. 8
      web/components/ui/Footer/Footer.module.scss
  20. 6
      web/components/ui/Sidebar/Sidebar.module.scss
  21. 4
      web/components/ui/followers/FollowerCollection/FollowerCollection.module.scss
  22. 2
      web/interfaces/client-config.model.ts
  23. 4
      web/pages/admin/config-public-details.tsx
  24. 4
      web/pages/admin/config/appearance/appearance.module.scss
  25. 256
      web/pages/admin/config/appearance/index.tsx
  26. 1
      web/stories/ReadwriteChat.stories.tsx
  27. 2
      web/style-definitions/build.sh
  28. 24
      web/style-definitions/tokens/color/default-theme.yaml
  29. 3
      web/style-definitions/tokens/color/owncast-colors.yaml
  30. 12
      web/styles/theme.less
  31. 14
      web/styles/variables.css
  32. 5
      web/types/config-section.ts
  33. 1
      web/utils/server-status-context.tsx

35
controllers/admin/appearance.go

@ -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")
}

48
controllers/admin/serverConfig.go

@ -35,17 +35,18 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
} }
response := serverConfigAdminResponse{ response := serverConfigAdminResponse{
InstanceDetails: webConfigResponse{ InstanceDetails: webConfigResponse{
Name: data.GetServerName(), Name: data.GetServerName(),
Summary: data.GetServerSummary(), Summary: data.GetServerSummary(),
Tags: data.GetServerMetadataTags(), Tags: data.GetServerMetadataTags(),
ExtraPageContent: data.GetExtraPageBodyContent(), ExtraPageContent: data.GetExtraPageBodyContent(),
StreamTitle: data.GetStreamTitle(), StreamTitle: data.GetStreamTitle(),
WelcomeMessage: data.GetServerWelcomeMessage(), WelcomeMessage: data.GetServerWelcomeMessage(),
OfflineMessage: data.GetCustomOfflineMessage(), OfflineMessage: data.GetCustomOfflineMessage(),
Logo: data.GetLogoPath(), Logo: data.GetLogoPath(),
SocialHandles: data.GetSocialHandles(), SocialHandles: data.GetSocialHandles(),
NSFW: data.GetNSFW(), NSFW: data.GetNSFW(),
CustomStyles: data.GetCustomStyles(), CustomStyles: data.GetCustomStyles(),
AppearanceVariables: data.GetCustomColorVariableValues(),
}, },
FFmpegPath: ffmpeg, FFmpegPath: ffmpeg,
StreamKey: data.GetStreamKey(), StreamKey: data.GetStreamKey(),
@ -124,18 +125,19 @@ type videoSettings struct {
} }
type webConfigResponse struct { type webConfigResponse struct {
Name string `json:"name"` Name string `json:"name"`
Summary string `json:"summary"` Summary string `json:"summary"`
WelcomeMessage string `json:"welcomeMessage"` WelcomeMessage string `json:"welcomeMessage"`
OfflineMessage string `json:"offlineMessage"` OfflineMessage string `json:"offlineMessage"`
Logo string `json:"logo"` Logo string `json:"logo"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Version string `json:"version"` Version string `json:"version"`
NSFW bool `json:"nsfw"` NSFW bool `json:"nsfw"`
ExtraPageContent string `json:"extraPageContent"` ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle"` // What's going on with the current stream StreamTitle string `json:"streamTitle"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"` SocialHandles []models.SocialHandle `json:"socialHandles"`
CustomStyles string `json:"customStyles"` CustomStyles string `json:"customStyles"`
AppearanceVariables map[string]string `json:"appearanceVariables"`
} }
type yp struct { type yp struct {

2
controllers/config.go

@ -30,6 +30,7 @@ type webConfigResponse struct {
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"` ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"` CustomStyles string `json:"customStyles"`
AppearanceVariables map[string]string `json:"appearanceVariables"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
Federation federationConfigResponse `json:"federation"` Federation federationConfigResponse `json:"federation"`
Notifications notificationsConfigResponse `json:"notifications"` Notifications notificationsConfigResponse `json:"notifications"`
@ -133,6 +134,7 @@ func getConfigResponse() webConfigResponse {
Federation: federationResponse, Federation: federationResponse,
Notifications: notificationsResponse, Notifications: notificationsResponse,
Authentication: authenticationResponse, Authentication: authenticationResponse,
AppearanceVariables: data.GetCustomColorVariableValues(),
} }
} }

12
core/data/config.go

@ -67,6 +67,7 @@ const (
hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications" hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications"
hideViewerCountKey = "hide_viewer_count" hideViewerCountKey = "hide_viewer_count"
customOfflineMessageKey = "custom_offline_message" customOfflineMessageKey = "custom_offline_message"
customColorVariableValuesKey = "custom_color_variable_values"
) )
// GetExtraPageBodyContent will return the user-supplied body content. // GetExtraPageBodyContent will return the user-supplied body content.
@ -932,3 +933,14 @@ func GetCustomOfflineMessage() string {
func SetCustomOfflineMessage(message string) error { func SetCustomOfflineMessage(message string) error {
return _datastore.SetString(customOfflineMessageKey, message) return _datastore.SetString(customOfflineMessageKey, message)
} }
// SetCustomColorVariableValues sets CSS variable names and values.
func SetCustomColorVariableValues(variables map[string]string) error {
return _datastore.SetStringMap(customColorVariableValuesKey, variables)
}
// GetCustomColorVariableValues gets CSS variable names and values.
func GetCustomColorVariableValues() map[string]string {
values, _ := _datastore.GetStringMap(customColorVariableValuesKey)
return values
}

7
core/data/configEntry.go

@ -19,6 +19,13 @@ func (c *ConfigEntry) getStringSlice() ([]string, error) {
return result, err return result, err
} }
func (c *ConfigEntry) getStringMap() (map[string]string, error) {
decoder := c.getDecoder()
var result map[string]string
err := decoder.Decode(&result)
return result, err
}
func (c *ConfigEntry) getString() (string, error) { func (c *ConfigEntry) getString() (string, error) {
decoder := c.getDecoder() decoder := c.getDecoder()
var result string var result string

34
core/data/data_test.go

@ -110,6 +110,40 @@ func TestCustomType(t *testing.T) {
} }
} }
func TestStringMap(t *testing.T) {
const testKey = "test string map key"
testMap := map[string]string{
"test string 1": "test string 2",
"test string 3": "test string 4",
}
// Save config entry to the database
if err := _datastore.Save(ConfigEntry{testKey, &testMap}); err != nil {
t.Error(err)
}
// Get the config entry from the database
entryResult, err := _datastore.Get(testKey)
if err != nil {
t.Error(err)
}
testResult, err := entryResult.getStringMap()
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", testResult)
if testResult["test string 1"] != testMap["test string 1"] {
t.Error("expected", testMap["test string 1"], "but test returned", testResult["test string 1"])
}
if testResult["test string 3"] != testMap["test string 3"] {
t.Error("expected", testMap["test string 3"], "but test returned", testResult["test string 3"])
}
}
// Custom type for testing // Custom type for testing
type TestStruct struct { type TestStruct struct {
Test string Test string

15
core/data/types.go

@ -59,3 +59,18 @@ func (ds *Datastore) SetBool(key string, value bool) error {
configEntry := ConfigEntry{key, value} configEntry := ConfigEntry{key, value}
return ds.Save(configEntry) return ds.Save(configEntry)
} }
// GetStringMap will return the string map value for a key.
func (ds *Datastore) GetStringMap(key string) (map[string]string, error) {
configEntry, err := ds.Get(key)
if err != nil {
return map[string]string{}, err
}
return configEntry.getStringMap()
}
// SetStringMap will set the string map value for a key.
func (ds *Datastore) SetStringMap(key string, value map[string]string) error {
configEntry := ConfigEntry{key, value}
return ds.Save(configEntry)
}

3
router/router.go

@ -197,6 +197,9 @@ func Start() error {
// Set video codec // Set video codec
http.HandleFunc("/api/admin/config/video/codec", middleware.RequireAdminAuth(admin.SetVideoCodec)) http.HandleFunc("/api/admin/config/video/codec", middleware.RequireAdminAuth(admin.SetVideoCodec))
// Set style/color/css values
http.HandleFunc("/api/admin/config/appearance", middleware.RequireAdminAuth(admin.SetCustomColorVariableValues))
// Return all webhooks // Return all webhooks
http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks)) http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks))

10
test/automated/api/configmanagement.test.js

@ -8,6 +8,11 @@ const offlineMessage = randomString();
const pageContent = `<p>${randomString()}</p>`; const pageContent = `<p>${randomString()}</p>`;
const tags = [randomString(), randomString(), randomString()]; const tags = [randomString(), randomString(), randomString()];
const latencyLevel = Math.floor(Math.random() * 4); const latencyLevel = Math.floor(Math.random() * 4);
const appearanceValues = {
variable1: randomString(),
variable2: randomString(),
variable3: randomString(),
};
const streamOutputVariants = { const streamOutputVariants = {
videoBitrate: randomNumber() * 100, videoBitrate: randomNumber() * 100,
@ -103,6 +108,11 @@ test('set offline message', async (done) => {
done(); done();
}); });
test('set custom style values', async (done) => {
const res = await sendConfigChangeRequest('appearance', appearanceValues);
done();
});
test('verify updated config values', async (done) => { test('verify updated config values', async (done) => {
const res = await request.get('/api/config'); const res = await request.get('/api/config');
expect(res.body.name).toBe(serverName); expect(res.body.name).toBe(serverName);

3
web/.storybook/stories-category-doc-pages/Colors.stories.mdx

@ -28,6 +28,7 @@ These color names are assigned to specific component variables. They can be over
'theme-color-palette-11', 'theme-color-palette-11',
'theme-color-palette-12', 'theme-color-palette-12',
'theme-color-palette-13', 'theme-color-palette-13',
'theme-color-palette-15',
'theme-color-palette-error', 'theme-color-palette-error',
'theme-color-palette-warning', 'theme-color-palette-warning',
'theme-color-background-main', 'theme-color-background-main',
@ -60,6 +61,7 @@ These color names are assigned to specific component variables. They can be over
'theme-color-components-modal-header-background', 'theme-color-components-modal-header-background',
'theme-color-components-modal-header-text', 'theme-color-components-modal-header-text',
'theme-color-components-modal-content-background', 'theme-color-components-modal-content-background',
'theme-color-components-content-background',
'theme-color-components-modal-content-text', 'theme-color-components-modal-content-text',
'theme-color-components-menu-background', 'theme-color-components-menu-background',
'theme-color-components-menu-item-text', 'theme-color-components-menu-item-text',
@ -93,6 +95,7 @@ They should not be overwritten, instead the theme variables should be overwritte
'color-owncast-palette-11', 'color-owncast-palette-11',
'color-owncast-palette-12', 'color-owncast-palette-12',
'color-owncast-palette-13', 'color-owncast-palette-13',
'color-owncast-palette-15',
]} ]}
/> />

4
web/components/MainLayout.tsx

@ -201,7 +201,9 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
<Menu.Item key="config-notify"> <Menu.Item key="config-notify">
<Link href="/admin/config-notify">Notifications</Link> <Link href="/admin/config-notify">Notifications</Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="config-appearance">
<Link href="/admin/config/appearance">Appearance</Link>
</Menu.Item>
<Menu.Item key="config-storage"> <Menu.Item key="config-storage">
<Link href="/admin/config-storage">S3 Storage</Link> <Link href="/admin/config-storage">S3 Storage</Link>
</Menu.Item> </Menu.Item>

1
web/components/chat/ChatActionMessage/ChatActionMessage.module.scss

@ -1,4 +1,5 @@
.chatAction { .chatAction {
padding: 5px; padding: 5px;
text-align: center; text-align: center;
color: var(--theme-color-components-chat-text);
} }

2
web/components/chat/ChatContainer/ChatContainer.module.scss

@ -42,7 +42,7 @@
.chatContainer { .chatContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--theme-color-background-chat); background-color: var(--theme-color-components-chat-background);
height: 100%; height: 100%;
} }
.virtuoso { .virtuoso {

3
web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss

@ -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);
} }

4
web/components/chat/ChatUserMessage/ChatUserMessage.module.scss

@ -9,6 +9,8 @@ $p-size: 8px;
position: relative; position: relative;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0px $p-size $p-size $p-size; padding: 0px $p-size $p-size $p-size;
color: var(--theme-color-components-chat-text);
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;
@ -63,7 +65,7 @@ $p-size: 8px;
.messagePadding { .messagePadding {
padding: 0px 0px; padding: 0px 0px;
padding-top: .4rem; padding-top: 0.4rem;
} }
.messagePaddingCollapsed { .messagePaddingCollapsed {

2
web/components/layouts/Main.tsx

@ -21,6 +21,7 @@ import { TitleNotifier } from '../TitleNotifier/TitleNotifier';
import { ServerRenderedHydration } from '../ServerRendered/ServerRenderedHydration'; import { ServerRenderedHydration } from '../ServerRendered/ServerRenderedHydration';
import Footer from '../ui/Footer/Footer'; import Footer from '../ui/Footer/Footer';
import { Theme } from '../theme/Theme';
export const Main: FC = () => { export const Main: FC = () => {
const [isMobile] = useRecoilState<boolean | undefined>(isMobileAtom); const [isMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
@ -111,6 +112,7 @@ export const Main: FC = () => {
<ClientConfigStore /> <ClientConfigStore />
<TitleNotifier /> <TitleNotifier />
<Theme />
<Layout ref={layoutRef} style={{ minHeight: '100vh' }}> <Layout ref={layoutRef} style={{ minHeight: '100vh' }}>
<Header name={title || name} chatAvailable={isChatAvailable} chatDisabled={chatDisabled} /> <Header name={title || name} chatAvailable={isChatAvailable} chatDisabled={chatDisabled} />
<Content /> <Content />

27
web/components/theme/Theme.tsx

@ -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}
`,
}}
/>
);
};

6
web/components/ui/CustomPageContent/CustomPageContent.module.scss

@ -7,16 +7,16 @@
.customPageContent { .customPageContent {
font-size: 1rem; font-size: 1rem;
line-height: 1.6em; line-height: 1.6em;
color: var(--theme-color-palette-0); color: var(--theme-color-components-text-on-light);
padding: calc(2 * var(--content-padding)); padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners); border-radius: var(--theme-rounded-corners);
width: 100%; width: 100%;
background-color: var(--theme-color-background-light); background-color: var(--theme-color-components-content-background);
hr { hr {
margin: 1.35em 0; margin: 1.35em 0;
border: 0; border: 0;
border-top: solid 1px var(--theme-text-secondary); border-top: solid 1px var(--theme-color-components-content-background);
} }
div.summary { div.summary {

8
web/components/ui/Footer/Footer.module.scss

@ -9,16 +9,12 @@
width: 100%; width: 100%;
color: var(--theme-color-components-text-on-dark); color: var(--theme-color-components-text-on-dark);
font-family: var(--theme-text-body-font-family); font-family: var(--theme-text-body-font-family);
padding: 0 .6rem; padding: 0 0.6rem;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
border-top: 1px solid rgba(214, 211, 211, 0.5); border-top: 1px solid rgba(214, 211, 211, 0.5);
a {
color: var(--theme-text-secondary);
}
.links { .links {
column-gap: 2rem; column-gap: 2rem;
width: auto; width: auto;

6
web/components/ui/Sidebar/Sidebar.module.scss

@ -1,11 +1,11 @@
@import '../../../styles/mixins.scss'; @import '../../../styles/mixins.scss';
.root { .root {
background-color: var(--theme-color-background-chat); background-color: var(--theme-color-components-chat-background);
display: none; display: none;
@include screen(desktop) { @include screen(desktop) {
position: sticky; position: sticky;
display: block; display: block;
} }
} }

4
web/components/ui/followers/FollowerCollection/FollowerCollection.module.scss

@ -1,6 +1,6 @@
.followers { .followers {
width: 100%; width: 100%;
background-color: var(--theme-color-background-light); background-color: var(--theme-color-components-content-background);
padding: 5px; padding: 5px;
} }
@ -8,5 +8,5 @@
padding: calc(2 * var(--content-padding)); padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners); border-radius: var(--theme-rounded-corners);
width: 100%; width: 100%;
background-color: var(--theme-color-background-light); background-color: var(--theme-color-components-content-background);
} }

2
web/interfaces/client-config.model.ts

@ -12,6 +12,7 @@ export interface ClientConfig {
chatDisabled: boolean; chatDisabled: boolean;
externalActions: any[]; externalActions: any[];
customStyles: string; customStyles: string;
appearanceVariables: Map<string, string>;
maxSocketPayloadSize: number; maxSocketPayloadSize: number;
federation: Federation; federation: Federation;
notifications: Notifications; notifications: Notifications;
@ -58,6 +59,7 @@ export function makeEmptyClientConfig(): ClientConfig {
chatDisabled: false, chatDisabled: false,
externalActions: [], externalActions: [],
customStyles: '', customStyles: '',
appearanceVariables: new Map(),
maxSocketPayloadSize: 0, maxSocketPayloadSize: 0,
federation: { federation: {
enabled: false, enabled: false,

4
web/pages/admin/config-public-details.tsx

@ -5,7 +5,6 @@ import { EditInstanceDetails } from '../../components/config/EditInstanceDetails
import { EditInstanceTags } from '../../components/config/EditInstanceTags'; import { EditInstanceTags } from '../../components/config/EditInstanceTags';
import { EditSocialLinks } from '../../components/config/EditSocialLinks'; import { EditSocialLinks } from '../../components/config/EditSocialLinks';
import { EditPageContent } from '../../components/config/EditPageContent'; import { EditPageContent } from '../../components/config/EditPageContent';
import { EditCustomStyles } from '../../components/config/EditCustomStyles';
const { Title } = Typography; const { Title } = Typography;
@ -42,9 +41,6 @@ export default function PublicFacingDetails() {
<div className="form-module page-content-module"> <div className="form-module page-content-module">
<EditPageContent /> <EditPageContent />
</div> </div>
<div className="form-module page-content-module">
<EditCustomStyles />
</div>
</div> </div>
); );
} }

4
web/pages/admin/config/appearance/appearance.module.scss

@ -0,0 +1,4 @@
.colorPicker {
width: 100%;
height: 50px;
}

256
web/pages/admin/config/appearance/index.tsx

@ -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
web/stories/ReadwriteChat.stories.tsx

@ -43,6 +43,7 @@ const Page = () => {
federation: undefined, federation: undefined,
notifications: undefined, notifications: undefined,
authentication: undefined, authentication: undefined,
appearanceVariables: undefined,
}; };
useEffect(() => { useEffect(() => {

2
web/style-definitions/build.sh

@ -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

24
web/style-definitions/tokens/color/default-theme.yaml

@ -87,6 +87,9 @@ theme:
14: 14:
value: 'var(--color-owncast-palette-14)' value: 'var(--color-owncast-palette-14)'
comment: '{color.owncast.palette.14.comment}' comment: '{color.owncast.palette.14.comment}'
15:
value: 'var(--color-owncast-palette-15)'
comment: '{color.owncast.palette.15.comment}'
error: error:
value: 'var(--color-owncast-palette-error)' value: 'var(--color-owncast-palette-error)'
comment: '{color.owncast.palette.error.comment}' comment: '{color.owncast.palette.error.comment}'
@ -99,14 +102,12 @@ theme:
value: 'var(--theme-color-palette-3)' value: 'var(--theme-color-palette-3)'
comment: '{theme.color.palette.3.comment}' comment: '{theme.color.palette.3.comment}'
light: light:
value: 'var(--theme-color-palette-14)' value: 'var(--theme-color-palette-3)'
comment: '{theme.color.palette.14.comment}' comment: '{theme.color.palette.3.comment}'
header: header:
value: 'var(--theme-color-palette-0)' value: 'var(--theme-color-palette-0)'
comment: '{theme.color.palette.0.comment}' comment: '{theme.color.palette.0.comment}'
chat:
value: 'var(--theme-color-palette-14)'
comment: '{theme.color.palette.14.comment}'
action: action:
value: 'var(--theme-color-palette-6)' value: 'var(--theme-color-palette-6)'
comment: '{theme.color.palette.6.comment}' comment: '{theme.color.palette.6.comment}'
@ -173,11 +174,16 @@ theme:
chat: chat:
background: background:
value: 'var(--theme-color-palette-1)' value: 'var(--theme-color-palette-4)'
comment: '{theme.color.palette.1.comment}' comment: '{theme.color.palette.4.comment}'
text: text:
value: 'var(--theme-color-palette-3)' value: 'var(--theme-color-palette-2)'
comment: '{theme.color.palette.3.comment}' comment: '{theme.color.palette.2.comment}'
content:
background:
value: 'var(--theme-color-palette-15)'
comment: '{theme.color.palette.15.comment}'
modal: modal:
header: header:

3
web/style-definitions/tokens/color/owncast-colors.yaml

@ -76,6 +76,9 @@ color:
14: 14:
value: '#f0f3f8' value: '#f0f3f8'
comment: 'Light background' comment: 'Light background'
15:
value: '#eff1f4'
comment: 'Lighter background'
error: error:
value: '#ff4b39' value: '#ff4b39'
comment: 'Error' comment: 'Error'

12
web/styles/theme.less

@ -1,6 +1,6 @@
// Do not edit directly // Do not edit directly
// Generated on Mon, 10 Oct 2022 23:54:14 GMT // Generated on Sun, 13 Nov 2022 04:09:55 GMT
// //
// How to edit these values: // How to edit these values:
// Edit the corresponding token file under the style-definitions directory // Edit the corresponding token file under the style-definitions directory
@ -58,12 +58,12 @@
@theme-color-palette-12: var(--color-owncast-palette-12); // Fun color 2 @theme-color-palette-12: var(--color-owncast-palette-12); // Fun color 2
@theme-color-palette-13: var(--color-owncast-palette-13); // Fun color 3 @theme-color-palette-13: var(--color-owncast-palette-13); // Fun color 3
@theme-color-palette-14: var(--color-owncast-palette-14); // Light background @theme-color-palette-14: var(--color-owncast-palette-14); // Light background
@theme-color-palette-15: var(--color-owncast-palette-15); // Lighter background
@theme-color-palette-error: var(--color-owncast-palette-error); // Error @theme-color-palette-error: var(--color-owncast-palette-error); // Error
@theme-color-palette-warning: var(--color-owncast-palette-warning); // Warning @theme-color-palette-warning: var(--color-owncast-palette-warning); // Warning
@theme-color-background-main: var(--theme-color-palette-3); // Light primary @theme-color-background-main: var(--theme-color-palette-3); // Light primary
@theme-color-background-light: var(--theme-color-palette-14); // Light background @theme-color-background-light: var(--theme-color-palette-3); // Light primary
@theme-color-background-header: var(--theme-color-palette-0); // Dark primary @theme-color-background-header: var(--theme-color-palette-0); // Dark primary
@theme-color-background-chat: var(--theme-color-palette-14); // Light background
@theme-color-action: var(--theme-color-palette-6); // Text link/secondary light text @theme-color-action: var(--theme-color-palette-6); // Text link/secondary light text
@theme-color-action-hover: var(--theme-color-palette-7); // Text link hover @theme-color-action-hover: var(--theme-color-palette-7); // Text link hover
@theme-color-action-disabled: var(--theme-color-palette-8); // Disabled background @theme-color-action-disabled: var(--theme-color-palette-8); // Disabled background
@ -83,8 +83,9 @@
@theme-color-components-secondary-button-text-disabled: var(--theme-color-action-disabled); // Disabled background @theme-color-components-secondary-button-text-disabled: var(--theme-color-action-disabled); // Disabled background
@theme-color-components-secondary-button-border: var(--theme-color-action); // Text link/secondary light text @theme-color-components-secondary-button-border: var(--theme-color-action); // Text link/secondary light text
@theme-color-components-secondary-button-border-disabled: var(--theme-color-action-disabled); // Disabled background @theme-color-components-secondary-button-border-disabled: var(--theme-color-action-disabled); // Disabled background
@theme-color-components-chat-background: var(--theme-color-palette-1); // Dark secondary @theme-color-components-chat-background: var(--theme-color-palette-4); // Light secondary
@theme-color-components-chat-text: var(--theme-color-palette-3); // Light primary @theme-color-components-chat-text: var(--theme-color-palette-2); // Dark alternate
@theme-color-components-content-background: var(--theme-color-palette-15); // Lighter background
@theme-color-components-modal-header-background: var(--theme-color-palette-1); // Dark secondary @theme-color-components-modal-header-background: var(--theme-color-palette-1); // Dark secondary
@theme-color-components-modal-header-text: var(--theme-color-palette-3); // Light primary @theme-color-components-modal-header-text: var(--theme-color-palette-3); // Light primary
@theme-color-components-modal-content-background: var(--theme-color-palette-3); // Light primary @theme-color-components-modal-content-background: var(--theme-color-palette-3); // Light primary
@ -126,6 +127,7 @@
@color-owncast-palette-12: #da9eff; // Fun color 2 @color-owncast-palette-12: #da9eff; // Fun color 2
@color-owncast-palette-13: #42bea6; // Fun color 3 @color-owncast-palette-13: #42bea6; // Fun color 3
@color-owncast-palette-14: #f0f3f8; // Light background @color-owncast-palette-14: #f0f3f8; // Light background
@color-owncast-palette-15: #eff1f4; // Lighter background
@color-owncast-palette-error: #ff4b39; // Error @color-owncast-palette-error: #ff4b39; // Error
@color-owncast-palette-warning: #ffc655; // Warning @color-owncast-palette-warning: #ffc655; // Warning
@font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; @font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';

14
web/styles/variables.css

@ -1,6 +1,6 @@
/** /**
* Do not edit directly * Do not edit directly
* Generated on Mon, 10 Oct 2022 23:54:14 GMT * Generated on Sun, 13 Nov 2022 04:09:55 GMT
* *
* How to edit these values: * How to edit these values:
* Edit the corresponding token file under the style-definitions directory * Edit the corresponding token file under the style-definitions directory
@ -64,12 +64,12 @@
--theme-color-palette-12: var(--color-owncast-palette-12); /* Fun color 2 */ --theme-color-palette-12: var(--color-owncast-palette-12); /* Fun color 2 */
--theme-color-palette-13: var(--color-owncast-palette-13); /* Fun color 3 */ --theme-color-palette-13: var(--color-owncast-palette-13); /* Fun color 3 */
--theme-color-palette-14: var(--color-owncast-palette-14); /* Light background */ --theme-color-palette-14: var(--color-owncast-palette-14); /* Light background */
--theme-color-palette-15: var(--color-owncast-palette-15); /* Lighter background */
--theme-color-palette-error: var(--color-owncast-palette-error); /* Error */ --theme-color-palette-error: var(--color-owncast-palette-error); /* Error */
--theme-color-palette-warning: var(--color-owncast-palette-warning); /* Warning */ --theme-color-palette-warning: var(--color-owncast-palette-warning); /* Warning */
--theme-color-background-main: var(--theme-color-palette-3); /* Light primary */ --theme-color-background-main: var(--theme-color-palette-3); /* Light primary */
--theme-color-background-light: var(--theme-color-palette-14); /* Light background */ --theme-color-background-light: var(--theme-color-palette-3); /* Light primary */
--theme-color-background-header: var(--theme-color-palette-0); /* Dark primary */ --theme-color-background-header: var(--theme-color-palette-0); /* Dark primary */
--theme-color-background-chat: var(--theme-color-palette-14); /* Light background */
--theme-color-action: var(--theme-color-palette-6); /* Text link/secondary light text */ --theme-color-action: var(--theme-color-palette-6); /* Text link/secondary light text */
--theme-color-action-hover: var(--theme-color-palette-7); /* Text link hover */ --theme-color-action-hover: var(--theme-color-palette-7); /* Text link hover */
--theme-color-action-disabled: var(--theme-color-palette-8); /* Disabled background */ --theme-color-action-disabled: var(--theme-color-palette-8); /* Disabled background */
@ -109,8 +109,11 @@
--theme-color-components-secondary-button-border-disabled: var( --theme-color-components-secondary-button-border-disabled: var(
--theme-color-action-disabled --theme-color-action-disabled
); /* Disabled background */ ); /* Disabled background */
--theme-color-components-chat-background: var(--theme-color-palette-1); /* Dark secondary */ --theme-color-components-chat-background: var(--theme-color-palette-4); /* Light secondary */
--theme-color-components-chat-text: var(--theme-color-palette-3); /* Light primary */ --theme-color-components-chat-text: var(--theme-color-palette-2); /* Dark alternate */
--theme-color-components-content-background: var(
--theme-color-palette-15
); /* Lighter background */
--theme-color-components-modal-header-background: var( --theme-color-components-modal-header-background: var(
--theme-color-palette-1 --theme-color-palette-1
); /* Dark secondary */ ); /* Dark secondary */
@ -162,6 +165,7 @@
--color-owncast-palette-12: #da9eff; /* Fun color 2 */ --color-owncast-palette-12: #da9eff; /* Fun color 2 */
--color-owncast-palette-13: #42bea6; /* Fun color 3 */ --color-owncast-palette-13: #42bea6; /* Fun color 3 */
--color-owncast-palette-14: #f0f3f8; /* Light background */ --color-owncast-palette-14: #f0f3f8; /* Light background */
--color-owncast-palette-15: #eff1f4; /* Lighter background */
--color-owncast-palette-error: #ff4b39; /* Error */ --color-owncast-palette-error: #ff4b39; /* Error */
--color-owncast-palette-warning: #ffc655; /* Warning */ --color-owncast-palette-warning: #ffc655; /* Warning */
--font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, --font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,

5
web/types/config-section.ts

@ -40,6 +40,7 @@ export interface ConfigInstanceDetailsFields {
tags: string[]; tags: string[];
title: string; title: string;
welcomeMessage: string; welcomeMessage: string;
appearanceVariables: AppearanceVariables;
} }
export type CpuUsageLevel = 1 | 2 | 3 | 4 | 5; export type CpuUsageLevel = 1 | 2 | 3 | 4 | 5;
@ -83,6 +84,10 @@ export interface S3Field {
forcePathStyle: boolean; forcePathStyle: boolean;
} }
type AppearanceVariables = {
[key: string]: string;
};
export interface ExternalAction { export interface ExternalAction {
title: string; title: string;
description: string; description: string;

1
web/utils/server-status-context.tsx

@ -22,6 +22,7 @@ export const initialServerConfigState: ConfigDetails = {
title: '', title: '',
welcomeMessage: '', welcomeMessage: '',
offlineMessage: '', offlineMessage: '',
appearanceVariables: {},
}, },
ffmpegPath: '', ffmpegPath: '',
rtmpServerPort: '', rtmpServerPort: '',

Loading…
Cancel
Save