diff --git a/models/user/user.go b/models/user/user.go index 6143992a2537b..fcfd8a74a18d1 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -201,7 +201,7 @@ func (u *User) BeforeUpdate() { // AfterLoad is invoked from XORM after filling all the fields of this object. func (u *User) AfterLoad() { if u.Theme == "" { - u.Theme = setting.UI.DefaultTheme + u.Theme = setting.Config().Theme.DefaultTheme.Value(context.Background()) } } @@ -663,7 +663,7 @@ func createUser(ctx context.Context, u *User, meta *Meta, createdByAdmin bool, o u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation u.EmailNotificationsPreference = setting.Admin.DefaultEmailNotification u.MaxRepoCreation = -1 - u.Theme = setting.UI.DefaultTheme + u.Theme = setting.Config().Theme.DefaultTheme.Value(ctx) u.IsRestricted = setting.Service.DefaultUserIsRestricted u.IsActive = !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm) diff --git a/models/user/user_test.go b/models/user/user_test.go index 4201ec4816c86..2ae3565832c7c 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -276,7 +276,7 @@ func TestCreateUserInvalidEmail(t *testing.T) { Email: "GiteaBot@gitea.io\r\n", Passwd: ";p['////..-++']", IsAdmin: false, - Theme: setting.UI.DefaultTheme, + Theme: setting.Config().Theme.DefaultTheme.Value(t.Context()), MustChangePassword: false, } diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go index 8ed86b9ac0eb9..cb5d7627edaf5 100644 --- a/modules/fileicon/render.go +++ b/modules/fileicon/render.go @@ -4,6 +4,7 @@ package fileicon import ( + "context" "html/template" "strings" @@ -34,7 +35,7 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { } func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML { - if setting.UI.FileIconTheme == "material" { + if setting.Config().Theme.DefaultFileIconTheme.Value(context.Background()) == "material" { return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry) } return BasicEntryIconHTML(entry) diff --git a/modules/setting/config.go b/modules/setting/config.go index 4c5d2df7d8a01..42d9702e3bcf0 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -10,6 +10,11 @@ import ( "code.gitea.io/gitea/modules/setting/config" ) +type ThemeStruct struct { + DefaultTheme *config.Value[string] + DefaultFileIconTheme *config.Value[string] +} + type PictureStruct struct { DisableGravatar *config.Value[bool] EnableFederatedAvatar *config.Value[bool] @@ -53,6 +58,7 @@ type RepositoryStruct struct { } type ConfigStruct struct { + Theme *ThemeStruct Picture *PictureStruct Repository *RepositoryStruct } @@ -65,6 +71,10 @@ var ( func initDefaultConfig() { config.SetCfgSecKeyGetter(&cfgSecKeyGetter{}) defaultConfig = &ConfigStruct{ + Theme: &ThemeStruct{ + DefaultTheme: config.ValueJSON[string]("theme.default_theme").WithFileConfig(config.CfgSecKey{Sec: "ui", Key: "DEFAULT_THEME"}).WithDefault("gitea-auto"), + DefaultFileIconTheme: config.ValueJSON[string]("theme.default_file_icon_theme").WithFileConfig(config.CfgSecKey{Sec: "ui", Key: "FILE_ICON_THEME"}).WithDefault("material"), + }, Picture: &PictureStruct{ DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}), EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}), diff --git a/modules/setting/ui.go b/modules/setting/ui.go index 3d9c916bf7f72..330edc2691789 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -26,9 +26,7 @@ var UI = struct { MaxDisplayFileSize int64 ShowUserEmail bool DefaultShowFullName bool - DefaultTheme string Themes []string - FileIconTheme string Reactions []string ReactionsLookup container.Set[string] `ini:"-"` CustomEmojis []string @@ -84,8 +82,6 @@ var UI = struct { CodeCommentLines: 4, ReactionMaxUserNum: 10, MaxDisplayFileSize: 8388608, - DefaultTheme: `gitea-auto`, - FileIconTheme: `material`, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, diff --git a/modules/structs/settings.go b/modules/structs/settings.go index 403afda9ff5bc..02a5e83b69d8e 100644 --- a/modules/structs/settings.go +++ b/modules/structs/settings.go @@ -23,6 +23,8 @@ type GeneralRepoSettings struct { type GeneralUISettings struct { // DefaultTheme is the default UI theme DefaultTheme string `json:"default_theme"` + // DefaultFileIconTheme is the default file icon theme + DefaultFileIconTheme string `json:"default_file_icon_theme"` // AllowedReactions contains the list of allowed emoji reactions AllowedReactions []string `json:"allowed_reactions"` // CustomEmojis contains the list of custom emojis diff --git a/modules/templates/helper.go b/modules/templates/helper.go index e454bce4bd3c2..7d66cb4e45ec6 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -5,6 +5,7 @@ package templates import ( + "context" "fmt" "html/template" "net/url" @@ -218,13 +219,14 @@ func evalTokens(tokens ...any) (any, error) { } func userThemeName(user *user_model.User) string { + defaultTheme := setting.Config().Theme.DefaultTheme.Value(context.Background()) if user == nil || user.Theme == "" { - return setting.UI.DefaultTheme + return defaultTheme } if webtheme.IsThemeAvailable(user.Theme) { return user.Theme } - return setting.UI.DefaultTheme + return defaultTheme } func isQueryParamEmpty(v any) bool { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b3eb7b1f4a4fd..78d2ce5e9c0dd 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3428,6 +3428,9 @@ config.session_life_time = Session Life Time config.https_only = HTTPS Only config.cookie_life_time = Cookie Life Time +config.theme = Theme Configuration +config.default_theme = Default Theme +config.default_file_icon_theme = Default File Icon Theme config.picture_config = Picture and Avatar Configuration config.picture_service = Picture Service config.disable_gravatar = Disable Gravatar diff --git a/routers/api/v1/settings/settings.go b/routers/api/v1/settings/settings.go index 94fbadeab0188..23d24b0cb9c19 100644 --- a/routers/api/v1/settings/settings.go +++ b/routers/api/v1/settings/settings.go @@ -22,9 +22,10 @@ func GetGeneralUISettings(ctx *context.APIContext) { // "200": // "$ref": "#/responses/GeneralUISettings" ctx.JSON(http.StatusOK, api.GeneralUISettings{ - DefaultTheme: setting.UI.DefaultTheme, - AllowedReactions: setting.UI.Reactions, - CustomEmojis: setting.UI.CustomEmojis, + DefaultTheme: setting.Config().Theme.DefaultTheme.Value(ctx), + DefaultFileIconTheme: setting.Config().Theme.DefaultFileIconTheme.Value(ctx), + AllowedReactions: setting.UI.Reactions, + CustomEmojis: setting.UI.CustomEmojis, }) } diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 774b31ab9842a..421ff9cf989b4 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" + "code.gitea.io/gitea/services/webtheme" "gitea.com/go-chi/session" ) @@ -192,6 +193,8 @@ func ConfigSettings(ctx *context.Context) { ctx.Data["PageIsAdminConfig"] = true ctx.Data["PageIsAdminConfigSettings"] = true ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.Data["AvailableThemes"] = webtheme.GetAvailableThemes() + ctx.Data["AvailableFileIconThemes"] = []string{"material", "basic"} ctx.HTML(http.StatusOK, tplConfigSettings) } @@ -231,6 +234,8 @@ func ChangeConfig(ctx *context.Context) { return json.Marshal(openWithEditorApps) } marshallers := map[string]func(string) ([]byte, error){ + cfg.Theme.DefaultTheme.DynKey(): marshalString(cfg.Theme.DefaultTheme.DefaultValue()), + cfg.Theme.DefaultFileIconTheme.DynKey(): marshalString(cfg.Theme.DefaultFileIconTheme.DefaultValue()), cfg.Picture.DisableGravatar.DynKey(): marshalBool, cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, diff --git a/services/user/user_test.go b/services/user/user_test.go index 48852b4cb9a39..64543d6c4e3f9 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -88,7 +88,7 @@ func TestCreateUser(t *testing.T) { Email: "GiteaBot@gitea.io", Passwd: ";p['////..-++']", IsAdmin: false, - Theme: setting.UI.DefaultTheme, + Theme: setting.Config().Theme.DefaultTheme.Value(t.Context()), MustChangePassword: false, } diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 4e89d6dbac13a..54ad7e260cc48 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -4,6 +4,7 @@ package webtheme import ( + "context" "regexp" "sort" "strings" @@ -107,19 +108,20 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { func initThemes() { availableThemes = nil + defaultTheme := setting.Config().Theme.DefaultTheme.Value(context.Background()) defer func() { availableThemeInternalNames = container.Set[string]{} for _, theme := range availableThemes { availableThemeInternalNames.Add(theme.InternalName) } - if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) { - setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme) + if !availableThemeInternalNames.Contains(defaultTheme) { + setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", defaultTheme) } }() cssFiles, err := public.AssetFS().ListFiles("/assets/css") if err != nil { log.Error("Failed to list themes: %v", err) - availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} + availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(defaultTheme)} return } var foundThemes []*ThemeMetaInfo @@ -144,14 +146,14 @@ func initThemes() { availableThemes = foundThemes } sort.Slice(availableThemes, func(i, j int) bool { - if availableThemes[i].InternalName == setting.UI.DefaultTheme { + if availableThemes[i].InternalName == defaultTheme { return true } return availableThemes[i].DisplayName < availableThemes[j].DisplayName }) if len(availableThemes) == 0 { setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme") - availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} + availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(defaultTheme)} } } diff --git a/templates/admin/config_settings/config_settings.tmpl b/templates/admin/config_settings/config_settings.tmpl index 1ef764a58bac5..d8cfc96d26971 100644 --- a/templates/admin/config_settings/config_settings.tmpl +++ b/templates/admin/config_settings/config_settings.tmpl @@ -1,5 +1,7 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +{{template "admin/config_settings/theme" .}} + {{template "admin/config_settings/avatars" .}} {{template "admin/config_settings/repository" .}} diff --git a/templates/admin/config_settings/theme.tmpl b/templates/admin/config_settings/theme.tmpl new file mode 100644 index 0000000000000..7ed2bb9ed5501 --- /dev/null +++ b/templates/admin/config_settings/theme.tmpl @@ -0,0 +1,36 @@ +

+ {{ctx.Locale.Tr "admin.config.theme"}} +

+ diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 77a622cb63546..75e6e28f5d73f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -25251,6 +25251,11 @@ }, "x-go-name": "CustomEmojis" }, + "default_file_icon_theme": { + "description": "DefaultFileIconTheme is the default file icon theme", + "type": "string", + "x-go-name": "DefaultFileIconTheme" + }, "default_theme": { "description": "DefaultTheme is the default UI theme", "type": "string", diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index d12addb1275db..6dcc69ebee41b 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -14,10 +14,12 @@ import ( "time" repo_model "code.gitea.io/gitea/models/repo" + system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/util" repo_service "code.gitea.io/gitea/services/repository" @@ -223,7 +225,13 @@ func testViewRepo1CloneLinkAuthorized(t *testing.T) { func testViewRepoWithSymlinks(t *testing.T) { defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.UI.FileIconTheme, "basic")() + defer func() { + err := system_model.SetSettings(t.Context(), map[string]string{ + setting.Config().Theme.DefaultFileIconTheme.DynKey(): "basic", + }) + assert.NoError(t, err) + config.GetDynGetter().InvalidateCache() + }() session := loginUser(t, "user2") req := NewRequest(t, "GET", "/user2/repo20.git") diff --git a/web_src/css/admin.css b/web_src/css/admin.css index cda38c6dddf22..a734125a30d7e 100644 --- a/web_src/css/admin.css +++ b/web_src/css/admin.css @@ -15,6 +15,8 @@ .admin dl.admin-dl-horizontal dd { line-height: var(--line-height-default); padding: 5px 0; + display: flex; + align-items: center; } .admin dl.admin-dl-horizontal dt { @@ -39,6 +41,10 @@ overflow-x: auto; /* if the screen width is small, many wide tables (eg: user list) need scroll bars */ } +.admin .ui.table.segment.dropdown-container { + overflow: visible; /* allow dropdown menus to extend beyond container boundaries */ +} + .admin .table th { white-space: nowrap; } diff --git a/web_src/js/features/admin/config.ts b/web_src/js/features/admin/config.ts index 16d7a2426f978..2950d78f1ba25 100644 --- a/web_src/js/features/admin/config.ts +++ b/web_src/js/features/admin/config.ts @@ -1,5 +1,6 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts'; import {POST} from '../../modules/fetch.ts'; +import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; @@ -21,4 +22,28 @@ export function initAdminConfigs(): void { } }); } + + // Handle theme config dropdowns + for (const el of elAdminConfig.querySelectorAll('.js-theme-config-dropdown')) { + fomanticQuery(el).dropdown({ + async onChange(value: string, _text: string, _$item: any) { + if (!value) return; + + const configKey = this.getAttribute('data-config-key'); + if (!configKey) return; + + try { + const resp = await POST(`${appSubUrl}/-/admin/config`, { + data: new URLSearchParams({key: configKey, value}), + }); + const json: Record = await resp.json(); + if (json.errorMessage) throw new Error(json.errorMessage); + } catch (ex) { + showTemporaryTooltip(this, ex.toString()); + // Revert the dropdown to the previous value on error + fomanticQuery(el).dropdown('restore defaults'); + } + }, + }); + } }