feat(web): the home page lists repositories as cards (#56)
- commit
- e95eebda317302f13d3d13c1fc33a5477e4bfa1b
- author
- brice · · 2026-10-18 06:17 UTC
- committer
- bnema · · 2026-10-18 06:17 UTC
- parent
- 8e01339
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
14 files · +770 -672
M internal/adapters/web/components.templ +24 -15
| @@ -32,26 +32,35 @@ templ CloneBlock(c Clone) { | |||
| 32 | 32 | </div> | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | // HomeRow is one row of the home list. Its id is the target of SSE swaps. | |
| 35 | + | // HomeRowView is the card of a repository in the home list. Its id is the | |
| 36 | + | // target of SSE swaps; the name link comes first, `enter` follows it. | |
| 36 | 37 | templ HomeRowView(r HomeRow) { | |
| 37 | - | <tr id={ r.ID } data-cursor?={ r.Cursor }> | |
| 38 | - | <td class="cur" aria-hidden="true"></td> | |
| 39 | - | <td class="c-name"> | |
| 38 | + | <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }> | |
| 39 | + | <h2 class="repo-name"> | |
| 40 | 40 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 41 | + | if r.Private { | |
| 42 | + | <span class="pill">private</span> | |
| 43 | + | } | |
| 41 | 44 | if r.Fresh { | |
| 42 | 45 | <span class="pill new">new push</span> | |
| 43 | 46 | } | |
| 44 | - | </td> | |
| 45 | - | <td class="c-desc dim">{ r.Desc }</td> | |
| 46 | - | <td class="c-when nowrap"> | |
| 47 | - | <time | |
| 48 | - | if r.PushedAt != "" { | |
| 49 | - | datetime={ r.PushedAt } | |
| 50 | - | } | |
| 51 | - | >{ r.Pushed }</time> | |
| 52 | - | </td> | |
| 53 | - | <td class="c-ref">{ r.Branch }</td> | |
| 54 | - | </tr> | |
| 47 | + | </h2> | |
| 48 | + | if r.Desc != "" { | |
| 49 | + | <p class="repo-desc">{ r.Desc }</p> | |
| 50 | + | } else { | |
| 51 | + | <p class="repo-desc mute">no description</p> | |
| 52 | + | } | |
| 53 | + | <p class="repo-meta"> | |
| 54 | + | if r.Branch != "" { | |
| 55 | + | <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span> | |
| 56 | + | } | |
| 57 | + | if r.PushedAt != "" { | |
| 58 | + | <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span> | |
| 59 | + | } else { | |
| 60 | + | <span>never pushed</span> | |
| 61 | + | } | |
| 62 | + | </p> | |
| 63 | + | </li> | |
| 55 | 64 | } | |
| 56 | 65 | ||
| 57 | 66 | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | |
| @@ -32,26 +32,35 @@ templ CloneBlock(c Clone) { | |||||
| 32 | </div> | 32 | </div> | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | - | // HomeRow is one row of the home list. Its id is the target of SSE swaps. | 35 | + | // HomeRowView is the card of a repository in the home list. Its id is the |
| 36 | + | // target of SSE swaps; the name link comes first, `enter` follows it. | |||
| 36 | templ HomeRowView(r HomeRow) { | 37 | templ HomeRowView(r HomeRow) { | ||
| 37 | - | <tr id={ r.ID } data-cursor?={ r.Cursor }> | 38 | + | <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }> |
| 38 | - | <td class="cur" aria-hidden="true"></td> | 39 | + | <h2 class="repo-name"> |
| 39 | - | <td class="c-name"> | |||
| 40 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | 40 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | ||
| 41 | + | if r.Private { | |||
| 42 | + | <span class="pill">private</span> | |||
| 43 | + | } | |||
| 41 | if r.Fresh { | 44 | if r.Fresh { | ||
| 42 | <span class="pill new">new push</span> | 45 | <span class="pill new">new push</span> | ||
| 43 | } | 46 | } | ||
| 44 | - | </td> | 47 | + | </h2> |
| 45 | - | <td class="c-desc dim">{ r.Desc }</td> | 48 | + | if r.Desc != "" { |
| 46 | - | <td class="c-when nowrap"> | 49 | + | <p class="repo-desc">{ r.Desc }</p> |
| 47 | - | <time | 50 | + | } else { |
| 48 | - | if r.PushedAt != "" { | 51 | + | <p class="repo-desc mute">no description</p> |
| 49 | - | datetime={ r.PushedAt } | 52 | + | } |
| 50 | - | } | 53 | + | <p class="repo-meta"> |
| 51 | - | >{ r.Pushed }</time> | 54 | + | if r.Branch != "" { |
| 52 | - | </td> | 55 | + | <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span> |
| 53 | - | <td class="c-ref">{ r.Branch }</td> | 56 | + | } |
| 54 | - | </tr> | 57 | + | if r.PushedAt != "" { |
| 58 | + | <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span> | |||
| 59 | + | } else { | |||
| 60 | + | <span>never pushed</span> | |||
| 61 | + | } | |||
| 62 | + | </p> | |||
| 63 | + | </li> | |||
| 55 | } | 64 | } | ||
| 56 | 65 | ||||
| 57 | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | 66 | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | ||
M internal/adapters/web/components_templ.go +206 -160 generated file: collapsed
M internal/adapters/web/events.go +1 -1
| @@ -45,7 +45,7 @@ import ( | |||
| 45 | 45 | // | |
| 46 | 46 | // Events, each with the id of the Watch event it comes from: | |
| 47 | 47 | // | |
| 48 | - | // home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr> | |
| 48 | + | // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li> | |
| 49 | 49 | // for a repository that is deleted or no longer visible | |
| 50 | 50 | // repo-last-push data: <dd id="last-push-<name>">…</dd> | |
| 51 | 51 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| @@ -45,7 +45,7 @@ import ( | |||||
| 45 | // | 45 | // | ||
| 46 | // Events, each with the id of the Watch event it comes from: | 46 | // Events, each with the id of the Watch event it comes from: | ||
| 47 | // | 47 | // | ||
| 48 | - | // home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr> | 48 | + | // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li> |
| 49 | // for a repository that is deleted or no longer visible | 49 | // for a repository that is deleted or no longer visible | ||
| 50 | // repo-last-push data: <dd id="last-push-<name>">…</dd> | 50 | // repo-last-push data: <dd id="last-push-<name>">…</dd> | ||
| 51 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | 51 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
M internal/adapters/web/fragments.go +1 -1
| @@ -116,7 +116,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, | |||
| 116 | 116 | case err != nil: | |
| 117 | 117 | return nil, err | |
| 118 | 118 | } | |
| 119 | - | row := homeRow(view, l.s.cfg.Now(), false) | |
| 119 | + | row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx)) | |
| 120 | 120 | row.Fresh = fresh | |
| 121 | 121 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | |
| 122 | 122 | return []fragment{f}, err | |
| @@ -116,7 +116,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, | |||||
| 116 | case err != nil: | 116 | case err != nil: | ||
| 117 | return nil, err | 117 | return nil, err | ||
| 118 | } | 118 | } | ||
| 119 | - | row := homeRow(view, l.s.cfg.Now(), false) | 119 | + | row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx)) |
| 120 | row.Fresh = fresh | 120 | row.Fresh = fresh | ||
| 121 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | 121 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | ||
| 122 | return []fragment{f}, err | 122 | return []fragment{f}, err | ||
M internal/adapters/web/fragments.templ +2 -2
| @@ -21,10 +21,10 @@ templ FactValue(f Fact) { | |||
| 21 | 21 | </dd> | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | - | // RepoGone replaces the row of a repository that is deleted or not visible | |
| 24 | + | // RepoGone replaces the card of a repository that is deleted or not visible | |
| 25 | 25 | // any more; the page removes the element. | |
| 26 | 26 | templ RepoGone(id string) { | |
| 27 | - | <tr id={ id } data-gone hidden></tr> | |
| 27 | + | <li id={ id } data-gone hidden></li> | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | // RefsBody is the rows of the refs table. | |
| @@ -21,10 +21,10 @@ templ FactValue(f Fact) { | |||||
| 21 | </dd> | 21 | </dd> | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | - | // RepoGone replaces the row of a repository that is deleted or not visible | 24 | + | // RepoGone replaces the card of a repository that is deleted or not visible |
| 25 | // any more; the page removes the element. | 25 | // any more; the page removes the element. | ||
| 26 | templ RepoGone(id string) { | 26 | templ RepoGone(id string) { | ||
| 27 | - | <tr id={ id } data-gone hidden></tr> | 27 | + | <li id={ id } data-gone hidden></li> |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | // RefsBody is the rows of the refs table. | 30 | // RefsBody is the rows of the refs table. | ||
M internal/adapters/web/fragments_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/home.go +29 -21
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/a-h/templ" | |
| 13 | 13 | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | 15 | "bnema.dev/ermite/internal/ports" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -48,14 +49,16 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) { | |||
| 48 | 49 | return repos, nil | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | - | // homeCols are the sortable columns of the home list, in order. | |
| 52 | - | var homeCols = []struct{ key, label, class string }{ | |
| 53 | - | {"name", "name", "c-name"}, | |
| 54 | - | {"desc", "description", "c-desc"}, | |
| 55 | - | {"push", "last push", "c-when"}, | |
| 56 | - | {"branch", "branch", "c-ref"}, | |
| 52 | + | // homeSorts are the orders the home list offers, in order. The query also | |
| 53 | + | // accepts "desc" and "branch", which the page does not link to. | |
| 54 | + | var homeSorts = []struct{ key, label string }{ | |
| 55 | + | {"push", "last push"}, | |
| 56 | + | {"name", "name"}, | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | // homeKeys are the keys of ?sort= that sortRepos knows. | |
| 60 | + | var homeKeys = []string{"push", "name", "desc", "branch"} | |
| 61 | + | ||
| 59 | 62 | // sortRepos orders repos by the column key; ties keep the order by name. | |
| 60 | 63 | func sortRepos(repos []ports.RepoView, key string, desc bool) { | |
| 61 | 64 | less := func(a, b ports.RepoView) bool { | |
| @@ -85,9 +88,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 85 | 88 | return nil, 0, err | |
| 86 | 89 | } | |
| 87 | 90 | key, desc := "push", true | |
| 88 | - | for _, c := range homeCols { | |
| 89 | - | if r.URL.Query().Get("sort") == c.key { | |
| 90 | - | key, desc = c.key, c.key == "push" | |
| 91 | + | for _, k := range homeKeys { | |
| 92 | + | if r.URL.Query().Get("sort") == k { | |
| 93 | + | key, desc = k, k == "push" | |
| 91 | 94 | } | |
| 92 | 95 | } | |
| 93 | 96 | if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" { | |
| @@ -96,23 +99,26 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 96 | 99 | sortRepos(repos, key, desc) | |
| 97 | 100 | ||
| 98 | 101 | now := s.cfg.Now() | |
| 102 | + | signed := SignedIn(ctx) | |
| 99 | 103 | p := HomePage{} | |
| 100 | - | for _, c := range homeCols { | |
| 101 | - | sc := SortCol{Label: c.label, Class: c.class, Dir: "none"} | |
| 104 | + | for _, c := range homeSorts { | |
| 105 | + | sl := SortLink{Label: c.label} | |
| 102 | 106 | next := "asc" | |
| 107 | + | if c.key == "push" { | |
| 108 | + | next = "desc" | |
| 109 | + | } | |
| 103 | 110 | if c.key == key { | |
| 104 | - | sc.Dir, sc.Mark = "ascending", "▲" | |
| 111 | + | sl.Mark, sl.State = "▲", "ascending, select for descending" | |
| 112 | + | next = "desc" | |
| 105 | 113 | if desc { | |
| 106 | - | sc.Dir, sc.Mark = "descending", "▼" | |
| 107 | - | } else { | |
| 108 | - | next = "desc" | |
| 114 | + | sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc" | |
| 109 | 115 | } | |
| 110 | 116 | } | |
| 111 | - | sc.Href = "/?sort=" + c.key + "&dir=" + next | |
| 112 | - | p.Cols = append(p.Cols, sc) | |
| 117 | + | sl.Href = "/?sort=" + c.key + "&dir=" + next | |
| 118 | + | p.Sorts = append(p.Sorts, sl) | |
| 113 | 119 | } | |
| 114 | 120 | for i, repo := range repos { | |
| 115 | - | p.Rows = append(p.Rows, homeRow(repo, now, i == 0)) | |
| 121 | + | p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed)) | |
| 116 | 122 | } | |
| 117 | 123 | ||
| 118 | 124 | l := s.base() | |
| @@ -122,12 +128,14 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 122 | 128 | return Home(l, p), http.StatusOK, nil | |
| 123 | 129 | } | |
| 124 | 130 | ||
| 125 | - | // homeRow is the row of a repository in the home list. | |
| 126 | - | func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow { | |
| 131 | + | // homeRow is the card of a repository in the home list. The private badge is | |
| 132 | + | // for a signed-in visitor: an anonymous one never gets a private repository. | |
| 133 | + | func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |
| 127 | 134 | name := r.Name.String() | |
| 128 | 135 | row := HomeRow{ | |
| 129 | 136 | ID: "repo-" + name, Name: name, Href: repoHref(name), | |
| 130 | - | Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor, | |
| 137 | + | Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor, | |
| 138 | + | Private: signed && r.Visibility == domain.VisibilityPrivate, | |
| 131 | 139 | } | |
| 132 | 140 | if !r.PushedAt.IsZero() { | |
| 133 | 141 | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 13 | 13 | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | "bnema.dev/ermite/internal/ports" | 15 | "bnema.dev/ermite/internal/ports" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -48,14 +49,16 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) { | |||||
| 48 | return repos, nil | 49 | return repos, nil | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | - | // homeCols are the sortable columns of the home list, in order. | 52 | + | // homeSorts are the orders the home list offers, in order. The query also |
| 52 | - | var homeCols = []struct{ key, label, class string }{ | 53 | + | // accepts "desc" and "branch", which the page does not link to. |
| 53 | - | {"name", "name", "c-name"}, | 54 | + | var homeSorts = []struct{ key, label string }{ |
| 54 | - | {"desc", "description", "c-desc"}, | 55 | + | {"push", "last push"}, |
| 55 | - | {"push", "last push", "c-when"}, | 56 | + | {"name", "name"}, |
| 56 | - | {"branch", "branch", "c-ref"}, | |||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | // homeKeys are the keys of ?sort= that sortRepos knows. | |||
| 60 | + | var homeKeys = []string{"push", "name", "desc", "branch"} | |||
| 61 | + | ||||
| 59 | // sortRepos orders repos by the column key; ties keep the order by name. | 62 | // sortRepos orders repos by the column key; ties keep the order by name. | ||
| 60 | func sortRepos(repos []ports.RepoView, key string, desc bool) { | 63 | func sortRepos(repos []ports.RepoView, key string, desc bool) { | ||
| 61 | less := func(a, b ports.RepoView) bool { | 64 | less := func(a, b ports.RepoView) bool { | ||
| @@ -85,9 +88,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 85 | return nil, 0, err | 88 | return nil, 0, err | ||
| 86 | } | 89 | } | ||
| 87 | key, desc := "push", true | 90 | key, desc := "push", true | ||
| 88 | - | for _, c := range homeCols { | 91 | + | for _, k := range homeKeys { |
| 89 | - | if r.URL.Query().Get("sort") == c.key { | 92 | + | if r.URL.Query().Get("sort") == k { |
| 90 | - | key, desc = c.key, c.key == "push" | 93 | + | key, desc = k, k == "push" |
| 91 | } | 94 | } | ||
| 92 | } | 95 | } | ||
| 93 | if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" { | 96 | if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" { | ||
| @@ -96,23 +99,26 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 96 | sortRepos(repos, key, desc) | 99 | sortRepos(repos, key, desc) | ||
| 97 | 100 | ||||
| 98 | now := s.cfg.Now() | 101 | now := s.cfg.Now() | ||
| 102 | + | signed := SignedIn(ctx) | |||
| 99 | p := HomePage{} | 103 | p := HomePage{} | ||
| 100 | - | for _, c := range homeCols { | 104 | + | for _, c := range homeSorts { |
| 101 | - | sc := SortCol{Label: c.label, Class: c.class, Dir: "none"} | 105 | + | sl := SortLink{Label: c.label} |
| 102 | next := "asc" | 106 | next := "asc" | ||
| 107 | + | if c.key == "push" { | |||
| 108 | + | next = "desc" | |||
| 109 | + | } | |||
| 103 | if c.key == key { | 110 | if c.key == key { | ||
| 104 | - | sc.Dir, sc.Mark = "ascending", "▲" | 111 | + | sl.Mark, sl.State = "▲", "ascending, select for descending" |
| 112 | + | next = "desc" | |||
| 105 | if desc { | 113 | if desc { | ||
| 106 | - | sc.Dir, sc.Mark = "descending", "▼" | 114 | + | sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc" |
| 107 | - | } else { | |||
| 108 | - | next = "desc" | |||
| 109 | } | 115 | } | ||
| 110 | } | 116 | } | ||
| 111 | - | sc.Href = "/?sort=" + c.key + "&dir=" + next | 117 | + | sl.Href = "/?sort=" + c.key + "&dir=" + next |
| 112 | - | p.Cols = append(p.Cols, sc) | 118 | + | p.Sorts = append(p.Sorts, sl) |
| 113 | } | 119 | } | ||
| 114 | for i, repo := range repos { | 120 | for i, repo := range repos { | ||
| 115 | - | p.Rows = append(p.Rows, homeRow(repo, now, i == 0)) | 121 | + | p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed)) |
| 116 | } | 122 | } | ||
| 117 | 123 | ||||
| 118 | l := s.base() | 124 | l := s.base() | ||
| @@ -122,12 +128,14 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 122 | return Home(l, p), http.StatusOK, nil | 128 | return Home(l, p), http.StatusOK, nil | ||
| 123 | } | 129 | } | ||
| 124 | 130 | ||||
| 125 | - | // homeRow is the row of a repository in the home list. | 131 | + | // homeRow is the card of a repository in the home list. The private badge is |
| 126 | - | func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow { | 132 | + | // for a signed-in visitor: an anonymous one never gets a private repository. |
| 133 | + | func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |||
| 127 | name := r.Name.String() | 134 | name := r.Name.String() | ||
| 128 | row := HomeRow{ | 135 | row := HomeRow{ | ||
| 129 | ID: "repo-" + name, Name: name, Href: repoHref(name), | 136 | ID: "repo-" + name, Name: name, Href: repoHref(name), | ||
| 130 | - | Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor, | 137 | + | Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor, |
| 138 | + | Private: signed && r.Visibility == domain.VisibilityPrivate, | |||
| 131 | } | 139 | } | ||
| 132 | if !r.PushedAt.IsZero() { | 140 | if !r.PushedAt.IsZero() { | ||
| 133 | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | 141 | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | ||
M internal/adapters/web/pages.templ +39 -32
| @@ -5,42 +5,49 @@ package web | |||
| 5 | 5 | // Home is the repository list. | |
| 6 | 6 | templ Home(l Layout, p HomePage) { | |
| 7 | 7 | @Shell(l, nil) { | |
| 8 | - | <div class="filter"> | |
| 9 | - | <label for="home-filter" class="sr">filter</label> | |
| 10 | - | <input | |
| 11 | - | id="home-filter" | |
| 12 | - | type="search" | |
| 13 | - | data-filter="#repo-rows > tr" | |
| 14 | - | placeholder="Filter repositories…" | |
| 15 | - | autocomplete="off" | |
| 16 | - | hx-on:input="ermite.filter(this)" | |
| 17 | - | /> | |
| 18 | - | </div> | |
| 19 | - | <table class="list home" aria-label="Repositories"> | |
| 20 | - | <thead> | |
| 21 | - | <tr> | |
| 22 | - | <th class="cur"></th> | |
| 23 | - | for _, c := range p.Cols { | |
| 24 | - | <th class={ c.Class } aria-sort={ c.Dir }> | |
| 25 | - | <a href={ templ.SafeURL(c.Href) }> | |
| 26 | - | { c.Label } | |
| 27 | - | if c.Mark != "" { | |
| 28 | - | <span aria-hidden="true">{ c.Mark }</span> | |
| 8 | + | <h1 class="sr">Repositories</h1> | |
| 9 | + | <div class="home"> | |
| 10 | + | <section class="home-repos" aria-label="Repositories"> | |
| 11 | + | <div class="home-bar"> | |
| 12 | + | <div class="filter"> | |
| 13 | + | <label for="home-filter" class="sr">filter</label> | |
| 14 | + | <input | |
| 15 | + | id="home-filter" | |
| 16 | + | type="search" | |
| 17 | + | data-filter="#repo-rows > li" | |
| 18 | + | placeholder="Filter repositories…" | |
| 19 | + | autocomplete="off" | |
| 20 | + | hx-on:input="ermite.filter(this)" | |
| 21 | + | /> | |
| 22 | + | </div> | |
| 23 | + | <nav class="sort" aria-label="Sort repositories"> | |
| 24 | + | <span class="mute">sort</span> | |
| 25 | + | for _, s := range p.Sorts { | |
| 26 | + | <a | |
| 27 | + | href={ templ.SafeURL(s.Href) } | |
| 28 | + | if s.Mark != "" { | |
| 29 | + | aria-current="true" | |
| 30 | + | } | |
| 31 | + | > | |
| 32 | + | { s.Label } | |
| 33 | + | if s.Mark != "" { | |
| 34 | + | <span aria-hidden="true">{ s.Mark }</span> | |
| 35 | + | <span class="sr">{ ", " + s.State }</span> | |
| 29 | 36 | } | |
| 30 | 37 | </a> | |
| 31 | - | </th> | |
| 38 | + | } | |
| 39 | + | </nav> | |
| 40 | + | </div> | |
| 41 | + | <ul class="repos" id="repo-rows" data-rows> | |
| 42 | + | for _, r := range p.Rows { | |
| 43 | + | @HomeRowView(r) | |
| 32 | 44 | } | |
| 33 | - | </tr> | |
| 34 | - | </thead> | |
| 35 | - | <tbody id="repo-rows" data-rows> | |
| 36 | - | for _, r := range p.Rows { | |
| 37 | - | @HomeRowView(r) | |
| 45 | + | </ul> | |
| 46 | + | if len(p.Rows) == 0 { | |
| 47 | + | <p class="lede dim">no repositories yet.</p> | |
| 38 | 48 | } | |
| 39 | - | </tbody> | |
| 40 | - | </table> | |
| 41 | - | if len(p.Rows) == 0 { | |
| 42 | - | <p class="lede dim">no repositories yet.</p> | |
| 43 | - | } | |
| 49 | + | </section> | |
| 50 | + | </div> | |
| 44 | 51 | } | |
| 45 | 52 | } | |
| 46 | 53 | ||
| @@ -5,42 +5,49 @@ package web | |||||
| 5 | // Home is the repository list. | 5 | // Home is the repository list. | ||
| 6 | templ Home(l Layout, p HomePage) { | 6 | templ Home(l Layout, p HomePage) { | ||
| 7 | @Shell(l, nil) { | 7 | @Shell(l, nil) { | ||
| 8 | - | <div class="filter"> | 8 | + | <h1 class="sr">Repositories</h1> |
| 9 | - | <label for="home-filter" class="sr">filter</label> | 9 | + | <div class="home"> |
| 10 | - | <input | 10 | + | <section class="home-repos" aria-label="Repositories"> |
| 11 | - | id="home-filter" | 11 | + | <div class="home-bar"> |
| 12 | - | type="search" | 12 | + | <div class="filter"> |
| 13 | - | data-filter="#repo-rows > tr" | 13 | + | <label for="home-filter" class="sr">filter</label> |
| 14 | - | placeholder="Filter repositories…" | 14 | + | <input |
| 15 | - | autocomplete="off" | 15 | + | id="home-filter" |
| 16 | - | hx-on:input="ermite.filter(this)" | 16 | + | type="search" |
| 17 | - | /> | 17 | + | data-filter="#repo-rows > li" |
| 18 | - | </div> | 18 | + | placeholder="Filter repositories…" |
| 19 | - | <table class="list home" aria-label="Repositories"> | 19 | + | autocomplete="off" |
| 20 | - | <thead> | 20 | + | hx-on:input="ermite.filter(this)" |
| 21 | - | <tr> | 21 | + | /> |
| 22 | - | <th class="cur"></th> | 22 | + | </div> |
| 23 | - | for _, c := range p.Cols { | 23 | + | <nav class="sort" aria-label="Sort repositories"> |
| 24 | - | <th class={ c.Class } aria-sort={ c.Dir }> | 24 | + | <span class="mute">sort</span> |
| 25 | - | <a href={ templ.SafeURL(c.Href) }> | 25 | + | for _, s := range p.Sorts { |
| 26 | - | { c.Label } | 26 | + | <a |
| 27 | - | if c.Mark != "" { | 27 | + | href={ templ.SafeURL(s.Href) } |
| 28 | - | <span aria-hidden="true">{ c.Mark }</span> | 28 | + | if s.Mark != "" { |
| 29 | + | aria-current="true" | |||
| 30 | + | } | |||
| 31 | + | > | |||
| 32 | + | { s.Label } | |||
| 33 | + | if s.Mark != "" { | |||
| 34 | + | <span aria-hidden="true">{ s.Mark }</span> | |||
| 35 | + | <span class="sr">{ ", " + s.State }</span> | |||
| 29 | } | 36 | } | ||
| 30 | </a> | 37 | </a> | ||
| 31 | - | </th> | 38 | + | } |
| 39 | + | </nav> | |||
| 40 | + | </div> | |||
| 41 | + | <ul class="repos" id="repo-rows" data-rows> | |||
| 42 | + | for _, r := range p.Rows { | |||
| 43 | + | @HomeRowView(r) | |||
| 32 | } | 44 | } | ||
| 33 | - | </tr> | 45 | + | </ul> |
| 34 | - | </thead> | 46 | + | if len(p.Rows) == 0 { |
| 35 | - | <tbody id="repo-rows" data-rows> | 47 | + | <p class="lede dim">no repositories yet.</p> |
| 36 | - | for _, r := range p.Rows { | |||
| 37 | - | @HomeRowView(r) | |||
| 38 | } | 48 | } | ||
| 39 | - | </tbody> | 49 | + | </section> |
| 40 | - | </table> | 50 | + | </div> |
| 41 | - | if len(p.Rows) == 0 { | |||
| 42 | - | <p class="lede dim">no repositories yet.</p> | |||
| 43 | - | } | |||
| 44 | } | 51 | } | ||
| 45 | } | 52 | } | ||
| 46 | 53 | ||||
M internal/adapters/web/pages_templ.go +369 -373 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +40 -6
| @@ -28,13 +28,47 @@ | |||
| 28 | 28 | & .c-sub { color: var(--ink-dim); } | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | /* home: the repositories as cards in a grid (one column on a phone); the | |
| 32 | + | page is a stack of sections, so others can sit beside or below */ | |
| 31 | 33 | .home { | |
| 32 | - | & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; } | |
| 33 | - | & th.c-name { font-family: var(--font); } | |
| 34 | - | & .c-desc { width: auto; } | |
| 35 | - | & .c-when { width: 130px; } | |
| 36 | - | & .c-ref { width: 120px; } | |
| 37 | - | & .c-lang { width: 120px; } | |
| 34 | + | display: flex; flex-direction: column; gap: var(--s5); | |
| 35 | + | & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); } | |
| 36 | + | & .home-bar .filter { flex: 1 1 260px; } | |
| 37 | + | & .sort { display: flex; align-items: center; gap: var(--s1); font-size: var(--fs-s); } | |
| 38 | + | & .sort > .mute { margin-right: var(--s1); } | |
| 39 | + | & .sort a { | |
| 40 | + | display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 var(--s2); | |
| 41 | + | color: var(--ink-dim); text-decoration: none; border: 1px solid transparent; border-radius: var(--r-s); | |
| 42 | + | } | |
| 43 | + | & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); } | |
| 44 | + | & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); } | |
| 45 | + | } | |
| 46 | + | .repos { | |
| 47 | + | display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3); | |
| 48 | + | margin: 0; padding: 0; list-style: none; | |
| 49 | + | & .repo { | |
| 50 | + | position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; | |
| 51 | + | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | |
| 52 | + | } | |
| 53 | + | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | |
| 54 | + | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); } | |
| 55 | + | & .repo[hidden] { display: none; } | |
| 56 | + | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | |
| 57 | + | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | |
| 58 | + | /* the whole card is the target of the name link */ | |
| 59 | + | & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); } | |
| 60 | + | & .repo-name a:focus-visible { outline: none; } | |
| 61 | + | & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; } | |
| 62 | + | & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; } | |
| 63 | + | & .repo-name .pill { margin-left: 0; } | |
| 64 | + | & .repo-desc { | |
| 65 | + | flex: 1; color: var(--ink-dim); | |
| 66 | + | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | |
| 67 | + | } | |
| 68 | + | & .repo-desc.mute { color: var(--ink-mute); font-style: italic; } | |
| 69 | + | & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); } | |
| 70 | + | & .repo-meta time { color: var(--ink-dim); } | |
| 71 | + | & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); } | |
| 38 | 72 | } | |
| 39 | 73 | .list.tree { | |
| 40 | 74 | & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; } | |
| @@ -28,13 +28,47 @@ | |||||
| 28 | & .c-sub { color: var(--ink-dim); } | 28 | & .c-sub { color: var(--ink-dim); } | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | /* home: the repositories as cards in a grid (one column on a phone); the | |||
| 32 | + | page is a stack of sections, so others can sit beside or below */ | |||
| 31 | .home { | 33 | .home { | ||
| 32 | - | & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; } | 34 | + | display: flex; flex-direction: column; gap: var(--s5); |
| 33 | - | & th.c-name { font-family: var(--font); } | 35 | + | & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); } |
| 34 | - | & .c-desc { width: auto; } | 36 | + | & .home-bar .filter { flex: 1 1 260px; } |
| 35 | - | & .c-when { width: 130px; } | 37 | + | & .sort { display: flex; align-items: center; gap: var(--s1); font-size: var(--fs-s); } |
| 36 | - | & .c-ref { width: 120px; } | 38 | + | & .sort > .mute { margin-right: var(--s1); } |
| 37 | - | & .c-lang { width: 120px; } | 39 | + | & .sort a { |
| 40 | + | display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 var(--s2); | |||
| 41 | + | color: var(--ink-dim); text-decoration: none; border: 1px solid transparent; border-radius: var(--r-s); | |||
| 42 | + | } | |||
| 43 | + | & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); } | |||
| 44 | + | & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); } | |||
| 45 | + | } | |||
| 46 | + | .repos { | |||
| 47 | + | display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3); | |||
| 48 | + | margin: 0; padding: 0; list-style: none; | |||
| 49 | + | & .repo { | |||
| 50 | + | position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; | |||
| 51 | + | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | |||
| 52 | + | } | |||
| 53 | + | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | |||
| 54 | + | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); } | |||
| 55 | + | & .repo[hidden] { display: none; } | |||
| 56 | + | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | |||
| 57 | + | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | |||
| 58 | + | /* the whole card is the target of the name link */ | |||
| 59 | + | & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); } | |||
| 60 | + | & .repo-name a:focus-visible { outline: none; } | |||
| 61 | + | & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; } | |||
| 62 | + | & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; } | |||
| 63 | + | & .repo-name .pill { margin-left: 0; } | |||
| 64 | + | & .repo-desc { | |||
| 65 | + | flex: 1; color: var(--ink-dim); | |||
| 66 | + | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | |||
| 67 | + | } | |||
| 68 | + | & .repo-desc.mute { color: var(--ink-mute); font-style: italic; } | |||
| 69 | + | & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); } | |||
| 70 | + | & .repo-meta time { color: var(--ink-dim); } | |||
| 71 | + | & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); } | |||
| 38 | } | 72 | } | ||
| 39 | .list.tree { | 73 | .list.tree { | ||
| 40 | & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; } | 74 | & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; } | ||
M internal/adapters/web/view.go +10 -8
| @@ -83,25 +83,27 @@ type HomeRow struct { | |||
| 83 | 83 | Pushed string | |
| 84 | 84 | PushedAt string // RFC 3339, "" when never pushed | |
| 85 | 85 | Branch string | |
| 86 | - | Tag string | |
| 87 | 86 | Cursor bool | |
| 87 | + | // Private marks a private repository; only set for a signed-in visitor. | |
| 88 | + | Private bool | |
| 88 | 89 | // Fresh marks a row that a push just updated (live updates only). | |
| 89 | 90 | Fresh bool | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | - | // SortCol is a column header of the home list; it links to its sort order. | |
| 93 | - | type SortCol struct { | |
| 93 | + | // SortLink is one way to order the home list; it links to that order. | |
| 94 | + | type SortLink struct { | |
| 94 | 95 | Label string | |
| 95 | 96 | Href string | |
| 96 | - | Dir string // "ascending" | "descending" | "none" | |
| 97 | - | Mark string | |
| 98 | - | Class string | |
| 97 | + | Mark string // "▲" or "▼" on the current order, "" otherwise | |
| 98 | + | // State says the current direction and what the link does, for screen | |
| 99 | + | // readers (the mark is hidden from them); "" off the current order. | |
| 100 | + | State string | |
| 99 | 101 | } | |
| 100 | 102 | ||
| 101 | 103 | // HomePage is the repository list. | |
| 102 | 104 | type HomePage struct { | |
| 103 | - | Cols []SortCol | |
| 104 | - | Rows []HomeRow | |
| 105 | + | Sorts []SortLink | |
| 106 | + | Rows []HomeRow | |
| 105 | 107 | } | |
| 106 | 108 | ||
| 107 | 109 | // Fact is one key/value row of the facts block. | |
| @@ -83,25 +83,27 @@ type HomeRow struct { | |||||
| 83 | Pushed string | 83 | Pushed string | ||
| 84 | PushedAt string // RFC 3339, "" when never pushed | 84 | PushedAt string // RFC 3339, "" when never pushed | ||
| 85 | Branch string | 85 | Branch string | ||
| 86 | - | Tag string | |||
| 87 | Cursor bool | 86 | Cursor bool | ||
| 87 | + | // Private marks a private repository; only set for a signed-in visitor. | |||
| 88 | + | Private bool | |||
| 88 | // Fresh marks a row that a push just updated (live updates only). | 89 | // Fresh marks a row that a push just updated (live updates only). | ||
| 89 | Fresh bool | 90 | Fresh bool | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 92 | - | // SortCol is a column header of the home list; it links to its sort order. | 93 | + | // SortLink is one way to order the home list; it links to that order. |
| 93 | - | type SortCol struct { | 94 | + | type SortLink struct { |
| 94 | Label string | 95 | Label string | ||
| 95 | Href string | 96 | Href string | ||
| 96 | - | Dir string // "ascending" | "descending" | "none" | 97 | + | Mark string // "▲" or "▼" on the current order, "" otherwise |
| 97 | - | Mark string | 98 | + | // State says the current direction and what the link does, for screen |
| 98 | - | Class string | 99 | + | // readers (the mark is hidden from them); "" off the current order. |
| 100 | + | State string | |||
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| 101 | // HomePage is the repository list. | 103 | // HomePage is the repository list. | ||
| 102 | type HomePage struct { | 104 | type HomePage struct { | ||
| 103 | - | Cols []SortCol | 105 | + | Sorts []SortLink |
| 104 | - | Rows []HomeRow | 106 | + | Rows []HomeRow |
| 105 | } | 107 | } | ||
| 106 | 108 | ||||
| 107 | // Fact is one key/value row of the facts block. | 109 | // Fact is one key/value row of the facts block. | ||
M internal/app/testdata/web/home.html +39 -46
| @@ -49,58 +49,51 @@ | |||
| 49 | 49 | </header> | |
| 50 | 50 | <div class="work"> | |
| 51 | 51 | <main id="main" tabindex="-1"> | |
| 52 | + | <h1 class="sr">Repositories</h1> | |
| 53 | + | <div class="home"> | |
| 54 | + | <section class="home-repos" aria-label="Repositories"> | |
| 55 | + | <div class="home-bar"> | |
| 52 | 56 | <div class="filter"> | |
| 53 | 57 | <label for="home-filter" class="sr">filter</label> | |
| 54 | - | <input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 58 | + | <input id="home-filter" type="search" data-filter="#repo-rows > li" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 55 | 59 | </div> | |
| 56 | - | <table class="list home" aria-label="Repositories"> | |
| 57 | - | <thead> | |
| 58 | - | <tr> | |
| 59 | - | <th class="cur"> | |
| 60 | - | </th> | |
| 61 | - | <th class="c-name" aria-sort="none"> | |
| 62 | - | <a href="/?sort=name&dir=asc">name </a> | |
| 63 | - | </th> | |
| 64 | - | <th class="c-desc" aria-sort="none"> | |
| 65 | - | <a href="/?sort=desc&dir=asc">description </a> | |
| 66 | - | </th> | |
| 67 | - | <th class="c-when" aria-sort="descending"> | |
| 68 | - | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |
| 60 | + | <nav class="sort" aria-label="Sort repositories"> | |
| 61 | + | <span class="mute">sort</span> | |
| 62 | + | <a href="/?sort=push&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> | |
| 63 | + | <span class="sr">, descending, select for ascending</span> | |
| 69 | 64 | </a> | |
| 70 | - | </th> | |
| 71 | - | <th class="c-ref" aria-sort="none"> | |
| 72 | - | <a href="/?sort=branch&dir=asc">branch </a> | |
| 73 | - | </th> | |
| 74 | - | </tr> | |
| 75 | - | </thead> | |
| 76 | - | <tbody id="repo-rows" data-rows> | |
| 77 | - | <tr id="repo-demo" data-cursor> | |
| 78 | - | <td class="cur" aria-hidden="true"> | |
| 79 | - | </td> | |
| 80 | - | <td class="c-name"> | |
| 65 | + | <a href="/?sort=name&dir=asc">name </a> | |
| 66 | + | </nav> | |
| 67 | + | </div> | |
| 68 | + | <ul class="repos" id="repo-rows" data-rows> | |
| 69 | + | <li id="repo-demo" class="repo" data-cursor> | |
| 70 | + | <h2 class="repo-name"> | |
| 81 | 71 | <a href="/demo">demo</a> | |
| 82 | - | </td> | |
| 83 | - | <td class="c-desc dim">A demo repository</td> | |
| 84 | - | <td class="c-when nowrap"> | |
| 85 | - | <time datetime="STAMP">AGO</time> | |
| 86 | - | </td> | |
| 87 | - | <td class="c-ref">main</td> | |
| 88 | - | </tr> | |
| 89 | - | <tr id="repo-quiet"> | |
| 90 | - | <td class="cur" aria-hidden="true"> | |
| 91 | - | </td> | |
| 92 | - | <td class="c-name"> | |
| 72 | + | </h2> | |
| 73 | + | <p class="repo-desc">A demo repository</p> | |
| 74 | + | <p class="repo-meta"> | |
| 75 | + | <span class="repo-branch"> | |
| 76 | + | <span class="ico i-branch" aria-hidden="true"> | |
| 77 | + | </span>main</span> | |
| 78 | + | <span>pushed <time datetime="STAMP" title="STAMP">AGO</time> | |
| 79 | + | </span> | |
| 80 | + | </p> | |
| 81 | + | </li> | |
| 82 | + | <li id="repo-quiet" class="repo"> | |
| 83 | + | <h2 class="repo-name"> | |
| 93 | 84 | <a href="/quiet">quiet</a> | |
| 94 | - | </td> | |
| 95 | - | <td class="c-desc dim"> | |
| 96 | - | </td> | |
| 97 | - | <td class="c-when nowrap"> | |
| 98 | - | <time>never</time> | |
| 99 | - | </td> | |
| 100 | - | <td class="c-ref">main</td> | |
| 101 | - | </tr> | |
| 102 | - | </tbody> | |
| 103 | - | </table> | |
| 85 | + | </h2> | |
| 86 | + | <p class="repo-desc mute">no description</p> | |
| 87 | + | <p class="repo-meta"> | |
| 88 | + | <span class="repo-branch"> | |
| 89 | + | <span class="ico i-branch" aria-hidden="true"> | |
| 90 | + | </span>main</span> | |
| 91 | + | <span>never pushed</span> | |
| 92 | + | </p> | |
| 93 | + | </li> | |
| 94 | + | </ul> | |
| 95 | + | </section> | |
| 96 | + | </div> | |
| 104 | 97 | </main> | |
| 105 | 98 | </div> | |
| 106 | 99 | <footer class="foot"> | |
| @@ -49,58 +49,51 @@ | |||||
| 49 | </header> | 49 | </header> | ||
| 50 | <div class="work"> | 50 | <div class="work"> | ||
| 51 | <main id="main" tabindex="-1"> | 51 | <main id="main" tabindex="-1"> | ||
| 52 | + | <h1 class="sr">Repositories</h1> | |||
| 53 | + | <div class="home"> | |||
| 54 | + | <section class="home-repos" aria-label="Repositories"> | |||
| 55 | + | <div class="home-bar"> | |||
| 52 | <div class="filter"> | 56 | <div class="filter"> | ||
| 53 | <label for="home-filter" class="sr">filter</label> | 57 | <label for="home-filter" class="sr">filter</label> | ||
| 54 | - | <input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | 58 | + | <input id="home-filter" type="search" data-filter="#repo-rows > li" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 55 | </div> | 59 | </div> | ||
| 56 | - | <table class="list home" aria-label="Repositories"> | 60 | + | <nav class="sort" aria-label="Sort repositories"> |
| 57 | - | <thead> | 61 | + | <span class="mute">sort</span> |
| 58 | - | <tr> | 62 | + | <a href="/?sort=push&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> |
| 59 | - | <th class="cur"> | 63 | + | <span class="sr">, descending, select for ascending</span> |
| 60 | - | </th> | |||
| 61 | - | <th class="c-name" aria-sort="none"> | |||
| 62 | - | <a href="/?sort=name&dir=asc">name </a> | |||
| 63 | - | </th> | |||
| 64 | - | <th class="c-desc" aria-sort="none"> | |||
| 65 | - | <a href="/?sort=desc&dir=asc">description </a> | |||
| 66 | - | </th> | |||
| 67 | - | <th class="c-when" aria-sort="descending"> | |||
| 68 | - | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |||
| 69 | </a> | 64 | </a> | ||
| 70 | - | </th> | 65 | + | <a href="/?sort=name&dir=asc">name </a> |
| 71 | - | <th class="c-ref" aria-sort="none"> | 66 | + | </nav> |
| 72 | - | <a href="/?sort=branch&dir=asc">branch </a> | 67 | + | </div> |
| 73 | - | </th> | 68 | + | <ul class="repos" id="repo-rows" data-rows> |
| 74 | - | </tr> | 69 | + | <li id="repo-demo" class="repo" data-cursor> |
| 75 | - | </thead> | 70 | + | <h2 class="repo-name"> |
| 76 | - | <tbody id="repo-rows" data-rows> | |||
| 77 | - | <tr id="repo-demo" data-cursor> | |||
| 78 | - | <td class="cur" aria-hidden="true"> | |||
| 79 | - | </td> | |||
| 80 | - | <td class="c-name"> | |||
| 81 | <a href="/demo">demo</a> | 71 | <a href="/demo">demo</a> | ||
| 82 | - | </td> | 72 | + | </h2> |
| 83 | - | <td class="c-desc dim">A demo repository</td> | 73 | + | <p class="repo-desc">A demo repository</p> |
| 84 | - | <td class="c-when nowrap"> | 74 | + | <p class="repo-meta"> |
| 85 | - | <time datetime="STAMP">AGO</time> | 75 | + | <span class="repo-branch"> |
| 86 | - | </td> | 76 | + | <span class="ico i-branch" aria-hidden="true"> |
| 87 | - | <td class="c-ref">main</td> | 77 | + | </span>main</span> |
| 88 | - | </tr> | 78 | + | <span>pushed <time datetime="STAMP" title="STAMP">AGO</time> |
| 89 | - | <tr id="repo-quiet"> | 79 | + | </span> |
| 90 | - | <td class="cur" aria-hidden="true"> | 80 | + | </p> |
| 91 | - | </td> | 81 | + | </li> |
| 92 | - | <td class="c-name"> | 82 | + | <li id="repo-quiet" class="repo"> |
| 83 | + | <h2 class="repo-name"> | |||
| 93 | <a href="/quiet">quiet</a> | 84 | <a href="/quiet">quiet</a> | ||
| 94 | - | </td> | 85 | + | </h2> |
| 95 | - | <td class="c-desc dim"> | 86 | + | <p class="repo-desc mute">no description</p> |
| 96 | - | </td> | 87 | + | <p class="repo-meta"> |
| 97 | - | <td class="c-when nowrap"> | 88 | + | <span class="repo-branch"> |
| 98 | - | <time>never</time> | 89 | + | <span class="ico i-branch" aria-hidden="true"> |
| 99 | - | </td> | 90 | + | </span>main</span> |
| 100 | - | <td class="c-ref">main</td> | 91 | + | <span>never pushed</span> |
| 101 | - | </tr> | 92 | + | </p> |
| 102 | - | </tbody> | 93 | + | </li> |
| 103 | - | </table> | 94 | + | </ul> |
| 95 | + | </section> | |||
| 96 | + | </div> | |||
| 104 | </main> | 97 | </main> | ||
| 105 | </div> | 98 | </div> | ||
| 106 | <footer class="foot"> | 99 | <footer class="foot"> | ||
M internal/app/web_events_test.go +4 -4
| @@ -122,7 +122,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 122 | 122 | assert.Less(t, time.Since(start), 2*time.Second) | |
| 123 | 123 | assert.Equal(t, "home-list", ev.name) | |
| 124 | 124 | assert.NotEmpty(t, ev.id) | |
| 125 | - | assert.Contains(t, ev.data, `<tr id="repo-pub"`) | |
| 125 | + | assert.Contains(t, ev.data, `<li id="repo-pub"`) | |
| 126 | 126 | assert.Contains(t, ev.data, `new push`) | |
| 127 | 127 | assert.Contains(t, ev.data, `href="/pub"`) | |
| 128 | 128 | ||
| @@ -131,7 +131,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 131 | 131 | require.NoError(t, err) | |
| 132 | 132 | ev = s.next(t, watchWait) | |
| 133 | 133 | assert.Equal(t, "home-list", ev.name) | |
| 134 | - | assert.Contains(t, ev.data, `<tr id="repo-fresh"`) | |
| 134 | + | assert.Contains(t, ev.data, `<li id="repo-fresh"`) | |
| 135 | 135 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | |
| 136 | 136 | assert.NotContains(t, ev.data, "new push") | |
| 137 | 137 | ||
| @@ -141,13 +141,13 @@ func TestWebEventsHome(t *testing.T) { | |||
| 141 | 141 | require.NoError(t, err) | |
| 142 | 142 | ev = s.next(t, watchWait) | |
| 143 | 143 | assert.Equal(t, "home-list", ev.name) | |
| 144 | - | assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`) | |
| 144 | + | assert.Contains(t, ev.data, `<li id="repo-fresh" data-gone`) | |
| 145 | 145 | assert.NotContains(t, ev.data, "fresh one") | |
| 146 | 146 | ||
| 147 | 147 | // And a deleted one. | |
| 148 | 148 | require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub"))) | |
| 149 | 149 | ev = s.next(t, watchWait) | |
| 150 | - | assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`) | |
| 150 | + | assert.Contains(t, ev.data, `<li id="repo-pub" data-gone`) | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | 153 | // AC5: the stream of a repository carries its last push; the refs page its | |
| @@ -122,7 +122,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 122 | assert.Less(t, time.Since(start), 2*time.Second) | 122 | assert.Less(t, time.Since(start), 2*time.Second) | ||
| 123 | assert.Equal(t, "home-list", ev.name) | 123 | assert.Equal(t, "home-list", ev.name) | ||
| 124 | assert.NotEmpty(t, ev.id) | 124 | assert.NotEmpty(t, ev.id) | ||
| 125 | - | assert.Contains(t, ev.data, `<tr id="repo-pub"`) | 125 | + | assert.Contains(t, ev.data, `<li id="repo-pub"`) |
| 126 | assert.Contains(t, ev.data, `new push`) | 126 | assert.Contains(t, ev.data, `new push`) | ||
| 127 | assert.Contains(t, ev.data, `href="/pub"`) | 127 | assert.Contains(t, ev.data, `href="/pub"`) | ||
| 128 | 128 | ||||
| @@ -131,7 +131,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 131 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 132 | ev = s.next(t, watchWait) | 132 | ev = s.next(t, watchWait) | ||
| 133 | assert.Equal(t, "home-list", ev.name) | 133 | assert.Equal(t, "home-list", ev.name) | ||
| 134 | - | assert.Contains(t, ev.data, `<tr id="repo-fresh"`) | 134 | + | assert.Contains(t, ev.data, `<li id="repo-fresh"`) |
| 135 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | 135 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | ||
| 136 | assert.NotContains(t, ev.data, "new push") | 136 | assert.NotContains(t, ev.data, "new push") | ||
| 137 | 137 | ||||
| @@ -141,13 +141,13 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 141 | require.NoError(t, err) | 141 | require.NoError(t, err) | ||
| 142 | ev = s.next(t, watchWait) | 142 | ev = s.next(t, watchWait) | ||
| 143 | assert.Equal(t, "home-list", ev.name) | 143 | assert.Equal(t, "home-list", ev.name) | ||
| 144 | - | assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`) | 144 | + | assert.Contains(t, ev.data, `<li id="repo-fresh" data-gone`) |
| 145 | assert.NotContains(t, ev.data, "fresh one") | 145 | assert.NotContains(t, ev.data, "fresh one") | ||
| 146 | 146 | ||||
| 147 | // And a deleted one. | 147 | // And a deleted one. | ||
| 148 | require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub"))) | 148 | require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub"))) | ||
| 149 | ev = s.next(t, watchWait) | 149 | ev = s.next(t, watchWait) | ||
| 150 | - | assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`) | 150 | + | assert.Contains(t, ev.data, `<li id="repo-pub" data-gone`) |
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | // AC5: the stream of a repository carries its last push; the refs page its | 153 | // AC5: the stream of a repository carries its last push; the refs page its | ||
M internal/app/web_session_test.go +3 -0
| @@ -118,6 +118,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||
| 118 | 118 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | |
| 119 | 119 | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") | |
| 120 | 120 | assert.NotContains(t, home.body, `action="/-/logout"`) | |
| 121 | + | assert.NotContains(t, home.body, `>private</span>`, "an anonymous visitor sees no private badge") | |
| 121 | 122 | assert.Equal(t, 404, b.get("/secret", nil).status) | |
| 122 | 123 | ||
| 123 | 124 | // The CLI asks for a link and prints it; stderr says when it ends. | |
| @@ -199,6 +200,8 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||
| 199 | 200 | } | |
| 200 | 201 | home = b.get("/", as(token)) | |
| 201 | 202 | assert.Contains(t, home.body, "secret") | |
| 203 | + | assert.Equal(t, 1, strings.Count(home.body, `<span class="pill">private</span>`), "only the private repository has the badge") | |
| 204 | + | assert.Regexp(t, `(?s)<li id="repo-secret".*?<span class="pill">private</span>.*?</li>\s*<li id="repo-pub"|<li id="repo-pub".*?</li>\s*<li id="repo-secret".*?<span class="pill">private</span>`, home.body) | |
| 202 | 205 | assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`) | |
| 203 | 206 | assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt") | |
| 204 | 207 | raw := b.get("/secret/raw/main/notes.txt", as(token)) | |
| @@ -118,6 +118,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||||
| 118 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | 118 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | ||
| 119 | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") | 119 | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") | ||
| 120 | assert.NotContains(t, home.body, `action="/-/logout"`) | 120 | assert.NotContains(t, home.body, `action="/-/logout"`) | ||
| 121 | + | assert.NotContains(t, home.body, `>private</span>`, "an anonymous visitor sees no private badge") | |||
| 121 | assert.Equal(t, 404, b.get("/secret", nil).status) | 122 | assert.Equal(t, 404, b.get("/secret", nil).status) | ||
| 122 | 123 | ||||
| 123 | // The CLI asks for a link and prints it; stderr says when it ends. | 124 | // The CLI asks for a link and prints it; stderr says when it ends. | ||
| @@ -199,6 +200,8 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||||
| 199 | } | 200 | } | ||
| 200 | home = b.get("/", as(token)) | 201 | home = b.get("/", as(token)) | ||
| 201 | assert.Contains(t, home.body, "secret") | 202 | assert.Contains(t, home.body, "secret") | ||
| 203 | + | assert.Equal(t, 1, strings.Count(home.body, `<span class="pill">private</span>`), "only the private repository has the badge") | |||
| 204 | + | assert.Regexp(t, `(?s)<li id="repo-secret".*?<span class="pill">private</span>.*?</li>\s*<li id="repo-pub"|<li id="repo-pub".*?</li>\s*<li id="repo-secret".*?<span class="pill">private</span>`, home.body) | |||
| 202 | assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`) | 205 | assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`) | ||
| 203 | assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt") | 206 | assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt") | ||
| 204 | raw := b.get("/secret/raw/main/notes.txt", as(token)) | 207 | raw := b.get("/secret/raw/main/notes.txt", as(token)) | ||