+31
-26
appview/db/issues.go
+31
-26
appview/db/issues.go
···
46
46
}
47
47
}
48
48
49
+
func (i *Issue) State() string {
50
+
if i.Open {
51
+
return "open"
52
+
}
53
+
return "closed"
54
+
}
55
+
49
56
type CommentListItem struct {
50
57
Self *IssueComment
51
58
Replies []*IssueComment
···
170
177
return &comment, nil
171
178
}
172
179
173
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
180
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
174
181
// ensure sequence exists
175
182
_, err := tx.Exec(`
176
183
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
180
187
return err
181
188
}
182
189
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
+
issues, err := GetIssues(
191
+
tx,
192
+
FilterEq("did", issue.Did),
193
+
FilterEq("rkey", issue.Rkey),
194
+
)
190
195
switch {
191
-
case err == sql.ErrNoRows:
192
-
return createNewIssue(tx, issue)
193
-
194
196
case err != nil:
195
197
return err
196
-
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))
197
202
default:
198
-
// Case 3: Issue exists - update it
199
-
return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64))
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)
200
212
}
201
213
}
202
214
···
223
235
return row.Scan(&issue.Id, &issue.IssueId)
224
236
}
225
237
226
-
func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error {
238
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
227
239
// update existing issue
228
240
_, err := tx.Exec(`
229
-
update issues
230
-
set title = ?, body = ?
241
+
update issues
242
+
set title = ?, body = ?, edited = ?
231
243
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
244
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
245
+
return err
241
246
}
242
247
243
248
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+3
-9
appview/ingester.go
+3
-9
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
-
"strings"
9
8
10
9
"time"
11
10
···
16
15
"tangled.sh/tangled.sh/core/api/tangled"
17
16
"tangled.sh/tangled.sh/core/appview/config"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
18
"tangled.sh/tangled.sh/core/appview/serververify"
21
19
"tangled.sh/tangled.sh/core/appview/validator"
22
20
"tangled.sh/tangled.sh/core/idresolver"
···
804
802
805
803
issue := db.IssueFromRecord(did, rkey, record)
806
804
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")
805
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
+
return fmt.Errorf("failed to validate issue: %w", err)
813
807
}
814
808
815
809
tx, err := ddb.BeginTx(ctx, nil)
···
819
813
}
820
814
defer tx.Rollback()
821
815
822
-
err = db.NewIssue(tx, &issue)
816
+
err = db.PutIssue(tx, &issue)
823
817
if err != nil {
824
818
l.Error("failed to create issue", "err", err)
825
819
return err
+130
-23
appview/issues/issues.go
+130
-23
appview/issues/issues.go
···
9
9
"log/slog"
10
10
"net/http"
11
11
"slices"
12
-
"strings"
13
12
"time"
14
13
15
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
23
22
"tangled.sh/tangled.sh/core/appview/notify"
24
23
"tangled.sh/tangled.sh/core/appview/oauth"
25
24
"tangled.sh/tangled.sh/core/appview/pages"
26
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
27
25
"tangled.sh/tangled.sh/core/appview/pagination"
28
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
29
27
"tangled.sh/tangled.sh/core/appview/validator"
···
103
101
Reactions: reactionCountMap,
104
102
UserReacted: userReactions,
105
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
+
}
106
224
}
107
225
108
226
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
669
787
RepoInfo: f.RepoInfo(user),
670
788
})
671
789
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
790
issue := &db.Issue{
691
791
RepoAt: f.RepoAt(),
692
792
Rkey: tid.TID(),
693
-
Title: title,
694
-
Body: body,
793
+
Title: r.FormValue("title"),
794
+
Body: r.FormValue("body"),
695
795
Did: user.Did,
696
796
Created: time.Now(),
697
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
+
698
805
record := issue.AsRecord()
699
806
700
807
// create an atproto record
···
738
845
}
739
846
defer rollback()
740
847
741
-
err = db.NewIssue(tx, issue)
848
+
err = db.PutIssue(tx, issue)
742
849
if err != nil {
743
850
log.Println("failed to create issue", err)
744
851
rp.pages.Notice(w, "issues", "Failed to create issue.")
+3
appview/issues/router.go
+3
appview/issues/router.go
···
29
29
r.Get("/reply", i.ReplyIssueComment)
30
30
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
31
31
})
32
+
r.Get("/edit", i.EditIssue)
33
+
r.Post("/edit", i.EditIssue)
34
+
r.Delete("/", i.DeleteIssue)
32
35
r.Post("/close", i.CloseIssue)
33
36
r.Post("/reopen", i.ReopenIssue)
34
37
})
+19
-11
appview/pages/pages.go
+19
-11
appview/pages/pages.go
···
886
886
OrderedReactionKinds []db.ReactionKind
887
887
Reactions map[db.ReactionKind]int
888
888
UserReacted map[db.ReactionKind]bool
889
+
}
889
890
890
-
State string
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)
891
906
}
892
907
893
908
type ThreadReactionFragmentParams struct {
···
901
916
return p.executePlain("repo/fragments/reaction", w, params)
902
917
}
903
918
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
919
type RepoNewIssueParams struct {
915
920
LoggedInUser *oauth.User
916
921
RepoInfo repoinfo.RepoInfo
922
+
Issue *db.Issue // existing issue if any -- passed when editing
917
923
Active string
924
+
Action string
918
925
}
919
926
920
927
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
921
928
params.Active = "issues"
929
+
params.Action = "create"
922
930
return p.executeRepo("repo/issues/new", w, params)
923
931
}
924
932
+1
-7
appview/pages/templates/repo/issues/fragments/commentList.html
+1
-7
appview/pages/templates/repo/issues/fragments/commentList.html
···
39
39
{{ end }}
40
40
</div>
41
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 }}
42
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
49
43
</div>
50
44
{{ end }}
51
45
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
11
11
{{ define "edit" }}
12
12
<a
13
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"
14
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
15
15
hx-swap="outerHTML"
16
16
hx-target="#comment-body-{{.Comment.Id}}">
17
17
{{ i "pencil" "size-3" }}
···
22
22
{{ define "delete" }}
23
23
<a
24
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 }}/"
25
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
26
26
hx-confirm="Are you sure you want to delete your comment?"
27
27
hx-swap="outerHTML"
28
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
5
{{ template "timestamp" . }}
6
6
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
7
7
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
-
{{ template "edit" . }}
9
-
{{ template "delete" . }}
8
+
{{ template "editIssueComment" . }}
9
+
{{ template "deleteIssueComment" . }}
10
10
{{ end }}
11
11
</div>
12
12
{{ end }}
···
32
32
</a>
33
33
{{ end }}
34
34
35
-
{{ define "edit" }}
35
+
{{ define "editIssueComment" }}
36
36
<a
37
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"
38
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
41
41
{{ i "pencil" "size-3" }}
42
42
</a>
43
43
{{ end }}
44
44
45
-
{{ define "delete" }}
45
+
{{ define "deleteIssueComment" }}
46
46
<a
47
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 }}/"
48
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
51
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
38
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
39
39
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
40
40
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
41
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
41
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
42
42
<button
43
43
id="close-button"
44
44
type="button"
···
84
84
}
85
85
});
86
86
</script>
87
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
87
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
88
88
<button
89
89
type="button"
90
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
4
id="reply-form-{{ .Comment.Id }}"
5
5
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
6
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
7
8
>
8
9
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
9
10
<textarea
···
12
13
class="w-full p-2"
13
14
placeholder="Leave a reply..."
14
15
autofocus
15
-
rows="3"></textarea>
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>
16
21
17
22
<input
18
23
type="text"
···
48
53
<button
49
54
id="reply-{{ .Comment.Id }}"
50
55
type="submit"
51
-
hx-disabled-elt="#reply-{{ .Comment.Id }}"
52
56
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
53
57
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
54
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
1
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
2
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
-
/>
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 }}
8
10
<input
9
11
class="w-full py-2 border-none focus:outline-none"
10
12
placeholder="Leave a reply..."
+86
-44
appview/pages/templates/repo/issues/issue.html
+86
-44
appview/pages/templates/repo/issues/issue.html
···
9
9
{{ end }}
10
10
11
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>
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>
18
54
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" }}
55
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
56
+
{{ template "issueActions" . }}
24
57
{{ end }}
58
+
</div>
59
+
{{ end }}
25
60
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>
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 }}
40
75
41
-
{{ if .Issue.Body }}
42
-
<article id="body" class="mt-4 prose dark:prose-invert">
43
-
{{ .Issue.Body | markdown }}
44
-
</article>
45
-
{{ end }}
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 }}
46
88
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>
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>
61
103
{{ end }}
62
104
63
105
{{ define "repoAfter" }}
+1
-33
appview/pages/templates/repo/issues/new.html
+1
-33
appview/pages/templates/repo/issues/new.html
···
1
1
{{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
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>
4
+
{{ template "repo/issues/fragments/putIssue" . }}
37
5
{{ end }}
+21
-3
appview/validator/issue.go
+21
-3
appview/validator/issue.go
···
5
5
"strings"
6
6
7
7
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
9
8
)
10
9
11
10
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
···
26
25
}
27
26
}
28
27
29
-
sanitizer := markup.NewSanitizer()
30
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
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 == "" {
31
49
return fmt.Errorf("body is empty after HTML sanitization")
32
50
}
33
51
+8
-3
appview/validator/validator.go
+8
-3
appview/validator/validator.go
···
1
1
package validator
2
2
3
-
import "tangled.sh/tangled.sh/core/appview/db"
3
+
import (
4
+
"tangled.sh/tangled.sh/core/appview/db"
5
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
+
)
4
7
5
8
type Validator struct {
6
-
db *db.DB
9
+
db *db.DB
10
+
sanitizer markup.Sanitizer
7
11
}
8
12
9
13
func New(db *db.DB) *Validator {
10
14
return &Validator{
11
-
db: db,
15
+
db: db,
16
+
sanitizer: markup.NewSanitizer(),
12
17
}
13
18
}
+1
-1
input.css
+1
-1
input.css
···
90
90
}
91
91
92
92
label {
93
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
93
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
94
94
}
95
95
input {
96
96
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;