Allow for user specific themes (#5668)

* add migration and basic UI for changing a user's theme

* update user themem

* use right text on button

* load theme based on users' selection

* load theme based on users' selection in pwa too

* update sample config

* delete older theme loading

* implement AfterLoad to set users' theme properly

* set up default theme when creating a user. This uses the installation wide theme

* use flash messages for error

* set default theme when creating a user from the cli

* fix @lunny review
This commit is contained in:
Lanre Adelowo 2019-01-09 18:22:57 +01:00 committed by techknowlogick
parent ea518681d9
commit 8d2c24f7f9
14 changed files with 157 additions and 11 deletions

View file

@ -340,6 +340,7 @@ func runCreateUser(c *cli.Context) error {
IsActive: true, IsActive: true,
IsAdmin: c.Bool("admin"), IsAdmin: c.Bool("admin"),
MustChangePassword: changePassword, MustChangePassword: changePassword,
Theme: setting.UI.DefaultTheme,
}); err != nil { }); err != nil {
return fmt.Errorf("CreateUser: %v", err) return fmt.Errorf("CreateUser: %v", err)
} }

View file

@ -85,6 +85,8 @@ MAX_DISPLAY_FILE_SIZE = 8388608
SHOW_USER_EMAIL = true SHOW_USER_EMAIL = true
; Set the default theme for the Gitea install ; Set the default theme for the Gitea install
DEFAULT_THEME = gitea DEFAULT_THEME = gitea
; All available themes
THEMES = gitea,arc-green
[ui.admin] [ui.admin]
; Number of users that are displayed on one page ; Number of users that are displayed on one page

View file

@ -18,7 +18,7 @@ import (
"github.com/Unknwon/com" "github.com/Unknwon/com"
"github.com/go-xorm/xorm" "github.com/go-xorm/xorm"
gouuid "github.com/satori/go.uuid" gouuid "github.com/satori/go.uuid"
"gopkg.in/ini.v1" ini "gopkg.in/ini.v1"
"code.gitea.io/gitea/modules/generate" "code.gitea.io/gitea/modules/generate"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@ -206,6 +206,8 @@ var migrations = []Migration{
NewMigration("clear nonused data which not deleted when user was deleted", clearNonusedData), NewMigration("clear nonused data which not deleted when user was deleted", clearNonusedData),
// v76 -> v77 // v76 -> v77
NewMigration("add pull request rebase with merge commit", addPullRequestRebaseWithMerge), NewMigration("add pull request rebase with merge commit", addPullRequestRebaseWithMerge),
// v77 -> v78
NewMigration("add theme to users", addUserDefaultTheme),
} }
// Migrate database to current version // Migrate database to current version

17
models/migrations/v77.go Normal file
View file

@ -0,0 +1,17 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package migrations
import (
"github.com/go-xorm/xorm"
)
func addUserDefaultTheme(x *xorm.Engine) error {
type User struct {
Theme string `xorm:"VARCHAR(30)"`
}
return x.Sync2(new(User))
}

View file

@ -140,6 +140,7 @@ type User struct {
// Preferences // Preferences
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"`
} }
// BeforeUpdate is invoked from XORM before updating this object. // BeforeUpdate is invoked from XORM before updating this object.
@ -165,6 +166,13 @@ func (u *User) BeforeUpdate() {
u.Description = base.TruncateString(u.Description, 255) u.Description = base.TruncateString(u.Description, 255)
} }
// 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
}
}
// SetLastLogin set time to last login // SetLastLogin set time to last login
func (u *User) SetLastLogin() { func (u *User) SetLastLogin() {
u.LastLoginUnix = util.TimeStampNow() u.LastLoginUnix = util.TimeStampNow()
@ -176,6 +184,12 @@ func (u *User) UpdateDiffViewStyle(style string) error {
return UpdateUserCols(u, "diff_view_style") return UpdateUserCols(u, "diff_view_style")
} }
// UpdateTheme updates a users' theme irrespective of the site wide theme
func (u *User) UpdateTheme(themeName string) error {
u.Theme = themeName
return UpdateUserCols(u, "theme")
}
// getEmail returns an noreply email, if the user has set to keep his // getEmail returns an noreply email, if the user has set to keep his
// email address private, otherwise the primary email address. // email address private, otherwise the primary email address.
func (u *User) getEmail() string { func (u *User) getEmail() string {
@ -777,6 +791,7 @@ func CreateUser(u *User) (err error) {
u.HashPassword(u.Passwd) u.HashPassword(u.Passwd)
u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization
u.MaxRepoCreation = -1 u.MaxRepoCreation = -1
u.Theme = setting.UI.DefaultTheme
if _, err = sess.Insert(u); err != nil { if _, err = sess.Insert(u); err != nil {
return err return err

View file

@ -12,7 +12,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/go-macaron/binding" "github.com/go-macaron/binding"
"gopkg.in/macaron.v1" macaron "gopkg.in/macaron.v1"
) )
// InstallForm form for installation page // InstallForm form for installation page
@ -189,6 +189,30 @@ func (f *AddEmailForm) Validate(ctx *macaron.Context, errs binding.Errors) bindi
return validate(errs, ctx.Data, f, ctx.Locale) return validate(errs, ctx.Data, f, ctx.Locale)
} }
// UpdateThemeForm form for updating a users' theme
type UpdateThemeForm struct {
Theme string `binding:"Required;MaxSize(30)"`
}
// Validate validates the field
func (f *UpdateThemeForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
return validate(errs, ctx.Data, f, ctx.Locale)
}
// IsThemeExists checks if the theme is a theme available in the config.
func (f UpdateThemeForm) IsThemeExists() bool {
var exists bool
for _, v := range setting.UI.Themes {
if strings.ToLower(v) == strings.ToLower(f.Theme) {
exists = true
break
}
}
return exists
}
// ChangePasswordForm form for changing password // ChangePasswordForm form for changing password
type ChangePasswordForm struct { type ChangePasswordForm struct {
OldPassword string `form:"old_password" binding:"MaxSize(255)"` OldPassword string `form:"old_password" binding:"MaxSize(255)"`

View file

@ -8,4 +8,5 @@ var (
defaultLangs = strings.Split("en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,uk-UA,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR", ",") defaultLangs = strings.Split("en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,uk-UA,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR", ",")
defaultLangNames = strings.Split("English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,Українська,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어", ",") defaultLangNames = strings.Split("English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,Українська,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어", ",")
defaultPullRequestWorkInProgressPrefixes = strings.Split("WIP:,[WIP]", ",") defaultPullRequestWorkInProgressPrefixes = strings.Split("WIP:,[WIP]", ",")
defaultThemes = strings.Split("gitea", "arc-green")
) )

View file

@ -33,9 +33,9 @@ import (
"github.com/go-macaron/session" "github.com/go-macaron/session"
_ "github.com/go-macaron/session/redis" // redis plugin for store session _ "github.com/go-macaron/session/redis" // redis plugin for store session
"github.com/go-xorm/core" "github.com/go-xorm/core"
"github.com/kballard/go-shellquote" shellquote "github.com/kballard/go-shellquote"
"github.com/mcuadros/go-version" version "github.com/mcuadros/go-version"
"gopkg.in/ini.v1" ini "gopkg.in/ini.v1"
"strk.kbt.io/projects/go/libravatar" "strk.kbt.io/projects/go/libravatar"
) )
@ -303,6 +303,7 @@ var (
MaxDisplayFileSize int64 MaxDisplayFileSize int64
ShowUserEmail bool ShowUserEmail bool
DefaultTheme string DefaultTheme string
Themes []string
Admin struct { Admin struct {
UserPagingNum int UserPagingNum int
@ -329,6 +330,7 @@ var (
ThemeColorMetaTag: `#6cc644`, ThemeColorMetaTag: `#6cc644`,
MaxDisplayFileSize: 8388608, MaxDisplayFileSize: 8388608,
DefaultTheme: `gitea`, DefaultTheme: `gitea`,
Themes: []string{`gitea`, `arc-green`},
Admin: struct { Admin: struct {
UserPagingNum int UserPagingNum int
RepoPagingNum int RepoPagingNum int

View file

@ -355,6 +355,7 @@ password_username_disabled = Non-local users are not allowed to change their use
full_name = Full Name full_name = Full Name
website = Website website = Website
location = Location location = Location
update_theme = Update Theme
update_profile = Update Profile update_profile = Update Profile
update_profile_success = Your profile has been updated. update_profile_success = Your profile has been updated.
change_username = Your username has been changed. change_username = Your username has been changed.
@ -362,6 +363,7 @@ change_username_prompt = Note: username changes also change your account URL.
continue = Continue continue = Continue
cancel = Cancel cancel = Cancel
language = Language language = Language
ui = Theme
lookup_avatar_by_mail = Look Up Avatar by Email Address lookup_avatar_by_mail = Look Up Avatar by Email Address
federated_avatar_lookup = Federated Avatar Lookup federated_avatar_lookup = Federated Avatar Lookup
@ -382,14 +384,18 @@ password_change_disabled = Non-local users can not update their password through
emails = Email Addresses emails = Email Addresses
manage_emails = Manage Email Addresses manage_emails = Manage Email Addresses
manage_themes = Select default theme
manage_openid = Manage OpenID Addresses manage_openid = Manage OpenID Addresses
email_desc = Your primary email address will be used for notifications and other operations. email_desc = Your primary email address will be used for notifications and other operations.
theme_desc = This will be your default theme across the site.
primary = Primary primary = Primary
primary_email = Make Primary primary_email = Make Primary
delete_email = Remove delete_email = Remove
email_deletion = Remove Email Address email_deletion = Remove Email Address
email_deletion_desc = The email address and related information will be removed from your account. Git commits by this email address will remain unchanged. Continue? email_deletion_desc = The email address and related information will be removed from your account. Git commits by this email address will remain unchanged. Continue?
email_deletion_success = The email address has been removed. email_deletion_success = The email address has been removed.
theme_update_success = Your theme was updated.
theme_update_error = The selected theme does not exist.
openid_deletion = Remove OpenID Address openid_deletion = Remove OpenID Address
openid_deletion_desc = Removing this OpenID address from your account will prevent you from signing in with it. Continue? openid_deletion_desc = Removing this OpenID address from your account will prevent you from signing in with it. Continue?
openid_deletion_success = The OpenID address has been removed. openid_deletion_success = The OpenID address has been removed.

View file

@ -42,7 +42,7 @@ import (
"github.com/go-macaron/toolbox" "github.com/go-macaron/toolbox"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/tstranex/u2f" "github.com/tstranex/u2f"
"gopkg.in/macaron.v1" macaron "gopkg.in/macaron.v1"
) )
// NewMacaron initializes Macaron instance. // NewMacaron initializes Macaron instance.
@ -243,6 +243,7 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Post("/email", bindIgnErr(auth.AddEmailForm{}), userSetting.EmailPost) m.Post("/email", bindIgnErr(auth.AddEmailForm{}), userSetting.EmailPost)
m.Post("/email/delete", userSetting.DeleteEmail) m.Post("/email/delete", userSetting.DeleteEmail)
m.Post("/delete", userSetting.DeleteAccount) m.Post("/delete", userSetting.DeleteAccount)
m.Post("/theme", bindIgnErr(auth.UpdateThemeForm{}), userSetting.UpdateUIThemePost)
}) })
m.Group("/security", func() { m.Group("/security", func() {
m.Get("", userSetting.Security) m.Get("", userSetting.Security)
@ -292,6 +293,7 @@ func RegisterRoutes(m *macaron.Macaron) {
}) })
}, reqSignIn, func(ctx *context.Context) { }, reqSignIn, func(ctx *context.Context) {
ctx.Data["PageIsUserSettings"] = true ctx.Data["PageIsUserSettings"] = true
ctx.Data["AllThemes"] = setting.UI.Themes
}) })
m.Group("/user", func() { m.Group("/user", func() {

View file

@ -168,6 +168,34 @@ func DeleteAccount(ctx *context.Context) {
} }
} }
// UpdateUIThemePost is used to update users' specific theme
func UpdateUIThemePost(ctx *context.Context, form auth.UpdateThemeForm) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsAccount"] = true
if ctx.HasError() {
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
if !form.IsThemeExists() {
ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
if err := ctx.User.UpdateTheme(form.Theme); err != nil {
ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
log.Trace("Update user theme: %s", ctx.User.Name)
ctx.Flash.Success(ctx.Tr("settings.theme_update_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
}
func loadAccountData(ctx *context.Context) { func loadAccountData(ctx *context.Context) {
emails, err := models.GetEmailAddresses(ctx.User.ID) emails, err := models.GetEmailAddresses(ctx.User.ID)
if err != nil { if err != nil {

View file

@ -6,7 +6,7 @@
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<title>{{if .Title}}{{.Title}} - {{end}}{{AppName}}</title> <title>{{if .Title}}{{.Title}} - {{end}}{{AppName}}</title>
<link rel="manifest" href="{{AppSubUrl}}/manifest.json"> <link rel="manifest" href="{{AppSubUrl}}/manifest.json">
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', function() { window.addEventListener('load', function() {
@ -147,7 +147,11 @@
<meta property="og:url" content="{{AppUrl}}" /> <meta property="og:url" content="{{AppUrl}}" />
<meta property="og:description" content="{{MetaDescription}}"> <meta property="og:description" content="{{MetaDescription}}">
{{end}} {{end}}
{{if ne DefaultTheme "gitea"}} {{if .IsSigned }}
{{ if ne .SignedUser.Theme "gitea" }}
<link rel="stylesheet" href="{{AppSubUrl}}/css/theme-{{.SignedUser.Theme}}.css">
{{end}}
{{else if ne DefaultTheme "gitea"}}
<link rel="stylesheet" href="{{AppSubUrl}}/css/theme-{{DefaultTheme}}.css"> <link rel="stylesheet" href="{{AppSubUrl}}/css/theme-{{DefaultTheme}}.css">
{{end}} {{end}}
{{template "custom/header" .}} {{template "custom/header" .}}

View file

@ -32,10 +32,14 @@ var urlsToCache = [
'{{AppSubUrl}}/vendor/plugins/jquery.minicolors/jquery.minicolors.css', '{{AppSubUrl}}/vendor/plugins/jquery.minicolors/jquery.minicolors.css',
'{{AppSubUrl}}/vendor/plugins/jquery.datetimepicker/jquery.datetimepicker.css', '{{AppSubUrl}}/vendor/plugins/jquery.datetimepicker/jquery.datetimepicker.css',
'{{AppSubUrl}}/vendor/plugins/dropzone/dropzone.css', '{{AppSubUrl}}/vendor/plugins/dropzone/dropzone.css',
{{if ne DefaultTheme "gitea"}} {{if .IsSigned }}
'{{AppSubUrl}}/css/theme-{{DefaultTheme}}.css', {{ if ne .SignedUser.Theme "gitea" }}
'{{AppSubUrl}}/css/theme-{{.SignedUser.Theme}}.css'
{{end}}
{{else if ne DefaultTheme "gitea"}}
'{{AppSubUrl}}/css/theme-{{DefaultTheme}}.css'
{{end}} {{end}}
// img // img
'{{AppSubUrl}}/img/gitea-sm.png', '{{AppSubUrl}}/img/gitea-sm.png',
'{{AppSubUrl}}/img/gitea-lg.png', '{{AppSubUrl}}/img/gitea-lg.png',

View file

@ -85,6 +85,44 @@
</form> </form>
</div> </div>
<h4 class="ui top attached header">
{{.i18n.Tr "settings.manage_themes"}}
</h4>
<div class="ui attached segment">
<div class="ui email list">
<div class="item">
{{.i18n.Tr "settings.theme_desc"}}
</div>
<form class="ui form" action="{{.Link}}/theme" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label for="ui">{{.i18n.Tr "settings.ui"}}</label>
<div class="ui selection dropdown" id="ui">
<input name="theme" type="hidden" value="{{.SignedUser.Theme}}">
<i class="dropdown icon"></i>
<div class="text">
{{range $i,$a := .AllThemes}}
{{if eq $.SignedUser.Theme $a}}{{$a}}{{end}}
{{end}}
</div>
<div class="menu">
{{range $i,$a := .AllThemes}}
<div class="item{{if eq $.SignedUser.Theme $a}} active selected{{end}}" data-value="{{$a}}">
{{$a}}
</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<button class="ui green button">{{$.i18n.Tr "settings.update_theme"}}</button>
</div>
</form>
</div>
</div>
<h4 class="ui top attached warning header"> <h4 class="ui top attached warning header">
{{.i18n.Tr "settings.delete_account"}} {{.i18n.Tr "settings.delete_account"}}
</h4> </h4>