A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b41ccc6005

b41ccc600593d0850d0afe1e833da34bca590121

parent: 77812a5bbf

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T18:52:31Z

web: dashboard homepage, landing page, explore, pinned repos (#10)

Logged-in users land on a dashboard: pinned repositories (new repo
pin/unpin commands, repo_pins table, migration 0012), open merge
requests, and open issues involving them (their repos by ownership,
grant, or org, plus anything they authored). Anonymous visitors get a
landing page describing the forge with a quickstart; the public repo
grid and its filter move to /explore (word already reserved).
Dashboard queries live in store/dashboard.go with a shared involvement
condition.
cmd/gitbay/main.go +2
@@ -218,6 +218,8 @@ func repoCmd() *cobra.Command {
218218 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
219219 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
220220 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
221 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
222 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
221223 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
222224 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
223225 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
docs/roadmap.org +2
@@ -54,6 +54,8 @@ step.
5454 - [[https://gitbay.org/krz/gitbay/issues/7][#7]] search — done 2026-08-24 (repo search by name/desc/topic,
5555 per-repo git grep on web+SSH; cross-repo indexer only if ever needed)
5656 - [[https://gitbay.org/krz/gitbay/issues/20][#20]] milestones and issue templates
57 [[https://gitbay.org/krz/gitbay/issues/30][#30]] activity graph on owner pages (platform-recorded activity
58 signal; events table already covers issues/MRs, extend to commits)
5759
5860 * Phase 3 — other people's forges
5961
docs/users.org +2
@@ -98,6 +98,8 @@ gitbay repo settings git-daemon you/project on # expose over git://
9898 gitbay repo topics add you/project cli forge # free-form tags, shown on the web
9999 gitbay repo search forge # find repos by name/description/topic
100100 gitbay repo grep you/project "some string" # literal git grep over the default branch
101gitbay repo pin you/project # pin to your web dashboard
102gitbay repo unpin you/project
101103 gitbay repo archive you/project # read-only: pushes and issue/MR
102104 gitbay repo unarchive you/project # writes refused, browsing intact
103105 #+end_src
e2e/dashboard_test.go added +112
@@ -0,0 +1,112 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestDashboard(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18
19 // Alice's repo with an open issue and an open MR authored by bob.
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
24 t.Fatal("grant failed")
25 }
26 work := t.TempDir()
27 env := inst.gitEnv(aliceKey)
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
36 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644)
37 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
39 mustGit(t, dir, env, "push", "-q", "origin", "feat")
40 if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
41 "--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
42 t.Fatal("mr create failed")
43 }
44 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
45 t.Fatal("issue create failed")
46 }
47
48 // Pins: read access required; unpinning something never pinned 404s.
49 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
50 t.Fatal("pin failed")
51 }
52 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 0 {
53 t.Fatal("unpin failed")
54 }
55 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 3 {
56 t.Fatal("double unpin should be not-found")
57 }
58 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
59 t.Fatal("re-pin failed")
60 }
61
62 // Anonymous homepage: landing with explore link, repos on /explore.
63 status, body := inst.get(t, "/")
64 if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "CLI-first") {
65 t.Fatalf("landing: %d", status)
66 }
67 if strings.Contains(body, "alice/app") {
68 t.Fatal("landing lists repositories")
69 }
70 _, body = inst.get(t, "/explore")
71 if !strings.Contains(body, "alice/app") {
72 t.Fatal("explore missing public repo")
73 }
74
75 // Logged-in homepage: dashboard with pinned repo and open items.
76 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
77 if code != 0 {
78 t.Fatalf("web login: %s", errOut)
79 }
80 var env2 struct {
81 Data struct {
82 URL string `json:"url"`
83 } `json:"data"`
84 }
85 json.Unmarshal([]byte(out), &env2)
86 loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
87 browser := newBrowser(t)
88 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
89 t.Fatalf("login: %d", status)
90 }
91 status, body = browserGet(t, browser, inst.base()+"/")
92 if status != 200 || !strings.Contains(body, "logged in as alice") {
93 t.Fatalf("dashboard: %d", status)
94 }
95 for _, want := range []string{
96 ">pinned</h2>", ">app<", // pinned card
97 "alice/app!1 from bob", "alice/app#1 todo one",
98 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
99 } {
100 if !strings.Contains(body, want) {
101 t.Errorf("dashboard missing %q", want)
102 }
103 }
104
105 // The MR diff is collapsed by default in a <details> with stats.
106 _, body = inst.get(t, "/alice/app/mrs/1")
107 if !strings.Contains(body, `<details class="difffold">`) ||
108 strings.Contains(body, `<details class="difffold" open`) ||
109 !strings.Contains(body, "1 file changed") {
110 t.Fatal("diff not collapsed with stats")
111 }
112}
e2e/description_test.go +2 −2
@@ -47,8 +47,8 @@ func TestRepoDescriptions(t *testing.T) {
4747 t.Fatalf("non-admin set: exit %d, want 4", code)
4848 }
4949
50 // Web: index, owner page, and repo header all show it.
51 for _, path := range []string{"/", "/alice", "/alice/tool"} {
50 // Web: explore, owner page, and repo header all show it.
51 for _, path := range []string{"/explore", "/alice", "/alice/tool"} {
5252 status, body := inst.get(t, path)
5353 if status != 200 || !strings.Contains(body, "better now") {
5454 t.Fatalf("description missing at %s (%d)", path, status)
e2e/org_test.go +2 −2
@@ -171,9 +171,9 @@ func TestOrganizations(t *testing.T) {
171171 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 {
172172 t.Fatal("org public repo failed")
173173 }
174 status, body := inst.get(t, "/")
174 status, body := inst.get(t, "/explore")
175175 if status != 200 || !strings.Contains(body, "puborg/site") {
176 t.Fatalf("org repo missing from index: %d", status)
176 t.Fatalf("org repo missing from explore: %d", status)
177177 }
178178 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
179179 t.Fatalf("org repo page: %d", status)
e2e/search_test.go +7 −7
@@ -69,18 +69,18 @@ func TestSearch(t *testing.T) {
6969 t.Fatalf("private grep parity: exit %d, %s", code, errOut)
7070 }
7171
72 // Web: index filter respects the query and visibility.
73 status, body := inst.get(t, "/?q=webapp")
72 // Web: explore filter respects the query and visibility.
73 status, body := inst.get(t, "/explore?q=webapp")
7474 if status != 200 || !strings.Contains(body, "alice/webapp") {
75 t.Fatalf("index filter: %d", status)
75 t.Fatalf("explore filter: %d", status)
7676 }
77 _, body = inst.get(t, "/?q=nomatchhere")
77 _, body = inst.get(t, "/explore?q=nomatchhere")
7878 if strings.Contains(body, "alice/webapp") {
79 t.Fatal("index filter did not filter")
79 t.Fatal("explore filter did not filter")
8080 }
81 _, body = inst.get(t, "/?q=hidden")
81 _, body = inst.get(t, "/explore?q=hidden")
8282 if strings.Contains(body, "secret") {
83 t.Fatal("private repo leaked on index search")
83 t.Fatal("private repo leaked on explore search")
8484 }
8585
8686 // Web: repo code search with mark and blob line anchor.
e2e/web_test.go +8 −3
@@ -66,13 +66,18 @@ func TestWebUI(t *testing.T) {
6666 t.Fatal("create private failed")
6767 }
6868
69 // Index lists the public repo, not the private one.
69 // Explore lists the public repo, not the private one; the anonymous
70 // homepage is a landing page pointing there.
7071 status, body := inst.get(t, "/")
72 if status != 200 || !strings.Contains(body, `href="/explore"`) {
73 t.Fatalf("landing: %d\n%s", status, body)
74 }
75 status, body = inst.get(t, "/explore")
7176 if status != 200 || !strings.Contains(body, "alice/site") {
72 t.Fatalf("index: %d\n%s", status, body)
77 t.Fatalf("explore: %d\n%s", status, body)
7378 }
7479 if strings.Contains(body, "secret") {
75 t.Fatal("index leaks private repo")
80 t.Fatal("explore leaks private repo")
7681 }
7782
7883 // Repo home: tree entries plus rendered README.
internal/control/repo.go +34
@@ -64,6 +64,10 @@ func init() {
6464 Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch})
6565 register(Command{Path: []string{"repo", "grep"},
6666 Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
67 register(Command{Path: []string{"repo", "pin"},
68 Summary: "pin a repository to your dashboard: repo pin <owner/name>", Run: runRepoPin})
69 register(Command{Path: []string{"repo", "unpin"},
70 Summary: "unpin a repository: repo unpin <owner/name>", Run: runRepoUnpin})
6771 }
6872
6973 const (
@@ -690,6 +694,36 @@ func runRepoGrep(c *Ctx, args []string) int {
690694 })
691695 }
692696
697func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
698func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
699
700func setPinned(c *Ctx, args []string, pin bool) int {
701 verb := "pin"
702 if !pin {
703 verb = "unpin"
704 }
705 if len(args) != 1 {
706 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
707 }
708 repo, code := resolveRepo(c, args[0], policy.CanRead)
709 if code >= 0 {
710 return code
711 }
712 if pin {
713 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
714 return c.fail(protocol.ExitFailure, "%v", err)
715 }
716 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
717 if errors.Is(err, store.ErrNotFound) {
718 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
719 }
720 return c.fail(protocol.ExitFailure, "%v", err)
721 }
722 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
723 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
724 })
725}
726
693727 func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
694728 func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
695729
internal/httpd/routes.go +1
@@ -27,6 +27,7 @@ func (s *Server) Routes() []Route {
2727 // Web UI, read-only. These exist in every mode.
2828 routes = append(routes,
2929 Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
30 Route{Method: "GET", Pattern: "/explore", Handler: s.explore},
3031 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
3132 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
3233 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage},
internal/store/dashboard.go added +108
@@ -0,0 +1,108 @@
1package store
2
3// DashboardItem is one open issue or MR row on the logged-in homepage.
4type DashboardItem struct {
5 RepoPath string
6 Number int64
7 Title string
8 Author string
9 State string
10 UpdatedAt string
11}
12
13// involvedRepos filters to repositories the user owns, is granted on, or
14// reaches through org membership — or rows the user authored anywhere.
15const involvedCond = `(
16 x.author_id = ?1
17 OR (r.owner_kind = 'user' AND r.owner_id = ?1)
18 OR EXISTS (SELECT 1 FROM repo_access a
19 WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1)
20 OR EXISTS (SELECT 1 FROM org_members mm
21 WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1)
22)`
23
24func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) {
25 rows, err := s.DB.Query(q, userID)
26 if err != nil {
27 return nil, err
28 }
29 defer rows.Close()
30 var out []DashboardItem
31 for rows.Next() {
32 var d DashboardItem
33 if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil {
34 return nil, err
35 }
36 out = append(out, d)
37 }
38 return out, rows.Err()
39}
40
41// DashboardMRs returns open merge requests involving the user: on their
42// repositories (owned, granted, org) or authored by them anywhere.
43func (s *Store) DashboardMRs(userID int64) ([]DashboardItem, error) {
44 return s.dashboardQuery(`
45 SELECT COALESCE(u.username, o.name) || '/' || r.name,
46 x.number, x.title, au.username, x.state, x.updated_at
47 FROM merge_requests x
48 JOIN repos r ON r.id = x.repo_id
49 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
50 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
51 JOIN users au ON au.id = x.author_id
52 WHERE x.state IN ('open', 'source_gone') AND `+involvedCond+`
53 ORDER BY x.updated_at DESC LIMIT 50`, userID)
54}
55
56// DashboardIssues is the issue counterpart of DashboardMRs.
57func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) {
58 return s.dashboardQuery(`
59 SELECT COALESCE(u.username, o.name) || '/' || r.name,
60 x.number, x.title, au.username, x.state, x.updated_at
61 FROM issues x
62 JOIN repos r ON r.id = x.repo_id
63 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
64 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
65 JOIN users au ON au.id = x.author_id
66 WHERE x.state = 'open' AND `+involvedCond+`
67 ORDER BY x.updated_at DESC LIMIT 50`, userID)
68}
69
70func (s *Store) PinRepo(userID, repoID int64) error {
71 _, err := s.DB.Exec(
72 "INSERT INTO repo_pins (user_id, repo_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
73 userID, repoID)
74 return err
75}
76
77func (s *Store) UnpinRepo(userID, repoID int64) error {
78 res, err := s.DB.Exec(
79 "DELETE FROM repo_pins WHERE user_id = ? AND repo_id = ?", userID, repoID)
80 if err != nil {
81 return err
82 }
83 if n, _ := res.RowsAffected(); n == 0 {
84 return ErrNotFound
85 }
86 return nil
87}
88
89// PinnedRepos returns the user's pinned repositories in pin order. The
90// caller applies visibility checks before rendering.
91func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
92 rows, err := s.DB.Query(repoSelect+`
93 JOIN repo_pins p ON p.repo_id = r.id AND p.user_id = ?
94 ORDER BY p.pinned_at`, userID)
95 if err != nil {
96 return nil, err
97 }
98 defer rows.Close()
99 var out []Repo
100 for rows.Next() {
101 r, err := scanRepo(rows)
102 if err != nil {
103 return nil, err
104 }
105 out = append(out, r)
106 }
107 return out, rows.Err()
108}
internal/store/migrations/0012_pins.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE repo_pins;
internal/store/migrations/0012_pins.up.sql added +6
@@ -0,0 +1,6 @@
1CREATE TABLE repo_pins (
2 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
4 pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
5 PRIMARY KEY (user_id, repo_id)
6);
internal/web/templates/dashboard.html added +38
@@ -0,0 +1,38 @@
1{{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "content"}}
3<div class="headrow">
4<h1>dashboard</h1>
5<span class="spacer"></span>
6<p class="toolbar">logged in as {{.Viewer}} · <a href="/{{.Viewer}}">your repositories</a> · <a href="/new">new repository</a> · <a href="/explore">explore</a> ·
7<form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>
8</div>
9{{if .Pinned}}<h2>pinned</h2>
10<div class="repogrid">
11{{range .Pinned}}<div class="repocard">
12 <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p>
13 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
14 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p>
15</div>
16{{end}}
17</div>{{end}}
18<h2>open merge requests <span class="count">{{len .MRs}}</span></h2>
19<ul class="issuelist">
20{{range .MRs}}<li>
21 <div class="issuemain">
22 <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p>
23 <p class="meta">{{.Author}} · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
24 </div>
25</li>
26{{else}}<li class="empty">no open merge requests</li>{{end}}
27</ul>
28<h2>open issues <span class="count">{{len .Issues}}</span></h2>
29<ul class="issuelist">
30{{range .Issues}}<li>
31 <div class="issuemain">
32 <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p>
33 <p class="meta">{{.Author}} · {{when .UpdatedAt}}</p>
34 </div>
35</li>
36{{else}}<li class="empty">no open issues</li>{{end}}
37</ul>
38{{end}}
internal/web/templates/explore.html added +18
@@ -0,0 +1,18 @@
1{{define "title"}}explore · {{.Site}}{{end}}
2{{define "content"}}
3<div class="headrow">
4<h1>explore</h1>
5<form method="get" action="/explore" class="searchform compact">
6 <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic">
7</form>
8<span class="spacer"></span>
9</div>
10<div class="repogrid">
11{{range .Repos}}<div class="repocard">
12 <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p>
13 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
14 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p>
15</div>
16{{else}}<p class="empty">no public repositories yet</p>{{end}}
17</div>
18{{end}}
internal/web/templates/index.html deleted −29
@@ -1,29 +0,0 @@
1{{define "title"}}{{.Site}}{{end}}
2{{define "content"}}
3<div class="headrow">
4<h1>repositories</h1>
5<form method="get" action="/" class="searchform compact">
6 <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic">
7</form>
8<span class="spacer"></span>
9{{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> ·
10<form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}}
11</div>
12<div class="repogrid">
13{{range .Repos}}<div class="repocard">
14 <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p>
15 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
16 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p>
17</div>
18{{else}}<p class="empty">no public repositories yet</p>{{end}}
19</div>
20{{if .Mine}}<h2>your private repositories</h2>
21<div class="repogrid">
22{{range .Mine}}<div class="repocard">
23 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a> <span class="chip chip-neutral">{{.Visibility}}</span></p>
24 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
25 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p>
26</div>
27{{end}}
28</div>{{end}}
29{{end}}
internal/web/templates/landing.html added +13
@@ -0,0 +1,13 @@
1{{define "title"}}{{.Site}}{{end}}
2{{define "content"}}
3<div class="landing">
4<h1>{{.Site}}</h1>
5<p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and
6merge requests are managed from your terminal with nothing but OpenSSH —
7the web is a fast, readable rendering of that state.</p>
8<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
9git clone ssh://git@{{.Host}}/owner/repo.git</pre>
10<p><a class="explorelink" href="/explore">explore public repositories →</a></p>
11{{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code></p>{{end}}
12</div>
13{{end}}