vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Simplify the home page around the instance introduction
ff9870f1a26a17d54c21d411000a6c7d58ab5c0d · Keenan Nemetz · 1d ago · parent 6ce635a8
Simplify the home page around the instance introduction Drop the page header and its hero actions. The instance name and sign-in links already live in the top bar, and the introduction is the place to say what the forge is. Move New repository into the Repositories heading, where it sits with the list it creates. Remove the introduction card header and its in-page editor. The introduction is profile/README.md in the instance repository, so it is read and pushed like every other customization. The POST / handler and its editor code go with it; an instance admin without an instance repository still gets the setup hint.
server/web/landing.go −95
| @@ -1,95 +0,0 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // The landing page's introduction, edited from the home page itself. The text is | ||
| 4 | // committed to the instance repository (forge/instancerepo), not written to | ||
| 5 | // vole.toml: the packaged service cannot write its config file, and the theme | ||
| 6 | // repository is where a customization belongs. The commit is the history; the | ||
| 7 | // audit log records who changed the page. | ||
| 8 | // | ||
| 9 | // The introduction is markdown written by an instance admin rather than pushed, | ||
| 10 | // but it still ends up in a page, so it is rendered through the same parser and | ||
| 11 | // sanitizer as an issue body. | ||
| 12 | |||
| 13 | import ( | ||
| 14 | "net/http" | ||
| 15 | "strconv" | ||
| 16 | "strings" | ||
| 17 | |||
| 18 | "github.com/rodent-software/vole/core/git" | ||
| 19 | "github.com/rodent-software/vole/forge/instancerepo" | ||
| 20 | ) | ||
| 21 | |||
| 22 | const ( | ||
| 23 | // maxIntroBytes bounds the saved markdown. The body bound leaves room for it | ||
| 24 | // plus the form overhead. | ||
| 25 | maxIntroBytes = 20000 | ||
| 26 | maxIntroForm = 64 << 10 | ||
| 27 | ) | ||
| 28 | |||
| 29 | // SaveHomeIntro answers the introduction card's form on the home page. An empty | ||
| 30 | // introduction removes profile/README.md, which is how the card's body goes away; | ||
| 31 | // the markdown is kept as written and rendered on the way out. | ||
| 32 | func (w *Web) SaveHomeIntro(rw http.ResponseWriter, r *http.Request) { | ||
| 33 | pr, ok := w.beginAdminPost(rw, r, maxIntroForm) | ||
| 34 | if !ok { | ||
| 35 | return | ||
| 36 | } | ||
| 37 | if w.InstanceRepo == nil || !w.InstanceRepo.Ready() { | ||
| 38 | w.serverError(rw, r, "the instance repository is not ready") | ||
| 39 | return | ||
| 40 | } | ||
| 41 | next := normalizeIntro(r.PostFormValue("intro")) | ||
| 42 | if len(next) > maxIntroBytes { | ||
| 43 | msg := "The introduction is longer than " + strconv.Itoa(maxIntroBytes) + " bytes." | ||
| 44 | w.renderIntroEditor(rw, r, next, msg, http.StatusBadRequest) | ||
| 45 | return | ||
| 46 | } | ||
| 47 | m, err := w.Repos.Open(w.InstanceRepo.Name()) | ||
| 48 | if err != nil { | ||
| 49 | w.renderIntroEditor(rw, r, next, "The instance repository could not be opened: "+err.Error(), http.StatusInternalServerError) | ||
| 50 | return | ||
| 51 | } | ||
| 52 | prev := "" | ||
| 53 | if snap := w.InstanceRepo.Current(); snap != nil { | ||
| 54 | prev = string(snap.Readme) | ||
| 55 | } | ||
| 56 | if normalizeIntro(prev) == next { | ||
| 57 | // Nothing changed: do not add a commit that says nothing. | ||
| 58 | http.Redirect(rw, r, "/", http.StatusSeeOther) | ||
| 59 | return | ||
| 60 | } | ||
| 61 | if _, err := git.CommitFilesBy(m.Git(), "refs/heads/"+m.DefaultBranch, func(files map[string][]byte) (string, error) { | ||
| 62 | if next == "" { | ||
| 63 | delete(files, instancerepo.ReadmePath) | ||
| 64 | } else { | ||
| 65 | files[instancerepo.ReadmePath] = []byte(next) | ||
| 66 | } | ||
| 67 | return pr.account.Name, nil | ||
| 68 | }, "Update the landing page introduction"); err != nil { | ||
| 69 | w.renderIntroEditor(rw, r, next, "The introduction could not be saved: "+err.Error(), http.StatusInternalServerError) | ||
| 70 | return | ||
| 71 | } | ||
| 72 | if err := w.InstanceRepo.Reload(); err != nil && w.Logger != nil { | ||
| 73 | w.Logger("instance repository reload after intro save: %v", err) | ||
| 74 | } | ||
| 75 | w.audit("web.home.intro", pr.account.Name, "", instancerepo.ReadmePath) | ||
| 76 | http.Redirect(rw, r, "/", http.StatusSeeOther) | ||
| 77 | } | ||
| 78 | |||
| 79 | // normalizeIntro is the one shape the store and the page agree on: no carriage | ||
| 80 | // returns, and no whitespace that would render as an empty block. | ||
| 81 | func normalizeIntro(raw string) string { | ||
| 82 | return strings.TrimSpace(strings.ReplaceAll(raw, "\r\n", "\n")) | ||
| 83 | } | ||
| 84 | |||
| 85 | // renderIntroEditor re-renders the home page with the editor open and the | ||
| 86 | // submitted text still in it, so a rejected save does not make the admin retype | ||
| 87 | // a paragraph. | ||
| 88 | func (w *Web) renderIntroEditor(rw http.ResponseWriter, r *http.Request, submitted, msg string, status int) { | ||
| 89 | data, err := w.homeView(r, "", true, &submitted, msg) | ||
| 90 | if err != nil { | ||
| 91 | w.serverError(rw, r, "index unavailable: "+err.Error()) | ||
| 92 | return | ||
| 93 | } | ||
| 94 | w.render(rw, "index", data, status) | ||
| 95 | } | ||