+111
-80
appview/issues/issues.go
+111
-80
appview/issues/issues.go
···
402
402
}
403
403
404
404
// rkey is optional, it was introduced later
405
-
if comment.Rkey != "" {
405
+
if newComment.Rkey != "" {
406
406
// update the record on pds
407
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
407
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
408
408
if err != nil {
409
-
// failed to get record
410
-
log.Println(err, rkey)
409
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
411
410
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
412
411
return
413
412
}
414
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
415
-
record, _ := data.UnmarshalJSON(value)
416
-
417
-
repoAt := record["repo"].(string)
418
-
issueAt := record["issue"].(string)
419
-
createdAt := record["createdAt"].(string)
420
413
421
414
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
422
415
Collection: tangled.RepoIssueCommentNSID,
423
416
Repo: user.Did,
424
-
Rkey: rkey,
417
+
Rkey: newComment.Rkey,
425
418
SwapRecord: ex.Cid,
426
419
Record: &lexutil.LexiconTypeDecoder{
427
-
Val: &tangled.RepoIssueComment{
428
-
Repo: &repoAt,
429
-
Issue: issueAt,
430
-
Owner: &comment.OwnerDid,
431
-
Body: newBody,
432
-
CreatedAt: createdAt,
433
-
},
420
+
Val: &record,
434
421
},
435
422
})
436
423
if err != nil {
437
-
log.Println(err)
424
+
l.Error("failed to update record on PDS", "err", err)
438
425
}
439
426
}
440
427
441
-
// optimistic update for htmx
442
-
comment.Body = newBody
443
-
comment.Edited = &edited
444
-
445
428
// return new comment body with htmx
446
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
429
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
447
430
LoggedInUser: user,
448
431
RepoInfo: f.RepoInfo(user),
449
432
Issue: issue,
450
-
Comment: comment,
433
+
Comment: &newComment,
451
434
})
452
-
return
453
-
454
435
}
436
+
}
455
437
456
438
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
457
439
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
···
540
522
user := rp.oauth.GetUser(r)
541
523
f, err := rp.repoResolver.Resolve(r)
542
524
if err != nil {
543
-
return
544
-
}
545
-
546
-
issueId := chi.URLParam(r, "issue")
547
-
issueIdInt, err := strconv.Atoi(issueId)
548
-
if err != nil {
549
-
http.Error(w, "bad issue id", http.StatusBadRequest)
550
-
log.Println("failed to parse issue id", err)
525
+
l.Error("failed to get repo and knot", "err", err)
551
526
return
552
527
}
553
528
554
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
555
-
if err != nil {
556
-
log.Println("failed to get issue", err)
557
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
529
+
issue, ok := r.Context().Value("issue").(*db.Issue)
530
+
if !ok {
531
+
l.Error("failed to get issue")
532
+
rp.pages.Error404(w)
558
533
return
559
534
}
560
535
561
-
commentId := chi.URLParam(r, "comment_id")
562
-
commentIdInt, err := strconv.Atoi(commentId)
536
+
commentId := chi.URLParam(r, "commentId")
537
+
comments, err := db.GetIssueComments(
538
+
rp.db,
539
+
db.FilterEq("id", commentId),
540
+
)
563
541
if err != nil {
564
-
http.Error(w, "bad comment id", http.StatusBadRequest)
565
-
log.Println("failed to parse issue id", err)
542
+
l.Error("failed to fetch comment", "id", commentId)
543
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
566
544
return
567
545
}
568
-
569
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
570
-
if err != nil {
571
-
http.Error(w, "bad comment id", http.StatusBadRequest)
546
+
if len(comments) != 1 {
547
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
548
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
572
549
return
573
550
}
551
+
comment := comments[0]
574
552
575
-
if comment.OwnerDid != user.Did {
553
+
if comment.Did != user.Did {
554
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
576
555
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
577
556
return
578
557
}
···
584
563
585
564
// optimistic deletion
586
565
deleted := time.Now()
587
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
566
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
588
567
if err != nil {
589
-
log.Println("failed to delete comment")
568
+
l.Error("failed to delete comment", "err", err)
590
569
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
591
570
return
592
571
}
···
614
593
comment.Deleted = &deleted
615
594
616
595
// htmx fragment of comment after deletion
617
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
596
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
618
597
LoggedInUser: user,
619
598
RepoInfo: f.RepoInfo(user),
620
599
Issue: issue,
621
-
Comment: comment,
600
+
Comment: &comment,
622
601
})
623
602
}
624
603
···
648
627
return
649
628
}
650
629
651
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
630
+
openVal := 0
631
+
if isOpen {
632
+
openVal = 1
633
+
}
634
+
issues, err := db.GetIssuesPaginated(
635
+
rp.db,
636
+
page,
637
+
db.FilterEq("repo_at", f.RepoAt()),
638
+
db.FilterEq("open", openVal),
639
+
)
652
640
if err != nil {
653
641
log.Println("failed to get issues", err)
654
642
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
665
653
}
666
654
667
655
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
656
+
l := rp.logger.With("handler", "NewIssue")
668
657
user := rp.oauth.GetUser(r)
669
658
670
659
f, err := rp.repoResolver.Resolve(r)
671
660
if err != nil {
672
-
log.Println("failed to get repo and knot", err)
661
+
l.Error("failed to get repo and knot", "err", err)
673
662
return
674
663
}
675
664
···
698
687
return
699
688
}
700
689
701
-
tx, err := rp.db.BeginTx(r.Context(), nil)
702
-
if err != nil {
703
-
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
704
-
return
705
-
}
706
-
707
690
issue := &db.Issue{
708
-
RepoAt: f.RepoAt(),
709
-
Rkey: tid.TID(),
710
-
Title: title,
711
-
Body: body,
712
-
OwnerDid: user.Did,
713
-
}
714
-
err = db.NewIssue(tx, issue)
715
-
if err != nil {
716
-
log.Println("failed to create issue", err)
717
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
718
-
return
691
+
RepoAt: f.RepoAt(),
692
+
Rkey: tid.TID(),
693
+
Title: title,
694
+
Body: body,
695
+
Did: user.Did,
696
+
Created: time.Now(),
719
697
}
698
+
record := issue.AsRecord()
720
699
700
+
// create an atproto record
721
701
client, err := rp.oauth.AuthorizedClient(r)
722
702
if err != nil {
723
-
log.Println("failed to get authorized client", err)
703
+
l.Error("failed to get authorized client", "err", err)
724
704
rp.pages.Notice(w, "issues", "Failed to create issue.")
725
705
return
726
706
}
727
-
atUri := f.RepoAt().String()
728
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
707
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
729
708
Collection: tangled.RepoIssueNSID,
730
709
Repo: user.Did,
731
710
Rkey: issue.Rkey,
732
711
Record: &lexutil.LexiconTypeDecoder{
733
-
Val: &tangled.RepoIssue{
734
-
Repo: atUri,
735
-
Title: title,
736
-
Body: &body,
737
-
},
712
+
Val: &record,
738
713
},
739
714
})
740
715
if err != nil {
716
+
l.Error("failed to create issue", "err", err)
717
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
718
+
return
719
+
}
720
+
atUri := resp.Uri
721
+
722
+
tx, err := rp.db.BeginTx(r.Context(), nil)
723
+
if err != nil {
724
+
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
725
+
return
726
+
}
727
+
rollback := func() {
728
+
err1 := tx.Rollback()
729
+
err2 := rollbackRecord(context.Background(), atUri, client)
730
+
731
+
if errors.Is(err1, sql.ErrTxDone) {
732
+
err1 = nil
733
+
}
734
+
735
+
if err := errors.Join(err1, err2); err != nil {
736
+
l.Error("failed to rollback txn", "err", err)
737
+
}
738
+
}
739
+
defer rollback()
740
+
741
+
err = db.NewIssue(tx, issue)
742
+
if err != nil {
741
743
log.Println("failed to create issue", err)
742
744
rp.pages.Notice(w, "issues", "Failed to create issue.")
743
745
return
744
746
}
745
747
748
+
if err = tx.Commit(); err != nil {
749
+
log.Println("failed to create issue", err)
750
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
751
+
return
752
+
}
753
+
754
+
// everything is successful, do not rollback the atproto record
755
+
atUri = ""
746
756
rp.notifier.NewIssue(r.Context(), issue)
747
-
748
757
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
749
758
return
750
759
}
751
760
}
761
+
762
+
// this is used to rollback changes made to the PDS
763
+
//
764
+
// it is a no-op if the provided ATURI is empty
765
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
766
+
if aturi == "" {
767
+
return nil
768
+
}
769
+
770
+
parsed := syntax.ATURI(aturi)
771
+
772
+
collection := parsed.Collection().String()
773
+
repo := parsed.Authority().String()
774
+
rkey := parsed.RecordKey().String()
775
+
776
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
777
+
Collection: collection,
778
+
Repo: repo,
779
+
Rkey: rkey,
780
+
})
781
+
return err
782
+
}
+28
-6
appview/pages/pages.go
+28
-6
appview/pages/pages.go
···
909
909
RepoInfo repoinfo.RepoInfo
910
910
Active string
911
911
Issue *db.Issue
912
-
Comments []db.Comment
912
+
CommentList []db.CommentListItem
913
913
IssueOwnerHandle string
914
914
915
915
OrderedReactionKinds []db.ReactionKind
···
955
955
LoggedInUser *oauth.User
956
956
RepoInfo repoinfo.RepoInfo
957
957
Issue *db.Issue
958
-
Comment *db.Comment
958
+
Comment *db.IssueComment
959
959
}
960
960
961
961
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
962
962
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
963
963
}
964
964
965
-
type SingleIssueCommentParams struct {
965
+
type ReplyIssueCommentPlaceholderParams struct {
966
966
LoggedInUser *oauth.User
967
967
RepoInfo repoinfo.RepoInfo
968
968
Issue *db.Issue
969
-
Comment *db.Comment
969
+
Comment *db.IssueComment
970
970
}
971
971
972
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
973
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
972
+
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
973
+
return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
974
+
}
975
+
976
+
type ReplyIssueCommentParams struct {
977
+
LoggedInUser *oauth.User
978
+
RepoInfo repoinfo.RepoInfo
979
+
Issue *db.Issue
980
+
Comment *db.IssueComment
981
+
}
982
+
983
+
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
984
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
985
+
}
986
+
987
+
type IssueCommentBodyParams struct {
988
+
LoggedInUser *oauth.User
989
+
RepoInfo repoinfo.RepoInfo
990
+
Issue *db.Issue
991
+
Comment *db.IssueComment
992
+
}
993
+
994
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
995
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
974
996
}
975
997
976
998
type RepoNewPullParams struct {
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
1
+
{{ define "repo/issues/fragments/issueCommentActions" }}
2
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
3
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
4
+
<div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2">
5
+
{{ template "edit" . }}
6
+
{{ template "delete" . }}
7
+
</div>
8
+
{{ end }}
9
+
{{ end }}
10
+
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" }}
18
+
edit
19
+
</a>
20
+
{{ end }}
21
+
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}}"
29
+
>
30
+
{{ i "trash-2" "size-3" }}
31
+
delete
32
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</a>
34
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/replyComment.html
+57
appview/pages/templates/repo/issues/fragments/replyComment.html
···
1
+
{{ define "repo/issues/fragments/replyComment" }}
2
+
<form
3
+
class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2"
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
10
+
id="reply-{{.Comment.Id}}-textarea"
11
+
name="body"
12
+
class="w-full p-2"
13
+
placeholder="Leave a reply..."
14
+
autofocus
15
+
rows="3"></textarea>
16
+
17
+
<input
18
+
type="text"
19
+
id="reply-to"
20
+
name="reply-to"
21
+
required
22
+
value="{{ .Comment.AtUri }}"
23
+
class="hidden"
24
+
/>
25
+
{{ template "replyActions" . }}
26
+
</form>
27
+
{{ end }}
28
+
29
+
{{ define "replyActions" }}
30
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
31
+
{{ template "cancel" . }}
32
+
{{ template "reply" . }}
33
+
</div>
34
+
{{ end }}
35
+
36
+
{{ define "cancel" }}
37
+
<button
38
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
39
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder"
40
+
hx-target="#reply-form-{{ .Comment.Id }}"
41
+
hx-swap="outerHTML">
42
+
{{ i "x" "size-4" }}
43
+
cancel
44
+
</button>
45
+
{{ end }}
46
+
47
+
{{ define "reply" }}
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" }}
55
+
reply
56
+
</button>
57
+
{{ end }}
+12
-160
appview/pages/templates/repo/issues/issue.html
+12
-160
appview/pages/templates/repo/issues/issue.html
···
32
32
</div>
33
33
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
34
opened by
35
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandleLink" $owner }}
35
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
37
36
<span class="select-none before:content-['\00B7']"></span>
38
37
{{ template "repo/fragments/time" .Issue.Created }}
39
38
</span>
···
62
61
{{ end }}
63
62
64
63
{{ define "repoAfter" }}
65
-
<section id="comments" class="my-2 mt-2 space-y-2 relative">
66
-
{{ range $index, $comment := .Comments }}
67
-
<div
68
-
id="comment-{{ .CommentId }}"
69
-
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
70
-
{{ if gt $index 0 }}
71
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
72
-
{{ end }}
73
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
74
-
</div>
75
-
{{ end }}
76
-
</section>
64
+
<div class="flex flex-col gap-4 mt-4">
65
+
{{
66
+
template "repo/issues/fragments/commentList"
67
+
(dict
68
+
"RepoInfo" $.RepoInfo
69
+
"LoggedInUser" $.LoggedInUser
70
+
"Issue" $.Issue
71
+
"CommentList" $.Issue.CommentList)
72
+
}}
77
73
78
-
{{ block "newComment" . }} {{ end }}
79
-
74
+
{{ template "repo/issues/fragments/newComment" . }}
75
+
<div>
80
76
{{ end }}
81
77
82
-
{{ define "newComment" }}
83
-
{{ if .LoggedInUser }}
84
-
<form
85
-
id="comment-form"
86
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
87
-
hx-on::after-request="if(event.detail.successful) this.reset()"
88
-
>
89
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
90
-
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
91
-
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
92
-
</div>
93
-
<textarea
94
-
id="comment-textarea"
95
-
name="body"
96
-
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
97
-
placeholder="Add to the discussion. Markdown is supported."
98
-
onkeyup="updateCommentForm()"
99
-
></textarea>
100
-
<div id="issue-comment"></div>
101
-
<div id="issue-action" class="error"></div>
102
-
</div>
103
-
104
-
<div class="flex gap-2 mt-2">
105
-
<button
106
-
id="comment-button"
107
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
108
-
type="submit"
109
-
hx-disabled-elt="#comment-button"
110
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
111
-
disabled
112
-
>
113
-
{{ i "message-square-plus" "w-4 h-4" }}
114
-
comment
115
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
116
-
</button>
117
-
118
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
119
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
120
-
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
121
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
122
-
<button
123
-
id="close-button"
124
-
type="button"
125
-
class="btn flex items-center gap-2"
126
-
hx-indicator="#close-spinner"
127
-
hx-trigger="click"
128
-
>
129
-
{{ i "ban" "w-4 h-4" }}
130
-
close
131
-
<span id="close-spinner" class="group">
132
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
133
-
</span>
134
-
</button>
135
-
<div
136
-
id="close-with-comment"
137
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
138
-
hx-trigger="click from:#close-button"
139
-
hx-disabled-elt="#close-with-comment"
140
-
hx-target="#issue-comment"
141
-
hx-indicator="#close-spinner"
142
-
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
143
-
hx-swap="none"
144
-
>
145
-
</div>
146
-
<div
147
-
id="close-issue"
148
-
hx-disabled-elt="#close-issue"
149
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
150
-
hx-trigger="click from:#close-button"
151
-
hx-target="#issue-action"
152
-
hx-indicator="#close-spinner"
153
-
hx-swap="none"
154
-
>
155
-
</div>
156
-
<script>
157
-
document.addEventListener('htmx:configRequest', function(evt) {
158
-
if (evt.target.id === 'close-with-comment') {
159
-
const commentText = document.getElementById('comment-textarea').value.trim();
160
-
if (commentText === '') {
161
-
evt.detail.parameters = {};
162
-
evt.preventDefault();
163
-
}
164
-
}
165
-
});
166
-
</script>
167
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
168
-
<button
169
-
type="button"
170
-
class="btn flex items-center gap-2"
171
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
172
-
hx-indicator="#reopen-spinner"
173
-
hx-swap="none"
174
-
>
175
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
176
-
reopen
177
-
<span id="reopen-spinner" class="group">
178
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
179
-
</span>
180
-
</button>
181
-
{{ end }}
182
-
183
-
<script>
184
-
function updateCommentForm() {
185
-
const textarea = document.getElementById('comment-textarea');
186
-
const commentButton = document.getElementById('comment-button');
187
-
const closeButton = document.getElementById('close-button');
188
-
189
-
if (textarea.value.trim() !== '') {
190
-
commentButton.removeAttribute('disabled');
191
-
} else {
192
-
commentButton.setAttribute('disabled', '');
193
-
}
194
-
195
-
if (closeButton) {
196
-
if (textarea.value.trim() !== '') {
197
-
closeButton.innerHTML = `
198
-
{{ i "ban" "w-4 h-4" }}
199
-
<span>close with comment</span>
200
-
<span id="close-spinner" class="group">
201
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
202
-
</span>`;
203
-
} else {
204
-
closeButton.innerHTML = `
205
-
{{ i "ban" "w-4 h-4" }}
206
-
<span>close</span>
207
-
<span id="close-spinner" class="group">
208
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
209
-
</span>`;
210
-
}
211
-
}
212
-
}
213
-
214
-
document.addEventListener('DOMContentLoaded', function() {
215
-
updateCommentForm();
216
-
});
217
-
</script>
218
-
</div>
219
-
</form>
220
-
{{ else }}
221
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
222
-
<a href="/login" class="underline">login</a> to join the discussion
223
-
</div>
224
-
{{ end }}
225
-
{{ end }}
+42
-44
appview/pages/templates/repo/issues/issues.html
+42
-44
appview/pages/templates/repo/issues/issues.html
···
37
37
{{ end }}
38
38
39
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
40
+
<div class="flex flex-col gap-2 mt-2">
41
+
{{ range .Issues }}
42
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
+
<div class="pb-2">
44
+
<a
45
+
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
+
class="no-underline hover:underline"
47
+
>
48
+
{{ .Title | description }}
49
+
<span class="text-gray-500">#{{ .IssueId }}</span>
50
+
</a>
51
+
</div>
52
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
+
{{ $icon := "ban" }}
55
+
{{ $state := "closed" }}
56
+
{{ if .Open }}
57
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
+
{{ $icon = "circle-dot" }}
59
+
{{ $state = "open" }}
60
+
{{ end }}
61
61
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
62
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
+
<span class="text-white dark:text-white">{{ $state }}</span>
65
+
</span>
66
66
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
69
-
</span>
67
+
<span class="ml-1">
68
+
{{ template "user/fragments/picHandleLink" .Did }}
69
+
</span>
70
70
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
71
+
<span class="before:content-['·']">
72
+
{{ template "repo/fragments/time" .Created }}
73
+
</span>
74
74
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq .Metadata.CommentCount 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
81
-
</span>
82
-
</p>
75
+
<span class="before:content-['·']">
76
+
{{ $s := "s" }}
77
+
{{ if eq (len .Comments) 1 }}
78
+
{{ $s = "" }}
79
+
{{ end }}
80
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
+
</span>
82
+
</p>
83
+
</div>
84
+
{{ end }}
83
85
</div>
84
-
{{ end }}
85
-
</div>
86
-
87
-
{{ block "pagination" . }} {{ end }}
88
-
86
+
{{ block "pagination" . }} {{ end }}
89
87
{{ end }}
90
88
91
89
{{ define "pagination" }}
+2
-2
appview/pages/templates/repo/issues/new.html
+2
-2
appview/pages/templates/repo/issues/new.html
···
3
3
{{ define "repoContent" }}
4
4
<form
5
5
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="mt-6 space-y-6"
6
+
class="space-y-6"
7
7
hx-swap="none"
8
8
hx-indicator="#spinner"
9
9
>
···
26
26
<button type="submit" class="btn-create flex items-center gap-2">
27
27
{{ i "circle-plus" "w-4 h-4" }}
28
28
create issue
29
-
<span id="create-pull-spinner" class="group">
29
+
<span id="spinner" class="group">
30
30
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
31
</span>
32
32
</button>