mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-03 08:02:36 +09: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/json"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/log"
 | 
						"code.gitea.io/gitea/modules/log"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// SessionConfig defines Session settings
 | 
					// SessionConfig defines Session settings
 | 
				
			||||||
@@ -49,10 +50,8 @@ func loadSessionFrom(rootCfg ConfigProvider) {
 | 
				
			|||||||
		checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig)
 | 
							checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea")
 | 
						SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea")
 | 
				
			||||||
	SessionConfig.CookiePath = AppSubURL
 | 
						// HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath
 | 
				
			||||||
	if SessionConfig.CookiePath == "" {
 | 
						SessionConfig.CookiePath = util.IfZero(AppSubURL, "/")
 | 
				
			||||||
		SessionConfig.CookiePath = "/"
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://"))
 | 
						SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://"))
 | 
				
			||||||
	SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400)
 | 
						SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400)
 | 
				
			||||||
	SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_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)
 | 
					// RenderHTML renders icons - arguments icon name (string), size (int), class (string)
 | 
				
			||||||
func RenderHTML(icon string, others ...any) template.HTML {
 | 
					func RenderHTML(icon string, others ...any) template.HTML {
 | 
				
			||||||
 | 
						if icon == "" {
 | 
				
			||||||
 | 
							return ""
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
 | 
						size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
 | 
				
			||||||
	if svgStr, ok := svgIcons[icon]; ok {
 | 
						if svgStr, ok := svgIcons[icon]; ok {
 | 
				
			||||||
		// the code is somewhat hacky, but it just works, because the SVG contents are all normalized
 | 
							// the code is somewhat hacky, but it just works, because the SVG contents are all normalized
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,6 @@ import (
 | 
				
			|||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user_model "code.gitea.io/gitea/models/user"
 | 
					 | 
				
			||||||
	"code.gitea.io/gitea/modules/base"
 | 
						"code.gitea.io/gitea/modules/base"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/htmlutil"
 | 
						"code.gitea.io/gitea/modules/htmlutil"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/markup"
 | 
						"code.gitea.io/gitea/modules/markup"
 | 
				
			||||||
@@ -21,7 +20,6 @@ import (
 | 
				
			|||||||
	"code.gitea.io/gitea/modules/templates/eval"
 | 
						"code.gitea.io/gitea/modules/templates/eval"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/util"
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
	"code.gitea.io/gitea/services/gitdiff"
 | 
						"code.gitea.io/gitea/services/gitdiff"
 | 
				
			||||||
	"code.gitea.io/gitea/services/webtheme"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// NewFuncMap returns functions for injecting to templates
 | 
					// NewFuncMap returns functions for injecting to templates
 | 
				
			||||||
@@ -130,7 +128,6 @@ func NewFuncMap() template.FuncMap {
 | 
				
			|||||||
		"DisableWebhooks": func() bool {
 | 
							"DisableWebhooks": func() bool {
 | 
				
			||||||
			return setting.DisableWebhooks
 | 
								return setting.DisableWebhooks
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		"UserThemeName": userThemeName,
 | 
					 | 
				
			||||||
		"NotificationSettings": func() map[string]any {
 | 
							"NotificationSettings": func() map[string]any {
 | 
				
			||||||
			return map[string]any{
 | 
								return map[string]any{
 | 
				
			||||||
				"MinTimeout":            int(setting.UI.Notification.MinTimeout / time.Millisecond),
 | 
									"MinTimeout":            int(setting.UI.Notification.MinTimeout / time.Millisecond),
 | 
				
			||||||
@@ -217,16 +214,6 @@ func evalTokens(tokens ...any) (any, error) {
 | 
				
			|||||||
	return n.Value, err
 | 
						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 {
 | 
					func isQueryParamEmpty(v any) bool {
 | 
				
			||||||
	return v == nil || v == false || v == 0 || v == int64(0) || v == ""
 | 
						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/markup/markdown"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/reqctx"
 | 
						"code.gitea.io/gitea/modules/reqctx"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/svg"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/translation"
 | 
						"code.gitea.io/gitea/modules/translation"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/util"
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/services/webtheme"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type RenderUtils struct {
 | 
					type RenderUtils struct {
 | 
				
			||||||
@@ -259,3 +261,18 @@ func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink strin
 | 
				
			|||||||
	htmlCode += "</span>"
 | 
						htmlCode += "</span>"
 | 
				
			||||||
	return template.HTML(htmlCode)
 | 
						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/session"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// SetRedirectToCookie convenience function to set the RedirectTo cookie consistently
 | 
					// 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
 | 
						// These are more specific than cookies without a trailing /, so
 | 
				
			||||||
	// we need to delete these if they exist.
 | 
						// we need to delete these if they exist.
 | 
				
			||||||
	deleteLegacySiteCookie(resp, name)
 | 
						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{
 | 
						cookie := &http.Cookie{
 | 
				
			||||||
		Name:     name,
 | 
							Name:     name,
 | 
				
			||||||
		Value:    url.QueryEscape(value),
 | 
							Value:    url.QueryEscape(value),
 | 
				
			||||||
		MaxAge:   maxAge,
 | 
							MaxAge:   maxAge,
 | 
				
			||||||
		Path:     setting.SessionConfig.CookiePath,
 | 
							Path:     util.IfZero(setting.SessionConfig.CookiePath, "/"),
 | 
				
			||||||
		Domain:   setting.SessionConfig.Domain,
 | 
							Domain:   setting.SessionConfig.Domain,
 | 
				
			||||||
		Secure:   setting.SessionConfig.Secure,
 | 
							Secure:   setting.SessionConfig.Secure,
 | 
				
			||||||
		HttpOnly: true,
 | 
							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})
 | 
						httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true})
 | 
				
			||||||
	w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
 | 
						w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	tmplCtx := context.TemplateContext{}
 | 
						tmplCtx := context.NewTemplateContext(req.Context(), req)
 | 
				
			||||||
	tmplCtx["Locale"] = middleware.Locale(w, req)
 | 
						tmplCtx["Locale"] = middleware.Locale(w, req)
 | 
				
			||||||
	ctxData := middleware.GetContextData(req.Context())
 | 
						ctxData := middleware.GetContextData(req.Context())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -133,7 +133,7 @@ func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	tmplCtx := giteacontext.TemplateContext{}
 | 
						tmplCtx := giteacontext.NewTemplateContext(req.Context(), req)
 | 
				
			||||||
	tmplCtx["Locale"] = middleware.Locale(w, req)
 | 
						tmplCtx["Locale"] = middleware.Locale(w, req)
 | 
				
			||||||
	ctxData := middleware.GetContextData(req.Context())
 | 
						ctxData := middleware.GetContextData(req.Context())
 | 
				
			||||||
	err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx)
 | 
						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/modules/web"
 | 
				
			||||||
	"code.gitea.io/gitea/routers/common"
 | 
						"code.gitea.io/gitea/routers/common"
 | 
				
			||||||
	"code.gitea.io/gitea/routers/web/healthcheck"
 | 
						"code.gitea.io/gitea/routers/web/healthcheck"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/routers/web/misc"
 | 
				
			||||||
	"code.gitea.io/gitea/services/forms"
 | 
						"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.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.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall)
 | 
				
			||||||
	r.Get("/post-install", InstallDone)
 | 
						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.Get("/api/healthz", healthcheck.Check)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	r.NotFound(installNotFound)
 | 
						r.NotFound(installNotFound)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	base.Mount("", r)
 | 
						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
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !webtheme.IsThemeAvailable(form.Theme) {
 | 
						if webtheme.GetThemeMetaInfo(form.Theme) == nil {
 | 
				
			||||||
		ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
 | 
							ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
 | 
				
			||||||
		ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
 | 
							ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -490,6 +490,9 @@ func registerWebRoutes(m *web.Router) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
 | 
						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.Group("/explore", func() {
 | 
				
			||||||
		m.Get("", func(ctx *context.Context) {
 | 
							m.Get("", func(ctx *context.Context) {
 | 
				
			||||||
			ctx.Redirect(setting.AppSubURL + "/explore/repos")
 | 
								ctx.Redirect(setting.AppSubURL + "/explore/repos")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -103,7 +103,7 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewTemplateContextForWeb(ctx *Context) TemplateContext {
 | 
					func NewTemplateContextForWeb(ctx *Context) TemplateContext {
 | 
				
			||||||
	tmplCtx := NewTemplateContext(ctx)
 | 
						tmplCtx := NewTemplateContext(ctx, ctx.Req)
 | 
				
			||||||
	tmplCtx["Locale"] = ctx.Base.Locale
 | 
						tmplCtx["Locale"] = ctx.Base.Locale
 | 
				
			||||||
	tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
 | 
						tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
 | 
				
			||||||
	tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
 | 
						tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,13 +5,16 @@ package context
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/services/webtheme"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var _ context.Context = TemplateContext(nil)
 | 
					var _ context.Context = TemplateContext(nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewTemplateContext(ctx context.Context) TemplateContext {
 | 
					func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
 | 
				
			||||||
	return TemplateContext{"_ctx": ctx}
 | 
						return TemplateContext{"_ctx": ctx, "_req": req}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c TemplateContext) parentContext() context.Context {
 | 
					func (c TemplateContext) parentContext() context.Context {
 | 
				
			||||||
@@ -33,3 +36,19 @@ func (c TemplateContext) Err() error {
 | 
				
			|||||||
func (c TemplateContext) Value(key any) any {
 | 
					func (c TemplateContext) Value(key any) any {
 | 
				
			||||||
	return c.parentContext().Value(key)
 | 
						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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var (
 | 
					var (
 | 
				
			||||||
	availableThemes   []*ThemeMetaInfo
 | 
						availableThemes   []*ThemeMetaInfo
 | 
				
			||||||
	availableThemeInternalNames container.Set[string]
 | 
						availableThemeMap map[string]*ThemeMetaInfo
 | 
				
			||||||
	themeOnce         sync.Once
 | 
						themeOnce         sync.Once
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,6 +31,22 @@ type ThemeMetaInfo struct {
 | 
				
			|||||||
	FileName       string
 | 
						FileName       string
 | 
				
			||||||
	InternalName   string
 | 
						InternalName   string
 | 
				
			||||||
	DisplayName    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 {
 | 
					func parseThemeMetaInfoToMap(cssContent string) map[string]string {
 | 
				
			||||||
@@ -54,7 +70,7 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string {
 | 
				
			|||||||
|('(\\'|[^'])*')
 | 
					|('(\\'|[^'])*')
 | 
				
			||||||
|([^'";]+)
 | 
					|([^'";]+)
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
\s*;
 | 
					\s*;?
 | 
				
			||||||
\s*
 | 
					\s*
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
@@ -102,17 +118,19 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
 | 
				
			|||||||
		return themeInfo
 | 
							return themeInfo
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	themeInfo.DisplayName = m["--theme-display-name"]
 | 
						themeInfo.DisplayName = m["--theme-display-name"]
 | 
				
			||||||
 | 
						themeInfo.ColorblindType = m["--theme-colorblind-type"]
 | 
				
			||||||
 | 
						themeInfo.ColorScheme = m["--theme-color-scheme"]
 | 
				
			||||||
	return themeInfo
 | 
						return themeInfo
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initThemes() {
 | 
					func initThemes() {
 | 
				
			||||||
	availableThemes = nil
 | 
						availableThemes = nil
 | 
				
			||||||
	defer func() {
 | 
						defer func() {
 | 
				
			||||||
		availableThemeInternalNames = container.Set[string]{}
 | 
							availableThemeMap = map[string]*ThemeMetaInfo{}
 | 
				
			||||||
		for _, theme := range availableThemes {
 | 
							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)
 | 
								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 {
 | 
							if availableThemes[i].InternalName == setting.UI.DefaultTheme {
 | 
				
			||||||
			return true
 | 
								return true
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType {
 | 
				
			||||||
 | 
								return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		return availableThemes[i].DisplayName < availableThemes[j].DisplayName
 | 
							return availableThemes[i].DisplayName < availableThemes[j].DisplayName
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if len(availableThemes) == 0 {
 | 
						if len(availableThemes) == 0 {
 | 
				
			||||||
@@ -160,7 +181,21 @@ func GetAvailableThemes() []*ThemeMetaInfo {
 | 
				
			|||||||
	return availableThemes
 | 
						return availableThemes
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func IsThemeAvailable(internalName string) bool {
 | 
					func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
 | 
				
			||||||
	themeOnce.Do(initThemes)
 | 
						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;
 | 
						--k2: real;
 | 
				
			||||||
}`)
 | 
					}`)
 | 
				
			||||||
	assert.Equal(t, map[string]string{"--k2": "real"}, m)
 | 
						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}}
 | 
							{{end}}
 | 
				
			||||||
	</div>
 | 
						</div>
 | 
				
			||||||
	<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
 | 
						<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">
 | 
							<div class="ui dropdown upward">
 | 
				
			||||||
			<span class="flex-text-inline">{{svg "octicon-globe" 14}} {{ctx.Locale.LangName}}</span>
 | 
								<span class="flex-text-inline">{{svg "octicon-globe" 14}} {{ctx.Locale.LangName}}</span>
 | 
				
			||||||
			<div class="menu language-menu">
 | 
								<div class="menu language-menu">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<!DOCTYPE html>
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}">
 | 
					<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
 | 
				
			||||||
<head>
 | 
					<head>
 | 
				
			||||||
	<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
						<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
				
			||||||
	<title>{{if .Title}}{{.Title}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title>
 | 
						<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/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.
 | 
					{{/* 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
 | 
					* ctx.Locale
 | 
				
			||||||
* .Flash
 | 
					* .Flash
 | 
				
			||||||
* .ErrorMsg
 | 
					* .ErrorMsg
 | 
				
			||||||
* .SignedUser (optional)
 | 
					* .SignedUser (optional)
 | 
				
			||||||
*/}}
 | 
					*/}}
 | 
				
			||||||
<!DOCTYPE html>
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}">
 | 
					<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
 | 
				
			||||||
<head>
 | 
					<head>
 | 
				
			||||||
	<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
						<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
				
			||||||
	<title>Internal Server Error - {{AppName}}</title>
 | 
						<title>Internal Server Error - {{AppName}}</title>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,11 +16,19 @@
 | 
				
			|||||||
				</div>
 | 
									</div>
 | 
				
			||||||
				<div class="field">
 | 
									<div class="field">
 | 
				
			||||||
					<label>{{ctx.Locale.Tr "settings.ui"}}</label>
 | 
										<label>{{ctx.Locale.Tr "settings.ui"}}</label>
 | 
				
			||||||
					<select name="theme" class="ui dropdown">
 | 
										<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}}
 | 
												{{range $theme := .AllThemes}}
 | 
				
			||||||
						<option value="{{$theme.InternalName}}" {{Iif (eq $.SignedUser.Theme $theme.InternalName) "selected"}}>{{$theme.DisplayName}}</option>
 | 
													{{$extraIconName := $theme.GetExtraIconName}}
 | 
				
			||||||
 | 
													<div class="item" data-value="{{$theme.InternalName}}">
 | 
				
			||||||
 | 
														{{$theme.DisplayName}} {{svg $extraIconName}}
 | 
				
			||||||
 | 
														<div class="description">{{$theme.GetDescription}}</div>
 | 
				
			||||||
 | 
													</div>
 | 
				
			||||||
							{{end}}
 | 
												{{end}}
 | 
				
			||||||
					</select>
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
				<div class="field">
 | 
									<div class="field">
 | 
				
			||||||
					<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button>
 | 
										<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -65,15 +65,34 @@
 | 
				
			|||||||
  flex-wrap: wrap;
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  gap: 1em;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.page-footer .right-links > a {
 | 
					.page-footer .right-links > a {
 | 
				
			||||||
  border-left: 1px solid var(--color-secondary-dark-1);
 | 
					  border-left: 1px solid var(--color-secondary-dark-1);
 | 
				
			||||||
  padding-left: 8px;
 | 
					  padding-left: 1em;
 | 
				
			||||||
  margin-left: 5px;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.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));
 | 
					  max-height: min(500px, calc(100vh - 60px));
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
  margin-bottom: 10px;
 | 
					  margin-bottom: 10px;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,5 +2,7 @@
 | 
				
			|||||||
@import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark);
 | 
					@import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gitea-theme-meta-info {
 | 
					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 {
 | 
					gitea-theme-meta-info {
 | 
				
			||||||
  --theme-display-name: "Auto";
 | 
					  --theme-display-name: "Auto";
 | 
				
			||||||
 | 
					  --theme-color-scheme: "auto";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
@import "./theme-gitea-dark.css";
 | 
					@import "./theme-gitea-dark.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gitea-theme-meta-info {
 | 
					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 */
 | 
					/* red/green colorblind-friendly colors */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
gitea-theme-meta-info {
 | 
					gitea-theme-meta-info {
 | 
				
			||||||
  --theme-display-name: "Dark";
 | 
					  --theme-display-name: "Dark";
 | 
				
			||||||
 | 
					  --theme-color-scheme: "dark";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
:root {
 | 
					:root {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
@import "./theme-gitea-light.css";
 | 
					@import "./theme-gitea-light.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gitea-theme-meta-info {
 | 
					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 */
 | 
					/* red/green colorblind-friendly colors */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
gitea-theme-meta-info {
 | 
					gitea-theme-meta-info {
 | 
				
			||||||
  --theme-display-name: "Light";
 | 
					  --theme-display-name: "Light";
 | 
				
			||||||
 | 
					  --theme-color-scheme: "light";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
:root {
 | 
					:root {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,14 @@
 | 
				
			|||||||
import {GET} from '../modules/fetch.ts';
 | 
					import {GET, POST} from '../modules/fetch.ts';
 | 
				
			||||||
import {showGlobalErrorMessage} from '../bootstrap.ts';
 | 
					import {showGlobalErrorMessage} from '../bootstrap.ts';
 | 
				
			||||||
import {fomanticQuery} from '../modules/fomantic/base.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 {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
 | 
				
			||||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
 | 
					import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
 | 
				
			||||||
import {initCompSearchRepoBox} from './comp/SearchRepoBox.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 navbar = document.querySelector('#navbar');
 | 
				
			||||||
  const btn = document.querySelector('#navbar-expand-toggle');
 | 
					  const btn = document.querySelector('#navbar-expand-toggle');
 | 
				
			||||||
  if (!navbar || !btn) return;
 | 
					  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) => {
 | 
					  document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => {
 | 
				
			||||||
    const item = (e.target as HTMLElement).closest('.item');
 | 
					    const item = (e.target as HTMLElement).closest('.item');
 | 
				
			||||||
    if (!item) return;
 | 
					    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() {
 | 
					export function initGlobalDropdown() {
 | 
				
			||||||
  // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
 | 
					  // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
 | 
				
			||||||
  registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
 | 
					  registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,7 +60,7 @@ import {initColorPickers} from './features/colorpicker.ts';
 | 
				
			|||||||
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
 | 
					import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
 | 
				
			||||||
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
 | 
					import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
 | 
				
			||||||
import {initGlobalFetchAction} from './features/common-fetch-action.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 {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
 | 
				
			||||||
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
 | 
					import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
 | 
				
			||||||
import {callInitFunctions} from './modules/init.ts';
 | 
					import {callInitFunctions} from './modules/init.ts';
 | 
				
			||||||
@@ -93,8 +93,7 @@ const initPerformanceTracer = callInitFunctions([
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  initInstall,
 | 
					  initInstall,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  initHeadNavbarContentToggle,
 | 
					  initCommmPageComponents,
 | 
				
			||||||
  initFootLanguageMenu,
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  initHeatmap,
 | 
					  initHeatmap,
 | 
				
			||||||
  initImageDiff,
 | 
					  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