mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 15:31:24 +00:00 
			
		
		
		
	Support selecting theme on the footer (#35741)
Fixes: https://github.com/go-gitea/gitea/pull/27576
This commit is contained in:
		| @@ -10,6 +10,7 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/json" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| // SessionConfig defines Session settings | ||||
| @@ -49,10 +50,8 @@ func loadSessionFrom(rootCfg ConfigProvider) { | ||||
| 		checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig) | ||||
| 	} | ||||
| 	SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") | ||||
| 	SessionConfig.CookiePath = AppSubURL | ||||
| 	if SessionConfig.CookiePath == "" { | ||||
| 		SessionConfig.CookiePath = "/" | ||||
| 	} | ||||
| 	// HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath | ||||
| 	SessionConfig.CookiePath = util.IfZero(AppSubURL, "/") | ||||
| 	SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://")) | ||||
| 	SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400) | ||||
| 	SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400) | ||||
|   | ||||
| @@ -58,6 +58,9 @@ func MockIcon(icon string) func() { | ||||
|  | ||||
| // RenderHTML renders icons - arguments icon name (string), size (int), class (string) | ||||
| func RenderHTML(icon string, others ...any) template.HTML { | ||||
| 	if icon == "" { | ||||
| 		return "" | ||||
| 	} | ||||
| 	size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) | ||||
| 	if svgStr, ok := svgIcons[icon]; ok { | ||||
| 		// the code is somewhat hacky, but it just works, because the SVG contents are all normalized | ||||
|   | ||||
| @@ -12,7 +12,6 @@ import ( | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/htmlutil" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| @@ -21,7 +20,6 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/templates/eval" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/services/gitdiff" | ||||
| 	"code.gitea.io/gitea/services/webtheme" | ||||
| ) | ||||
|  | ||||
| // NewFuncMap returns functions for injecting to templates | ||||
| @@ -130,7 +128,6 @@ func NewFuncMap() template.FuncMap { | ||||
| 		"DisableWebhooks": func() bool { | ||||
| 			return setting.DisableWebhooks | ||||
| 		}, | ||||
| 		"UserThemeName": userThemeName, | ||||
| 		"NotificationSettings": func() map[string]any { | ||||
| 			return map[string]any{ | ||||
| 				"MinTimeout":            int(setting.UI.Notification.MinTimeout / time.Millisecond), | ||||
| @@ -217,16 +214,6 @@ func evalTokens(tokens ...any) (any, error) { | ||||
| 	return n.Value, err | ||||
| } | ||||
|  | ||||
| func userThemeName(user *user_model.User) string { | ||||
| 	if user == nil || user.Theme == "" { | ||||
| 		return setting.UI.DefaultTheme | ||||
| 	} | ||||
| 	if webtheme.IsThemeAvailable(user.Theme) { | ||||
| 		return user.Theme | ||||
| 	} | ||||
| 	return setting.UI.DefaultTheme | ||||
| } | ||||
|  | ||||
| func isQueryParamEmpty(v any) bool { | ||||
| 	return v == nil || v == false || v == 0 || v == int64(0) || v == "" | ||||
| } | ||||
|   | ||||
| @@ -23,8 +23,10 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/markup/markdown" | ||||
| 	"code.gitea.io/gitea/modules/reqctx" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/svg" | ||||
| 	"code.gitea.io/gitea/modules/translation" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/services/webtheme" | ||||
| ) | ||||
|  | ||||
| type RenderUtils struct { | ||||
| @@ -259,3 +261,18 @@ func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink strin | ||||
| 	htmlCode += "</span>" | ||||
| 	return template.HTML(htmlCode) | ||||
| } | ||||
|  | ||||
| func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML { | ||||
| 	svgName := "octicon-paintbrush" | ||||
| 	switch info.ColorScheme { | ||||
| 	case "dark": | ||||
| 		svgName = "octicon-moon" | ||||
| 	case "light": | ||||
| 		svgName = "octicon-sun" | ||||
| 	case "auto": | ||||
| 		svgName = "gitea-eclipse" | ||||
| 	} | ||||
| 	icon := svg.RenderHTML(svgName, iconSize) | ||||
| 	extraIcon := svg.RenderHTML(info.GetExtraIconName(), iconSize) | ||||
| 	return htmlutil.HTMLFormat(`<div class="theme-menu-item" data-tooltip-content="%s">%s %s %s</div>`, info.GetDescription(), icon, info.DisplayName, extraIcon) | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/session" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| // SetRedirectToCookie convenience function to set the RedirectTo cookie consistently | ||||
| @@ -39,11 +40,13 @@ func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) { | ||||
| 	// These are more specific than cookies without a trailing /, so | ||||
| 	// we need to delete these if they exist. | ||||
| 	deleteLegacySiteCookie(resp, name) | ||||
|  | ||||
| 	// HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath | ||||
| 	cookie := &http.Cookie{ | ||||
| 		Name:     name, | ||||
| 		Value:    url.QueryEscape(value), | ||||
| 		MaxAge:   maxAge, | ||||
| 		Path:     setting.SessionConfig.CookiePath, | ||||
| 		Path:     util.IfZero(setting.SessionConfig.CookiePath, "/"), | ||||
| 		Domain:   setting.SessionConfig.Domain, | ||||
| 		Secure:   setting.SessionConfig.Secure, | ||||
| 		HttpOnly: true, | ||||
|   | ||||
							
								
								
									
										1
									
								
								public/assets/img/svg/gitea-colorblind-redgreen.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/assets/img/svg/gitea-colorblind-redgreen.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 40 40" class="svg gitea-colorblind-redgreen" width="16" height="16" aria-hidden="true"><g clip-path="url(#gitea-colorblind-redgreen__a)"><rect width="40" height="40" fill="#0000" rx="20"/><path fill="#0566d5" d="M34.284 34.284c7.81-7.81 7.81-20.474 0-28.284L6 34.284c7.81 7.81 20.474 7.81 28.284 0"/><path fill="#e7a100" d="M34.283 34.284c7.81-7.81 7.81-20.474 0-28.284L20.14 20.142z"/><circle cx="20" cy="20" r="18" fill="#0000" stroke="#aaa" stroke-width="4"/></g><defs><clipPath id="gitea-colorblind-redgreen__a"><rect width="40" height="40" fill="#0000" rx="20"/></clipPath></defs></svg> | ||||
| After Width: | Height: | Size: 656 B | 
							
								
								
									
										1
									
								
								public/assets/img/svg/gitea-eclipse.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/assets/img/svg/gitea-eclipse.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg viewBox="490 490 820 820" class="svg gitea-eclipse" xmlns="http://www.w3.org/2000/svg" width="16" height="16" aria-hidden="true"><path d="M866.7 582.1A321.3 321.3 0 0 0 738.6 623a317.3 317.3 0 0 0-109.1 108.5A418 418 0 0 0 609 772a335.3 335.3 0 0 0-19.6 71.5 205.2 205.2 0 0 0-2.8 45c0 25.8.2 29.3 2.8 45 4.1 25.4 9.9 46.4 19.6 71.5a314.2 314.2 0 0 0 111.6 137.3A306.8 306.8 0 0 0 893 1196a308.6 308.6 0 0 0 303.6-262.5c2.6-15.7 2.8-19.2 2.8-45s-.2-29.3-2.8-45A335.3 335.3 0 0 0 1177 772a314.2 314.2 0 0 0-111.6-137.3A308.3 308.3 0 0 0 918 582c-13-1.1-38.2-1.1-51.3.1M747 663.5l-2.4 16.7c-4 26.4-4.9 41.1-4.3 65.3a323.7 323.7 0 0 0 37.2 145c18.2 36 41.3 66.6 72 95.5a346.4 346.4 0 0 0 208.5 93.1l18 1.6 4.5.5-8.5 8a259.3 259.3 0 0 1-141.5 65.8 281 281 0 0 1-123.9-11.4 267.2 267.2 0 0 1-181.7-269.9c2-27.6 5.7-47.6 13.3-70.7a281.2 281.2 0 0 1 46.4-85c8-10.1 28-30.2 37.9-38.1 13.8-11.1 24.5-18.3 24.5-16.4"/></svg> | ||||
| After Width: | Height: | Size: 919 B | 
| @@ -35,7 +35,7 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { | ||||
| 	httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) | ||||
| 	w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) | ||||
|  | ||||
| 	tmplCtx := context.TemplateContext{} | ||||
| 	tmplCtx := context.NewTemplateContext(req.Context(), req) | ||||
| 	tmplCtx["Locale"] = middleware.Locale(w, req) | ||||
| 	ctxData := middleware.GetContextData(req.Context()) | ||||
|  | ||||
|   | ||||
| @@ -133,7 +133,7 @@ func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	tmplCtx := giteacontext.TemplateContext{} | ||||
| 	tmplCtx := giteacontext.NewTemplateContext(req.Context(), req) | ||||
| 	tmplCtx["Locale"] = middleware.Locale(w, req) | ||||
| 	ctxData := middleware.GetContextData(req.Context()) | ||||
| 	err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/routers/common" | ||||
| 	"code.gitea.io/gitea/routers/web/healthcheck" | ||||
| 	"code.gitea.io/gitea/routers/web/misc" | ||||
| 	"code.gitea.io/gitea/services/forms" | ||||
| ) | ||||
|  | ||||
| @@ -32,7 +33,11 @@ func Routes() *web.Router { | ||||
| 	r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL | ||||
| 	r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall) | ||||
| 	r.Get("/post-install", InstallDone) | ||||
|  | ||||
| 	r.Get("/-/web-theme/list", misc.WebThemeList) | ||||
| 	r.Post("/-/web-theme/apply", misc.WebThemeApply) | ||||
| 	r.Get("/api/healthz", healthcheck.Check) | ||||
|  | ||||
| 	r.NotFound(installNotFound) | ||||
|  | ||||
| 	base.Mount("", r) | ||||
|   | ||||
							
								
								
									
										42
									
								
								routers/web/misc/webtheme.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								routers/web/misc/webtheme.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| // Copyright 2025 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package misc | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/optional" | ||||
| 	"code.gitea.io/gitea/modules/templates" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/modules/web/middleware" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| 	user_service "code.gitea.io/gitea/services/user" | ||||
| 	"code.gitea.io/gitea/services/webtheme" | ||||
| ) | ||||
|  | ||||
| func WebThemeList(ctx *context.Context) { | ||||
| 	curWebTheme := ctx.TemplateContext.CurrentWebTheme() | ||||
| 	renderUtils := templates.NewRenderUtils(ctx) | ||||
| 	allThemes := webtheme.GetAvailableThemes() | ||||
|  | ||||
| 	var results []map[string]any | ||||
| 	for _, theme := range allThemes { | ||||
| 		results = append(results, map[string]any{ | ||||
| 			"name":  renderUtils.RenderThemeItem(theme, 14), | ||||
| 			"value": theme.InternalName, | ||||
| 			"class": "item js-aria-clickable" + util.Iif(theme.InternalName == curWebTheme.InternalName, " selected", ""), | ||||
| 		}) | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, map[string]any{"results": results}) | ||||
| } | ||||
|  | ||||
| func WebThemeApply(ctx *context.Context) { | ||||
| 	themeName := ctx.FormString("theme") | ||||
| 	if ctx.Doer != nil { | ||||
| 		opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)} | ||||
| 		_ = user_service.UpdateUser(ctx, ctx.Doer, opts) | ||||
| 	} else { | ||||
| 		middleware.SetSiteCookie(ctx.Resp, "gitea_theme", themeName, 0) | ||||
| 	} | ||||
| } | ||||
| @@ -369,7 +369,7 @@ func UpdateUIThemePost(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if !webtheme.IsThemeAvailable(form.Theme) { | ||||
| 	if webtheme.GetThemeMetaInfo(form.Theme) == nil { | ||||
| 		ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) | ||||
| 		ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") | ||||
| 		return | ||||
|   | ||||
| @@ -490,6 +490,9 @@ func registerWebRoutes(m *web.Router) { | ||||
|  | ||||
| 	m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup) | ||||
|  | ||||
| 	m.Get("/-/web-theme/list", misc.WebThemeList) | ||||
| 	m.Post("/-/web-theme/apply", optSignInIgnoreCsrf, misc.WebThemeApply) | ||||
|  | ||||
| 	m.Group("/explore", func() { | ||||
| 		m.Get("", func(ctx *context.Context) { | ||||
| 			ctx.Redirect(setting.AppSubURL + "/explore/repos") | ||||
|   | ||||
| @@ -103,7 +103,7 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) { | ||||
| } | ||||
|  | ||||
| func NewTemplateContextForWeb(ctx *Context) TemplateContext { | ||||
| 	tmplCtx := NewTemplateContext(ctx) | ||||
| 	tmplCtx := NewTemplateContext(ctx, ctx.Req) | ||||
| 	tmplCtx["Locale"] = ctx.Base.Locale | ||||
| 	tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) | ||||
| 	tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) | ||||
|   | ||||
| @@ -5,13 +5,16 @@ package context | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/gitea/services/webtheme" | ||||
| ) | ||||
|  | ||||
| var _ context.Context = TemplateContext(nil) | ||||
|  | ||||
| func NewTemplateContext(ctx context.Context) TemplateContext { | ||||
| 	return TemplateContext{"_ctx": ctx} | ||||
| func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext { | ||||
| 	return TemplateContext{"_ctx": ctx, "_req": req} | ||||
| } | ||||
|  | ||||
| func (c TemplateContext) parentContext() context.Context { | ||||
| @@ -33,3 +36,19 @@ func (c TemplateContext) Err() error { | ||||
| func (c TemplateContext) Value(key any) any { | ||||
| 	return c.parentContext().Value(key) | ||||
| } | ||||
|  | ||||
| func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { | ||||
| 	req := c["_req"].(*http.Request) | ||||
| 	var themeName string | ||||
| 	if webCtx := GetWebContext(c); webCtx != nil { | ||||
| 		if webCtx.Doer != nil { | ||||
| 			themeName = webCtx.Doer.Theme | ||||
| 		} | ||||
| 	} | ||||
| 	if themeName == "" { | ||||
| 		if cookieTheme, _ := req.Cookie("gitea_theme"); cookieTheme != nil { | ||||
| 			themeName = cookieTheme.Value | ||||
| 		} | ||||
| 	} | ||||
| 	return webtheme.GuaranteeGetThemeMetaInfo(themeName) | ||||
| } | ||||
|   | ||||
| @@ -17,9 +17,9 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	availableThemes             []*ThemeMetaInfo | ||||
| 	availableThemeInternalNames container.Set[string] | ||||
| 	themeOnce                   sync.Once | ||||
| 	availableThemes   []*ThemeMetaInfo | ||||
| 	availableThemeMap map[string]*ThemeMetaInfo | ||||
| 	themeOnce         sync.Once | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -28,9 +28,25 @@ const ( | ||||
| ) | ||||
|  | ||||
| type ThemeMetaInfo struct { | ||||
| 	FileName     string | ||||
| 	InternalName string | ||||
| 	DisplayName  string | ||||
| 	FileName       string | ||||
| 	InternalName   string | ||||
| 	DisplayName    string | ||||
| 	ColorblindType string | ||||
| 	ColorScheme    string | ||||
| } | ||||
|  | ||||
| func (info *ThemeMetaInfo) GetDescription() string { | ||||
| 	if info.ColorblindType == "red-green" { | ||||
| 		return "Red-green colorblind friendly" | ||||
| 	} | ||||
| 	return "" | ||||
| } | ||||
|  | ||||
| func (info *ThemeMetaInfo) GetExtraIconName() string { | ||||
| 	if info.ColorblindType == "red-green" { | ||||
| 		return "gitea-colorblind-redgreen" | ||||
| 	} | ||||
| 	return "" | ||||
| } | ||||
|  | ||||
| func parseThemeMetaInfoToMap(cssContent string) map[string]string { | ||||
| @@ -54,7 +70,7 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { | ||||
| |('(\\'|[^'])*') | ||||
| |([^'";]+) | ||||
| ) | ||||
| \s*; | ||||
| \s*;? | ||||
| \s* | ||||
| ) | ||||
| ` | ||||
| @@ -102,17 +118,19 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { | ||||
| 		return themeInfo | ||||
| 	} | ||||
| 	themeInfo.DisplayName = m["--theme-display-name"] | ||||
| 	themeInfo.ColorblindType = m["--theme-colorblind-type"] | ||||
| 	themeInfo.ColorScheme = m["--theme-color-scheme"] | ||||
| 	return themeInfo | ||||
| } | ||||
|  | ||||
| func initThemes() { | ||||
| 	availableThemes = nil | ||||
| 	defer func() { | ||||
| 		availableThemeInternalNames = container.Set[string]{} | ||||
| 		availableThemeMap = map[string]*ThemeMetaInfo{} | ||||
| 		for _, theme := range availableThemes { | ||||
| 			availableThemeInternalNames.Add(theme.InternalName) | ||||
| 			availableThemeMap[theme.InternalName] = theme | ||||
| 		} | ||||
| 		if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) { | ||||
| 		if availableThemeMap[setting.UI.DefaultTheme] == nil { | ||||
| 			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) | ||||
| 		} | ||||
| 	}() | ||||
| @@ -147,6 +165,9 @@ func initThemes() { | ||||
| 		if availableThemes[i].InternalName == setting.UI.DefaultTheme { | ||||
| 			return true | ||||
| 		} | ||||
| 		if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType { | ||||
| 			return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType | ||||
| 		} | ||||
| 		return availableThemes[i].DisplayName < availableThemes[j].DisplayName | ||||
| 	}) | ||||
| 	if len(availableThemes) == 0 { | ||||
| @@ -160,7 +181,21 @@ func GetAvailableThemes() []*ThemeMetaInfo { | ||||
| 	return availableThemes | ||||
| } | ||||
|  | ||||
| func IsThemeAvailable(internalName string) bool { | ||||
| func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { | ||||
| 	themeOnce.Do(initThemes) | ||||
| 	return availableThemeInternalNames.Contains(internalName) | ||||
| 	return availableThemeMap[internalName] | ||||
| } | ||||
|  | ||||
| // GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, | ||||
| // to simplify the caller's logic, especially for templates. | ||||
| // There are already enough warnings messages if the default theme is not available. | ||||
| func GuaranteeGetThemeMetaInfo(internalName string) *ThemeMetaInfo { | ||||
| 	info := GetThemeMetaInfo(internalName) | ||||
| 	if info == nil { | ||||
| 		info = GetThemeMetaInfo(setting.UI.DefaultTheme) | ||||
| 	} | ||||
| 	if info == nil { | ||||
| 		info = &ThemeMetaInfo{DisplayName: "unavailable", InternalName: "unavailable", FileName: "unavailable"} | ||||
| 	} | ||||
| 	return info | ||||
| } | ||||
|   | ||||
| @@ -34,4 +34,10 @@ gitea-theme-meta-info { | ||||
| 	--k2: real; | ||||
| }`) | ||||
| 	assert.Equal(t, map[string]string{"--k2": "real"}, m) | ||||
|  | ||||
| 	// compressed CSS, no trailing semicolon | ||||
| 	m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1"}`) | ||||
| 	assert.Equal(t, map[string]string{"--k1": "v1"}, m) | ||||
| 	m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1";--k2:"v2"}`) | ||||
| 	assert.Equal(t, map[string]string{"--k1": "v1", "--k2": "v2"}, m) | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,10 @@ | ||||
| 		{{end}} | ||||
| 	</div> | ||||
| 	<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}"> | ||||
| 		<div class="ui dropdown custom" id="footer-theme-selector"> | ||||
| 			<span class="default-text">{{ctx.RenderUtils.RenderThemeItem ctx.CurrentWebTheme 16}}</span> | ||||
| 			<div class="menu theme-menu"></div> | ||||
| 		</div> | ||||
| 		<div class="ui dropdown upward"> | ||||
| 			<span class="flex-text-inline">{{svg "octicon-globe" 14}} {{ctx.Locale.LangName}}</span> | ||||
| 			<div class="menu language-menu"> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}"> | ||||
| <html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}"> | ||||
| <head> | ||||
| 	<meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| 	<title>{{if .Title}}{{.Title}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title> | ||||
|   | ||||
| @@ -1,2 +1,2 @@ | ||||
| <link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}"> | ||||
| <link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{UserThemeName .SignedUser | PathEscape}}.css?v={{AssetVersion}}"> | ||||
| <link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{ctx.CurrentWebTheme.InternalName | PathEscape}}.css?v={{AssetVersion}}"> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| {{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics. | ||||
| * base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl, UserThemeName | ||||
| * base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl | ||||
| * ctx.Locale | ||||
| * .Flash | ||||
| * .ErrorMsg | ||||
| * .SignedUser (optional) | ||||
| */}} | ||||
| <!DOCTYPE html> | ||||
| <html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}"> | ||||
| <html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}"> | ||||
| <head> | ||||
| 	<meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| 	<title>Internal Server Error - {{AppName}}</title> | ||||
|   | ||||
| @@ -16,11 +16,19 @@ | ||||
| 				</div> | ||||
| 				<div class="field"> | ||||
| 					<label>{{ctx.Locale.Tr "settings.ui"}}</label> | ||||
| 					<select name="theme" class="ui dropdown"> | ||||
| 						{{range $theme := .AllThemes}} | ||||
| 						<option value="{{$theme.InternalName}}" {{Iif (eq $.SignedUser.Theme $theme.InternalName) "selected"}}>{{$theme.DisplayName}}</option> | ||||
| 						{{end}} | ||||
| 					</select> | ||||
| 					<div class="ui selection dropdown"> | ||||
| 						<input type="hidden" name="theme" value="{{$.SignedUser.Theme}}"> | ||||
| 						<div class="text"></div> {{svg "octicon-triangle-down" 14 "dropdown icon"}} | ||||
| 						<div class="menu flex-items-menu"> | ||||
| 							{{range $theme := .AllThemes}} | ||||
| 								{{$extraIconName := $theme.GetExtraIconName}} | ||||
| 								<div class="item" data-value="{{$theme.InternalName}}"> | ||||
| 									{{$theme.DisplayName}} {{svg $extraIconName}} | ||||
| 									<div class="description">{{$theme.GetDescription}}</div> | ||||
| 								</div> | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="field"> | ||||
| 					<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button> | ||||
|   | ||||
| @@ -65,15 +65,34 @@ | ||||
|   flex-wrap: wrap; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   gap: 1em; | ||||
| } | ||||
|  | ||||
| .page-footer .right-links > a { | ||||
|   border-left: 1px solid var(--color-secondary-dark-1); | ||||
|   padding-left: 8px; | ||||
|   margin-left: 5px; | ||||
|   padding-left: 1em; | ||||
| } | ||||
|  | ||||
| .page-footer .ui.dropdown .menu.language-menu { | ||||
| /* the theme item is also used for the menu's "default text" display */ | ||||
| .page-footer .ui.dropdown .theme-menu-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.5em; | ||||
| } | ||||
|  | ||||
| /* Fomantic UI dropdown "remote items by API" can't change parent "item" element, | ||||
| so we use "theme-menu-item" in the "item" and add tooltip to the inner one. | ||||
| Then the inner one needs to get padding and parent "item" padding needs to be removed */ | ||||
| .page-footer .menu.theme-menu > .item { | ||||
|   padding: 0 !important; | ||||
| } | ||||
|  | ||||
| .page-footer .menu.theme-menu > .item > .theme-menu-item { | ||||
|   padding: 11px 16px; | ||||
| } | ||||
|  | ||||
| .page-footer .ui.dropdown .menu.language-menu, | ||||
| .page-footer .ui.dropdown .menu.theme-menu { | ||||
|   max-height: min(500px, calc(100vh - 60px)); | ||||
|   overflow-y: auto; | ||||
|   margin-bottom: 10px; | ||||
|   | ||||
| @@ -2,5 +2,7 @@ | ||||
| @import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark); | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Auto (Red/Green Colorblind-friendly)"; | ||||
|   --theme-display-name: "Auto"; | ||||
|   --theme-colorblind-type: "red-green"; | ||||
|   --theme-color-scheme: "auto"; | ||||
| } | ||||
|   | ||||
| @@ -3,4 +3,5 @@ | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Auto"; | ||||
|   --theme-color-scheme: "auto"; | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| @import "./theme-gitea-dark.css"; | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Dark (Red/Green Colorblind-friendly)"; | ||||
|   --theme-display-name: "Dark"; | ||||
|   --theme-colorblind-type: "red-green"; | ||||
|   --theme-color-scheme: "dark"; | ||||
| } | ||||
|  | ||||
| /* red/green colorblind-friendly colors */ | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Dark"; | ||||
|   --theme-color-scheme: "dark"; | ||||
| } | ||||
|  | ||||
| :root { | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| @import "./theme-gitea-light.css"; | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Light (Red/Green Colorblind-friendly)"; | ||||
|   --theme-display-name: "Light"; | ||||
|   --theme-colorblind-type: "red-green"; | ||||
|   --theme-color-scheme: "light"; | ||||
| } | ||||
|  | ||||
| /* red/green colorblind-friendly colors */ | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  | ||||
| gitea-theme-meta-info { | ||||
|   --theme-display-name: "Light"; | ||||
|   --theme-color-scheme: "light"; | ||||
| } | ||||
|  | ||||
| :root { | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| import {GET} from '../modules/fetch.ts'; | ||||
| import {GET, POST} from '../modules/fetch.ts'; | ||||
| import {showGlobalErrorMessage} from '../bootstrap.ts'; | ||||
| import {fomanticQuery} from '../modules/fomantic/base.ts'; | ||||
| import {queryElems} from '../utils/dom.ts'; | ||||
| import {addDelegatedEventListener, queryElems} from '../utils/dom.ts'; | ||||
| import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; | ||||
| import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; | ||||
| import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts'; | ||||
|  | ||||
| const {appUrl} = window.config; | ||||
| const {appUrl, appSubUrl} = window.config; | ||||
|  | ||||
| export function initHeadNavbarContentToggle() { | ||||
| function initHeadNavbarContentToggle() { | ||||
|   const navbar = document.querySelector('#navbar'); | ||||
|   const btn = document.querySelector('#navbar-expand-toggle'); | ||||
|   if (!navbar || !btn) return; | ||||
| @@ -20,7 +20,7 @@ export function initHeadNavbarContentToggle() { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function initFootLanguageMenu() { | ||||
| function initFooterLanguageMenu() { | ||||
|   document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => { | ||||
|     const item = (e.target as HTMLElement).closest('.item'); | ||||
|     if (!item) return; | ||||
| @@ -30,6 +30,27 @@ export function initFootLanguageMenu() { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function initFooterThemeSelector() { | ||||
|   const elDropdown = document.querySelector('#footer-theme-selector'); | ||||
|   if (!elDropdown) return; // some pages don't have footer, for example: 500.tmpl | ||||
|   const $dropdown = fomanticQuery(elDropdown); | ||||
|   $dropdown.dropdown({ | ||||
|     direction: 'upward', | ||||
|     apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false}, | ||||
|   }); | ||||
|   addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => { | ||||
|     const themeName = el.getAttribute('data-value'); | ||||
|     await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`); | ||||
|     window.location.reload(); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function initCommmPageComponents() { | ||||
|   initHeadNavbarContentToggle(); | ||||
|   initFooterLanguageMenu(); | ||||
|   initFooterThemeSelector(); | ||||
| } | ||||
|  | ||||
| export function initGlobalDropdown() { | ||||
|   // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. | ||||
|   registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => { | ||||
|   | ||||
| @@ -60,7 +60,7 @@ import {initColorPickers} from './features/colorpicker.ts'; | ||||
| import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; | ||||
| import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; | ||||
| import {initGlobalFetchAction} from './features/common-fetch-action.ts'; | ||||
| import {initFootLanguageMenu, initGlobalComponent, initGlobalDropdown, initGlobalInput, initHeadNavbarContentToggle} from './features/common-page.ts'; | ||||
| import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts'; | ||||
| import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; | ||||
| import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; | ||||
| import {callInitFunctions} from './modules/init.ts'; | ||||
| @@ -93,8 +93,7 @@ const initPerformanceTracer = callInitFunctions([ | ||||
|  | ||||
|   initInstall, | ||||
|  | ||||
|   initHeadNavbarContentToggle, | ||||
|   initFootLanguageMenu, | ||||
|   initCommmPageComponents, | ||||
|  | ||||
|   initHeatmap, | ||||
|   initImageDiff, | ||||
|   | ||||
							
								
								
									
										13
									
								
								web_src/svg/gitea-colorblind-redgreen.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web_src/svg/gitea-colorblind-redgreen.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|   <g clip-path="url(#clip0)"> | ||||
|     <rect width="40" height="40" rx="20" fill="#0000"/> | ||||
|     <path d="M34.2843 34.2842C42.0948 26.4737 42.0948 13.8104 34.2843 5.9999L6 34.2842C13.8105 42.0947 26.4738 42.0947 34.2843 34.2842Z" fill="#0566D5"/> | ||||
|     <path d="M34.2828 34.2842C42.0932 26.4737 42.0932 13.8104 34.2828 5.99995L20.1406 20.1421L34.2828 34.2842Z" fill="#E7A100"/> | ||||
|     <circle cx="20" cy="20" r="18" fill="#0000" stroke="#aaa" stroke-width="4"/> | ||||
|   </g> | ||||
|   <defs> | ||||
|     <clipPath id="clip0"> | ||||
|       <rect width="40" height="40" rx="20" fill="#0000"/> | ||||
|     </clipPath> | ||||
|   </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 678 B | 
							
								
								
									
										1
									
								
								web_src/svg/gitea-eclipse.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web_src/svg/gitea-eclipse.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg viewBox="490 490 820 820"><path d="M866.7 582.1A321.3 321.3 0 0 0 738.6 623a317.3 317.3 0 0 0-109.1 108.5A418 418 0 0 0 609 772a335.3 335.3 0 0 0-19.6 71.5 205.2 205.2 0 0 0-2.8 45c0 25.8.2 29.3 2.8 45 4.1 25.4 9.9 46.4 19.6 71.5a314.2 314.2 0 0 0 111.6 137.3A306.8 306.8 0 0 0 893 1196a308.6 308.6 0 0 0 303.6-262.5c2.6-15.7 2.8-19.2 2.8-45s-.2-29.3-2.8-45A335.3 335.3 0 0 0 1177 772a314.2 314.2 0 0 0-111.6-137.3A308.3 308.3 0 0 0 918 582c-13-1.1-38.2-1.1-51.3.1zM747 663.5l-2.4 16.7c-4 26.4-4.9 41.1-4.3 65.3a323.7 323.7 0 0 0 37.2 145c18.2 36 41.3 66.6 72 95.5a346.4 346.4 0 0 0 208.5 93.1l18 1.6 4.5.5-8.5 8a259.3 259.3 0 0 1-141.5 65.8 281 281 0 0 1-123.9-11.4 267.2 267.2 0 0 1-181.7-269.9c2-27.6 5.7-47.6 13.3-70.7a281.2 281.2 0 0 1 46.4-85c8-10.1 28-30.2 37.9-38.1 13.8-11.1 24.5-18.3 24.5-16.4z"/></svg> | ||||
| After Width: | Height: | Size: 818 B | 
		Reference in New Issue
	
	Block a user