feat(profile): add a repositories tab and pin up to six repositories to the profile
9 files changed, +139 −7
- flow_test.go modified +35 −0
- static/style.css modified +4 −0
- store.go modified +38 −2
- templates/meta.html modified +1 −0
- templates/org.html modified +1 −1
- templates/repositories.html added +9 −0
- templates/stars.html modified +1 −1
- templates/user.html modified +2 −2
- web.go modified +48 −1
flow_test.go modified
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "fmt" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "net/http/httptest" |
| 6 | 7 | "net/url" |
| @@ -268,3 +269,37 @@ func TestAdminSections(t *testing.T) { | ||
| 268 | 269 | t.Error("bad email mode accepted") |
| 269 | 270 | } |
| 270 | 271 | } |
| 272 | + | |
| 273 | +func TestPins(t *testing.T) { | |
| 274 | + s, h := testServer(t) | |
| 275 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 276 | + mallory, _ := s.store.CreateUser("mallory", "correct horse") | |
| 277 | + var ids []int64 | |
| 278 | + for _, n := range []string{"a", "b", "c", "d", "e", "f", "g"} { | |
| 279 | + r, _ := s.store.CreateRepo(alice, n, "", Public, "") | |
| 280 | + ids = append(ids, r.ID) | |
| 281 | + } | |
| 282 | + own, _ := s.store.CreateSession(alice.ID) | |
| 283 | + other, _ := s.store.CreateSession(mallory.ID) | |
| 284 | + pin := func(id int64, cookie string) *httptest.ResponseRecorder { | |
| 285 | + return post(h, "/~alice/repositories", url.Values{"repo": {fmt.Sprint(id)}, "action": {"pin"}}, cookie) | |
| 286 | + } | |
| 287 | + if w := pin(ids[0], other); w.Code != 404 { | |
| 288 | + t.Errorf("someone else pinned alice's repo: %d", w.Code) | |
| 289 | + } | |
| 290 | + pin(ids[2], own) | |
| 291 | + pin(ids[0], own) | |
| 292 | + body := get(h, "/~alice", "").Body.String() | |
| 293 | + if !strings.Contains(body, "Pinned") || strings.Index(body, `href="/~alice/c"`) > strings.Index(body, `href="/~alice/a"`) || strings.Contains(body, `href="/~alice/b"`) { | |
| 294 | + t.Error("profile does not show exactly the pinned repos in pin order") | |
| 295 | + } | |
| 296 | + for _, id := range ids[1:] { | |
| 297 | + pin(id, own) | |
| 298 | + } | |
| 299 | + if n := len(s.store.Pins(alice.ID)); n != maxPins { | |
| 300 | + t.Errorf("%d pins, want the cap of %d", n, maxPins) | |
| 301 | + } | |
| 302 | + if w := get(h, "/~alice/repositories", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `href="/~alice/g"`) { | |
| 303 | + t.Error("repositories page does not list every repo") | |
| 304 | + } | |
| 305 | +} | |
static/style.css modified
| @@ -436,3 +436,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 436 | 436 | form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem} |
| 437 | 437 | table.keys form.inline{margin:0} |
| 438 | 438 | input.rank{width:4rem} |
| 439 | +.pinhead{display:flex;align-items:baseline;justify-content:space-between;font-weight:400;color:var(--muted);margin:1rem 0 .5rem} | |
| 440 | +.pinhead a{font-size:.9rem;color:var(--accent)} | |
| 441 | +.event h4 .pinform{margin:0 0 0 auto} | |
| 442 | +.event h4 .starcount~.pinform{margin-left:.6rem} | |
store.go modified
| @@ -79,6 +79,7 @@ var migrations = []string{ | ||
| 79 | 79 | `alter table repos add column featured integer not null default 0`, |
| 80 | 80 | `alter table users add column org integer not null default 0`, |
| 81 | 81 | `alter table users add column admin integer not null default 0`, |
| 82 | + `alter table repos add column pinned integer not null default 0`, | |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | const extraSchema = ` |
| @@ -532,7 +533,7 @@ func (s *Store) UserByToken(name, token string) (User, bool) { | ||
| 532 | 533 | } |
| 533 | 534 | |
| 534 | 535 | func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) { |
| 535 | - if !validName(name) || name == "avatar" || name == "stars" || name == "feed" { | |
| 536 | + if !validName(name) || reservedRepo(name) { | |
| 536 | 537 | return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") |
| 537 | 538 | } |
| 538 | 539 | tags, err := parseTags(rawTags) |
| @@ -1005,7 +1006,7 @@ func (s *Store) DeleteUser(name string) error { | ||
| 1005 | 1006 | } |
| 1006 | 1007 | |
| 1007 | 1008 | func (s *Store) RenameRepo(id int64, name string) error { |
| 1008 | - if !validName(name) || name == "avatar" || name == "stars" || name == "feed" { | |
| 1009 | + if !validName(name) || reservedRepo(name) { | |
| 1009 | 1010 | return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") |
| 1010 | 1011 | } |
| 1011 | 1012 | _, err := s.db.Exec(`update repos set name = ? where id = ?`, name, id) |
| @@ -1079,3 +1080,38 @@ func validEmail(email string) bool { | ||
| 1079 | 1080 | addr, err := mail.ParseAddress(email) |
| 1080 | 1081 | return err == nil && addr.Address == email && len(email) <= 200 && !strings.ContainsAny(email, "\r\n<>,;\"") |
| 1081 | 1082 | } |
| 1083 | + | |
| 1084 | +const maxPins = 6 | |
| 1085 | + | |
| 1086 | +func reservedRepo(name string) bool { | |
| 1087 | + return name == "avatar" || name == "stars" || name == "feed" || name == "repositories" | |
| 1088 | +} | |
| 1089 | + | |
| 1090 | +func (s *Store) Pins(owner int64) map[int64]int { | |
| 1091 | + pins := map[int64]int{} | |
| 1092 | + rows, err := s.db.Query(`select id, pinned from repos where owner = ? and pinned > 0`, owner) | |
| 1093 | + if err != nil { | |
| 1094 | + return pins | |
| 1095 | + } | |
| 1096 | + defer rows.Close() | |
| 1097 | + for rows.Next() { | |
| 1098 | + var id int64 | |
| 1099 | + var rank int | |
| 1100 | + if rows.Scan(&id, &rank) == nil { | |
| 1101 | + pins[id] = rank | |
| 1102 | + } | |
| 1103 | + } | |
| 1104 | + return pins | |
| 1105 | +} | |
| 1106 | + | |
| 1107 | +func (s *Store) Pin(owner, repo int64, on bool) error { | |
| 1108 | + if !on { | |
| 1109 | + _, err := s.db.Exec(`update repos set pinned = 0 where id = ? and owner = ?`, repo, owner) | |
| 1110 | + return err | |
| 1111 | + } | |
| 1112 | + if len(s.Pins(owner)) >= maxPins { | |
| 1113 | + return fmt.Errorf("Only %d repositories can be pinned, unpin one first", maxPins) | |
| 1114 | + } | |
| 1115 | + _, err := s.db.Exec(`update repos set pinned = (select coalesce(max(pinned), 0) + 1 from repos where owner = ?) where id = ? and owner = ? and pinned = 0`, owner, repo, owner) | |
| 1116 | + return err | |
| 1117 | +} | |
templates/meta.html modified
| @@ -9,3 +9,4 @@ | ||
| 9 | 9 | </div> |
| 10 | 10 | {{end}} |
| 11 | 11 | {{define "who"}}{{with person .Email}}<a href="/~{{.}}">{{$.Author}}</a>{{else}}{{.Author}}{{end}}{{end}} |
| 12 | +{{define "repoitem"}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}} | |
templates/org.html modified
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | <div class="columns orgbody"> |
| 19 | 19 | <div class="main"> |
| 20 | 20 | {{with .Data.Activity}}<section class="activity"><div class="heathead"><p class="muted small">{{.Total}} commit{{if ne .Total 1}}s{{end}} across the organisation {{if .Year}}in {{.Year}}{{else}}in the past 12 months{{end}}</p><nav class="years"><a href="?"{{if not .Year}} class="active"{{end}}>past 12 months</a>{{range .Years}}<a href="?year={{.}}"{{if eq . $.Data.Activity.Year}} class="active"{{end}}>{{.}}</a>{{end}}</nav></div>{{.Grid}}<p class="legend muted small">less <i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i> more</p></section>{{end}} |
| 21 | -{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This organisation does not have any repositories.</p>{{end}} | |
| 21 | +{{if .Data.Repos}}<h4 class="pinhead">{{if .Data.Pinned}}Pinned{{else}}Recently updated{{end}}<a href="/~{{.Data.Owner.Name}}/repositories">all repositories ▸</a></h4><div class="events">{{range .Data.Repos}}{{template "repoitem" .}}{{end}}</div>{{else}}<p class="info">This organisation does not have any repositories.</p>{{end}} | |
| 22 | 22 | </div> |
| 23 | 23 | <aside class="people"> |
| 24 | 24 | <h4>People <span class="count">{{len .Data.Members}}</span></h4> |
templates/repositories.html added
| @@ -0,0 +1,9 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$d := .Data}} | |
| 3 | +<div class="userhead"> | |
| 4 | +<h2>~{{$d.Owner.Name}}</h2> | |
| 5 | +<nav class="tabs small"><a href="/~{{$d.Owner.Name}}">about</a><a class="active" href="/~{{$d.Owner.Name}}/repositories">repositories{{if $d.Repos}} <span class="count">{{len $d.Repos}}</span>{{end}}</a>{{if not $d.Org}}<a href="/~{{$d.Owner.Name}}/stars">stars{{if $d.Count}} <span class="count">{{$d.Count}}</span>{{end}}</a>{{end}}<a href="/~{{$d.Owner.Name}}/feed">feed</a></nav> | |
| 6 | +</div> | |
| 7 | +{{if $d.Member}}<p class="muted small">Pinned repositories show on the profile page, up to {{$d.Max}} of them in the order they were pinned.</p>{{end}} | |
| 8 | +{{if $d.Repos}}<div class="events">{{range $d.Repos}}{{$pin := index $d.Pins .ID}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if $pin}} <span class="tag">pinned</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}{{if $d.Member}}<form method="post" action="/~{{$d.Owner.Name}}/repositories" class="pinform"><input type="hidden" name="repo" value="{{.ID}}"><button class="btn small" name="action" value="{{if $pin}}unpin{{else}}pin{{end}}">{{if $pin}}Unpin{{else}}Pin{{end}}</button></form>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">No repositories yet.</p>{{end}} | |
| 9 | +{{end}} | |
templates/stars.html modified
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="userhead"> |
| 3 | 3 | <h2>~{{.Data.Owner.Name}}</h2> |
| 4 | -<nav class="tabs small"><a href="/~{{.Data.Owner.Name}}">about</a><a class="active" href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a></nav> | |
| 4 | +<nav class="tabs small"><a href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repositories">repositories</a><a class="active" href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a></nav> | |
| 5 | 5 | </div> |
| 6 | 6 | {{if .Data.Starred}}<div class="events">{{range .Data.Starred}}<div class="event"><h4><a href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a>{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">~{{.Data.Owner.Name}} has not starred anything yet.</p>{{end}} |
| 7 | 7 | {{end}} |
templates/user.html modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$own := and .User (eq .User.ID .Data.Owner.ID)}} |
| 4 | 4 | <div class="userhead"> |
| 5 | 5 | <h2>~{{.Data.Owner.Name}}</h2> |
| 6 | -<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/feed">feed</a></nav> | |
| 6 | +<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repositories">repositories{{if .Data.Total}} <span class="count">{{.Data.Total}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/feed">feed</a></nav> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns user"> |
| 9 | 9 | <aside class="profile"> |
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | </aside> |
| 22 | 22 | <div class="main"> |
| 23 | 23 | {{with .Data.Activity}}<section class="activity"><div class="heathead"><p class="muted small">{{.Total}} commit{{if ne .Total 1}}s{{end}} {{if .Year}}in {{.Year}}{{else}}in the past 12 months{{end}}</p><nav class="years"><a href="?"{{if not .Year}} class="active"{{end}}>past 12 months</a>{{range .Years}}<a href="?year={{.}}"{{if eq . $.Data.Activity.Year}} class="active"{{end}}>{{.}}</a>{{end}}</nav></div>{{.Grid}}<p class="legend muted small">less <i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i> more</p></section>{{end}} |
| 24 | -{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} | |
| 24 | +{{if .Data.Repos}}<h4 class="pinhead">{{if .Data.Pinned}}Pinned{{else}}Recently updated{{end}}<a href="/~{{.Data.Owner.Name}}/repositories">all repositories ▸</a></h4><div class="events">{{range .Data.Repos}}{{template "repoitem" .}}{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} | |
| 25 | 25 | </div> |
| 26 | 26 | </div> |
| 27 | 27 | {{end}} |
web.go modified
| @@ -827,7 +827,12 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 827 | 827 | if year < 1970 || year > time.Now().Year() { |
| 828 | 828 | year = 0 |
| 829 | 829 | } |
| 830 | - data := map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio, "Count": len(starred), "Activity": s.activity(owner, year), "Member": member} | |
| 830 | + shown := pinnedRepos(repos, s.store.Pins(owner.ID)) | |
| 831 | + pinned := len(shown) > 0 | |
| 832 | + if !pinned { | |
| 833 | + shown = repos[:min(len(repos), maxPins)] | |
| 834 | + } | |
| 835 | + data := map[string]any{"Owner": owner, "Repos": shown, "Total": len(repos), "Pinned": pinned, "Profile": profile, "Bio": bio, "Count": len(starred), "Activity": s.activity(owner, year), "Member": member} | |
| 831 | 836 | if org { |
| 832 | 837 | var people []userHit |
| 833 | 838 | for _, m := range s.store.Members(owner.ID) { |
| @@ -863,6 +868,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 863 | 868 | s.orgSettings(w, r, owner, name) |
| 864 | 869 | return |
| 865 | 870 | } |
| 871 | + if name == "repositories" && len(parts) == 2 { | |
| 872 | + s.repositories(w, r, owner, user) | |
| 873 | + return | |
| 874 | + } | |
| 866 | 875 | if name == "stars" && len(parts) == 2 && r.Method == "GET" { |
| 867 | 876 | starred, _ := s.store.StarredRepos(owner.ID) |
| 868 | 877 | s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}}) |
| @@ -1578,3 +1587,41 @@ func cacheScope(repo Repo) string { | ||
| 1578 | 1587 | } |
| 1579 | 1588 | return "public" |
| 1580 | 1589 | } |
| 1590 | + | |
| 1591 | +func pinnedRepos(repos []Repo, pins map[int64]int) []Repo { | |
| 1592 | + var out []Repo | |
| 1593 | + for _, r := range repos { | |
| 1594 | + if pins[r.ID] > 0 { | |
| 1595 | + out = append(out, r) | |
| 1596 | + } | |
| 1597 | + } | |
| 1598 | + slices.SortFunc(out, func(a, b Repo) int { return pins[a.ID] - pins[b.ID] }) | |
| 1599 | + return out | |
| 1600 | +} | |
| 1601 | + | |
| 1602 | +func (s *Server) repositories(w http.ResponseWriter, r *http.Request, owner User, user *User) { | |
| 1603 | + member := user != nil && (user.ID == owner.ID || s.store.IsMember(owner.ID, user.ID)) | |
| 1604 | + if r.Method == "POST" { | |
| 1605 | + if !member { | |
| 1606 | + s.fail(w, r, 404, "Not found") | |
| 1607 | + return | |
| 1608 | + } | |
| 1609 | + id, _ := strconv.ParseInt(r.FormValue("repo"), 10, 64) | |
| 1610 | + if err := s.store.Pin(owner.ID, id, r.FormValue("action") == "pin"); err != nil { | |
| 1611 | + http.Redirect(w, r, "/~"+owner.Name+"/repositories?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) | |
| 1612 | + return | |
| 1613 | + } | |
| 1614 | + http.Redirect(w, r, "/~"+owner.Name+"/repositories", http.StatusSeeOther) | |
| 1615 | + return | |
| 1616 | + } | |
| 1617 | + repos, err := s.store.ReposByOwner(owner.ID, member) | |
| 1618 | + if err != nil { | |
| 1619 | + s.fail(w, r, 500, err.Error()) | |
| 1620 | + return | |
| 1621 | + } | |
| 1622 | + repos = s.byUpdate(repos) | |
| 1623 | + starred, _ := s.store.StarredRepos(owner.ID) | |
| 1624 | + pins := s.store.Pins(owner.ID) | |
| 1625 | + data := map[string]any{"Owner": owner, "Repos": repos, "Pins": pins, "Member": member, "Count": len(starred), "Org": s.store.IsOrg(owner.ID), "Max": maxPins} | |
| 1626 | + s.render(w, r, "repositories", page{Title: "~" + owner.Name + " repositories", User: user, Error: r.URL.Query().Get("error"), Data: data}) | |
| 1627 | +} | |