~gitpocket/gitpocket

1

feat(profile): generated identicons

commit
20596d989af6d5379c73ccc5d6d896426228c226 browse tree
parent
af0cd9fea0edd82d17098469545099771e9c040d
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-05 21:41 UTC

8 files changed, +59 −18

avatar.go modified

@@ -2,11 +2,14 @@ package main
22
33 import (
44 "bytes"
5+ "crypto/sha256"
56 "errors"
67 "image"
8+ "image/color"
79 _ "image/gif"
810 _ "image/jpeg"
911 "image/png"
12+ "math"
1013 "sync"
1114
1215 "golang.org/x/image/draw"
@@ -48,3 +51,46 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) {
4851 }
4952 return out.Bytes(), nil
5053 }
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}
389389 .hit .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.85rem}
390390 .hit .meta{display:flex;gap:1.1rem;flex-wrap:wrap;color:var(--muted);font-size:.85rem;margin-top:.35rem}
391391 .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)}
394393 .hit .bio{color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
395394 .langbar{display:flex;height:.5rem;border-radius:4px;overflow:hidden;margin:.2rem 0 .5rem;background:var(--line)}
396395 .langbar span{display:block;height:100%}
@@ -420,8 +419,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
420419 .orglist h4{font-size:.85rem;color:var(--muted);margin:0 0 .4rem;font-weight:600}
421420 .orgicons{display:flex;flex-wrap:wrap;gap:.4rem}
422421 .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}
425423 .memberlist{list-style:none;margin:0 0 .8rem;padding:0;max-width:32rem}
426424 .memberlist li{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--line)}
427425 .memberlist form{margin:0}
@@ -430,13 +428,11 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
430428 .people h4 .count{font-weight:400;margin-left:.2rem}
431429 .people ul{list-style:none;margin:0;padding:0}
432430 .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)}
434432 .people li b{font-weight:600;font-size:.93rem;line-height:1.2}
435433 .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}
437434 .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)}
440436 .orgident h2{font-size:1.6rem;line-height:1.2;margin:0}
441437 .orgident .handle{color:var(--muted);margin:.1rem 0 .45rem}
442438 .orgident .bio{margin:0 0 .45rem}
@@ -445,7 +441,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
445441 .orgident .facts li{margin:0}
446442 .orgactions{display:flex;gap:.5rem}
447443 .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}}
449445 .dashhead span{display:flex;gap:.5rem}
450446 .admintabs{display:inline-flex;margin-bottom:1rem}
451447 form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem}

templates/org.html modified

@@ -2,7 +2,7 @@
22 {{$p := .Data.Profile}}
33 {{$o := .Data.Owner}}
44 <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>
66 <div class="orgident">
77 <h2>{{with $p.Display}}{{.}}{{else}}{{$o.Name}}{{end}}</h2>
88 <p class="handle">~{{$o.Name}}</p>
@@ -23,7 +23,7 @@
2323 </div>
2424 <aside class="people">
2525 <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>
2727 <h4>Feeds</h4>
2828 <p class="small"><a href="/~{{$o.Name}}/feed">Commits across ~{{$o.Name}}</a></p>
2929 </aside>

templates/orgsettings.html modified

@@ -4,7 +4,7 @@
44 <h3 class="rule">Edit <a href="/~{{$o.Name}}">~{{$o.Name}}</a></h3>
55 <form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform">
66 <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>
88 <div>
99 <label>Choose a picture <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
1010 <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 @@
2424 {{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}}
2525 {{else}}
2626 <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}}
2828 {{end}}
2929 </div>
3030 </div>

templates/settings.html modified

@@ -3,7 +3,7 @@
33 <h3 class="rule">Edit your profile</h3>
44 <p class="muted small">Disk usage {{size .Data.Used}}{{if .Data.Quota}} of {{size .Data.Quota}}{{end}}.</p>
55 <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>
77 <div>
88 <label>Choose an avatar <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
99 <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 @@
77 </div>
88 <div class="columns user">
99 <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="">
1111 <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div>
1212 {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}}
1313 <ul class="facts">
@@ -16,7 +16,7 @@
1616 {{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}}
1717 <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>
1818 </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}}
2020 {{if $own}}<div class="ownactions"><a class="btn block" href="/settings">Edit profile</a></div>{{end}}
2121 </aside>
2222 <div class="main">

web.go modified

@@ -779,8 +779,7 @@ func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) {
779779 func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) {
780780 data, err := os.ReadFile(s.avatarPath(owner.ID))
781781 if err != nil {
782- http.NotFound(w, r)
783- return
782+ data = identicon(owner.Name)
784783 }
785784 w.Header().Set("Content-Type", "image/png")
786785 w.Header().Set("X-Content-Type-Options", "nosniff")