feat(profile): generated identicons
8 files changed, +59 −18
- avatar.go modified +46 −0
- static/style.css modified +5 −9
- templates/org.html modified +2 −2
- templates/orgsettings.html modified +1 −1
- templates/search.html modified +1 −1
- templates/settings.html modified +1 −1
- templates/user.html modified +2 −2
- web.go modified +1 −2
avatar.go modified
| @@ -2,11 +2,14 @@ package main | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | + "crypto/sha256" | |
| 5 | 6 | "errors" |
| 6 | 7 | "image" |
| 8 | + "image/color" | |
| 7 | 9 | _ "image/gif" |
| 8 | 10 | _ "image/jpeg" |
| 9 | 11 | "image/png" |
| 12 | + "math" | |
| 10 | 13 | "sync" |
| 11 | 14 | |
| 12 | 15 | "golang.org/x/image/draw" |
| @@ -48,3 +51,46 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) { | ||
| 48 | 51 | } |
| 49 | 52 | return out.Bytes(), nil |
| 50 | 53 | } |
| 54 | + | |
| 55 | +func identicon(seed string) []byte { | |
| 56 | + sum := sha256.Sum256([]byte(seed)) | |
| 57 | + h := float64(uint16(sum[28])<<8|uint16(sum[29])) / 65536 | |
| 58 | + fg := hsl(h, 0.45+float64(sum[30])/255*0.2, 0.5+float64(sum[31])/255*0.12) | |
| 59 | + const cell, pad = 44, 18 | |
| 60 | + img := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) | |
| 61 | + draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{240, 240, 240, 255}}, image.Point{}, draw.Src) | |
| 62 | + for i := 0; i < 15; i++ { | |
| 63 | + if sum[i]&1 != 0 { | |
| 64 | + continue | |
| 65 | + } | |
| 66 | + row, col := i%5, i/5 | |
| 67 | + for _, c := range []int{col, 4 - col} { | |
| 68 | + r := image.Rect(pad+c*cell, pad+row*cell, pad+(c+1)*cell, pad+(row+1)*cell) | |
| 69 | + draw.Draw(img, r, &image.Uniform{fg}, image.Point{}, draw.Src) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + var out bytes.Buffer | |
| 73 | + png.Encode(&out, img) | |
| 74 | + return out.Bytes() | |
| 75 | +} | |
| 76 | + | |
| 77 | +func hsl(h, s, l float64) color.RGBA { | |
| 78 | + q := l + s - l*s | |
| 79 | + if l < 0.5 { | |
| 80 | + q = l * (1 + s) | |
| 81 | + } | |
| 82 | + p := 2*l - q | |
| 83 | + ch := func(t float64) uint8 { | |
| 84 | + t -= math.Floor(t) | |
| 85 | + switch { | |
| 86 | + case t < 1.0/6: | |
| 87 | + return uint8(255 * (p + (q-p)*6*t)) | |
| 88 | + case t < 0.5: | |
| 89 | + return uint8(255 * q) | |
| 90 | + case t < 2.0/3: | |
| 91 | + return uint8(255 * (p + (q-p)*(2.0/3-t)*6)) | |
| 92 | + } | |
| 93 | + return uint8(255 * p) | |
| 94 | + } | |
| 95 | + return color.RGBA{ch(h + 1.0/3), ch(h), ch(h - 1.0/3), 255} | |
| 96 | +} | |
static/style.css modified
| @@ -389,8 +389,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 389 | 389 | .hit .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.85rem} |
| 390 | 390 | .hit .meta{display:flex;gap:1.1rem;flex-wrap:wrap;color:var(--muted);font-size:.85rem;margin-top:.35rem} |
| 391 | 391 | .hit.user{display:grid;grid-template-columns:3rem 1fr;gap:.9rem;align-items:start} |
| 392 | -.hitavatar img,.hitavatar span{display:flex;width:3rem;height:3rem;border-radius:6px;object-fit:cover;border:1px solid var(--line)} | |
| 393 | -.hitavatar span{align-items:center;justify-content:center;background:var(--soft);color:var(--muted);font-size:1.3rem;text-transform:uppercase} | |
| 392 | +.hitavatar img{display:flex;width:3rem;height:3rem;border-radius:6px;object-fit:cover;border:1px solid var(--line)} | |
| 394 | 393 | .hit .bio{color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} |
| 395 | 394 | .langbar{display:flex;height:.5rem;border-radius:4px;overflow:hidden;margin:.2rem 0 .5rem;background:var(--line)} |
| 396 | 395 | .langbar span{display:block;height:100%} |
| @@ -420,8 +419,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 420 | 419 | .orglist h4{font-size:.85rem;color:var(--muted);margin:0 0 .4rem;font-weight:600} |
| 421 | 420 | .orgicons{display:flex;flex-wrap:wrap;gap:.4rem} |
| 422 | 421 | .orglist a{display:flex} |
| 423 | -.orglist img,.orglist span{display:flex;width:2rem;height:2rem;border-radius:2px;object-fit:cover;font-size:.8rem} | |
| 424 | -.hit.org .hitavatar span{background:var(--accent);color:#fff;border-radius:2px} | |
| 422 | +.orglist img{display:flex;width:2rem;height:2rem;border-radius:2px;object-fit:cover;font-size:.8rem} | |
| 425 | 423 | .memberlist{list-style:none;margin:0 0 .8rem;padding:0;max-width:32rem} |
| 426 | 424 | .memberlist li{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--line)} |
| 427 | 425 | .memberlist form{margin:0} |
| @@ -430,13 +428,11 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 430 | 428 | .people h4 .count{font-weight:400;margin-left:.2rem} |
| 431 | 429 | .people ul{list-style:none;margin:0;padding:0} |
| 432 | 430 | .people li a{display:grid;grid-template-columns:2.2rem 1fr;column-gap:.6rem;align-items:center;padding:.3rem 0;color:var(--fg);text-decoration:none} |
| 433 | -.people li img,.people li span{grid-row:1/3;display:flex;width:2.2rem;height:2.2rem;border-radius:50%;object-fit:cover;border:1px solid var(--line)} | |
| 431 | +.people li img{grid-row:1/3;display:flex;width:2.2rem;height:2.2rem;border-radius:50%;object-fit:cover;border:1px solid var(--line)} | |
| 434 | 432 | .people li b{font-weight:600;font-size:.93rem;line-height:1.2} |
| 435 | 433 | .people li small{color:var(--muted);line-height:1.2} |
| 436 | -.people li span,.orglist span{align-items:center;justify-content:center;background:var(--accent);color:#fff;font-weight:600;text-transform:uppercase} | |
| 437 | 434 | .orghead{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:1.3rem;align-items:start;margin:-.4rem 0 1.3rem;padding-bottom:1.2rem;border-bottom:1px solid var(--bar)} |
| 438 | -.orgmark img,.orgmark span{display:flex;width:6rem;height:6rem;border-radius:10px;object-fit:cover;border:1px solid var(--line);background:var(--soft)} | |
| 439 | -.orgmark span{align-items:center;justify-content:center;background:var(--accent);color:#fff;font-size:2.6rem;font-weight:600;text-transform:uppercase} | |
| 435 | +.orgmark img{display:flex;width:6rem;height:6rem;border-radius:10px;object-fit:cover;border:1px solid var(--line);background:var(--soft)} | |
| 440 | 436 | .orgident h2{font-size:1.6rem;line-height:1.2;margin:0} |
| 441 | 437 | .orgident .handle{color:var(--muted);margin:.1rem 0 .45rem} |
| 442 | 438 | .orgident .bio{margin:0 0 .45rem} |
| @@ -445,7 +441,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 445 | 441 | .orgident .facts li{margin:0} |
| 446 | 442 | .orgactions{display:flex;gap:.5rem} |
| 447 | 443 | .columns.orgbody{grid-template-columns:minmax(0,1fr) 280px;gap:2rem} |
| 448 | -@media(max-width:800px){.orghead{grid-template-columns:auto minmax(0,1fr)}.orgactions{grid-column:1/-1}.orgmark img,.orgmark span{width:4.5rem;height:4.5rem;font-size:2rem}.columns.orgbody{grid-template-columns:1fr}} | |
| 444 | +@media(max-width:800px){.orghead{grid-template-columns:auto minmax(0,1fr)}.orgactions{grid-column:1/-1}.orgmark img{width:4.5rem;height:4.5rem;font-size:2rem}.columns.orgbody{grid-template-columns:1fr}} | |
| 449 | 445 | .dashhead span{display:flex;gap:.5rem} |
| 450 | 446 | .admintabs{display:inline-flex;margin-bottom:1rem} |
| 451 | 447 | form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem} |
templates/org.html modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$p := .Data.Profile}} |
| 3 | 3 | {{$o := .Data.Owner}} |
| 4 | 4 | <header class="orghead"> |
| 5 | -<div class="orgmark">{{if $p.Avatar}}<img src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt="">{{else}}<span>{{slice $o.Name 0 1}}</span>{{end}}</div> | |
| 5 | +<div class="orgmark"><img src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt=""></div> | |
| 6 | 6 | <div class="orgident"> |
| 7 | 7 | <h2>{{with $p.Display}}{{.}}{{else}}{{$o.Name}}{{end}}</h2> |
| 8 | 8 | <p class="handle">~{{$o.Name}}</p> |
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | </div> |
| 24 | 24 | <aside class="people"> |
| 25 | 25 | <h4>People <span class="count">{{len .Data.Members}}</span></h4> |
| 26 | -<ul>{{range .Data.Members}}<li><a href="/~{{.Name}}">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}<b>{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</b><small>~{{.Name}}</small></a></li>{{end}}</ul> | |
| 26 | +<ul>{{range .Data.Members}}<li><a href="/~{{.Name}}"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""><b>{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</b><small>~{{.Name}}</small></a></li>{{end}}</ul> | |
| 27 | 27 | <h4>Feeds</h4> |
| 28 | 28 | <p class="small"><a href="/~{{$o.Name}}/feed">Commits across ~{{$o.Name}}</a></p> |
| 29 | 29 | </aside> |
templates/orgsettings.html modified
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <h3 class="rule">Edit <a href="/~{{$o.Name}}">~{{$o.Name}}</a></h3> |
| 5 | 5 | <form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform"> |
| 6 | 6 | <input type="hidden" name="org" value="{{$o.Name}}"> |
| 7 | -<div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div> | |
| 7 | +<div class="avatarbox"><img class="avatar" src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt=""></div> | |
| 8 | 8 | <div> |
| 9 | 9 | <label>Choose a picture <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label> |
| 10 | 10 | <small>If the image is not square you can pick the crop below. It is resized to 256×256 and stored as PNG.</small> |
templates/search.html modified
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | {{range $d.Repos}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{with .Description}}<p>{{.}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a>{{end}}</p>{{end}}<p class="meta">{{with .Language}}<span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span>{{end}}{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No repositories match. Try a shorter query or clear the language filter.</p>{{end}} |
| 25 | 25 | {{else}} |
| 26 | 26 | <div class="resulthead"><h3 class="plain">{{len $d.Users}} user{{if ne (len $d.Users) 1}}s{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} writing {{.}}{{end}}</h3></div> |
| 27 | -{{range $d.Users}}<div class="hit user{{if .Org}} org{{end}}"><a href="/~{{.Name}}" class="hitavatar">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}</a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>{{if .Org}}Since{{else}}Joined{{end}} {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}} | |
| 27 | +{{range $d.Users}}<div class="hit user{{if .Org}} org{{end}}"><a href="/~{{.Name}}" class="hitavatar"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>{{if .Org}}Since{{else}}Joined{{end}} {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}} | |
| 28 | 28 | {{end}} |
| 29 | 29 | </div> |
| 30 | 30 | </div> |
templates/settings.html modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <h3 class="rule">Edit your profile</h3> |
| 4 | 4 | <p class="muted small">Disk usage {{size .Data.Used}}{{if .Data.Quota}} of {{size .Data.Quota}}{{end}}.</p> |
| 5 | 5 | <form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform"> |
| 6 | -<div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div> | |
| 6 | +<div class="avatarbox"><img class="avatar" src="/~{{.User.Name}}/avatar?v={{$p.Avatar}}" alt=""></div> | |
| 7 | 7 | <div> |
| 8 | 8 | <label>Choose an avatar <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label> |
| 9 | 9 | <small>If the image is not square you can pick the crop below. It is resized to 256×256 and stored as PNG.</small> |
templates/user.html modified
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns user"> |
| 9 | 9 | <aside class="profile"> |
| 10 | -{{if $p.Avatar}}<img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}} | |
| 10 | +<img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt=""> | |
| 11 | 11 | <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div> |
| 12 | 12 | {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}} |
| 13 | 13 | <ul class="facts"> |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | {{if $p.Website}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.8 9.2a3 3 0 0 0 4.2 0l2.2-2.2a3 3 0 0 0-4.2-4.2l-.9.9"/><path d="M9.2 6.8a3 3 0 0 0-4.2 0L2.8 9a3 3 0 0 0 4.2 4.2l.9-.9"/></svg><a href="{{$p.Website}}" rel="nofollow me">{{bare $p.Website}}</a></li>{{end}} |
| 17 | 17 | <li title="{{date .Data.Owner.Created}}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"/></svg>Joined {{.Data.Owner.Created.Format "January 2006"}}</li> |
| 18 | 18 | </ul> |
| 19 | -{{with .Data.Orgs}}<div class="orglist"><h4>Organisations</h4><div class="orgicons">{{range .}}<a href="/~{{.Name}}" title="{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}" aria-label="~{{.Name}}">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}</a>{{end}}</div></div>{{end}} | |
| 19 | +{{with .Data.Orgs}}<div class="orglist"><h4>Organisations</h4><div class="orgicons">{{range .}}<a href="/~{{.Name}}" title="{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}" aria-label="~{{.Name}}"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a>{{end}}</div></div>{{end}} | |
| 20 | 20 | {{if $own}}<div class="ownactions"><a class="btn block" href="/settings">Edit profile</a></div>{{end}} |
| 21 | 21 | </aside> |
| 22 | 22 | <div class="main"> |
web.go modified
| @@ -779,8 +779,7 @@ func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) { | ||
| 779 | 779 | func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) { |
| 780 | 780 | data, err := os.ReadFile(s.avatarPath(owner.ID)) |
| 781 | 781 | if err != nil { |
| 782 | - http.NotFound(w, r) | |
| 783 | - return | |
| 782 | + data = identicon(owner.Name) | |
| 784 | 783 | } |
| 785 | 784 | w.Header().Set("Content-Type", "image/png") |
| 786 | 785 | w.Header().Set("X-Content-Type-Options", "nosniff") |