Monorepo for Tangled tangled.org

appview: allow editing issues

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li d7caef6a 045b2783

verified
+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
··· 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
··· 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
··· 29 r.Get("/reply", i.ReplyIssueComment) 30 r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 }) 32 r.Post("/close", i.CloseIssue) 33 r.Post("/reopen", i.ReopenIssue) 34 })
··· 29 r.Get("/reply", i.ReplyIssueComment) 30 r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 r.Post("/close", i.CloseIssue) 36 r.Post("/reopen", i.ReopenIssue) 37 })
+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
··· 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
··· 39 {{ end }} 40 </div> 41 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 </div> 44 {{ end }} 45
+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
··· 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
··· 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
···
··· 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
··· 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
··· 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
··· 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 {{ define "title" }}new issue &middot; {{ .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 }}
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + {{ template "repo/issues/fragments/putIssue" . }} 5 {{ end }}
+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
··· 1 package validator 2 3 - import "tangled.sh/tangled.sh/core/appview/db" 4 5 type Validator struct { 6 - db *db.DB 7 } 8 9 func New(db *db.DB) *Validator { 10 return &Validator{ 11 - db: db, 12 } 13 }
··· 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
··· 90 } 91 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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;
··· 90 } 91 92 label { 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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;