From 813f8692f0c9dab6bd41c287f3877cbf990f5691 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 12 Nov 2022 20:26:55 -0800 Subject: [PATCH] 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 --- controllers/admin/appearance.go | 35 +++ controllers/admin/serverConfig.go | 48 ++-- controllers/config.go | 2 + core/data/config.go | 12 + core/data/configEntry.go | 7 + core/data/data_test.go | 34 +++ core/data/types.go | 15 + router/router.go | 3 + test/automated/api/configmanagement.test.js | 10 + .../Colors.stories.mdx | 3 + web/components/MainLayout.tsx | 4 +- .../ChatActionMessage.module.scss | 1 + .../ChatContainer/ChatContainer.module.scss | 2 +- .../ChatJoinMessage.module.scss | 3 +- .../ChatUserMessage.module.scss | 4 +- web/components/layouts/Main.tsx | 2 + web/components/theme/Theme.tsx | 27 ++ .../CustomPageContent.module.scss | 6 +- web/components/ui/Footer/Footer.module.scss | 8 +- web/components/ui/Sidebar/Sidebar.module.scss | 6 +- .../FollowerCollection.module.scss | 4 +- web/interfaces/client-config.model.ts | 2 + web/pages/admin/config-public-details.tsx | 4 - .../config/appearance/appearance.module.scss | 4 + web/pages/admin/config/appearance/index.tsx | 256 ++++++++++++++++++ web/stories/ReadwriteChat.stories.tsx | 1 + web/style-definitions/build.sh | 2 +- .../tokens/color/default-theme.yaml | 24 +- .../tokens/color/owncast-colors.yaml | 3 + web/styles/theme.less | 12 +- web/styles/variables.css | 14 +- web/types/config-section.ts | 5 + web/utils/server-status-context.tsx | 1 + 33 files changed, 499 insertions(+), 65 deletions(-) create mode 100644 controllers/admin/appearance.go create mode 100644 web/components/theme/Theme.tsx create mode 100644 web/pages/admin/config/appearance/appearance.module.scss create mode 100644 web/pages/admin/config/appearance/index.tsx diff --git a/controllers/admin/appearance.go b/controllers/admin/appearance.go new file mode 100644 index 000000000..4904ec325 --- /dev/null +++ b/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") +} diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 7acc25fee..d0129fbfd 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -35,17 +35,18 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { } response := serverConfigAdminResponse{ InstanceDetails: webConfigResponse{ - Name: data.GetServerName(), - Summary: data.GetServerSummary(), - Tags: data.GetServerMetadataTags(), - ExtraPageContent: data.GetExtraPageBodyContent(), - StreamTitle: data.GetStreamTitle(), - WelcomeMessage: data.GetServerWelcomeMessage(), - OfflineMessage: data.GetCustomOfflineMessage(), - Logo: data.GetLogoPath(), - SocialHandles: data.GetSocialHandles(), - NSFW: data.GetNSFW(), - CustomStyles: data.GetCustomStyles(), + Name: data.GetServerName(), + Summary: data.GetServerSummary(), + Tags: data.GetServerMetadataTags(), + ExtraPageContent: data.GetExtraPageBodyContent(), + StreamTitle: data.GetStreamTitle(), + WelcomeMessage: data.GetServerWelcomeMessage(), + OfflineMessage: data.GetCustomOfflineMessage(), + Logo: data.GetLogoPath(), + SocialHandles: data.GetSocialHandles(), + NSFW: data.GetNSFW(), + CustomStyles: data.GetCustomStyles(), + AppearanceVariables: data.GetCustomColorVariableValues(), }, FFmpegPath: ffmpeg, StreamKey: data.GetStreamKey(), @@ -124,18 +125,19 @@ type videoSettings struct { } type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - WelcomeMessage string `json:"welcomeMessage"` - OfflineMessage string `json:"offlineMessage"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - CustomStyles string `json:"customStyles"` + Name string `json:"name"` + Summary string `json:"summary"` + WelcomeMessage string `json:"welcomeMessage"` + OfflineMessage string `json:"offlineMessage"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + CustomStyles string `json:"customStyles"` + AppearanceVariables map[string]string `json:"appearanceVariables"` } type yp struct { diff --git a/controllers/config.go b/controllers/config.go index b2a9997f1..212582e02 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -30,6 +30,7 @@ type webConfigResponse struct { ChatDisabled bool `json:"chatDisabled"` ExternalActions []models.ExternalAction `json:"externalActions"` CustomStyles string `json:"customStyles"` + AppearanceVariables map[string]string `json:"appearanceVariables"` MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` Federation federationConfigResponse `json:"federation"` Notifications notificationsConfigResponse `json:"notifications"` @@ -133,6 +134,7 @@ func getConfigResponse() webConfigResponse { Federation: federationResponse, Notifications: notificationsResponse, Authentication: authenticationResponse, + AppearanceVariables: data.GetCustomColorVariableValues(), } } diff --git a/core/data/config.go b/core/data/config.go index 09f1676b6..f3f4866e7 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -67,6 +67,7 @@ const ( hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications" hideViewerCountKey = "hide_viewer_count" customOfflineMessageKey = "custom_offline_message" + customColorVariableValuesKey = "custom_color_variable_values" ) // GetExtraPageBodyContent will return the user-supplied body content. @@ -932,3 +933,14 @@ func GetCustomOfflineMessage() string { func SetCustomOfflineMessage(message string) error { 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 +} diff --git a/core/data/configEntry.go b/core/data/configEntry.go index c33f0310a..938924368 100644 --- a/core/data/configEntry.go +++ b/core/data/configEntry.go @@ -19,6 +19,13 @@ func (c *ConfigEntry) getStringSlice() ([]string, error) { 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) { decoder := c.getDecoder() var result string diff --git a/core/data/data_test.go b/core/data/data_test.go index 2a6dd14db..e1a0a86b7 100644 --- a/core/data/data_test.go +++ b/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 type TestStruct struct { Test string diff --git a/core/data/types.go b/core/data/types.go index 0da6c26c6..5405e6c12 100644 --- a/core/data/types.go +++ b/core/data/types.go @@ -59,3 +59,18 @@ func (ds *Datastore) SetBool(key string, value bool) error { configEntry := ConfigEntry{key, value} 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) +} diff --git a/router/router.go b/router/router.go index 7da2af2b7..e1fadfc84 100644 --- a/router/router.go +++ b/router/router.go @@ -197,6 +197,9 @@ func Start() error { // Set video codec 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 http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks)) diff --git a/test/automated/api/configmanagement.test.js b/test/automated/api/configmanagement.test.js index 7e605b13e..1ba17f07c 100644 --- a/test/automated/api/configmanagement.test.js +++ b/test/automated/api/configmanagement.test.js @@ -8,6 +8,11 @@ const offlineMessage = randomString(); const pageContent = `

${randomString()}

`; const tags = [randomString(), randomString(), randomString()]; const latencyLevel = Math.floor(Math.random() * 4); +const appearanceValues = { + variable1: randomString(), + variable2: randomString(), + variable3: randomString(), +}; const streamOutputVariants = { videoBitrate: randomNumber() * 100, @@ -103,6 +108,11 @@ test('set offline message', async (done) => { done(); }); +test('set custom style values', async (done) => { + const res = await sendConfigChangeRequest('appearance', appearanceValues); + done(); +}); + test('verify updated config values', async (done) => { const res = await request.get('/api/config'); expect(res.body.name).toBe(serverName); diff --git a/web/.storybook/stories-category-doc-pages/Colors.stories.mdx b/web/.storybook/stories-category-doc-pages/Colors.stories.mdx index f0ddd52b4..d76ac31ab 100644 --- a/web/.storybook/stories-category-doc-pages/Colors.stories.mdx +++ b/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-12', 'theme-color-palette-13', + 'theme-color-palette-15', 'theme-color-palette-error', 'theme-color-palette-warning', '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-text', 'theme-color-components-modal-content-background', + 'theme-color-components-content-background', 'theme-color-components-modal-content-text', 'theme-color-components-menu-background', '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-12', 'color-owncast-palette-13', + 'color-owncast-palette-15', ]} /> diff --git a/web/components/MainLayout.tsx b/web/components/MainLayout.tsx index 2d8c90f17..f3a5c9aaa 100644 --- a/web/components/MainLayout.tsx +++ b/web/components/MainLayout.tsx @@ -201,7 +201,9 @@ export const MainLayout: FC = ({ children }) => { Notifications - + + Appearance + S3 Storage diff --git a/web/components/chat/ChatActionMessage/ChatActionMessage.module.scss b/web/components/chat/ChatActionMessage/ChatActionMessage.module.scss index f7971ebd0..90351a6b8 100644 --- a/web/components/chat/ChatActionMessage/ChatActionMessage.module.scss +++ b/web/components/chat/ChatActionMessage/ChatActionMessage.module.scss @@ -1,4 +1,5 @@ .chatAction { padding: 5px; text-align: center; + color: var(--theme-color-components-chat-text); } diff --git a/web/components/chat/ChatContainer/ChatContainer.module.scss b/web/components/chat/ChatContainer/ChatContainer.module.scss index 01cce6488..f717d4dda 100644 --- a/web/components/chat/ChatContainer/ChatContainer.module.scss +++ b/web/components/chat/ChatContainer/ChatContainer.module.scss @@ -42,7 +42,7 @@ .chatContainer { display: flex; flex-direction: column; - background-color: var(--theme-color-background-chat); + background-color: var(--theme-color-components-chat-background); height: 100%; } .virtuoso { diff --git a/web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss b/web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss index 3943742a2..627681b05 100644 --- a/web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss +++ b/web/components/chat/ChatJoinMessage/ChatJoinMessage.module.scss @@ -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); } diff --git a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss index 7aca8c195..8e842e1eb 100644 --- a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss +++ b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss @@ -9,6 +9,8 @@ $p-size: 8px; position: relative; font-size: 0.9rem; padding: 0px $p-size $p-size $p-size; + color: var(--theme-color-components-chat-text); + .user { display: flex; align-items: center; @@ -63,7 +65,7 @@ $p-size: 8px; .messagePadding { padding: 0px 0px; - padding-top: .4rem; + padding-top: 0.4rem; } .messagePaddingCollapsed { diff --git a/web/components/layouts/Main.tsx b/web/components/layouts/Main.tsx index 073c43aad..9161b1849 100644 --- a/web/components/layouts/Main.tsx +++ b/web/components/layouts/Main.tsx @@ -21,6 +21,7 @@ import { TitleNotifier } from '../TitleNotifier/TitleNotifier'; import { ServerRenderedHydration } from '../ServerRendered/ServerRenderedHydration'; import Footer from '../ui/Footer/Footer'; +import { Theme } from '../theme/Theme'; export const Main: FC = () => { const [isMobile] = useRecoilState(isMobileAtom); @@ -111,6 +112,7 @@ export const Main: FC = () => { +
diff --git a/web/components/theme/Theme.tsx b/web/components/theme/Theme.tsx new file mode 100644 index 000000000..ec9356737 --- /dev/null +++ b/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(clientConfigStateAtom); + const { appearanceVariables, customStyles } = clientConfig; + + const appearanceVars = Object.keys(appearanceVariables) + .filter(variable => !!appearanceVariables[variable]) + .map(variable => `--${variable}: ${appearanceVariables[variable]}`); + + return ( +