]> source.dussan.org Git - gitea.git/commitdiff
custom avatar upload
authorUnknwon <joe2010xtmf@163.com>
Fri, 21 Nov 2014 15:58:08 +0000 (10:58 -0500)
committerUnknwon <joe2010xtmf@163.com>
Fri, 21 Nov 2014 15:58:08 +0000 (10:58 -0500)
20 files changed:
README.md
README_ZH.md
cmd/web.go
conf/app.ini
conf/locale/locale_en-US.ini
gogs.go
models/action.go
models/user.go
modules/auth/user_form.go
modules/avatar/avatar.go
modules/setting/setting.go
routers/user/home.go
routers/user/setting.go
templates/.VERSION
templates/repo/commits_table.tmpl
templates/repo/diff.tmpl
templates/repo/view_list.tmpl
templates/user/dashboard/feeds.tmpl
templates/user/profile.tmpl
templates/user/settings/profile.tmpl

index 176815d0b9d7e03aa7b4b07597d83f2005daa936..f14c4c1baf4245336044899bac35138d57147372 100644 (file)
--- a/README.md
+++ b/README.md
@@ -71,7 +71,6 @@ There are 5 ways to install Gogs:
 - Router and middleware mechanism of [Macaron](https://github.com/Unknwon/macaron).
 - Mail Service, modules design is inspired by [WeTalk](https://github.com/beego/wetalk).
 - System Monitor Status is inspired by [GoBlog](https://github.com/fuxiaohei/goblog).
-- Usage and modification from [beego](http://beego.me) modules.
 - Thanks [lavachen](http://www.lavachen.cn/) and [Rocker](http://weibo.com/rocker1989) for designing Logo.
 - Thanks [gobuild.io](http://gobuild.io) for providing binary compile and download service.
 - Thanks [Crowdin](https://crowdin.com/project/gogs) for providing open source translation plan.
index ae60c36b3483b8e902f4dd75fa8be611a1c83548..7adc5dc5667340015aeae3912d0e34339c21dc62 100644 (file)
@@ -59,8 +59,7 @@ Gogs 的目标是打造一个最简单、最快速和最轻松的方式搭建自
 
 ## 特别鸣谢
 
-- [Macaron](https://github.com/Unknwon/macaron) 的路由与中间件机制。
-- [beego](http://beego.me) 模块的使用与修改。
+- 基于 [Macaron](https://github.com/Unknwon/macaron) 的路由与中间件机制。
 - 基于 [WeTalk](https://github.com/beego/wetalk) 修改的邮件服务和模块设计。
 - 基于 [GoBlog](https://github.com/fuxiaohei/goblog) 修改的系统监视状态。
 - 感谢 [gobuild.io](http://gobuild.io) 提供二进制编译与下载服务。
index de222d6faeebac491557deacbc296f2397a09711..c2017a2850a0f05c2e2ab6ad7528ae1b77d4d05e 100644 (file)
@@ -94,6 +94,13 @@ func newMacaron() *macaron.Macaron {
                        SkipLogging: !setting.DisableRouterLog,
                },
        ))
+       m.Use(macaron.Static(
+               setting.AvatarUploadPath,
+               macaron.StaticOptions{
+                       Prefix:      "avatars",
+                       SkipLogging: !setting.DisableRouterLog,
+               },
+       ))
        m.Use(macaron.Renderer(macaron.RenderOptions{
                Directory:  path.Join(setting.StaticRootPath, "templates"),
                Funcs:      []template.FuncMap{base.TemplateFuncs},
@@ -214,6 +221,7 @@ func runWeb(*cli.Context) {
        m.Group("/user/settings", func() {
                m.Get("", user.Settings)
                m.Post("", bindIgnErr(auth.UpdateProfileForm{}), user.SettingsPost)
+               m.Post("/avatar", binding.MultipartForm(auth.UploadAvatarForm{}), user.SettingsAvatar)
                m.Get("/password", user.SettingsPassword)
                m.Post("/password", bindIgnErr(auth.ChangePasswordForm{}), user.SettingsPasswordPost)
                m.Get("/ssh", user.SettingsSSHKeys)
index dbae8a4fd0b2c0c72f40dd0e28288f3fd9bdc6b0..6374c2423fa1bb31e293035542a1971eebaba1d3 100644 (file)
@@ -167,6 +167,7 @@ SESSION_LIFE_TIME = 86400
 [picture]
 ; The place to picture data, either "server" or "qiniu", default is "server"
 SERVICE = server
+AVATAR_UPLOAD_PATH = data/avatars
 ; Chinese users can choose "duoshuo"
 GRAVATAR_SOURCE = gravatar
 DISABLE_GRAVATAR = false
index 3cfc9671582a23a27a3dde979dd992048695fe28..6334966602ae3afd26dedc46ae2774984193f1f7 100644 (file)
@@ -173,6 +173,7 @@ target_branch_not_exist = Target branch does not exist
 
 [user]
 change_avatar = Change your avatar at gravatar.com
+change_custom_avatar = Change your avatar in settings
 join_on = Joined on
 repositories = Repositories
 activity = Public Activity
@@ -201,6 +202,10 @@ change_username = Username Changed
 change_username_desc = Username has been changed, do you want to continue? This will affect all links relate to your account.
 continue = Continue
 cancel = Cancel
+choose_new_avatar = Choose new avatar
+upload_avatar = Upload Avatar
+uploaded_avatar_not_a_image = Uploaded file is not a image
+upload_avatar_success = Your new avatar has been uploaded successfully.
 
 change_password = Change Password
 old_password = Current Password
diff --git a/gogs.go b/gogs.go
index 656b1a2cc45f8d94820be2b3e71b20aec52db757..4027659863b82640cc07e4f0264e0c6f7fd4914c 100644 (file)
--- a/gogs.go
+++ b/gogs.go
@@ -17,7 +17,7 @@ import (
        "github.com/gogits/gogs/modules/setting"
 )
 
-const APP_VER = "0.5.8.1119 Beta"
+const APP_VER = "0.5.8.1121 Beta"
 
 func init() {
        runtime.GOMAXPROCS(runtime.NumCPU())
index 334d143d2c14efb1a57e188f147b5bc864473ea3..269fd753e877f0b525535fd51864332c961d35b5 100644 (file)
@@ -58,6 +58,7 @@ type Action struct {
        ActUserId    int64  // Action user id.
        ActUserName  string // Action user name.
        ActEmail     string
+       ActAvatar    string `xorm:"-"`
        RepoId       int64
        RepoUserName string
        RepoName     string
index 31f4a289ff688593ccbce8fda774a5d93ba4fb97..1337ca23509a2bbc01744469f8724ebc28cc343b 100644 (file)
@@ -5,17 +5,21 @@
 package models
 
 import (
+       "bytes"
        "container/list"
        "crypto/sha256"
        "encoding/hex"
        "errors"
        "fmt"
+       "image"
+       "image/jpeg"
        "os"
        "path/filepath"
        "strings"
        "time"
 
        "github.com/Unknwon/com"
+       "github.com/nfnt/resize"
 
        "github.com/gogits/gogs/modules/base"
        "github.com/gogits/gogs/modules/git"
@@ -45,33 +49,40 @@ var (
 
 // User represents the object of individual and member of organization.
 type User struct {
-       Id            int64
-       LowerName     string `xorm:"UNIQUE NOT NULL"`
-       Name          string `xorm:"UNIQUE NOT NULL"`
-       FullName      string
-       Email         string `xorm:"UNIQUE NOT NULL"`
-       Passwd        string `xorm:"NOT NULL"`
-       LoginType     LoginType
-       LoginSource   int64 `xorm:"NOT NULL DEFAULT 0"`
-       LoginName     string
-       Type          UserType
-       Orgs          []*User       `xorm:"-"`
-       Repos         []*Repository `xorm:"-"`
+       Id          int64
+       LowerName   string `xorm:"UNIQUE NOT NULL"`
+       Name        string `xorm:"UNIQUE NOT NULL"`
+       FullName    string
+       Email       string `xorm:"UNIQUE NOT NULL"`
+       Passwd      string `xorm:"NOT NULL"`
+       LoginType   LoginType
+       LoginSource int64 `xorm:"NOT NULL DEFAULT 0"`
+       LoginName   string
+       Type        UserType
+       Orgs        []*User       `xorm:"-"`
+       Repos       []*Repository `xorm:"-"`
+       Location    string
+       Website     string
+       Rands       string    `xorm:"VARCHAR(10)"`
+       Salt        string    `xorm:"VARCHAR(10)"`
+       Created     time.Time `xorm:"CREATED"`
+       Updated     time.Time `xorm:"UPDATED"`
+
+       // Permissions.
+       IsActive     bool
+       IsAdmin      bool
+       AllowGitHook bool
+
+       // Avatar.
+       Avatar          string `xorm:"VARCHAR(2048) NOT NULL"`
+       AvatarEmail     string `xorm:"NOT NULL"`
+       UseCustomAvatar bool
+
+       // Counters.
        NumFollowers  int
        NumFollowings int
        NumStars      int
        NumRepos      int
-       Avatar        string `xorm:"VARCHAR(2048) NOT NULL"`
-       AvatarEmail   string `xorm:"NOT NULL"`
-       Location      string
-       Website       string
-       IsActive      bool
-       IsAdmin       bool
-       AllowGitHook  bool
-       Rands         string    `xorm:"VARCHAR(10)"`
-       Salt          string    `xorm:"VARCHAR(10)"`
-       Created       time.Time `xorm:"CREATED"`
-       Updated       time.Time `xorm:"UPDATED"`
 
        // For organization.
        Description string
@@ -96,9 +107,12 @@ func (u *User) HomeLink() string {
 
 // AvatarLink returns user gravatar link.
 func (u *User) AvatarLink() string {
-       if setting.DisableGravatar {
+       switch {
+       case u.UseCustomAvatar:
+               return setting.AppSubUrl + "/avatars/" + com.ToStr(u.Id)
+       case setting.DisableGravatar:
                return setting.AppSubUrl + "/img/avatar_default.jpg"
-       } else if setting.Service.EnableCacheAvatar {
+       case setting.Service.EnableCacheAvatar:
                return setting.AppSubUrl + "/avatar/" + u.Avatar
        }
        return setting.GravatarSource + u.Avatar
@@ -126,6 +140,43 @@ func (u *User) ValidtePassword(passwd string) bool {
        return u.Passwd == newUser.Passwd
 }
 
+// UploadAvatar saves custom avatar for user.
+// FIXME: splite uploads to different subdirs in case we have massive users.
+func (u *User) UploadAvatar(data []byte) error {
+       savePath := filepath.Join(setting.AvatarUploadPath, com.ToStr(u.Id))
+       u.UseCustomAvatar = true
+
+       img, _, err := image.Decode(bytes.NewReader(data))
+       if err != nil {
+               return err
+       }
+       m := resize.Resize(200, 200, img, resize.NearestNeighbor)
+
+       sess := x.NewSession()
+       defer sess.Close()
+       if err = sess.Begin(); err != nil {
+               return err
+       }
+
+       if _, err = sess.Id(u.Id).AllCols().Update(u); err != nil {
+               sess.Rollback()
+               return err
+       }
+
+       fw, err := os.Create(savePath)
+       if err != nil {
+               sess.Rollback()
+               return err
+       }
+       defer fw.Close()
+       if err = jpeg.Encode(fw, m, nil); err != nil {
+               sess.Rollback()
+               return err
+       }
+
+       return sess.Commit()
+}
+
 // IsOrganization returns true if user is actually a organization.
 func (u *User) IsOrganization() bool {
        return u.Type == ORGANIZATION
@@ -517,41 +568,38 @@ func GetUserIdsByNames(names []string) []int64 {
 
 // UserCommit represtns a commit with validation of user.
 type UserCommit struct {
-       UserName string
+       User *User
        *git.Commit
 }
 
 // ValidateCommitWithEmail chceck if author's e-mail of commit is corresponsind to a user.
-func ValidateCommitWithEmail(c *git.Commit) (uname string) {
+func ValidateCommitWithEmail(c *git.Commit) *User {
        u, err := GetUserByEmail(c.Author.Email)
-       if err == nil {
-               uname = u.Name
+       if err != nil {
+               return nil
        }
-       return uname
+       return u
 }
 
 // ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users.
 func ValidateCommitsWithEmails(oldCommits *list.List) *list.List {
-       emails := map[string]string{}
+       emails := map[string]*User{}
        newCommits := list.New()
        e := oldCommits.Front()
        for e != nil {
                c := e.Value.(*git.Commit)
 
-               uname := ""
+               var u *User
                if v, ok := emails[c.Author.Email]; !ok {
-                       u, err := GetUserByEmail(c.Author.Email)
-                       if err == nil {
-                               uname = u.Name
-                       }
-                       emails[c.Author.Email] = uname
+                       u, _ = GetUserByEmail(c.Author.Email)
+                       emails[c.Author.Email] = u
                } else {
-                       uname = v
+                       u = v
                }
 
                newCommits.PushBack(UserCommit{
-                       UserName: uname,
-                       Commit:   c,
+                       User:   u,
+                       Commit: c,
                })
                e = e.Next()
        }
index 6046a8d1ee65a072109f3cb20d48b4dc69a1a3aa..afdd8be0c94ebd0b28b6f52a13031bd966189524 100644 (file)
@@ -5,6 +5,8 @@
 package auth
 
 import (
+       "mime/multipart"
+
        "github.com/Unknwon/macaron"
        "github.com/macaron-contrib/binding"
 )
@@ -86,6 +88,14 @@ func (f *UpdateProfileForm) Validate(ctx *macaron.Context, errs binding.Errors)
        return validate(errs, ctx.Data, f, ctx.Locale)
 }
 
+type UploadAvatarForm struct {
+       Avatar *multipart.FileHeader `form:"avatar" binding:"Required"`
+}
+
+func (f *UploadAvatarForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
+       return validate(errs, ctx.Data, f, ctx.Locale)
+}
+
 type ChangePasswordForm struct {
        OldPassword string `form:"old_password" binding:"Required;MinSize(6);MaxSize(255)"`
        Password    string `form:"password" binding:"Required;MinSize(6);MaxSize(255)"`
index fb198da149e508d255defe472a6188870aa5a8ec..144fda387e50bf11d617e1f998c4723e53fe620c 100644 (file)
@@ -121,7 +121,7 @@ func (this *Avatar) Encode(wr io.Writer, size int) (err error) {
        if img, err = decodeImageFile(imgPath); err != nil {
                return
        }
-       m := resize.Resize(uint(size), 0, img, resize.Lanczos3)
+       m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
        return jpeg.Encode(wr, m, nil)
 }
 
index 0f293b6905436a37862c30db81ff39c16f35dade..49bd11c4c25270de0760cf8bc340ce73f5839b7b 100644 (file)
@@ -66,9 +66,10 @@ var (
        ScriptType   string
 
        // Picture settings.
-       PictureService  string
-       GravatarSource  string
-       DisableGravatar bool
+       PictureService   string
+       AvatarUploadPath string
+       GravatarSource   string
+       DisableGravatar  bool
 
        // Log settings.
        LogRootPath string
@@ -259,6 +260,9 @@ func NewConfigContext() {
        ScriptType = Cfg.MustValue("repository", "SCRIPT_TYPE", "bash")
 
        PictureService = Cfg.MustValueRange("picture", "SERVICE", "server", []string{"server"})
+       AvatarUploadPath = Cfg.MustValue("picture", "AVATAR_UPLOAD_PATH", "data/avatars")
+       os.MkdirAll(AvatarUploadPath, os.ModePerm)
+
        switch Cfg.MustValue("picture", "GRAVATAR_SOURCE", "gravatar") {
        case "duoshuo":
                GravatarSource = "http://gravatar.duoshuo.com/avatar/"
index 031872fca9cd9e3c13bd29e380caeace7d09935a..1bb9701104ad6b7310df8d302fdba292b3d1aa6d 100644 (file)
@@ -100,6 +100,13 @@ func Dashboard(ctx *middleware.Context) {
                                continue
                        }
                }
+               // FIXME: cache results?
+               u, err := models.GetUserByName(act.ActUserName)
+               if err != nil {
+                       ctx.Handle(500, "GetUserByName", err)
+                       return
+               }
+               act.ActAvatar = u.AvatarLink()
                feeds = append(feeds, act)
        }
        ctx.Data["Feeds"] = feeds
index bb0fa9103e47006bd64890792673066e439b86d3..559e10fd89f1bfadfc14f7aa1e5a2fc2efd76856 100644 (file)
@@ -5,6 +5,7 @@
 package user
 
 import (
+       "io/ioutil"
        "strings"
 
        "github.com/Unknwon/com"
@@ -83,6 +84,34 @@ func SettingsPost(ctx *middleware.Context, form auth.UpdateProfileForm) {
        ctx.Redirect(setting.AppSubUrl + "/user/settings")
 }
 
+// FIXME: limit size.
+func SettingsAvatar(ctx *middleware.Context, form auth.UploadAvatarForm) {
+       defer ctx.Redirect(setting.AppSubUrl + "/user/settings")
+
+       if form.Avatar != nil {
+               fr, err := form.Avatar.Open()
+               if err != nil {
+                       ctx.Flash.Error(err.Error())
+                       return
+               }
+
+               data, err := ioutil.ReadAll(fr)
+               if err != nil {
+                       ctx.Flash.Error(err.Error())
+                       return
+               }
+               if _, ok := base.IsImageFile(data); !ok {
+                       ctx.Flash.Error(ctx.Tr("settings.uploaded_avatar_not_a_image"))
+                       return
+               }
+               if err = ctx.User.UploadAvatar(data); err != nil {
+                       ctx.Flash.Error(err.Error())
+                       return
+               }
+               ctx.Flash.Success(ctx.Tr("settings.upload_avatar_success"))
+       }
+}
+
 func SettingsPassword(ctx *middleware.Context) {
        ctx.Data["Title"] = ctx.Tr("settings")
        ctx.Data["PageIsUserSettings"] = true
index 92a14ffc74cf426e9a0963df0a725cc14af5c813..5de719a000227449311abd10284bbe05fca579cd 100644 (file)
@@ -1 +1 @@
-0.5.8.1119 Beta
\ No newline at end of file
+0.5.8.1121 Beta
\ No newline at end of file
index d48d61a5a73c10774c420d8e6e658010c2241361..eb819e387fb453e1401f9d936e94a5e56c5eef80 100644 (file)
             {{$r := List .Commits}}
             {{range $r}}
             <tr>
-                <td class="author"><img class="avatar-20" src="{{AvatarLink .Author.Email}}" alt=""/>&nbsp;&nbsp;&nbsp;{{if .UserName}}<a href="{{AppSubUrl}}/{{.UserName}}">{{.Author.Name}}</a>{{else}}{{.Author.Name}}{{end}}</td>
+                <td class="author">
+                    {{if .User}}
+                    <img class="avatar-20" src="{{.User.AvatarLink}}" alt=""/>&nbsp;&nbsp;&nbsp;<a href="{{AppSubUrl}}/{{.User.Name}}">{{.Author.Name}}</a>
+                    {{else}}
+                    <img class="avatar-20" src="{{AvatarLink .Author.Email}}" alt=""/>&nbsp;&nbsp;&nbsp;{{.Author.Name}}
+                    {{end}}
+                </td>
                 <td class="sha"><a rel="nofollow" class="label label-green" href="{{AppSubUrl}}/{{$username}}/{{$reponame}}/commit/{{.Id}} ">{{SubStr .Id.String 0 10}} </a></td>
                 <td class="message"><span class="text-truncate">{{.Summary}}</span></td>
                 <td class="date">{{TimeSince .Author.When $.Lang}}</td>
index 7c0d1b6bb8b25dce93bbdfa72f38fef6c5986f19..8e6b1b07963cfdeef8e8d4de3c619edd9a9b25e1 100644 (file)
                     </ul>
                 </span>
                 <p class="author">
-                    <img class="avatar-30" src="{{AvatarLink .Commit.Author.Email}}" />
                     {{if .Author}}
-                    <a href="{{AppSubUrl}}/{{.Author}}"><strong>{{.Commit.Author.Name}}</strong></a>
+                    <img class="avatar-30" src="{{.Author.AvatarLink}}" />
+                    <a href="{{AppSubUrl}}/{{.Author.Name}}"><strong>{{.Commit.Author.Name}}</strong></a>
                     {{else}}
+                    <img class="avatar-30" src="{{AvatarLink .Commit.Author.Email}}" />
                     <strong>{{.Commit.Author.Name}}</strong>
                     {{end}}
                     <span class="text-grey" id="authored-time">{{TimeSince .Commit.Author.When $.Lang}}</span> 
index e65d7c1134ca7e57970abe0c4b3e2970fa72c587..d516eac94cb573d255da512146042e7bc15cb95a 100644 (file)
@@ -3,8 +3,14 @@
     <tr>
         <th colspan="4" class="clear">
             <span class="author left">
+                {{if .LastCommitUser}}
+                <img class="avatar-24 radius" src="{{.LastCommitUser.AvatarLink}}" />
+                <a href="{{AppSubUrl}}/{{.LastCommitUser.Name}}"><strong>{{.LastCommit.Author.Name}}</strong></a>:
+                {{else}}
                 <img class="avatar-24 radius" src="{{AvatarLink .LastCommit.Author.Email}}" />
-                {{if .LastCommitUser}}<a href="{{AppSubUrl}}/{{.LastCommitUser}}">{{end}}<strong>{{.LastCommit.Author.Name}}</strong>:{{if .LastCommitUser}}</a>{{end}}
+                <strong>{{.LastCommit.Author.Name}}</strong>:
+                {{end}}
+                &nbsp;
             </span>
             <span class="last-commit"><a href="{{.RepoLink}}/commit/{{.LastCommit.Id}}" rel="nofollow">
                 <strong>{{ShortSha .LastCommit.Id.String}}</strong></a>
index 57c97def2ed70612c73c4946a6264e2593b4ba38..834e5f0a5601cc751043d6136c802b00c463f4cc 100644 (file)
@@ -1,7 +1,7 @@
 {{range .Feeds}}
 <div class="news clear">
     <div class="avatar left">
-        <img class="avatar-30" src="{{AvatarLink .GetActEmail}}" alt="">
+        <img class="avatar-30" src="{{.ActAvatar}}" alt="">
     </div>
     <div class="content left {{if eq .GetOpType 5}}push-news{{end}} grid-4-5">
         <p class="text-bold">
index 46bc99bda4d293d6abfc31c7dd86b71dbb3af233..44c2212383e6767e5b7c9895e523651ae05e7e00 100644 (file)
@@ -4,7 +4,11 @@
     <div id="user-profile-page" class="container clear">
         <div class="grid-1-5 left">
             <div>
+                {{if .Owner.UseCustomAvatar}}
+                <a href="{{AppSubUrl}}/user/settings" id="profile-avatar" original-title="{{.i18n.Tr "user.change_custom_avatar"}}">
+                {{else}}
                 <a href="http://gravatar.com/emails/" id="profile-avatar" original-title="{{.i18n.Tr "user.change_avatar"}}">
+                {{end}}
                     <img class="profile-avatar" src="{{.Owner.AvatarLink}}?s=200"title="{{.Owner.Name}}"/>
                 </a>
                 <div class="text-center" id="profile-name">
index 5338d295f6ad16195d52ed67ce1038bc5b3a5bdf..85db6f8951f26de6edfdc5db0b90d28f29a7ffa2 100644 (file)
                         <div class="panel-header">
                             <strong>{{.i18n.Tr "settings.public_profile"}}</strong>
                         </div>
-                        <form class="form form-align panel-body" id="user-profile-form" action="{{AppSubUrl}}/user/settings" method="post">
-                            {{.CsrfTokenHtml}}
-                               <div class="text-center panel-desc">{{.i18n.Tr "settings.profile_desc"}}</div>
-                            <div class="field">
-                                <label>{{.i18n.Tr "settings.uid"}}</label>
-                                <label class="text-left">{{.SignedUser.Id}}</label>
-                            </div>
-                            <div class="field">
-                                <label class="req" for="username">{{.i18n.Tr "username"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_UserName}}ipt-error{{end}}" id="username" name="uname" type="text" value="{{.SignedUser.Name}}" data-uname="{{.SignedUser.Name}}" required />
-                            </div>
-                            <div class="white-popup-block mfp-hide" id="change-username-modal">
-                                <h1 class="text-red">{{.i18n.Tr "settings.change_username"}}</h1>
-                                <p>{{.i18n.Tr "settings.change_username_desc"}}</p>
-                                <br>
-                                <button class="btn btn-red btn-large btn-radius" id="change-username-submit">{{.i18n.Tr "settings.continue"}}</button>
-                                <button class="btn btn-large btn-radius popup-modal-dismiss">{{.i18n.Tr "settings.cancel"}}</button>
-                            </div>
-                            <div class="field">
-                                <label for="full-name">{{.i18n.Tr "settings.full_name"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_FullName}}ipt-error{{end}}" id="full-name" name="fullname" type="text" value="{{.SignedUser.FullName}}" />
-                            </div>
-                            <div class="field">
-                                <label class="req" for="email">{{.i18n.Tr "email"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_Email}}ipt-error{{end}}" id="email" name="email" type="email" value="{{.SignedUser.Email}}" required />
-                            </div>
-                            <div class="field">
-                                <label for="website">{{.i18n.Tr "settings.website"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_Website}}ipt-error{{end}}" id="website" name="website" type="url" value="{{.SignedUser.Website}}" />
-                            </div>
-                            <div class="field">
-                                <label for="location">{{.i18n.Tr "settings.location"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_Location}}ipt-error{{end}}" id="location" name="location" type="text" value="{{.SignedUser.Location}}" />
-                            </div>
-                            <div class="field">
-                                <label for="gravatar-email">Gravatar {{.i18n.Tr "email"}}</label>
-                                <input class="ipt ipt-large ipt-radius {{if .Err_Avatar}}ipt-error{{end}}" id="gravatar-email" name="avatar" type="text" value="{{.SignedUser.AvatarEmail}}" />
-                            </div>
-                            <div class="field">
-                                <label></label>
-                                <button class="btn btn-green btn-large btn-radius" id="change-username-btn" href="#change-username-modal">{{.i18n.Tr "settings.update_profile"}}</button>
-                            </div>
-                        </form>
+                        <div class="panel-body">
+                            <form class="form form-align" id="user-profile-form" action="{{AppSubUrl}}/user/settings" method="post">
+                                {{.CsrfTokenHtml}}
+                                <div class="text-center panel-desc">{{.i18n.Tr "settings.profile_desc"}}</div>
+                                <div class="field">
+                                    <label>{{.i18n.Tr "settings.uid"}}</label>
+                                    <label class="text-left">{{.SignedUser.Id}}</label>
+                                </div>
+                                <div class="field">
+                                    <label class="req" for="username">{{.i18n.Tr "username"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_UserName}}ipt-error{{end}}" id="username" name="uname" type="text" value="{{.SignedUser.Name}}" data-uname="{{.SignedUser.Name}}" required />
+                                </div>
+                                <div class="white-popup-block mfp-hide" id="change-username-modal">
+                                    <h1 class="text-red">{{.i18n.Tr "settings.change_username"}}</h1>
+                                    <p>{{.i18n.Tr "settings.change_username_desc"}}</p>
+                                    <br>
+                                    <button class="btn btn-red btn-large btn-radius" id="change-username-submit">{{.i18n.Tr "settings.continue"}}</button>
+                                    <button class="btn btn-large btn-radius popup-modal-dismiss">{{.i18n.Tr "settings.cancel"}}</button>
+                                </div>
+                                <div class="field">
+                                    <label for="full-name">{{.i18n.Tr "settings.full_name"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_FullName}}ipt-error{{end}}" id="full-name" name="fullname" type="text" value="{{.SignedUser.FullName}}" />
+                                </div>
+                                <div class="field">
+                                    <label class="req" for="email">{{.i18n.Tr "email"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_Email}}ipt-error{{end}}" id="email" name="email" type="email" value="{{.SignedUser.Email}}" required />
+                                </div>
+                                <div class="field">
+                                    <label for="website">{{.i18n.Tr "settings.website"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_Website}}ipt-error{{end}}" id="website" name="website" type="url" value="{{.SignedUser.Website}}" />
+                                </div>
+                                <div class="field">
+                                    <label for="location">{{.i18n.Tr "settings.location"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_Location}}ipt-error{{end}}" id="location" name="location" type="text" value="{{.SignedUser.Location}}" />
+                                </div>
+                                <div class="field">
+                                    <label for="gravatar-email">Gravatar {{.i18n.Tr "email"}}</label>
+                                    <input class="ipt ipt-large ipt-radius {{if .Err_Avatar}}ipt-error{{end}}" id="gravatar-email" name="avatar" type="text" value="{{.SignedUser.AvatarEmail}}" />
+                                </div>
+                                <div class="field">
+                                    <label></label>
+                                    <button class="btn btn-green btn-large btn-radius" id="change-username-btn" href="#change-username-modal">{{.i18n.Tr "settings.update_profile"}}</button>
+                                </div>
+                            </form>
+                            <hr>
+                            <form class="form form-align" id="user-profile-form" action="{{AppSubUrl}}/user/settings/avatar" method="post" enctype="multipart/form-data">
+                                {{.CsrfTokenHtml}}
+                                <div class="field">
+                                    <label>{{.i18n.Tr "settings.choose_new_avatar"}}</label>
+                                    <input name="avatar" type="file" required />
+                                </div>
+                                <div class="field">
+                                    <label></label>
+                                    <button class="btn btn-green btn-large btn-radius">{{.i18n.Tr "settings.upload_avatar"}}</button>
+                                </div>
+                            </form>
+                        </div>
                     </div>
                 </div>
             </div>