Add discussions feature

59a04c995a7ef98bc6f691d79000d7e61936dfb6
Alexis Sellier committed ago 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" "$@"