+111
-80
appview/issues/issues.go
+111
-80
appview/issues/issues.go
···
402
}
403
404
// rkey is optional, it was introduced later
405
-
if comment.Rkey != "" {
406
// update the record on pds
407
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
408
if err != nil {
409
-
// failed to get record
410
-
log.Println(err, rkey)
411
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
412
return
413
}
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
421
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
422
Collection: tangled.RepoIssueCommentNSID,
423
Repo: user.Did,
424
-
Rkey: rkey,
425
SwapRecord: ex.Cid,
426
Record: &lexutil.LexiconTypeDecoder{
427
-
Val: &tangled.RepoIssueComment{
428
-
Repo: &repoAt,
429
-
Issue: issueAt,
430
-
Owner: &comment.OwnerDid,
431
-
Body: newBody,
432
-
CreatedAt: createdAt,
433
-
},
434
},
435
})
436
if err != nil {
437
-
log.Println(err)
438
}
439
}
440
441
-
// optimistic update for htmx
442
-
comment.Body = newBody
443
-
comment.Edited = &edited
444
-
445
// return new comment body with htmx
446
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
447
LoggedInUser: user,
448
RepoInfo: f.RepoInfo(user),
449
Issue: issue,
450
-
Comment: comment,
451
})
452
-
return
453
-
454
}
455
456
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
457
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
···
540
user := rp.oauth.GetUser(r)
541
f, err := rp.repoResolver.Resolve(r)
542
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)
551
return
552
}
553
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.")
558
return
559
}
560
561
-
commentId := chi.URLParam(r, "comment_id")
562
-
commentIdInt, err := strconv.Atoi(commentId)
563
if err != nil {
564
-
http.Error(w, "bad comment id", http.StatusBadRequest)
565
-
log.Println("failed to parse issue id", err)
566
return
567
}
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)
572
return
573
}
574
575
-
if comment.OwnerDid != user.Did {
576
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
577
return
578
}
···
584
585
// optimistic deletion
586
deleted := time.Now()
587
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
588
if err != nil {
589
-
log.Println("failed to delete comment")
590
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
591
return
592
}
···
614
comment.Deleted = &deleted
615
616
// htmx fragment of comment after deletion
617
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
618
LoggedInUser: user,
619
RepoInfo: f.RepoInfo(user),
620
Issue: issue,
621
-
Comment: comment,
622
})
623
}
624
···
648
return
649
}
650
651
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
652
if err != nil {
653
log.Println("failed to get issues", err)
654
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
665
}
666
667
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
668
user := rp.oauth.GetUser(r)
669
670
f, err := rp.repoResolver.Resolve(r)
671
if err != nil {
672
-
log.Println("failed to get repo and knot", err)
673
return
674
}
675
···
698
return
699
}
700
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
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
719
}
720
721
client, err := rp.oauth.AuthorizedClient(r)
722
if err != nil {
723
-
log.Println("failed to get authorized client", err)
724
rp.pages.Notice(w, "issues", "Failed to create issue.")
725
return
726
}
727
-
atUri := f.RepoAt().String()
728
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
729
Collection: tangled.RepoIssueNSID,
730
Repo: user.Did,
731
Rkey: issue.Rkey,
732
Record: &lexutil.LexiconTypeDecoder{
733
-
Val: &tangled.RepoIssue{
734
-
Repo: atUri,
735
-
Title: title,
736
-
Body: &body,
737
-
},
738
},
739
})
740
if err != nil {
741
log.Println("failed to create issue", err)
742
rp.pages.Notice(w, "issues", "Failed to create issue.")
743
return
744
}
745
746
rp.notifier.NewIssue(r.Context(), issue)
747
-
748
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
749
return
750
}
751
}
···
402
}
403
404
// rkey is optional, it was introduced later
405
+
if newComment.Rkey != "" {
406
// update the record on pds
407
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
408
if err != nil {
409
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
410
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
411
return
412
}
413
414
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
415
Collection: tangled.RepoIssueCommentNSID,
416
Repo: user.Did,
417
+
Rkey: newComment.Rkey,
418
SwapRecord: ex.Cid,
419
Record: &lexutil.LexiconTypeDecoder{
420
+
Val: &record,
421
},
422
})
423
if err != nil {
424
+
l.Error("failed to update record on PDS", "err", err)
425
}
426
}
427
428
// return new comment body with htmx
429
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
430
LoggedInUser: user,
431
RepoInfo: f.RepoInfo(user),
432
Issue: issue,
433
+
Comment: &newComment,
434
})
435
}
436
+
}
437
438
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
439
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
···
522
user := rp.oauth.GetUser(r)
523
f, err := rp.repoResolver.Resolve(r)
524
if err != nil {
525
+
l.Error("failed to get repo and knot", "err", err)
526
return
527
}
528
529
+
issue, ok := r.Context().Value("issue").(*db.Issue)
530
+
if !ok {
531
+
l.Error("failed to get issue")
532
+
rp.pages.Error404(w)
533
return
534
}
535
536
+
commentId := chi.URLParam(r, "commentId")
537
+
comments, err := db.GetIssueComments(
538
+
rp.db,
539
+
db.FilterEq("id", commentId),
540
+
)
541
if err != nil {
542
+
l.Error("failed to fetch comment", "id", commentId)
543
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
544
return
545
}
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)
549
return
550
}
551
+
comment := comments[0]
552
553
+
if comment.Did != user.Did {
554
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
555
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
556
return
557
}
···
563
564
// optimistic deletion
565
deleted := time.Now()
566
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
567
if err != nil {
568
+
l.Error("failed to delete comment", "err", err)
569
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
570
return
571
}
···
593
comment.Deleted = &deleted
594
595
// htmx fragment of comment after deletion
596
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
597
LoggedInUser: user,
598
RepoInfo: f.RepoInfo(user),
599
Issue: issue,
600
+
Comment: &comment,
601
})
602
}
603
···
627
return
628
}
629
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
+
)
640
if err != nil {
641
log.Println("failed to get issues", err)
642
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
653
}
654
655
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
656
+
l := rp.logger.With("handler", "NewIssue")
657
user := rp.oauth.GetUser(r)
658
659
f, err := rp.repoResolver.Resolve(r)
660
if err != nil {
661
+
l.Error("failed to get repo and knot", "err", err)
662
return
663
}
664
···
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
701
client, err := rp.oauth.AuthorizedClient(r)
702
if err != nil {
703
+
l.Error("failed to get authorized client", "err", err)
704
rp.pages.Notice(w, "issues", "Failed to create issue.")
705
return
706
}
707
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
708
Collection: tangled.RepoIssueNSID,
709
Repo: user.Did,
710
Rkey: issue.Rkey,
711
Record: &lexutil.LexiconTypeDecoder{
712
+
Val: &record,
713
},
714
})
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 {
743
log.Println("failed to create issue", err)
744
rp.pages.Notice(w, "issues", "Failed to create issue.")
745
return
746
}
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 = ""
756
rp.notifier.NewIssue(r.Context(), issue)
757
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
758
return
759
}
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
RepoInfo repoinfo.RepoInfo
910
Active string
911
Issue *db.Issue
912
-
Comments []db.Comment
913
IssueOwnerHandle string
914
915
OrderedReactionKinds []db.ReactionKind
···
955
LoggedInUser *oauth.User
956
RepoInfo repoinfo.RepoInfo
957
Issue *db.Issue
958
-
Comment *db.Comment
959
}
960
961
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
962
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
963
}
964
965
-
type SingleIssueCommentParams struct {
966
LoggedInUser *oauth.User
967
RepoInfo repoinfo.RepoInfo
968
Issue *db.Issue
969
-
Comment *db.Comment
970
}
971
972
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
973
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
974
}
975
976
type RepoNewPullParams struct {
···
909
RepoInfo repoinfo.RepoInfo
910
Active string
911
Issue *db.Issue
912
+
CommentList []db.CommentListItem
913
IssueOwnerHandle string
914
915
OrderedReactionKinds []db.ReactionKind
···
955
LoggedInUser *oauth.User
956
RepoInfo repoinfo.RepoInfo
957
Issue *db.Issue
958
+
Comment *db.IssueComment
959
}
960
961
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
962
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
963
}
964
965
+
type ReplyIssueCommentPlaceholderParams struct {
966
LoggedInUser *oauth.User
967
RepoInfo repoinfo.RepoInfo
968
Issue *db.Issue
969
+
Comment *db.IssueComment
970
}
971
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)
996
}
997
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
</div>
33
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
opened by
35
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandleLink" $owner }}
37
<span class="select-none before:content-['\00B7']"></span>
38
{{ template "repo/fragments/time" .Issue.Created }}
39
</span>
···
62
{{ end }}
63
64
{{ 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>
77
78
-
{{ block "newComment" . }} {{ end }}
79
-
80
{{ end }}
81
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 }}
···
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>
···
61
{{ end }}
62
63
{{ define "repoAfter" }}
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
+
}}
73
74
+
{{ template "repo/issues/fragments/newComment" . }}
75
+
<div>
76
{{ end }}
77
+42
-44
appview/pages/templates/repo/issues/issues.html
+42
-44
appview/pages/templates/repo/issues/issues.html
···
37
{{ end }}
38
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 }}
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>
66
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
69
-
</span>
70
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
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>
83
</div>
84
-
{{ end }}
85
-
</div>
86
-
87
-
{{ block "pagination" . }} {{ end }}
88
-
89
{{ end }}
90
91
{{ define "pagination" }}
···
37
{{ end }}
38
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 }}
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>
66
67
+
<span class="ml-1">
68
+
{{ template "user/fragments/picHandleLink" .Did }}
69
+
</span>
70
71
+
<span class="before:content-['·']">
72
+
{{ template "repo/fragments/time" .Created }}
73
+
</span>
74
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 }}
85
</div>
86
+
{{ block "pagination" . }} {{ end }}
87
{{ end }}
88
89
{{ define "pagination" }}
+2
-2
appview/pages/templates/repo/issues/new.html
+2
-2
appview/pages/templates/repo/issues/new.html
···
3
{{ define "repoContent" }}
4
<form
5
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="mt-6 space-y-6"
7
hx-swap="none"
8
hx-indicator="#spinner"
9
>
···
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="create-pull-spinner" class="group">
30
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
</span>
32
</button>
···
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
>
···
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>