~gitpocket/gitpocket

feat(profile): add a repositories tab and pin up to six repositories to the profile

commit
b61134ce8c21b9a45ea6aac077138759c7a4381b browse tree
parent
6d62ad17e750639ddf3d54fcbfc50da2561fe4b5
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 12:34 UTC

9 files changed, +139 −7

flow_test.go modified

@@ -1,6 +1,7 @@
11 package main
22
33 import (
4+ "fmt"
45 "net/http"
56 "net/http/httptest"
67 "net/url"
@@ -268,3 +269,37 @@ func TestAdminSections(t *testing.T) {
268269 t.Error("bad email mode accepted")
269270 }
270271 }
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}
436436 form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem}
437437 table.keys form.inline{margin:0}
438438 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{
7979 `alter table repos add column featured integer not null default 0`,
8080 `alter table users add column org integer not null default 0`,
8181 `alter table users add column admin integer not null default 0`,
82+ `alter table repos add column pinned integer not null default 0`,
8283 }
8384
8485 const extraSchema = `
@@ -532,7 +533,7 @@ func (s *Store) UserByToken(name, token string) (User, bool) {
532533 }
533534
534535 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) {
536537 return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
537538 }
538539 tags, err := parseTags(rawTags)
@@ -1005,7 +1006,7 @@ func (s *Store) DeleteUser(name string) error {
10051006 }
10061007
10071008 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) {
10091010 return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
10101011 }
10111012 _, err := s.db.Exec(`update repos set name = ? where id = ?`, name, id)
@@ -1079,3 +1080,38 @@ func validEmail(email string) bool {
10791080 addr, err := mail.ParseAddress(email)
10801081 return err == nil && addr.Address == email && len(email) <= 200 && !strings.ContainsAny(email, "\r\n<>,;\"")
10811082 }
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 @@
99 </div>
1010 {{end}}
1111 {{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 @@
1818 <div class="columns orgbody">
1919 <div class="main">
2020 {{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}}
2222 </div>
2323 <aside class="people">
2424 <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 @@
11 {{define "content"}}
22 <div class="userhead">
33 <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>
55 </div>
66 {{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}}
77 {{end}}

templates/user.html modified

@@ -3,7 +3,7 @@
33 {{$own := and .User (eq .User.ID .Data.Owner.ID)}}
44 <div class="userhead">
55 <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>
77 </div>
88 <div class="columns user">
99 <aside class="profile">
@@ -21,7 +21,7 @@
2121 </aside>
2222 <div class="main">
2323 {{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}}
2525 </div>
2626 </div>
2727 {{end}}

web.go modified

@@ -827,7 +827,12 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
827827 if year < 1970 || year > time.Now().Year() {
828828 year = 0
829829 }
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}
831836 if org {
832837 var people []userHit
833838 for _, m := range s.store.Members(owner.ID) {
@@ -863,6 +868,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
863868 s.orgSettings(w, r, owner, name)
864869 return
865870 }
871+ if name == "repositories" && len(parts) == 2 {
872+ s.repositories(w, r, owner, user)
873+ return
874+ }
866875 if name == "stars" && len(parts) == 2 && r.Method == "GET" {
867876 starred, _ := s.store.StarredRepos(owner.ID)
868877 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 {
15781587 }
15791588 return "public"
15801589 }
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+}