Add discussions feature
59a04c995a7ef98bc6f691d79000d7e61936dfb6
1 parent
afe0ef99
.gitignore
+1 -0
| 1 | 1 | .claude |
|
| 2 | 2 | /bin |
|
| 3 | 3 | /forge |
|
| 4 | + | /forge.db |
discuss.go
+138 -1
| 132 | 132 | defer rows.Close() |
|
| 133 | 133 | ||
| 134 | 134 | var out []Discussion |
|
| 135 | 135 | for rows.Next() { |
|
| 136 | 136 | var d Discussion |
|
| 137 | + | var lastActive string |
|
| 137 | 138 | if err := rows.Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar, |
|
| 138 | - | &d.CreatedAt, &d.ReplyCount, &d.LastActive); err != nil { |
|
| 139 | + | &d.CreatedAt, &d.ReplyCount, &lastActive); err != nil { |
|
| 139 | 140 | continue |
|
| 140 | 141 | } |
|
| 142 | + | for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { |
|
| 143 | + | if t, err := time.Parse(layout, lastActive); err == nil { |
|
| 144 | + | d.LastActive = t |
|
| 145 | + | break |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 141 | 148 | out = append(out, d) |
|
| 142 | 149 | } |
|
| 143 | 150 | return out |
|
| 144 | 151 | } |
|
| 145 | 152 |
| 433 | 440 | pd := s.newPageData(r, repo, "discussions", "") |
|
| 434 | 441 | pd.Data = newDiscussionData{} |
|
| 435 | 442 | s.tmpl.render(w, "discussion_new", pd) |
|
| 436 | 443 | } |
|
| 437 | 444 | ||
| 445 | + | // siteDiscussionRepo is the sentinel repo name used in the database for |
|
| 446 | + | // site-level forum discussions (not scoped to any repository). |
|
| 447 | + | const siteDiscussionRepo = "_site" |
|
| 448 | + | ||
| 449 | + | func (s *server) routeSiteDiscussions(w http.ResponseWriter, r *http.Request, rest string) { |
|
| 450 | + | switch { |
|
| 451 | + | case rest == "" || rest == "/": |
|
| 452 | + | s.handleSiteDiscussions(w, r) |
|
| 453 | + | case rest == "new": |
|
| 454 | + | s.handleNewSiteDiscussion(w, r) |
|
| 455 | + | default: |
|
| 456 | + | s.handleSiteDiscussion(w, r, rest) |
|
| 457 | + | } |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | func (s *server) handleSiteDiscussions(w http.ResponseWriter, r *http.Request) { |
|
| 461 | + | type discussionsData struct { |
|
| 462 | + | Discussions []Discussion |
|
| 463 | + | IsEmpty bool |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | discussions := listDiscussions(s.db, siteDiscussionRepo) |
|
| 467 | + | pd := s.newPageData(r, nil, "forum", "") |
|
| 468 | + | pd.Data = discussionsData{ |
|
| 469 | + | Discussions: discussions, |
|
| 470 | + | IsEmpty: len(discussions) == 0, |
|
| 471 | + | } |
|
| 472 | + | s.tmpl.render(w, "discussions", pd) |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | func (s *server) handleSiteDiscussion(w http.ResponseWriter, r *http.Request, idStr string) { |
|
| 476 | + | type discussionData struct { |
|
| 477 | + | Discussion *Discussion |
|
| 478 | + | Replies []Reply |
|
| 479 | + | Error string |
|
| 480 | + | } |
|
| 481 | + | ||
| 482 | + | id, err := strconv.Atoi(idStr) |
|
| 483 | + | if err != nil { |
|
| 484 | + | s.renderError(w, r, http.StatusNotFound, "Discussion not found") |
|
| 485 | + | return |
|
| 486 | + | } |
|
| 487 | + | ||
| 488 | + | d, err := getDiscussion(s.db, id) |
|
| 489 | + | if err != nil || d.Repo != siteDiscussionRepo { |
|
| 490 | + | s.renderError(w, r, http.StatusNotFound, "Discussion not found") |
|
| 491 | + | return |
|
| 492 | + | } |
|
| 493 | + | handle := getSessionHandle(r) |
|
| 494 | + | ||
| 495 | + | if r.Method == http.MethodPost { |
|
| 496 | + | if handle == "" { |
|
| 497 | + | s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply") |
|
| 498 | + | return |
|
| 499 | + | } |
|
| 500 | + | ||
| 501 | + | body := strings.TrimSpace(r.FormValue("body")) |
|
| 502 | + | if body == "" { |
|
| 503 | + | pd := s.newPageData(r, nil, "forum", "") |
|
| 504 | + | pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."} |
|
| 505 | + | s.tmpl.render(w, "discussion", pd) |
|
| 506 | + | return |
|
| 507 | + | } |
|
| 508 | + | if len(body) > maxBodyLen { |
|
| 509 | + | body = body[:maxBodyLen] |
|
| 510 | + | } |
|
| 511 | + | ||
| 512 | + | replyID, err := createReply(s.db, id, body, handle) |
|
| 513 | + | if err != nil { |
|
| 514 | + | s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply") |
|
| 515 | + | return |
|
| 516 | + | } |
|
| 517 | + | ||
| 518 | + | http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d#reply-%d", s.baseURL, d.ID, replyID), http.StatusSeeOther) |
|
| 519 | + | return |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | pd := s.newPageData(r, nil, "forum", "") |
|
| 523 | + | pd.Data = discussionData{ |
|
| 524 | + | Discussion: d, |
|
| 525 | + | Replies: getReplies(s.db, id), |
|
| 526 | + | } |
|
| 527 | + | s.tmpl.render(w, "discussion", pd) |
|
| 528 | + | } |
|
| 529 | + | ||
| 530 | + | func (s *server) handleNewSiteDiscussion(w http.ResponseWriter, r *http.Request) { |
|
| 531 | + | type newDiscussionData struct { |
|
| 532 | + | Error string |
|
| 533 | + | Title string |
|
| 534 | + | Body string |
|
| 535 | + | } |
|
| 536 | + | ||
| 537 | + | handle := getSessionHandle(r) |
|
| 538 | + | if handle == "" { |
|
| 539 | + | http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/discussions/new", s.baseURL, s.baseURL), http.StatusSeeOther) |
|
| 540 | + | return |
|
| 541 | + | } |
|
| 542 | + | ||
| 543 | + | if r.Method == http.MethodPost { |
|
| 544 | + | title := strings.TrimSpace(r.FormValue("title")) |
|
| 545 | + | body := strings.TrimSpace(r.FormValue("body")) |
|
| 546 | + | ||
| 547 | + | if title == "" { |
|
| 548 | + | pd := s.newPageData(r, nil, "forum", "") |
|
| 549 | + | pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body} |
|
| 550 | + | s.tmpl.render(w, "discussion_new", pd) |
|
| 551 | + | return |
|
| 552 | + | } |
|
| 553 | + | if len(title) > maxTitleLen { |
|
| 554 | + | title = title[:maxTitleLen] |
|
| 555 | + | } |
|
| 556 | + | if len(body) > maxBodyLen { |
|
| 557 | + | body = body[:maxBodyLen] |
|
| 558 | + | } |
|
| 559 | + | ||
| 560 | + | id, err := createDiscussion(s.db, siteDiscussionRepo, title, body, handle) |
|
| 561 | + | if err != nil { |
|
| 562 | + | s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion") |
|
| 563 | + | return |
|
| 564 | + | } |
|
| 565 | + | ||
| 566 | + | http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d", s.baseURL, id), http.StatusSeeOther) |
|
| 567 | + | return |
|
| 568 | + | } |
|
| 569 | + | ||
| 570 | + | pd := s.newPageData(r, nil, "forum", "") |
|
| 571 | + | pd.Data = newDiscussionData{} |
|
| 572 | + | s.tmpl.render(w, "discussion_new", pd) |
|
| 573 | + | } |
|
| 574 | + | ||
| 438 | 575 | func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { |
|
| 439 | 576 | type loginData struct { |
|
| 440 | 577 | Error string |
|
| 441 | 578 | Handle string |
|
| 442 | 579 | ReturnTo string |
handler.go
+21 -1
| 83 | 83 | w.Header().Set("Content-Type", "font/ttf") |
|
| 84 | 84 | w.Header().Set("Cache-Control", "public, max-age=86400") |
|
| 85 | 85 | w.Write(data) |
|
| 86 | 86 | } |
|
| 87 | 87 | ||
| 88 | + | func (s *server) serveAvatar(w http.ResponseWriter, r *http.Request) { |
|
| 89 | + | name := strings.TrimPrefix(r.URL.Path, "/avatars/") |
|
| 90 | + | data, err := avatarsFS.ReadFile("static/avatars/" + name) |
|
| 91 | + | if err != nil { |
|
| 92 | + | http.NotFound(w, r) |
|
| 93 | + | return |
|
| 94 | + | } |
|
| 95 | + | w.Header().Set("Content-Type", "image/svg+xml") |
|
| 96 | + | w.Header().Set("Cache-Control", "public, max-age=86400") |
|
| 97 | + | w.Write(data) |
|
| 98 | + | } |
|
| 99 | + | ||
| 88 | 100 | func (s *server) route(w http.ResponseWriter, r *http.Request) { |
|
| 89 | 101 | path := strings.TrimPrefix(r.URL.Path, s.baseURL) |
|
| 90 | 102 | path = strings.Trim(path, "/") |
|
| 91 | 103 | ||
| 92 | 104 | if path == "" { |
| 101 | 113 | if path == "logout" { |
|
| 102 | 114 | s.handleLogout(w, r) |
|
| 103 | 115 | return |
|
| 104 | 116 | } |
|
| 105 | 117 | ||
| 118 | + | // Site-level forum (discussions not scoped to a repo). |
|
| 119 | + | if path == "discussions" || strings.HasPrefix(path, "discussions/") { |
|
| 120 | + | rest := strings.TrimPrefix(path, "discussions") |
|
| 121 | + | rest = strings.TrimPrefix(rest, "/") |
|
| 122 | + | s.routeSiteDiscussions(w, r, rest) |
|
| 123 | + | return |
|
| 124 | + | } |
|
| 125 | + | ||
| 106 | 126 | segments := strings.SplitN(path, "/", 3) |
|
| 107 | 127 | repoName := strings.TrimSuffix(segments[0], ".git") |
|
| 108 | 128 | ||
| 109 | 129 | repo, ok := s.repos[repoName] |
|
| 110 | 130 | if !ok { |
| 154 | 174 | } |
|
| 155 | 175 | repos := make([]*RepoInfo, 0, len(s.sorted)) |
|
| 156 | 176 | for _, name := range s.sorted { |
|
| 157 | 177 | repos = append(repos, s.repos[name]) |
|
| 158 | 178 | } |
|
| 159 | - | pd := s.newPageData(r, nil, "", "") |
|
| 179 | + | pd := s.newPageData(r, nil, "repositories", "") |
|
| 160 | 180 | pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0} |
|
| 161 | 181 | s.tmpl.render(w, "index", pd) |
|
| 162 | 182 | } |
|
| 163 | 183 | ||
| 164 | 184 | type homeData struct { |
main.go
+2 -4
| 32 | 32 | description := flag.String("description", "", "site description shown on the index page") |
|
| 33 | 33 | baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)") |
|
| 34 | 34 | nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)") |
|
| 35 | 35 | username := flag.String("username", "", "HTTP basic auth username (requires -password)") |
|
| 36 | 36 | password := flag.String("password", "", "HTTP basic auth password (requires -username)") |
|
| 37 | - | dbPath := flag.String("db", "", "path to SQLite database for discussions (default: <scan-path>/forge.db)") |
|
| 37 | + | dbPath := flag.String("db-path", "./forge.db", "path to SQLite database for discussions") |
|
| 38 | 38 | flag.Parse() |
|
| 39 | 39 | ||
| 40 | 40 | if (*username == "") != (*password == "") { |
|
| 41 | 41 | log.Fatal("-username and -password must both be set, or both be omitted") |
|
| 42 | 42 | } |
| 62 | 62 | tmpl, err := loadTemplates() |
|
| 63 | 63 | if err != nil { |
|
| 64 | 64 | log.Fatalf("load templates: %v", err) |
|
| 65 | 65 | } |
|
| 66 | 66 | ||
| 67 | - | if *dbPath == "" { |
|
| 68 | - | *dbPath = filepath.Join(abs, "forge.db") |
|
| 69 | - | } |
|
| 70 | 67 | db, err := openDB(*dbPath) |
|
| 71 | 68 | if err != nil { |
|
| 72 | 69 | log.Fatalf("open database: %v", err) |
|
| 73 | 70 | } |
|
| 74 | 71 | defer db.Close() |
| 89 | 86 | mux := http.NewServeMux() |
|
| 90 | 87 | mux.HandleFunc("/style.css", srv.serveCSS) |
|
| 91 | 88 | mux.HandleFunc("/radiant.svg", srv.serveLogo) |
|
| 92 | 89 | mux.HandleFunc("/js/", srv.serveJS) |
|
| 93 | 90 | mux.HandleFunc("/fonts/", srv.serveFont) |
|
| 91 | + | mux.HandleFunc("/avatars/", srv.serveAvatar) |
|
| 94 | 92 | mux.HandleFunc("/", srv.route) |
|
| 95 | 93 | ||
| 96 | 94 | var handler http.Handler = mux |
|
| 97 | 95 | if srv.username != "" { |
|
| 98 | 96 | handler = srv.basicAuth(mux) |
scripts/seed-forum.sh
added
+101 -0
| 1 | + | #!/bin/sh |
|
| 2 | + | # |
|
| 3 | + | # Seed the forum (site-level discussions) with sample data for testing. |
|
| 4 | + | # Usage: ./scripts/seed-forum.sh [path/to/forge.db] |
|
| 5 | + | # |
|
| 6 | + | set -e |
|
| 7 | + | ||
| 8 | + | DB="${1:-forge.db}" |
|
| 9 | + | ||
| 10 | + | if [ ! -f "$DB" ]; then |
|
| 11 | + | echo "Database not found: $DB" |
|
| 12 | + | echo "Usage: $0 [path/to/forge.db]" |
|
| 13 | + | exit 1 |
|
| 14 | + | fi |
|
| 15 | + | ||
| 16 | + | echo "Seeding forum in $DB ..." |
|
| 17 | + | ||
| 18 | + | sqlite3 "$DB" <<'SQL' |
|
| 19 | + | -- Discussion 1: welcome / intro |
|
| 20 | + | INSERT INTO discussions (repo, title, body, author, created_at) |
|
| 21 | + | VALUES ('_site', 'Welcome to the Radiant forum', |
|
| 22 | + | 'Hey everyone, this is the general discussion space for all things Radiant. |
|
| 23 | + | ||
| 24 | + | Feel free to introduce yourself, ask questions, or share what you are working on. |
|
| 25 | + | ||
| 26 | + | A few ground rules: |
|
| 27 | + | - Be kind and constructive. |
|
| 28 | + | - Stay on topic (use per-repo discussions for repo-specific issues). |
|
| 29 | + | - Have fun.', |
|
| 30 | + | 'cloudhead.io', datetime('now', '-14 days')); |
|
| 31 | + | ||
| 32 | + | -- Replies to discussion 1 |
|
| 33 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 34 | + | VALUES (last_insert_rowid(), 'Thanks for setting this up! Excited to be here.', |
|
| 35 | + | 'alice.bsky.social', datetime('now', '-13 days')); |
|
| 36 | + | ||
| 37 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 38 | + | VALUES ((SELECT MAX(id) FROM discussions), 'Great to see a forum that works without JavaScript.', |
|
| 39 | + | 'bob.bsky.social', datetime('now', '-12 days')); |
|
| 40 | + | ||
| 41 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 42 | + | VALUES ((SELECT MAX(id) FROM discussions), 'Agreed, this is refreshing. Looking forward to contributing.', |
|
| 43 | + | 'alice.bsky.social', datetime('now', '-11 days')); |
|
| 44 | + | ||
| 45 | + | -- Discussion 2: build question |
|
| 46 | + | INSERT INTO discussions (repo, title, body, author, created_at) |
|
| 47 | + | VALUES ('_site', 'Cross-compiling forge for ARM?', |
|
| 48 | + | 'Has anyone tried cross-compiling the forge binary for ARM (e.g. Raspberry Pi)? |
|
| 49 | + | ||
| 50 | + | I tried GOARCH=arm64 go build . and it built fine, but I am wondering if there are any gotchas with the SQLite dependency on musl vs glibc.', |
|
| 51 | + | 'bob.bsky.social', datetime('now', '-10 days')); |
|
| 52 | + | ||
| 53 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 54 | + | VALUES (last_insert_rowid(), 'I run it on a Pi 4 with no issues. Just make sure you have CGO_ENABLED=1 and a cross-compiler installed for the SQLite bits.', |
|
| 55 | + | 'alice.bsky.social', datetime('now', '-9 days')); |
|
| 56 | + | ||
| 57 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 58 | + | VALUES ((SELECT MAX(id) FROM discussions), 'Thanks, that did the trick!', |
|
| 59 | + | 'bob.bsky.social', datetime('now', '-8 days')); |
|
| 60 | + | ||
| 61 | + | -- Discussion 3: feature idea |
|
| 62 | + | INSERT INTO discussions (repo, title, body, author, created_at) |
|
| 63 | + | VALUES ('_site', 'Idea: RSS feed for repository updates', |
|
| 64 | + | 'It would be nice to have an RSS/Atom feed at /feed or /rss that lists recent commits across all public repos. That way people could subscribe and stay up to date without polling the web UI. |
|
| 65 | + | ||
| 66 | + | Anyone else interested in this?', |
|
| 67 | + | 'alice.bsky.social', datetime('now', '-7 days')); |
|
| 68 | + | ||
| 69 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 70 | + | VALUES (last_insert_rowid(), 'Yes! I would use this. Per-repo feeds would be great too.', |
|
| 71 | + | 'bob.bsky.social', datetime('now', '-6 days')); |
|
| 72 | + | ||
| 73 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 74 | + | VALUES ((SELECT MAX(id) FROM discussions), 'I might take a crack at this. Seems straightforward to add a handler that shells out to git log --all.', |
|
| 75 | + | 'cloudhead.io', datetime('now', '-4 days')); |
|
| 76 | + | ||
| 77 | + | -- Discussion 4: a shorter thread |
|
| 78 | + | INSERT INTO discussions (repo, title, body, author, created_at) |
|
| 79 | + | VALUES ('_site', 'Syntax highlighting for more languages?', |
|
| 80 | + | 'Currently forge ships hirad.js and hiril.js for Radiance and RIL. Any plans to support other languages, or is the intent to keep it minimal?', |
|
| 81 | + | 'bob.bsky.social', datetime('now', '-3 days')); |
|
| 82 | + | ||
| 83 | + | INSERT INTO replies (discussion_id, body, author, created_at) |
|
| 84 | + | VALUES (last_insert_rowid(), 'I think keeping it minimal is the right call. You can always add your own highlighter scripts if needed.', |
|
| 85 | + | 'cloudhead.io', datetime('now', '-2 days')); |
|
| 86 | + | ||
| 87 | + | -- Discussion 5: no replies yet |
|
| 88 | + | INSERT INTO discussions (repo, title, body, author, created_at) |
|
| 89 | + | VALUES ('_site', 'Deploying behind nginx reverse proxy', |
|
| 90 | + | 'I am trying to set up forge behind nginx with a /git base URL. I pass -base-url /git but static assets (CSS, fonts) are not loading. Has anyone got a working nginx config they can share?', |
|
| 91 | + | 'alice.bsky.social', datetime('now', '-1 days')); |
|
| 92 | + | ||
| 93 | + | -- Avatars |
|
| 94 | + | INSERT OR IGNORE INTO avatars (handle, url) VALUES |
|
| 95 | + | ('cloudhead.io', '/avatars/cloudhead.svg'), |
|
| 96 | + | ('alice.bsky.social', '/avatars/alice.svg'), |
|
| 97 | + | ('bob.bsky.social', '/avatars/bob.svg'); |
|
| 98 | + | ||
| 99 | + | SQL |
|
| 100 | + | ||
| 101 | + | echo "Done. Inserted 5 discussions with replies and avatars." |
static/avatars/alice.svg
added
+9 -0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> |
|
| 2 | + | <circle cx="32" cy="32" r="32" fill="#e8a87c"/> |
|
| 3 | + | <circle cx="32" cy="26" r="12" fill="#fae3c8"/> |
|
| 4 | + | <circle cx="27" cy="24" r="2" fill="#334"/> |
|
| 5 | + | <circle cx="37" cy="24" r="2" fill="#334"/> |
|
| 6 | + | <path d="M28 30q4 3 8 0" stroke="#334" stroke-width="1.5" fill="none" stroke-linecap="round"/> |
|
| 7 | + | <path d="M18 20q2-10 14-8t14 8c0 0-4-2-14-2s-14 2-14 2z" fill="#c0392b"/> |
|
| 8 | + | <rect x="16" y="44" width="32" height="20" rx="4" fill="#e74c3c"/> |
|
| 9 | + | </svg> |
static/avatars/bob.svg
added
+9 -0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> |
|
| 2 | + | <circle cx="32" cy="32" r="32" fill="#85c1e9"/> |
|
| 3 | + | <circle cx="32" cy="26" r="12" fill="#fddcb5"/> |
|
| 4 | + | <circle cx="27" cy="24" r="2" fill="#334"/> |
|
| 5 | + | <circle cx="37" cy="24" r="2" fill="#334"/> |
|
| 6 | + | <path d="M28 30q4 3 8 0" stroke="#334" stroke-width="1.5" fill="none" stroke-linecap="round"/> |
|
| 7 | + | <path d="M20 20q4-6 12-4t12 4" fill="#8e6b3e" stroke="none"/> |
|
| 8 | + | <rect x="16" y="44" width="32" height="20" rx="4" fill="#2980b9"/> |
|
| 9 | + | </svg> |
static/avatars/cloudhead.svg
added
+9 -0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> |
|
| 2 | + | <circle cx="32" cy="32" r="32" fill="#5b7a9d"/> |
|
| 3 | + | <circle cx="32" cy="26" r="12" fill="#f0dcc0"/> |
|
| 4 | + | <circle cx="27" cy="24" r="2" fill="#334"/> |
|
| 5 | + | <circle cx="37" cy="24" r="2" fill="#334"/> |
|
| 6 | + | <path d="M28 30q4 3 8 0" stroke="#334" stroke-width="1.5" fill="none" stroke-linecap="round"/> |
|
| 7 | + | <path d="M20 18q4-8 12-6t12 6" fill="#445" stroke="none"/> |
|
| 8 | + | <rect x="16" y="44" width="32" height="20" rx="4" fill="#667"/> |
|
| 9 | + | </svg> |
static/style.css
+52 -52
| 157 | 157 | .nav-btn:hover { color: var(--fg); } |
|
| 158 | 158 | ||
| 159 | 159 | .avatar { |
|
| 160 | 160 | width: 24px; |
|
| 161 | 161 | height: 24px; |
|
| 162 | - | border-radius: 4px; |
|
| 162 | + | border-radius: 50%; |
|
| 163 | 163 | border: 1px solid var(--border); |
|
| 164 | 164 | object-fit: cover; |
|
| 165 | + | vertical-align: middle; |
|
| 166 | + | margin-right: 0.25rem; |
|
| 165 | 167 | } |
|
| 166 | 168 | ||
| 167 | 169 | .avatar-menu { |
|
| 168 | 170 | position: relative; |
|
| 169 | 171 | } |
| 838 | 840 | } |
|
| 839 | 841 | .btn:hover { background: var(--bg-alt); text-decoration: none; } |
|
| 840 | 842 | .btn:disabled { opacity: 0.5; cursor: default; } |
|
| 841 | 843 | main .btn { font-size: 1rem; } |
|
| 842 | 844 | ||
| 843 | - | .discussion-list { |
|
| 844 | - | width: 100%; |
|
| 845 | - | border: 1px solid var(--border); |
|
| 846 | - | border-radius: 4px; |
|
| 847 | - | } |
|
| 848 | - | .discussion-list td { border: none; padding: 0.5rem 0.75rem; } |
|
| 849 | - | .discussion-list tr:hover { background: var(--bg-alt); } |
|
| 850 | - | .discussion-list .discussion-title { font-weight: 600; } |
|
| 851 | - | .discussion-list .discussion-title a { color: var(--fg); } |
|
| 852 | - | .discussion-list .discussion-title a:hover { color: var(--link); } |
|
| 853 | - | .discussion-list .discussion-meta { |
|
| 854 | - | white-space: nowrap; |
|
| 855 | - | color: var(--fg-dim); |
|
| 856 | - | font-size: 0.875rem; |
|
| 857 | - | text-align: right; |
|
| 858 | - | } |
|
| 845 | + | .discussion-list td { border-bottom: none; } |
|
| 846 | + | .discussion-list tbody tr:hover { background: var(--bg-alt); } |
|
| 847 | + | .discussion-list th:first-child, |
|
| 848 | + | .discussion-list td:first-child { padding-left: 0.5rem; } |
|
| 849 | + | .discussion-list th:last-child, |
|
| 850 | + | .discussion-list td:last-child { padding-right: 0.5rem; } |
|
| 851 | + | .discussion-list .discussion-title a { color: var(--link); } |
|
| 859 | 852 | .discussion-list .discussion-replies { |
|
| 860 | 853 | white-space: nowrap; |
|
| 861 | 854 | color: var(--fg-dim); |
|
| 862 | - | font-size: 0.875rem; |
|
| 863 | 855 | text-align: right; |
|
| 864 | 856 | width: 1%; |
|
| 865 | 857 | } |
|
| 866 | 858 | ||
| 867 | - | .discussion-thread { max-width: 48rem; } |
|
| 868 | - | ||
| 869 | - | .post { |
|
| 859 | + | .comment { |
|
| 870 | 860 | border: 1px solid var(--border); |
|
| 871 | 861 | border-radius: 4px; |
|
| 872 | 862 | margin-bottom: 1rem; |
|
| 873 | 863 | } |
|
| 874 | - | .post-header { |
|
| 875 | - | padding: 0.75rem; |
|
| 876 | - | border-bottom: 1px solid var(--border); |
|
| 877 | - | } |
|
| 878 | - | .post-header h2 { |
|
| 879 | - | margin: 0; |
|
| 864 | + | .topic-title { |
|
| 880 | 865 | font-size: 1.125rem; |
|
| 881 | 866 | font-weight: 600; |
|
| 867 | + | margin: 1rem 0; |
|
| 882 | 868 | } |
|
| 883 | - | .post .post-meta { |
|
| 884 | - | padding: 0.5rem 0.75rem; |
|
| 885 | - | border-bottom: 1px solid var(--border); |
|
| 886 | - | } |
|
| 887 | - | .post .post-body { |
|
| 888 | - | padding: 0.75rem; |
|
| 889 | - | } |
|
| 890 | - | ||
| 891 | - | .post-meta { |
|
| 869 | + | .comment-header { |
|
| 892 | 870 | display: flex; |
|
| 893 | - | align-items: baseline; |
|
| 871 | + | align-items: center; |
|
| 894 | 872 | gap: 0.75rem; |
|
| 873 | + | padding: 0.75rem 0.75rem 0.5rem; |
|
| 874 | + | font-size: 1rem; |
|
| 875 | + | color: var(--fg); |
|
| 876 | + | } |
|
| 877 | + | .comment-date { |
|
| 878 | + | margin-left: auto; |
|
| 895 | 879 | font-size: 0.875rem; |
|
| 880 | + | color: var(--fg-dim); |
|
| 881 | + | white-space: nowrap; |
|
| 896 | 882 | } |
|
| 897 | - | .post-meta .author { |
|
| 898 | - | font-family: var(--mono); |
|
| 883 | + | .comment-author { |
|
| 884 | + | display: inline-flex; |
|
| 885 | + | align-items: center; |
|
| 886 | + | gap: 0.25rem; |
|
| 887 | + | } |
|
| 888 | + | .comment-author a { |
|
| 899 | 889 | color: var(--fg); |
|
| 890 | + | text-decoration: none; |
|
| 900 | 891 | font-weight: 600; |
|
| 901 | 892 | } |
|
| 902 | - | .post-meta .date { color: var(--fg-dim); } |
|
| 893 | + | .comment-author a:hover { |
|
| 894 | + | text-decoration: underline; |
|
| 895 | + | } |
|
| 903 | 896 | ||
| 904 | - | .post-body pre { |
|
| 905 | - | margin: 0; |
|
| 906 | - | font-family: var(--sans); |
|
| 897 | + | .post-body { |
|
| 898 | + | padding: 0.25rem 0.75rem 0.75rem; |
|
| 907 | 899 | font-size: 1rem; |
|
| 908 | 900 | line-height: 1.5; |
|
| 909 | - | white-space: pre-wrap; |
|
| 910 | - | word-wrap: break-word; |
|
| 901 | + | } |
|
| 902 | + | .post-body p { |
|
| 903 | + | margin: 0.5rem 0; |
|
| 904 | + | } |
|
| 905 | + | .post-body p:first-child { |
|
| 906 | + | margin-top: 0; |
|
| 907 | + | } |
|
| 908 | + | .post-body p:last-child { |
|
| 909 | + | margin-bottom: 0; |
|
| 911 | 910 | } |
|
| 912 | 911 | ||
| 913 | 912 | .discussion-form { |
|
| 914 | 913 | margin-top: 1rem; |
|
| 915 | 914 | } |
|
| 916 | 915 | .discussion-form label { |
|
| 917 | 916 | display: block; |
|
| 918 | 917 | font-size: 0.875rem; |
|
| 919 | 918 | font-weight: 600; |
|
| 920 | - | margin-bottom: 0.25rem; |
|
| 919 | + | margin-bottom: 0.5rem; |
|
| 921 | 920 | color: var(--fg); |
|
| 922 | 921 | } |
|
| 923 | 922 | .discussion-form input[type="text"], |
|
| 924 | 923 | .discussion-form input[type="password"], |
|
| 925 | 924 | .discussion-form textarea { |
| 941 | 940 | } |
|
| 942 | 941 | .discussion-form textarea { |
|
| 943 | 942 | resize: vertical; |
|
| 944 | 943 | min-height: 4rem; |
|
| 945 | 944 | } |
|
| 945 | + | .discussion-form input:user-invalid, |
|
| 946 | + | .discussion-form textarea:user-invalid { |
|
| 947 | + | border-color: var(--del-fg); |
|
| 948 | + | } |
|
| 946 | 949 | ||
| 947 | 950 | .form-actions { |
|
| 948 | 951 | display: flex; |
|
| 949 | 952 | align-items: center; |
|
| 950 | 953 | gap: 1rem; |
|
| 951 | 954 | } |
|
| 952 | 955 | ||
| 953 | 956 | .form-error { |
|
| 954 | 957 | color: var(--del-fg); |
|
| 955 | - | font-weight: 600; |
|
| 956 | - | margin-bottom: 1rem; |
|
| 958 | + | font-size: 0.875rem; |
|
| 957 | 959 | } |
|
| 958 | 960 | ||
| 959 | 961 | .sign-in-prompt { |
|
| 960 | 962 | color: var(--fg-dim); |
|
| 961 | 963 | font-size: 0.875rem; |
|
| 962 | - | padding: 0.75rem 0; |
|
| 964 | + | padding: 0.75rem 0 0; |
|
| 965 | + | margin: 0; |
|
| 963 | 966 | } |
|
| 964 | 967 | ||
| 965 | 968 | .login-page { |
|
| 966 | 969 | max-width: 24rem; |
|
| 967 | 970 | margin: 1rem 0 1rem 2rem; |
| 1030 | 1033 | .discussions-header { |
|
| 1031 | 1034 | flex-wrap: wrap; |
|
| 1032 | 1035 | gap: 0.5rem; |
|
| 1033 | 1036 | } |
|
| 1034 | 1037 | ||
| 1035 | - | .discussion-list .discussion-meta { |
|
| 1036 | - | display: none; |
|
| 1037 | - | } |
|
| 1038 | 1038 | } |
template.go
+45 -0
| 22 | 22 | var logoContent []byte |
|
| 23 | 23 | ||
| 24 | 24 | //go:embed static/fonts/* |
|
| 25 | 25 | var fontsFS embed.FS |
|
| 26 | 26 | ||
| 27 | + | //go:embed static/avatars/* |
|
| 28 | + | var avatarsFS embed.FS |
|
| 29 | + | ||
| 27 | 30 | //go:embed static/js/hirad.js |
|
| 28 | 31 | var hiradJS []byte |
|
| 29 | 32 | ||
| 30 | 33 | //go:embed static/js/hiril.js |
|
| 31 | 34 | var hirilJS []byte |
| 172 | 175 | if depth == 0 { |
|
| 173 | 176 | return "" |
|
| 174 | 177 | } |
|
| 175 | 178 | return fmt.Sprintf("padding-left: %grem", float64(depth)*1.2) |
|
| 176 | 179 | }, |
|
| 180 | + | "formatBody": func(s string) template.HTML { |
|
| 181 | + | s = strings.TrimSpace(s) |
|
| 182 | + | if s == "" { |
|
| 183 | + | return "" |
|
| 184 | + | } |
|
| 185 | + | paragraphs := strings.Split(s, "\n\n") |
|
| 186 | + | var b strings.Builder |
|
| 187 | + | for _, p := range paragraphs { |
|
| 188 | + | p = strings.TrimSpace(p) |
|
| 189 | + | if p == "" { |
|
| 190 | + | continue |
|
| 191 | + | } |
|
| 192 | + | // Apply autolink-style URL linking within each paragraph. |
|
| 193 | + | var pb strings.Builder |
|
| 194 | + | last := 0 |
|
| 195 | + | for _, loc := range urlRe.FindAllStringIndex(p, -1) { |
|
| 196 | + | end := loc[1] |
|
| 197 | + | for end > loc[0] && strings.ContainsRune(".,;:!?)]", rune(p[end-1])) { |
|
| 198 | + | end-- |
|
| 199 | + | } |
|
| 200 | + | pb.WriteString(html.EscapeString(p[last:loc[0]])) |
|
| 201 | + | u := p[loc[0]:end] |
|
| 202 | + | pb.WriteString(`<a href="`) |
|
| 203 | + | pb.WriteString(html.EscapeString(u)) |
|
| 204 | + | pb.WriteString(`">`) |
|
| 205 | + | pb.WriteString(html.EscapeString(u)) |
|
| 206 | + | pb.WriteString(`</a>`) |
|
| 207 | + | last = end |
|
| 208 | + | } |
|
| 209 | + | pb.WriteString(html.EscapeString(p[last:])) |
|
| 210 | + | b.WriteString("<p>") |
|
| 211 | + | b.WriteString(pb.String()) |
|
| 212 | + | b.WriteString("</p>") |
|
| 213 | + | } |
|
| 214 | + | return template.HTML(b.String()) |
|
| 215 | + | }, |
|
| 216 | + | "discussionPath": func(baseURL, repo string) string { |
|
| 217 | + | if repo == "" { |
|
| 218 | + | return baseURL + "/discussions" |
|
| 219 | + | } |
|
| 220 | + | return baseURL + "/" + repo + "/discussions" |
|
| 221 | + | }, |
|
| 177 | 222 | "autolink": func(s string) template.HTML { |
|
| 178 | 223 | var b strings.Builder |
|
| 179 | 224 | last := 0 |
|
| 180 | 225 | for _, loc := range urlRe.FindAllStringIndex(s, -1) { |
|
| 181 | 226 | // Strip trailing punctuation. |
templates/discussion.html
+29 -34
| 1 | 1 | {{define "content"}} |
|
| 2 | - | <div class="discussion-thread"> |
|
| 3 | - | <div class="post"> |
|
| 4 | - | <div class="post-header"> |
|
| 5 | - | <h2>{{.Data.Discussion.Title}}</h2> |
|
| 6 | - | </div> |
|
| 7 | - | <div class="post-meta"> |
|
| 8 | - | {{if .Data.Discussion.Avatar}}<img class="avatar" src="{{.Data.Discussion.Avatar}}" alt="" width="20" height="20">{{end}}<span class="author">{{.Data.Discussion.Author}}</span> |
|
| 9 | - | <span class="date">{{timeAgo .Data.Discussion.CreatedAt}} ago</span> |
|
| 10 | - | </div> |
|
| 11 | - | {{if .Data.Discussion.Body}} |
|
| 12 | - | <div class="post-body"><pre>{{autolink .Data.Discussion.Body}}</pre></div> |
|
| 13 | - | {{end}} |
|
| 14 | - | </div> |
|
| 2 | + | {{$dpath := discussionPath .BaseURL .Repo}} |
|
| 3 | + | <h2 class="topic-title">{{.Data.Discussion.Title}}</h2> |
|
| 15 | 4 | ||
| 16 | - | {{range .Data.Replies}} |
|
| 17 | - | <div class="post" id="reply-{{.ID}}"> |
|
| 18 | - | <div class="post-meta"> |
|
| 19 | - | {{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20">{{end}}<span class="author">{{.Author}}</span> |
|
| 20 | - | <span class="date">{{timeAgo .CreatedAt}} ago</span> |
|
| 21 | - | </div> |
|
| 22 | - | <div class="post-body"><pre>{{autolink .Body}}</pre></div> |
|
| 5 | + | <div class="comment"> |
|
| 6 | + | <div class="comment-header"> |
|
| 7 | + | <span class="comment-author">{{if .Data.Discussion.Avatar}}<img class="avatar" src="{{.Data.Discussion.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Data.Discussion.Author}}">{{.Data.Discussion.Author}}</a></span> |
|
| 8 | + | <span class="comment-date">{{timeAgo .Data.Discussion.CreatedAt}} ago</span> |
|
| 23 | 9 | </div> |
|
| 24 | - | {{end}} |
|
| 10 | + | {{if .Data.Discussion.Body}}<div class="post-body">{{formatBody .Data.Discussion.Body}}</div>{{end}} |
|
| 11 | + | </div> |
|
| 25 | 12 | ||
| 26 | - | {{if .Data.Error}} |
|
| 27 | - | <p class="form-error">{{.Data.Error}}</p> |
|
| 28 | - | {{end}} |
|
| 13 | + | {{range .Data.Replies}} |
|
| 14 | + | <div class="comment" id="reply-{{.ID}}"> |
|
| 15 | + | <div class="comment-header"> |
|
| 16 | + | <span class="comment-author">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a></span> |
|
| 17 | + | <span class="comment-date">{{timeAgo .CreatedAt}} ago</span> |
|
| 18 | + | </div> |
|
| 19 | + | <div class="post-body">{{formatBody .Body}}</div> |
|
| 20 | + | </div> |
|
| 21 | + | {{end}} |
|
| 29 | 22 | ||
| 30 | - | {{if .Handle}} |
|
| 31 | - | <div class="discussion-form"> |
|
| 32 | - | <form method="POST" action="{{.BaseURL}}/{{.Repo}}/discussions/{{.Data.Discussion.ID}}"> |
|
| 33 | - | <label for="body">Reply as <strong>{{.Handle}}</strong></label> |
|
| 34 | - | <textarea id="body" name="body" rows="4" placeholder="Write a reply…" required></textarea> |
|
| 23 | + | {{if .Handle}} |
|
| 24 | + | <div class="discussion-form"> |
|
| 25 | + | <form method="POST" action="{{$dpath}}/{{.Data.Discussion.ID}}" novalidate> |
|
| 26 | + | <label for="body">Reply as <strong>{{.Handle}}</strong></label> |
|
| 27 | + | <textarea id="body" name="body" rows="4" placeholder="Write a reply…" required></textarea> |
|
| 28 | + | <div class="form-actions"> |
|
| 35 | 29 | <button type="submit" class="btn">Post reply</button> |
|
| 36 | - | </form> |
|
| 37 | - | </div> |
|
| 38 | - | {{else}} |
|
| 39 | - | <p class="sign-in-prompt"><a href="{{.BaseURL}}/login?return={{.BaseURL}}/{{.Repo}}/discussions/{{.Data.Discussion.ID}}">Sign in with Bluesky</a> to reply.</p> |
|
| 40 | - | {{end}} |
|
| 30 | + | {{if .Data.Error}}<span class="form-error">{{.Data.Error}}</span>{{end}} |
|
| 31 | + | </div> |
|
| 32 | + | </form> |
|
| 41 | 33 | </div> |
|
| 34 | + | {{else}} |
|
| 35 | + | <p class="sign-in-prompt"><a href="{{.BaseURL}}/login?return={{$dpath}}/{{.Data.Discussion.ID}}">Sign in</a> to reply.</p> |
|
| 36 | + | {{end}} |
|
| 42 | 37 | {{end}} |
templates/discussion_new.html
+3 -2
| 1 | 1 | {{define "content"}} |
|
| 2 | + | {{$dpath := discussionPath .BaseURL .Repo}} |
|
| 2 | 3 | <div class="discussion-thread"> |
|
| 3 | 4 | <h2>New discussion</h2> |
|
| 4 | 5 | ||
| 5 | 6 | {{if .Data.Error}} |
|
| 6 | 7 | <p class="form-error">{{.Data.Error}}</p> |
|
| 7 | 8 | {{end}} |
|
| 8 | 9 | ||
| 9 | 10 | <div class="discussion-form"> |
|
| 10 | - | <form method="POST" action="{{.BaseURL}}/{{.Repo}}/discussions/new"> |
|
| 11 | + | <form method="POST" action="{{$dpath}}/new" novalidate> |
|
| 11 | 12 | <label for="title">Title</label> |
|
| 12 | 13 | <input type="text" id="title" name="title" value="{{.Data.Title}}" required maxlength="200" placeholder="Discussion title"> |
|
| 13 | 14 | <label for="body">Body</label> |
|
| 14 | 15 | <textarea id="body" name="body" rows="8" placeholder="Write your discussion…">{{.Data.Body}}</textarea> |
|
| 15 | 16 | <div class="form-actions"> |
|
| 16 | 17 | <button type="submit" class="btn">Create discussion</button> |
|
| 17 | - | <a href="{{.BaseURL}}/{{.Repo}}/discussions">Cancel</a> |
|
| 18 | + | <a href="{{$dpath}}">Cancel</a> |
|
| 18 | 19 | </div> |
|
| 19 | 20 | </form> |
|
| 20 | 21 | </div> |
|
| 21 | 22 | </div> |
|
| 22 | 23 | {{end}} |
templates/discussions.html
+18 -12
| 1 | 1 | {{define "content"}} |
|
| 2 | + | {{$dpath := discussionPath .BaseURL .Repo}} |
|
| 3 | + | {{if .Handle}} |
|
| 2 | 4 | <div class="discussions-header"> |
|
| 3 | - | <h2>Discussions</h2> |
|
| 4 | - | {{if .Handle}} |
|
| 5 | 5 | <div class="discussions-actions"> |
|
| 6 | - | <a href="{{.BaseURL}}/{{.Repo}}/discussions/new" class="btn">New discussion</a> |
|
| 6 | + | <a href="{{$dpath}}/new" class="btn">New discussion</a> |
|
| 7 | 7 | </div> |
|
| 8 | - | {{end}} |
|
| 9 | 8 | </div> |
|
| 9 | + | {{end}} |
|
| 10 | 10 | ||
| 11 | 11 | {{if .Data.IsEmpty}} |
|
| 12 | 12 | <p class="empty">No discussions yet.</p> |
|
| 13 | 13 | {{else}} |
|
| 14 | 14 | <table class="discussion-list"> |
|
| 15 | + | <thead> |
|
| 16 | + | <tr> |
|
| 17 | + | <th>Topic</th> |
|
| 18 | + | <th class="desktop">Author</th> |
|
| 19 | + | <th class="desktop">Replies</th> |
|
| 20 | + | <th>Active</th> |
|
| 21 | + | </tr> |
|
| 22 | + | </thead> |
|
| 15 | 23 | <tbody> |
|
| 16 | 24 | {{range .Data.Discussions}} |
|
| 17 | - | <tr> |
|
| 18 | - | <td class="discussion-title"><a href="{{$.BaseURL}}/{{$.Repo}}/discussions/{{.ID}}">{{.Title}}</a></td> |
|
| 19 | - | <td class="discussion-meta"> |
|
| 20 | - | <span class="author">{{.Author}}</span> |
|
| 21 | - | <span class="date">{{timeAgo .CreatedAt}} ago</span> |
|
| 22 | - | </td> |
|
| 23 | - | <td class="discussion-replies">{{.ReplyCount}} {{if eq .ReplyCount 1}}reply{{else}}replies{{end}}</td> |
|
| 24 | - | </tr> |
|
| 25 | + | <tr> |
|
| 26 | + | <td class="discussion-title"><a href="{{$dpath}}/{{.ID}}">{{.Title}}</a></td> |
|
| 27 | + | <td class="author desktop">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a></td> |
|
| 28 | + | <td class="discussion-replies desktop">{{.ReplyCount}}</td> |
|
| 29 | + | <td class="date">{{timeAgo .LastActive}}</td> |
|
| 30 | + | </tr> |
|
| 25 | 31 | {{end}} |
|
| 26 | 32 | </tbody> |
|
| 27 | 33 | </table> |
|
| 28 | 34 | {{end}} |
|
| 29 | 35 | {{end}} |
templates/layout.html
+2 -0
| 9 | 9 | <body> |
|
| 10 | 10 | <div class="container"> |
|
| 11 | 11 | {{if not .Repo}} |
|
| 12 | 12 | <nav class="repo-nav"> |
|
| 13 | 13 | <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/">{{.SiteTitle}}</a>{{if .SiteDescription}}<span class="repo-desc desktop">{{.SiteDescription}}</span>{{end}}</span> |
|
| 14 | + | <a href="{{.BaseURL}}/" class="tab{{if eq .Section "repositories"}} active{{end}}">repositories</a> |
|
| 15 | + | <a href="{{.BaseURL}}/discussions" class="tab{{if eq .Section "forum"}} active{{end}}">discussions</a> |
|
| 14 | 16 | <span class="nav-auth">{{if .Handle}}<details class="avatar-menu"><summary>{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="24" height="24">{{else}}<span class="avatar-placeholder">{{.Handle}}</span>{{end}}</summary><div class="avatar-dropdown"><span class="signed-in">{{.Handle}}</span><a href="{{.BaseURL}}/logout?return={{.BaseURL}}/" class="btn nav-btn">Sign out</a></div></details>{{else}}<a href="{{.BaseURL}}/login?return={{.BaseURL}}/" class="btn nav-btn">Sign in</a>{{end}}</span> |
|
| 15 | 17 | </nav> |
|
| 16 | 18 | {{end}} |
|
| 17 | 19 | {{if .Repo}} |
|
| 18 | 20 | <nav class="repo-nav"> |
watch
+1 -1
| 1 | 1 | #!/bin/sh |
|
| 2 | 2 | # Rebuild and restart on source changes. |
|
| 3 | 3 | # Requires `entr(1)`. |
|
| 4 | 4 | find . -name '*.go' -o -name '*.html' -o -name '*.css' \ |
|
| 5 | - | | entr -r go run . -scan-path ~/src/radiant -non-bare -description "Radiant computer repositories" "$@" |
|
| 5 | + | | entr -r go run . -scan-path ~/src/radiant -non-bare -title "code.radiant.computer" -description "Radiant computer repositories" "$@" |