feat(index): link the importer from the dashboard
5 files changed, +42 −12
- forge/flow_test.go modified +5 −2
- forge/web.go modified +27 −8
- web/static/style.css modified +7 −0
- web/templates/blob.tmpl modified +2 −1
- web/templates/index.tmpl modified +1 −1
forge/flow_test.go modified
| @@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | "path/filepath" |
| 15 | 15 | "strings" |
| 16 | 16 | "testing" |
| 17 | + "time" | |
| 17 | 18 | ) |
| 18 | 19 | |
| 19 | 20 | func testServer(t *testing.T) (*Server, http.Handler) { |
| @@ -289,9 +290,11 @@ func TestPins(t *testing.T) { | ||
| 289 | 290 | } |
| 290 | 291 | pin(ids[2], own) |
| 291 | 292 | pin(ids[0], own) |
| 293 | + past := time.Now().Add(-time.Hour) | |
| 294 | + os.Chtimes(filepath.Join(s.root, "repos", "alice", "c.git"), past, past) | |
| 292 | 295 | body := get(h, "/~alice", "").Body.String() |
| 293 | - if !strings.Contains(body, `href="/~alice/c"`) || 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") | |
| 296 | + if !strings.Contains(body, `href="/~alice/c"`) || strings.Index(body, `href="/~alice/a"`) > strings.Index(body, `href="/~alice/c"`) || strings.Contains(body, `href="/~alice/b"`) { | |
| 297 | + t.Error("profile does not show exactly the pinned repos, most recently updated first") | |
| 295 | 298 | } |
| 296 | 299 | for _, id := range ids[1:] { |
| 297 | 300 | pin(id, own) |
forge/web.go modified
| @@ -147,6 +147,7 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server { | ||
| 147 | 147 | s.policy.AllowElements("video", "source") |
| 148 | 148 | s.policy.AllowAttrs("src", "type").OnElements("video", "source") |
| 149 | 149 | s.policy.AllowAttrs("controls", "muted", "loop", "playsinline", "poster", "width", "height").OnElements("video") |
| 150 | + s.policy.AllowAttrs("align").Matching(regexp.MustCompile(`^(center|left|right)$`)).OnElements("p", "div", "h1", "h2", "h3", "h4") | |
| 150 | 151 | s.writeSigners() |
| 151 | 152 | s.writeKeyring() |
| 152 | 153 | s.installHooks() |
| @@ -588,10 +589,12 @@ func repoUpdated(dir string) time.Time { | ||
| 588 | 589 | } |
| 589 | 590 | consider(filepath.Join(dir, "packed-refs")) |
| 590 | 591 | for _, sub := range []string{"heads", "tags"} { |
| 591 | - entries, _ := os.ReadDir(filepath.Join(dir, "refs", sub)) | |
| 592 | - for _, e := range entries { | |
| 593 | - consider(filepath.Join(dir, "refs", sub, e.Name())) | |
| 594 | - } | |
| 592 | + filepath.WalkDir(filepath.Join(dir, "refs", sub), func(p string, d fs.DirEntry, err error) error { | |
| 593 | + if err == nil && !d.IsDir() { | |
| 594 | + consider(p) | |
| 595 | + } | |
| 596 | + return nil | |
| 597 | + }) | |
| 595 | 598 | } |
| 596 | 599 | if latest.IsZero() { |
| 597 | 600 | consider(dir) |
| @@ -1188,7 +1191,18 @@ func (s *Server) renderMarkdown(src []byte) template.HTML { | ||
| 1188 | 1191 | return s.renderMarkdownAt(src, "", "") |
| 1189 | 1192 | } |
| 1190 | 1193 | |
| 1191 | -var relLink = regexp.MustCompile(`(src|href)="([^"]+)"`) | |
| 1194 | +var ( | |
| 1195 | + relLink = regexp.MustCompile(`(src|href|poster)="([^"]+)"`) | |
| 1196 | + posterLink = regexp.MustCompile(`<a href="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>\s*<img src="([^"]+)"[^>]*>\s*</a>`) | |
| 1197 | + videoImg = regexp.MustCompile(`<img src="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>`) | |
| 1198 | + videoLine = regexp.MustCompile(`<p>\s*<a href="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>[^<]*</a>\s*</p>`) | |
| 1199 | +) | |
| 1200 | + | |
| 1201 | +func embedVideos(out []byte) []byte { | |
| 1202 | + out = posterLink.ReplaceAll(out, []byte(`<video controls playsinline preload="metadata" src="$1" poster="$2"></video>`)) | |
| 1203 | + out = videoImg.ReplaceAll(out, []byte(`<video controls playsinline preload="metadata" src="$1"></video>`)) | |
| 1204 | + return videoLine.ReplaceAll(out, []byte(`<p><video controls playsinline preload="metadata" src="$1"></video></p>`)) | |
| 1205 | +} | |
| 1192 | 1206 | |
| 1193 | 1207 | func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML { |
| 1194 | 1208 | src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) |
| @@ -1196,7 +1210,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template | ||
| 1196 | 1210 | if err := s.markdown.Convert(src, &buf); err != nil { |
| 1197 | 1211 | return template.HTML("<pre>" + template.HTMLEscapeString(string(src)) + "</pre>") |
| 1198 | 1212 | } |
| 1199 | - out := s.policy.SanitizeBytes(buf.Bytes()) | |
| 1213 | + out := embedVideos(s.policy.SanitizeBytes(buf.Bytes())) | |
| 1200 | 1214 | if rawBase != "" { |
| 1201 | 1215 | out = relLink.ReplaceAllFunc(out, func(m []byte) []byte { |
| 1202 | 1216 | parts := relLink.FindSubmatch(m) |
| @@ -1206,7 +1220,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template | ||
| 1206 | 1220 | } |
| 1207 | 1221 | target = strings.TrimPrefix(target, "./") |
| 1208 | 1222 | base := treeBase |
| 1209 | - if attr == "src" { | |
| 1223 | + if attr != "href" { | |
| 1210 | 1224 | base = rawBase |
| 1211 | 1225 | } |
| 1212 | 1226 | return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`) |
| @@ -1286,6 +1300,12 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 1286 | 1300 | case "blob": |
| 1287 | 1301 | size := g.Size(rp.Commit, filePath) |
| 1288 | 1302 | data["Size"] = size |
| 1303 | + if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { | |
| 1304 | + data["File"] = true | |
| 1305 | + data["Media"], _, _ = strings.Cut(kind, "/") | |
| 1306 | + s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Data: data}) | |
| 1307 | + return | |
| 1308 | + } | |
| 1289 | 1309 | var content []byte |
| 1290 | 1310 | if size <= maxRender { |
| 1291 | 1311 | if content, err = g.Blob(rp.Commit, filePath); err != nil { |
| @@ -1720,7 +1740,6 @@ func pinnedRepos(repos []Repo, pins map[int64]int) []Repo { | ||
| 1720 | 1740 | out = append(out, r) |
| 1721 | 1741 | } |
| 1722 | 1742 | } |
| 1723 | - slices.SortFunc(out, func(a, b Repo) int { return pins[a.ID] - pins[b.ID] }) | |
| 1724 | 1743 | return out |
| 1725 | 1744 | } |
| 1726 | 1745 | |
web/static/style.css modified
| @@ -333,6 +333,13 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem} | ||
| 333 | 333 | .landing .more .btn{min-width:20rem;text-align:center} |
| 334 | 334 | @media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}} |
| 335 | 335 | .readme video,.readme img{max-width:100%;height:auto} |
| 336 | +.readme video{display:block;margin:.6rem auto;background:#000} | |
| 337 | +.readme [align=center]{text-align:center} | |
| 338 | +.readme [align=right]{text-align:right} | |
| 339 | +.media{display:flex;justify-content:center;padding:1rem 0} | |
| 340 | +.media video,.media img{max-width:100%;max-height:80vh;height:auto} | |
| 341 | +.media video{background:#000} | |
| 342 | +.media audio{width:min(100%,40rem)} | |
| 336 | 343 | .auth h3{white-space:nowrap} |
| 337 | 344 | .pickaxe{display:flex;gap:.5rem;margin:0 0 1rem;flex-wrap:wrap}.pickaxe input{flex:1;min-width:14rem} |
| 338 | 345 | .pickaxe select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--edge);padding:.3rem .4rem} |
web/templates/blob.tmpl modified
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{template "filebar" .Data}} |
| 4 | -{{if .Data.Binary}}<p class="muted">Binary file, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> | |
| 4 | +{{if .Data.Media}}{{$src := printf "/%s/raw/%s/%s" $p.Repo.Path $p.Ref .Data.Path}}<div class="media">{{if eq .Data.Media "video"}}<video controls playsinline preload="metadata" src="{{$src}}"></video>{{else if eq .Data.Media "audio"}}<audio controls preload="metadata" src="{{$src}}"></audio>{{else}}<img src="{{$src}}" alt="{{.Data.Path}}">{{end}}</div> | |
| 5 | +{{else if .Data.Binary}}<p class="muted">Binary file, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> | |
| 5 | 6 | {{else if .Data.Large}}<p class="muted">File too large to display, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> |
| 6 | 7 | {{else if .Data.Markdown}}<p class="muted small"><a href="?source=1">View source</a></p><article class="readme">{{.Data.Markdown}}</article> |
| 7 | 8 | {{else if .Data.Highlighted}}<div class="code">{{.Data.Highlighted}}</div> |
web/templates/index.tmpl modified
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{if .User}} |
| 3 | 3 | <div class="dash"> |
| 4 | -<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organization</a> <a class="btn primary" href="/create">New repository</a></span></div> | |
| 4 | +<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organization</a> <a class="btn" href="/import">Import repository</a> <a class="btn primary" href="/create">New repository</a></span></div> | |
| 5 | 5 | {{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p class="muted">You have no repositories yet.</p>{{end}} |
| 6 | 6 | {{if .Data.Shared}}<h3>Shared with you</h3>{{template "repolist" .Data.Shared}}{{end}}</section> |
| 7 | 7 | {{else}} |