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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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
··· 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
··· 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 1 {{ define "title" }}new issue &middot; {{ .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
··· 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
··· 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
··· 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;