+31
-26
appview/db/issues.go
+31
-26
appview/db/issues.go
···
46
}
47
}
48
49
type CommentListItem struct {
50
Self *IssueComment
51
Replies []*IssueComment
···
170
return &comment, nil
171
}
172
173
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
174
// ensure sequence exists
175
_, err := tx.Exec(`
176
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
180
return err
181
}
182
183
-
// check if issue already exists
184
-
var existingRowId, existingIssueId sql.NullInt64
185
-
err = tx.QueryRow(`
186
-
select rowid, issue_id from issues
187
-
where did = ? and rkey = ?
188
-
`, issue.Did, issue.Rkey).Scan(&existingRowId, &existingIssueId)
189
-
190
switch {
191
-
case err == sql.ErrNoRows:
192
-
return createNewIssue(tx, issue)
193
-
194
case err != nil:
195
return err
196
-
197
default:
198
-
// Case 3: Issue exists - update it
199
-
return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64))
200
}
201
}
202
···
223
return row.Scan(&issue.Id, &issue.IssueId)
224
}
225
226
-
func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error {
227
// update existing issue
228
_, err := tx.Exec(`
229
-
update issues
230
-
set title = ?, body = ?
231
where did = ? and rkey = ?
232
-
`, issue.Title, issue.Body, issue.Did, issue.Rkey)
233
-
if err != nil {
234
-
return err
235
-
}
236
-
237
-
// set the values from existing record
238
-
issue.Id = existingRowId
239
-
issue.IssueId = existingIssueId
240
-
return nil
241
}
242
243
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
···
46
}
47
}
48
49
+
func (i *Issue) State() string {
50
+
if i.Open {
51
+
return "open"
52
+
}
53
+
return "closed"
54
+
}
55
+
56
type CommentListItem struct {
57
Self *IssueComment
58
Replies []*IssueComment
···
177
return &comment, nil
178
}
179
180
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
181
// ensure sequence exists
182
_, err := tx.Exec(`
183
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
187
return err
188
}
189
190
+
issues, err := GetIssues(
191
+
tx,
192
+
FilterEq("did", issue.Did),
193
+
FilterEq("rkey", issue.Rkey),
194
+
)
195
switch {
196
case err != nil:
197
return err
198
+
case len(issues) == 0:
199
+
return createNewIssue(tx, issue)
200
+
case len(issues) != 1: // should be unreachable
201
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
202
default:
203
+
// if content is identical, do not edit
204
+
existingIssue := issues[0]
205
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
206
+
return nil
207
+
}
208
+
209
+
issue.Id = existingIssue.Id
210
+
issue.IssueId = existingIssue.IssueId
211
+
return updateIssue(tx, issue)
212
}
213
}
214
···
235
return row.Scan(&issue.Id, &issue.IssueId)
236
}
237
238
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
239
// update existing issue
240
_, err := tx.Exec(`
241
+
update issues
242
+
set title = ?, body = ?, edited = ?
243
where did = ? and rkey = ?
244
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
245
+
return err
246
}
247
248
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+3
-9
appview/ingester.go
+3
-9
appview/ingester.go
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
-
"strings"
9
10
"time"
11
···
16
"tangled.sh/tangled.sh/core/api/tangled"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
"tangled.sh/tangled.sh/core/appview/serververify"
21
"tangled.sh/tangled.sh/core/appview/validator"
22
"tangled.sh/tangled.sh/core/idresolver"
···
804
805
issue := db.IssueFromRecord(did, rkey, record)
806
807
-
sanitizer := markup.NewSanitizer()
808
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
809
-
return fmt.Errorf("title is empty after HTML sanitization")
810
-
}
811
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
812
-
return fmt.Errorf("body is empty after HTML sanitization")
813
}
814
815
tx, err := ddb.BeginTx(ctx, nil)
···
819
}
820
defer tx.Rollback()
821
822
-
err = db.NewIssue(tx, &issue)
823
if err != nil {
824
l.Error("failed to create issue", "err", err)
825
return err
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
9
"time"
10
···
15
"tangled.sh/tangled.sh/core/api/tangled"
16
"tangled.sh/tangled.sh/core/appview/config"
17
"tangled.sh/tangled.sh/core/appview/db"
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
"tangled.sh/tangled.sh/core/appview/validator"
20
"tangled.sh/tangled.sh/core/idresolver"
···
802
803
issue := db.IssueFromRecord(did, rkey, record)
804
805
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
+
return fmt.Errorf("failed to validate issue: %w", err)
807
}
808
809
tx, err := ddb.BeginTx(ctx, nil)
···
813
}
814
defer tx.Rollback()
815
816
+
err = db.PutIssue(tx, &issue)
817
if err != nil {
818
l.Error("failed to create issue", "err", err)
819
return err
+130
-23
appview/issues/issues.go
+130
-23
appview/issues/issues.go
···
9
"log/slog"
10
"net/http"
11
"slices"
12
-
"strings"
13
"time"
14
15
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
23
"tangled.sh/tangled.sh/core/appview/notify"
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
"tangled.sh/tangled.sh/core/appview/pages"
26
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
27
"tangled.sh/tangled.sh/core/appview/pagination"
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
29
"tangled.sh/tangled.sh/core/appview/validator"
···
103
Reactions: reactionCountMap,
104
UserReacted: userReactions,
105
})
106
}
107
108
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
669
RepoInfo: f.RepoInfo(user),
670
})
671
case http.MethodPost:
672
-
title := r.FormValue("title")
673
-
body := r.FormValue("body")
674
-
675
-
if title == "" || body == "" {
676
-
rp.pages.Notice(w, "issues", "Title and body are required")
677
-
return
678
-
}
679
-
680
-
sanitizer := markup.NewSanitizer()
681
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
682
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
683
-
return
684
-
}
685
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
687
-
return
688
-
}
689
-
690
issue := &db.Issue{
691
RepoAt: f.RepoAt(),
692
Rkey: tid.TID(),
693
-
Title: title,
694
-
Body: body,
695
Did: user.Did,
696
Created: time.Now(),
697
}
698
record := issue.AsRecord()
699
700
// create an atproto record
···
738
}
739
defer rollback()
740
741
-
err = db.NewIssue(tx, issue)
742
if err != nil {
743
log.Println("failed to create issue", err)
744
rp.pages.Notice(w, "issues", "Failed to create issue.")
···
9
"log/slog"
10
"net/http"
11
"slices"
12
"time"
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
22
"tangled.sh/tangled.sh/core/appview/notify"
23
"tangled.sh/tangled.sh/core/appview/oauth"
24
"tangled.sh/tangled.sh/core/appview/pages"
25
"tangled.sh/tangled.sh/core/appview/pagination"
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
"tangled.sh/tangled.sh/core/appview/validator"
···
101
Reactions: reactionCountMap,
102
UserReacted: userReactions,
103
})
104
+
}
105
+
106
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
107
+
l := rp.logger.With("handler", "EditIssue")
108
+
user := rp.oauth.GetUser(r)
109
+
f, err := rp.repoResolver.Resolve(r)
110
+
if err != nil {
111
+
log.Println("failed to get repo and knot", err)
112
+
return
113
+
}
114
+
115
+
issue, ok := r.Context().Value("issue").(*db.Issue)
116
+
if !ok {
117
+
l.Error("failed to get issue")
118
+
rp.pages.Error404(w)
119
+
return
120
+
}
121
+
122
+
switch r.Method {
123
+
case http.MethodGet:
124
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(user),
127
+
Issue: issue,
128
+
})
129
+
case http.MethodPost:
130
+
noticeId := "issues"
131
+
newIssue := issue
132
+
newIssue.Title = r.FormValue("title")
133
+
newIssue.Body = r.FormValue("body")
134
+
135
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
136
+
l.Error("validation error", "err", err)
137
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
138
+
return
139
+
}
140
+
141
+
newRecord := newIssue.AsRecord()
142
+
143
+
// edit an atproto record
144
+
client, err := rp.oauth.AuthorizedClient(r)
145
+
if err != nil {
146
+
l.Error("failed to get authorized client", "err", err)
147
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
148
+
return
149
+
}
150
+
151
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
152
+
if err != nil {
153
+
l.Error("failed to get record", "err", err)
154
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
155
+
return
156
+
}
157
+
158
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
159
+
Collection: tangled.RepoIssueNSID,
160
+
Repo: user.Did,
161
+
Rkey: newIssue.Rkey,
162
+
SwapRecord: ex.Cid,
163
+
Record: &lexutil.LexiconTypeDecoder{
164
+
Val: &newRecord,
165
+
},
166
+
})
167
+
if err != nil {
168
+
l.Error("failed to edit record on PDS", "err", err)
169
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
170
+
return
171
+
}
172
+
173
+
// modify on DB -- TODO: transact this cleverly
174
+
tx, err := rp.db.Begin()
175
+
if err != nil {
176
+
l.Error("failed to edit issue on DB", "err", err)
177
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
178
+
return
179
+
}
180
+
defer tx.Rollback()
181
+
182
+
err = db.PutIssue(tx, newIssue)
183
+
if err != nil {
184
+
log.Println("failed to edit issue", err)
185
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
186
+
return
187
+
}
188
+
189
+
if err = tx.Commit(); err != nil {
190
+
l.Error("failed to edit issue", "err", err)
191
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
192
+
return
193
+
}
194
+
195
+
rp.pages.HxRefresh(w)
196
+
}
197
+
}
198
+
199
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
200
+
l := rp.logger.With("handler", "DeleteIssue")
201
+
user := rp.oauth.GetUser(r)
202
+
f, err := rp.repoResolver.Resolve(r)
203
+
if err != nil {
204
+
log.Println("failed to get repo and knot", err)
205
+
return
206
+
}
207
+
208
+
issue, ok := r.Context().Value("issue").(*db.Issue)
209
+
if !ok {
210
+
l.Error("failed to get issue")
211
+
rp.pages.Error404(w)
212
+
return
213
+
}
214
+
215
+
switch r.Method {
216
+
case http.MethodGet:
217
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
218
+
LoggedInUser: user,
219
+
RepoInfo: f.RepoInfo(user),
220
+
Issue: issue,
221
+
})
222
+
case http.MethodPost:
223
+
}
224
}
225
226
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
787
RepoInfo: f.RepoInfo(user),
788
})
789
case http.MethodPost:
790
issue := &db.Issue{
791
RepoAt: f.RepoAt(),
792
Rkey: tid.TID(),
793
+
Title: r.FormValue("title"),
794
+
Body: r.FormValue("body"),
795
Did: user.Did,
796
Created: time.Now(),
797
}
798
+
799
+
if err := rp.validator.ValidateIssue(issue); err != nil {
800
+
l.Error("validation error", "err", err)
801
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
802
+
return
803
+
}
804
+
805
record := issue.AsRecord()
806
807
// create an atproto record
···
845
}
846
defer rollback()
847
848
+
err = db.PutIssue(tx, issue)
849
if err != nil {
850
log.Println("failed to create issue", err)
851
rp.pages.Notice(w, "issues", "Failed to create issue.")
+3
appview/issues/router.go
+3
appview/issues/router.go
+19
-11
appview/pages/pages.go
+19
-11
appview/pages/pages.go
···
886
OrderedReactionKinds []db.ReactionKind
887
Reactions map[db.ReactionKind]int
888
UserReacted map[db.ReactionKind]bool
889
890
-
State string
891
}
892
893
type ThreadReactionFragmentParams struct {
···
901
return p.executePlain("repo/fragments/reaction", w, params)
902
}
903
904
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
905
-
params.Active = "issues"
906
-
if params.Issue.Open {
907
-
params.State = "open"
908
-
} else {
909
-
params.State = "closed"
910
-
}
911
-
return p.executeRepo("repo/issues/issue", w, params)
912
-
}
913
-
914
type RepoNewIssueParams struct {
915
LoggedInUser *oauth.User
916
RepoInfo repoinfo.RepoInfo
917
Active string
918
}
919
920
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
921
params.Active = "issues"
922
return p.executeRepo("repo/issues/new", w, params)
923
}
924
···
886
OrderedReactionKinds []db.ReactionKind
887
Reactions map[db.ReactionKind]int
888
UserReacted map[db.ReactionKind]bool
889
+
}
890
891
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
892
+
params.Active = "issues"
893
+
return p.executeRepo("repo/issues/issue", w, params)
894
+
}
895
+
896
+
type EditIssueParams struct {
897
+
LoggedInUser *oauth.User
898
+
RepoInfo repoinfo.RepoInfo
899
+
Issue *db.Issue
900
+
Action string
901
+
}
902
+
903
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
904
+
params.Action = "edit"
905
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
906
}
907
908
type ThreadReactionFragmentParams struct {
···
916
return p.executePlain("repo/fragments/reaction", w, params)
917
}
918
919
type RepoNewIssueParams struct {
920
LoggedInUser *oauth.User
921
RepoInfo repoinfo.RepoInfo
922
+
Issue *db.Issue // existing issue if any -- passed when editing
923
Active string
924
+
Action string
925
}
926
927
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
928
params.Active = "issues"
929
+
params.Action = "create"
930
return p.executeRepo("repo/issues/new", w, params)
931
}
932
+1
-7
appview/pages/templates/repo/issues/fragments/commentList.html
+1
-7
appview/pages/templates/repo/issues/fragments/commentList.html
···
39
{{ end }}
40
</div>
41
42
-
{{ if $root.LoggedInUser }}
43
-
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
44
-
{{ else }}
45
-
<div class="p-2 border-t border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400">
46
-
<a class="underline" href="/login">login</a> to reply to this discussion
47
-
</div>
48
-
{{ end }}
49
</div>
50
{{ end }}
51
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
11
{{ define "edit" }}
12
<a
13
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
14
-
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/edit"
15
hx-swap="outerHTML"
16
hx-target="#comment-body-{{.Comment.Id}}">
17
{{ i "pencil" "size-3" }}
···
22
{{ define "delete" }}
23
<a
24
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
25
-
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/"
26
hx-confirm="Are you sure you want to delete your comment?"
27
hx-swap="outerHTML"
28
hx-target="#comment-body-{{.Comment.Id}}"
···
11
{{ define "edit" }}
12
<a
13
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
14
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
15
hx-swap="outerHTML"
16
hx-target="#comment-body-{{.Comment.Id}}">
17
{{ i "pencil" "size-3" }}
···
22
{{ define "delete" }}
23
<a
24
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
25
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
26
hx-confirm="Are you sure you want to delete your comment?"
27
hx-swap="outerHTML"
28
hx-target="#comment-body-{{.Comment.Id}}"
+6
-6
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+6
-6
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
5
{{ template "timestamp" . }}
6
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
7
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
-
{{ template "edit" . }}
9
-
{{ template "delete" . }}
10
{{ end }}
11
</div>
12
{{ end }}
···
32
</a>
33
{{ end }}
34
35
-
{{ define "edit" }}
36
<a
37
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
-
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/edit"
39
hx-swap="outerHTML"
40
hx-target="#comment-body-{{.Comment.Id}}">
41
{{ i "pencil" "size-3" }}
42
</a>
43
{{ end }}
44
45
-
{{ define "delete" }}
46
<a
47
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
-
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/"
49
hx-confirm="Are you sure you want to delete your comment?"
50
hx-swap="outerHTML"
51
hx-target="#comment-body-{{.Comment.Id}}"
···
5
{{ template "timestamp" . }}
6
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
7
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
+
{{ template "editIssueComment" . }}
9
+
{{ template "deleteIssueComment" . }}
10
{{ end }}
11
</div>
12
{{ end }}
···
32
</a>
33
{{ end }}
34
35
+
{{ define "editIssueComment" }}
36
<a
37
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
hx-swap="outerHTML"
40
hx-target="#comment-body-{{.Comment.Id}}">
41
{{ i "pencil" "size-3" }}
42
</a>
43
{{ end }}
44
45
+
{{ define "deleteIssueComment" }}
46
<a
47
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
hx-confirm="Are you sure you want to delete your comment?"
50
hx-swap="outerHTML"
51
hx-target="#comment-body-{{.Comment.Id}}"
+2
-2
appview/pages/templates/repo/issues/fragments/newComment.html
+2
-2
appview/pages/templates/repo/issues/fragments/newComment.html
···
38
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
39
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
40
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
41
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
42
<button
43
id="close-button"
44
type="button"
···
84
}
85
});
86
</script>
87
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
88
<button
89
type="button"
90
class="btn flex items-center gap-2"
···
38
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
39
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
40
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
41
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
42
<button
43
id="close-button"
44
type="button"
···
84
}
85
});
86
</script>
87
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
88
<button
89
type="button"
90
class="btn flex items-center gap-2"
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
···
1
+
{{ define "repo/issues/fragments/putIssue" }}
2
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
3
+
<form
4
+
{{ if eq .Action "edit" }}
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
6
+
{{ else }}
7
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
8
+
{{ end }}
9
+
hx-swap="none"
10
+
hx-indicator="#spinner">
11
+
<div class="flex flex-col gap-2">
12
+
<div>
13
+
<label for="title">title</label>
14
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
15
+
</div>
16
+
<div>
17
+
<label for="body">body</label>
18
+
<textarea
19
+
name="body"
20
+
id="body"
21
+
rows="6"
22
+
class="w-full resize-y"
23
+
placeholder="Describe your issue. Markdown is supported."
24
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
25
+
</div>
26
+
<div class="flex justify-between">
27
+
<div id="issues" class="error"></div>
28
+
<div class="flex gap-2 items-center">
29
+
<a
30
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
31
+
type="button"
32
+
{{ if .Issue }}
33
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
34
+
{{ else }}
35
+
href="/{{ .RepoInfo.FullName }}/issues"
36
+
{{ end }}
37
+
>
38
+
{{ i "x" "w-4 h-4" }}
39
+
cancel
40
+
</a>
41
+
<button type="submit" class="btn-create flex items-center gap-2">
42
+
{{ if eq .Action "edit" }}
43
+
{{ i "pencil" "w-4 h-4" }}
44
+
{{ .Action }} issue
45
+
{{ else }}
46
+
{{ i "circle-plus" "w-4 h-4" }}
47
+
{{ .Action }} issue
48
+
{{ end }}
49
+
<span id="spinner" class="group">
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</span>
52
+
</button>
53
+
</div>
54
+
</div>
55
+
</div>
56
+
</form>
57
+
{{ end }}
+6
-2
appview/pages/templates/repo/issues/fragments/replyComment.html
+6
-2
appview/pages/templates/repo/issues/fragments/replyComment.html
···
4
id="reply-form-{{ .Comment.Id }}"
5
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
hx-on::after-request="if(event.detail.successful) this.reset()"
7
>
8
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
9
<textarea
···
12
class="w-full p-2"
13
placeholder="Leave a reply..."
14
autofocus
15
-
rows="3"></textarea>
16
17
<input
18
type="text"
···
48
<button
49
id="reply-{{ .Comment.Id }}"
50
type="submit"
51
-
hx-disabled-elt="#reply-{{ .Comment.Id }}"
52
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
53
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
54
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
···
4
id="reply-form-{{ .Comment.Id }}"
5
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
8
>
9
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
10
<textarea
···
13
class="w-full p-2"
14
placeholder="Leave a reply..."
15
autofocus
16
+
rows="3"
17
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
18
+
hx-target="#reply-form-{{ .Comment.Id }}"
19
+
hx-get="#"
20
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
21
22
<input
23
type="text"
···
53
<button
54
id="reply-{{ .Comment.Id }}"
55
type="submit"
56
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
57
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
58
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+7
-5
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
+7
-5
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
1
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
2
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
3
-
<img
4
-
src="{{ tinyAvatar .LoggedInUser.Did }}"
5
-
alt=""
6
-
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
7
-
/>
8
<input
9
class="w-full py-2 border-none focus:outline-none"
10
placeholder="Leave a reply..."
···
1
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
2
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
3
+
{{ if .LoggedInUser }}
4
+
<img
5
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
6
+
alt=""
7
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
8
+
/>
9
+
{{ end }}
10
<input
11
class="w-full py-2 border-none focus:outline-none"
12
placeholder="Leave a reply..."
+86
-44
appview/pages/templates/repo/issues/issue.html
+86
-44
appview/pages/templates/repo/issues/issue.html
···
9
{{ end }}
10
11
{{ define "repoContent" }}
12
-
<header class="pb-2">
13
-
<h1 class="text-2xl">
14
-
{{ .Issue.Title | description }}
15
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
16
-
</h1>
17
-
</header>
18
19
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
20
-
{{ $icon := "ban" }}
21
-
{{ if eq .State "open" }}
22
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
23
-
{{ $icon = "circle-dot" }}
24
{{ end }}
25
26
-
<section class="mt-2">
27
-
<div class="inline-flex items-center gap-2">
28
-
<div id="state"
29
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
30
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
-
<span class="text-white">{{ .State }}</span>
32
-
</div>
33
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
-
opened by
35
-
{{ template "user/fragments/picHandleLink" .Issue.Did }}
36
-
<span class="select-none before:content-['\00B7']"></span>
37
-
{{ template "repo/fragments/time" .Issue.Created }}
38
-
</span>
39
-
</div>
40
41
-
{{ if .Issue.Body }}
42
-
<article id="body" class="mt-4 prose dark:prose-invert">
43
-
{{ .Issue.Body | markdown }}
44
-
</article>
45
-
{{ end }}
46
47
-
<div class="flex items-center gap-2 mt-2">
48
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
49
-
{{ range $kind := .OrderedReactionKinds }}
50
-
{{
51
-
template "repo/fragments/reaction"
52
-
(dict
53
-
"Kind" $kind
54
-
"Count" (index $.Reactions $kind)
55
-
"IsReacted" (index $.UserReacted $kind)
56
-
"ThreadAt" $.Issue.AtUri)
57
-
}}
58
-
{{ end }}
59
-
</div>
60
-
</section>
61
{{ end }}
62
63
{{ define "repoAfter" }}
···
9
{{ end }}
10
11
{{ define "repoContent" }}
12
+
<section id="issue-{{ .Issue.IssueId }}">
13
+
{{ template "issueHeader" .Issue }}
14
+
{{ template "issueInfo" . }}
15
+
{{ if .Issue.Body }}
16
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
17
+
{{ end }}
18
+
{{ template "issueReactions" . }}
19
+
</section>
20
+
{{ end }}
21
+
22
+
{{ define "issueHeader" }}
23
+
<header class="pb-2">
24
+
<h1 class="text-2xl">
25
+
{{ .Title | description }}
26
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
27
+
</h1>
28
+
</header>
29
+
{{ end }}
30
+
31
+
{{ define "issueInfo" }}
32
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
33
+
{{ $icon := "ban" }}
34
+
{{ if eq .Issue.State "open" }}
35
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
36
+
{{ $icon = "circle-dot" }}
37
+
{{ end }}
38
+
<div class="inline-flex items-center gap-2">
39
+
<div id="state"
40
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
41
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
42
+
<span class="text-white">{{ .Issue.State }}</span>
43
+
</div>
44
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
45
+
opened by
46
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
47
+
<span class="select-none before:content-['\00B7']"></span>
48
+
{{ if .Issue.Edited }}
49
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
50
+
{{ else }}
51
+
{{ template "repo/fragments/time" .Issue.Created }}
52
+
{{ end }}
53
+
</span>
54
55
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
56
+
{{ template "issueActions" . }}
57
{{ end }}
58
+
</div>
59
+
{{ end }}
60
61
+
{{ define "issueActions" }}
62
+
{{ template "editIssue" . }}
63
+
{{ template "deleteIssue" . }}
64
+
{{ end }}
65
+
66
+
{{ define "editIssue" }}
67
+
<a
68
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
69
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
70
+
hx-swap="innerHTML"
71
+
hx-target="#issue-{{.Issue.IssueId}}">
72
+
{{ i "pencil" "size-3" }}
73
+
</a>
74
+
{{ end }}
75
76
+
{{ define "deleteIssue" }}
77
+
<a
78
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
79
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/delete"
80
+
hx-confirm="Are you sure you want to delete your issue?"
81
+
hx-swap="innerHTML"
82
+
hx-target="#comment-body-{{.Issue.IssueId}}"
83
+
>
84
+
{{ i "trash-2" "size-3" }}
85
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
86
+
</a>
87
+
{{ end }}
88
89
+
{{ define "issueReactions" }}
90
+
<div class="flex items-center gap-2 mt-2">
91
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
92
+
{{ range $kind := .OrderedReactionKinds }}
93
+
{{
94
+
template "repo/fragments/reaction"
95
+
(dict
96
+
"Kind" $kind
97
+
"Count" (index $.Reactions $kind)
98
+
"IsReacted" (index $.UserReacted $kind)
99
+
"ThreadAt" $.Issue.AtUri)
100
+
}}
101
+
{{ end }}
102
+
</div>
103
{{ end }}
104
105
{{ define "repoAfter" }}
+1
-33
appview/pages/templates/repo/issues/new.html
+1
-33
appview/pages/templates/repo/issues/new.html
···
1
{{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
-
<form
5
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="space-y-6"
7
-
hx-swap="none"
8
-
hx-indicator="#spinner"
9
-
>
10
-
<div class="flex flex-col gap-4">
11
-
<div>
12
-
<label for="title">title</label>
13
-
<input type="text" name="title" id="title" class="w-full" />
14
-
</div>
15
-
<div>
16
-
<label for="body">body</label>
17
-
<textarea
18
-
name="body"
19
-
id="body"
20
-
rows="6"
21
-
class="w-full resize-y"
22
-
placeholder="Describe your issue. Markdown is supported."
23
-
></textarea>
24
-
</div>
25
-
<div>
26
-
<button type="submit" class="btn-create flex items-center gap-2">
27
-
{{ i "circle-plus" "w-4 h-4" }}
28
-
create issue
29
-
<span id="spinner" class="group">
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</span>
32
-
</button>
33
-
</div>
34
-
</div>
35
-
<div id="issues" class="error"></div>
36
-
</form>
37
{{ end }}
+21
-3
appview/validator/issue.go
+21
-3
appview/validator/issue.go
···
5
"strings"
6
7
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
9
)
10
11
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
···
26
}
27
}
28
29
-
sanitizer := markup.NewSanitizer()
30
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
31
return fmt.Errorf("body is empty after HTML sanitization")
32
}
33
···
5
"strings"
6
7
"tangled.sh/tangled.sh/core/appview/db"
8
)
9
10
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
···
25
}
26
}
27
28
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
29
+
return fmt.Errorf("body is empty after HTML sanitization")
30
+
}
31
+
32
+
return nil
33
+
}
34
+
35
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
+
if issue.Title == "" {
37
+
return fmt.Errorf("issue title is empty")
38
+
}
39
+
40
+
if issue.Body == "" {
41
+
return fmt.Errorf("issue body is empty")
42
+
}
43
+
44
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
45
+
return fmt.Errorf("title is empty after HTML sanitization")
46
+
}
47
+
48
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
49
return fmt.Errorf("body is empty after HTML sanitization")
50
}
51
+8
-3
appview/validator/validator.go
+8
-3
appview/validator/validator.go
···
1
package validator
2
3
+
import (
4
+
"tangled.sh/tangled.sh/core/appview/db"
5
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
+
)
7
8
type Validator struct {
9
+
db *db.DB
10
+
sanitizer markup.Sanitizer
11
}
12
13
func New(db *db.DB) *Validator {
14
return &Validator{
15
+
db: db,
16
+
sanitizer: markup.NewSanitizer(),
17
}
18
}
+1
-1
input.css
+1
-1
input.css