this repo has no description

appview/issues: rework issues to be better (tm)

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

oppi.li 8dae8b4d 51610149

verified
+767 -581
+345 -266
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 12 "strings" 11 13 "time" 12 14 13 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 15 17 lexutil "github.com/bluesky-social/indigo/lex/util" 16 18 "github.com/go-chi/chi/v5" 17 19 ··· 24 26 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 27 "tangled.sh/tangled.sh/core/appview/pagination" 26 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 + "tangled.sh/tangled.sh/core/appview/validator" 30 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 31 "tangled.sh/tangled.sh/core/idresolver" 32 + tlog "tangled.sh/tangled.sh/core/log" 28 33 "tangled.sh/tangled.sh/core/tid" 29 34 ) 30 35 ··· 36 41 db *db.DB 37 42 config *config.Config 38 43 notifier notify.Notifier 44 + logger *slog.Logger 45 + validator *validator.Validator 39 46 } 40 47 41 48 func New( ··· 55 62 db: db, 56 63 config: config, 57 64 notifier: notifier, 65 + logger: tlog.New("issues"), 66 + validator: validator, 58 67 } 59 68 } 60 69 61 70 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 71 + l := rp.logger.With("handler", "RepoSingleIssue") 62 72 user := rp.oauth.GetUser(r) 63 73 f, err := rp.repoResolver.Resolve(r) 64 74 if err != nil { ··· 66 76 return 67 77 } 68 78 69 - issueId := chi.URLParam(r, "issue") 70 - issueIdInt, err := strconv.Atoi(issueId) 71 - if err != nil { 72 - http.Error(w, "bad issue id", http.StatusBadRequest) 73 - log.Println("failed to parse issue id", err) 74 - return 75 - } 76 - 77 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 - if err != nil { 79 - log.Println("failed to get issue and comments", err) 80 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 + issue, ok := r.Context().Value("issue").(*db.Issue) 80 + if !ok { 81 + l.Error("failed to get issue") 82 + rp.pages.Error404(w) 81 83 return 82 84 } 83 85 84 86 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 87 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 88 + l.Error("failed to get issue reactions", "err", err) 88 89 } 89 90 90 91 userReactions := map[db.ReactionKind]bool{} ··· 92 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 94 } 94 95 95 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 - if err != nil { 97 - log.Println("failed to resolve issue owner", err) 98 - } 99 - 100 96 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 - LoggedInUser: user, 102 - RepoInfo: f.RepoInfo(user), 103 - Issue: issue, 104 - Comments: comments, 105 - 106 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 107 - 97 + LoggedInUser: user, 98 + RepoInfo: f.RepoInfo(user), 99 + Issue: issue, 100 + CommentList: issue.CommentList(), 108 101 OrderedReactionKinds: db.OrderedReactionKinds, 109 102 Reactions: reactionCountMap, 110 103 UserReacted: userReactions, ··· 113 106 } 114 107 115 108 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 109 + l := rp.logger.With("handler", "CloseIssue") 116 110 user := rp.oauth.GetUser(r) 117 111 f, err := rp.repoResolver.Resolve(r) 118 112 if err != nil { 119 - log.Println("failed to get repo and knot", err) 120 - return 121 - } 122 - 123 - issueId := chi.URLParam(r, "issue") 124 - issueIdInt, err := strconv.Atoi(issueId) 125 - if err != nil { 126 - http.Error(w, "bad issue id", http.StatusBadRequest) 127 - log.Println("failed to parse issue id", err) 113 + l.Error("failed to get repo and knot", "err", err) 128 114 return 129 115 } 130 116 131 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 - if err != nil { 133 - log.Println("failed to get issue", err) 134 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 117 + issue, ok := r.Context().Value("issue").(*db.Issue) 118 + if !ok { 119 + l.Error("failed to get issue") 120 + rp.pages.Error404(w) 135 121 return 136 122 } 137 123 ··· 142 128 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 143 129 return user.Did == collab.Did 144 130 }) 145 - isIssueOwner := user.Did == issue.OwnerDid 131 + isIssueOwner := user.Did == issue.Did 146 132 147 133 // TODO: make this more granular 148 134 if isIssueOwner || isCollaborator { 149 - 150 - closed := tangled.RepoIssueStateClosed 151 - 152 - client, err := rp.oauth.AuthorizedClient(r) 153 - if err != nil { 154 - log.Println("failed to get authorized client", err) 155 - return 156 - } 157 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 - Repo: user.Did, 160 - Rkey: tid.TID(), 161 - Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 166 - }, 167 - }) 168 - 169 - if err != nil { 170 - log.Println("failed to update issue state", err) 171 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 172 - return 173 - } 174 - 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 135 + err = db.CloseIssues( 136 + rp.db, 137 + db.FilterEq("id", issue.Id), 138 + ) 176 139 if err != nil { 177 140 log.Println("failed to close issue", err) 178 141 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 142 return 180 143 } 181 144 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 145 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 183 146 return 184 147 } else { 185 148 log.Println("user is not permitted to close issue") ··· 189 152 } 190 153 191 154 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 155 + l := rp.logger.With("handler", "ReopenIssue") 192 156 user := rp.oauth.GetUser(r) 193 157 f, err := rp.repoResolver.Resolve(r) 194 158 if err != nil { ··· 196 160 return 197 161 } 198 162 199 - issueId := chi.URLParam(r, "issue") 200 - issueIdInt, err := strconv.Atoi(issueId) 201 - if err != nil { 202 - http.Error(w, "bad issue id", http.StatusBadRequest) 203 - log.Println("failed to parse issue id", err) 204 - return 205 - } 206 - 207 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 - if err != nil { 209 - log.Println("failed to get issue", err) 210 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 163 + issue, ok := r.Context().Value("issue").(*db.Issue) 164 + if !ok { 165 + l.Error("failed to get issue") 166 + rp.pages.Error404(w) 211 167 return 212 168 } 213 169 ··· 218 174 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 175 return user.Did == collab.Did 220 176 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 177 + isIssueOwner := user.Did == issue.Did 222 178 223 179 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 180 + err := db.ReopenIssues( 181 + rp.db, 182 + db.FilterEq("id", issue.Id), 183 + ) 225 184 if err != nil { 226 185 log.Println("failed to reopen issue", err) 227 186 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 187 return 229 188 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 189 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 231 190 return 232 191 } else { 233 192 log.Println("user is not the owner of the repo") ··· 237 196 } 238 197 239 198 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 199 + l := rp.logger.With("handler", "NewIssueComment") 240 200 user := rp.oauth.GetUser(r) 241 201 f, err := rp.repoResolver.Resolve(r) 242 202 if err != nil { 243 - log.Println("failed to get repo and knot", err) 203 + l.Error("failed to get repo and knot", "err", err) 244 204 return 245 205 } 246 206 247 - issueId := chi.URLParam(r, "issue") 248 - issueIdInt, err := strconv.Atoi(issueId) 249 - if err != nil { 250 - http.Error(w, "bad issue id", http.StatusBadRequest) 251 - log.Println("failed to parse issue id", err) 207 + issue, ok := r.Context().Value("issue").(*db.Issue) 208 + if !ok { 209 + l.Error("failed to get issue") 210 + rp.pages.Error404(w) 252 211 return 253 212 } 254 213 255 - switch r.Method { 256 - case http.MethodPost: 257 - body := r.FormValue("body") 258 - if body == "" { 259 - rp.pages.Notice(w, "issue", "Body is required") 260 - return 261 - } 262 - 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 214 + body := r.FormValue("body") 215 + if body == "" { 216 + rp.pages.Notice(w, "issue", "Body is required") 217 + return 218 + } 265 219 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 - OwnerDid: user.Did, 268 - RepoAt: f.RepoAt(), 269 - Issue: issueIdInt, 270 - CommentId: commentId, 271 - Body: body, 272 - Rkey: rkey, 273 - }) 220 + replyToUri := r.FormValue("reply-to") 221 + var replyTo *string 222 + if replyToUri != "" { 223 + uri, err := syntax.ParseATURI(replyToUri) 274 224 if err != nil { 275 - log.Println("failed to create comment", err) 225 + l.Error("failed to get parse replyTo", "err", err, "replyTo", replyToUri) 276 226 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 277 227 return 278 228 } 279 - 280 - createdAt := time.Now().Format(time.RFC3339) 281 - ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 - if err != nil { 284 - log.Println("failed to get issue at", err) 229 + if uri.Collection() != tangled.RepoIssueCommentNSID { 230 + l.Error("invalid replyTo collection", "collection", uri.Collection()) 285 231 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 232 return 287 233 } 234 + u := uri.String() 235 + replyTo = &u 236 + } 288 237 289 - atUri := f.RepoAt().String() 290 - client, err := rp.oauth.AuthorizedClient(r) 291 - if err != nil { 292 - log.Println("failed to get authorized client", err) 293 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 - return 295 - } 296 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 - Collection: tangled.RepoIssueCommentNSID, 298 - Repo: user.Did, 299 - Rkey: rkey, 300 - Record: &lexutil.LexiconTypeDecoder{ 301 - Val: &tangled.RepoIssueComment{ 302 - Repo: &atUri, 303 - Issue: issueAt, 304 - Owner: &ownerDid, 305 - Body: body, 306 - CreatedAt: createdAt, 307 - }, 308 - }, 309 - }) 310 - if err != nil { 311 - log.Println("failed to create comment", err) 312 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 - return 314 - } 238 + comment := db.IssueComment{ 239 + Did: user.Did, 240 + Rkey: tid.TID(), 241 + IssueAt: issue.AtUri().String(), 242 + ReplyTo: replyTo, 243 + Body: body, 244 + Created: time.Now(), 245 + } 246 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 247 + l.Error("failed to validate comment", "err", err) 248 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 249 + return 250 + } 251 + record := comment.AsRecord() 315 252 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 253 + client, err := rp.oauth.AuthorizedClient(r) 254 + if err != nil { 255 + l.Error("failed to get authorized client", "err", err) 256 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 317 257 return 318 258 } 319 - } 320 259 321 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 - user := rp.oauth.GetUser(r) 323 - f, err := rp.repoResolver.Resolve(r) 260 + // create a record first 261 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 262 + Collection: tangled.RepoIssueCommentNSID, 263 + Repo: comment.Did, 264 + Rkey: comment.Rkey, 265 + Record: &lexutil.LexiconTypeDecoder{ 266 + Val: &record, 267 + }, 268 + }) 324 269 if err != nil { 325 - log.Println("failed to get repo and knot", err) 270 + l.Error("failed to create comment", "err", err) 271 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 326 272 return 327 273 } 274 + atUri := resp.Uri 275 + defer func() { 276 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 277 + l.Error("rollback failed", "err", err) 278 + } 279 + }() 328 280 329 - issueId := chi.URLParam(r, "issue") 330 - issueIdInt, err := strconv.Atoi(issueId) 281 + commentId, err := db.AddIssueComment(rp.db, comment) 331 282 if err != nil { 332 - http.Error(w, "bad issue id", http.StatusBadRequest) 333 - log.Println("failed to parse issue id", err) 283 + l.Error("failed to create comment", "err", err) 284 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 334 285 return 335 286 } 336 287 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 288 + // reset atUri to make rollback a no-op 289 + atUri = "" 290 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 291 + } 292 + 293 + func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 294 + l := rp.logger.With("handler", "IssueComment") 295 + user := rp.oauth.GetUser(r) 296 + f, err := rp.repoResolver.Resolve(r) 339 297 if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 298 + l.Error("failed to get repo and knot", "err", err) 342 299 return 343 300 } 344 301 345 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 346 - if err != nil { 347 - log.Println("failed to get issue", err) 348 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 302 + issue, ok := r.Context().Value("issue").(*db.Issue) 303 + if !ok { 304 + l.Error("failed to get issue") 305 + rp.pages.Error404(w) 349 306 return 350 307 } 351 308 352 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 309 + commentId := chi.URLParam(r, "commentId") 310 + comments, err := db.GetIssueComments( 311 + rp.db, 312 + db.FilterEq("id", commentId), 313 + ) 353 314 if err != nil { 354 - http.Error(w, "bad comment id", http.StatusBadRequest) 315 + l.Error("failed to fetch comment", "id", commentId) 316 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 355 317 return 356 318 } 319 + if len(comments) != 1 { 320 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 321 + http.Error(w, "invalid comment id", http.StatusBadRequest) 322 + return 323 + } 324 + comment := comments[0] 357 325 358 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 326 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 359 327 LoggedInUser: user, 360 328 RepoInfo: f.RepoInfo(user), 361 329 Issue: issue, 362 - Comment: comment, 330 + Comment: &comment, 363 331 }) 364 332 } 365 333 366 334 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 335 + l := rp.logger.With("handler", "EditIssueComment") 367 336 user := rp.oauth.GetUser(r) 368 337 f, err := rp.repoResolver.Resolve(r) 369 338 if err != nil { 370 - log.Println("failed to get repo and knot", err) 339 + l.Error("failed to get repo and knot", "err", err) 371 340 return 372 341 } 373 342 374 - issueId := chi.URLParam(r, "issue") 375 - issueIdInt, err := strconv.Atoi(issueId) 376 - if err != nil { 377 - http.Error(w, "bad issue id", http.StatusBadRequest) 378 - log.Println("failed to parse issue id", err) 343 + issue, ok := r.Context().Value("issue").(*db.Issue) 344 + if !ok { 345 + l.Error("failed to get issue") 346 + rp.pages.Error404(w) 379 347 return 380 348 } 381 349 382 - commentId := chi.URLParam(r, "comment_id") 383 - commentIdInt, err := strconv.Atoi(commentId) 350 + commentId := chi.URLParam(r, "commentId") 351 + comments, err := db.GetIssueComments( 352 + rp.db, 353 + db.FilterEq("id", commentId), 354 + ) 384 355 if err != nil { 385 - http.Error(w, "bad comment id", http.StatusBadRequest) 386 - log.Println("failed to parse issue id", err) 356 + l.Error("failed to fetch comment", "id", commentId) 357 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 387 358 return 388 359 } 389 - 390 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 391 - if err != nil { 392 - log.Println("failed to get issue", err) 393 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 360 + if len(comments) != 1 { 361 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 362 + http.Error(w, "invalid comment id", http.StatusBadRequest) 394 363 return 395 364 } 365 + comment := comments[0] 396 366 397 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 398 - if err != nil { 399 - http.Error(w, "bad comment id", http.StatusBadRequest) 400 - return 401 - } 402 - 403 - if comment.OwnerDid != user.Did { 367 + if comment.Did != user.Did { 368 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 404 369 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 370 return 406 371 } ··· 411 376 LoggedInUser: user, 412 377 RepoInfo: f.RepoInfo(user), 413 378 Issue: issue, 414 - Comment: comment, 379 + Comment: &comment, 415 380 }) 416 381 case http.MethodPost: 417 382 // extract form value ··· 422 387 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 388 return 424 389 } 425 - rkey := comment.Rkey 390 + 391 + now := time.Now() 392 + newComment := comment 393 + newComment.Body = newBody 394 + newComment.Edited = &now 395 + record := newComment.AsRecord() 426 396 427 - // optimistic update 428 - edited := time.Now() 429 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 397 + _, err = db.AddIssueComment(rp.db, newComment) 430 398 if err != nil { 431 399 log.Println("failed to perferom update-description query", err) 432 400 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 434 402 } 435 403 436 404 // rkey is optional, it was introduced later 437 - if comment.Rkey != "" { 405 + if newComment.Rkey != "" { 438 406 // update the record on pds 439 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 407 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 440 408 if err != nil { 441 - // failed to get record 442 - log.Println(err, rkey) 409 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 443 410 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 411 return 445 412 } 446 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 447 - record, _ := data.UnmarshalJSON(value) 448 - 449 - repoAt := record["repo"].(string) 450 - issueAt := record["issue"].(string) 451 - createdAt := record["createdAt"].(string) 452 413 453 414 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 415 Collection: tangled.RepoIssueCommentNSID, 455 416 Repo: user.Did, 456 - Rkey: rkey, 417 + Rkey: newComment.Rkey, 457 418 SwapRecord: ex.Cid, 458 419 Record: &lexutil.LexiconTypeDecoder{ 459 - Val: &tangled.RepoIssueComment{ 460 - Repo: &repoAt, 461 - Issue: issueAt, 462 - Owner: &comment.OwnerDid, 463 - Body: newBody, 464 - CreatedAt: createdAt, 465 - }, 420 + Val: &record, 466 421 }, 467 422 }) 468 423 if err != nil { 469 - log.Println(err) 424 + l.Error("failed to update record on PDS", "err", err) 470 425 } 471 426 } 472 427 473 - // optimistic update for htmx 474 - comment.Body = newBody 475 - comment.Edited = &edited 476 - 477 428 // return new comment body with htmx 478 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 429 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 479 430 LoggedInUser: user, 480 431 RepoInfo: f.RepoInfo(user), 481 432 Issue: issue, 482 - Comment: comment, 433 + Comment: &newComment, 483 434 }) 435 + } 436 + } 437 + 438 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 439 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 440 + user := rp.oauth.GetUser(r) 441 + f, err := rp.repoResolver.Resolve(r) 442 + if err != nil { 443 + l.Error("failed to get repo and knot", "err", err) 484 444 return 445 + } 485 446 447 + issue, ok := r.Context().Value("issue").(*db.Issue) 448 + if !ok { 449 + l.Error("failed to get issue") 450 + rp.pages.Error404(w) 451 + return 486 452 } 487 453 454 + commentId := chi.URLParam(r, "commentId") 455 + comments, err := db.GetIssueComments( 456 + rp.db, 457 + db.FilterEq("id", commentId), 458 + ) 459 + if err != nil { 460 + l.Error("failed to fetch comment", "id", commentId) 461 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 462 + return 463 + } 464 + if len(comments) != 1 { 465 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 466 + http.Error(w, "invalid comment id", http.StatusBadRequest) 467 + return 468 + } 469 + comment := comments[0] 470 + 471 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 472 + LoggedInUser: user, 473 + RepoInfo: f.RepoInfo(user), 474 + Issue: issue, 475 + Comment: &comment, 476 + }) 488 477 } 489 478 490 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 479 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 480 + l := rp.logger.With("handler", "ReplyIssueComment") 491 481 user := rp.oauth.GetUser(r) 492 482 f, err := rp.repoResolver.Resolve(r) 493 483 if err != nil { 494 - log.Println("failed to get repo and knot", err) 484 + l.Error("failed to get repo and knot", "err", err) 485 + return 486 + } 487 + 488 + issue, ok := r.Context().Value("issue").(*db.Issue) 489 + if !ok { 490 + l.Error("failed to get issue") 491 + rp.pages.Error404(w) 495 492 return 496 493 } 497 494 498 - issueId := chi.URLParam(r, "issue") 499 - issueIdInt, err := strconv.Atoi(issueId) 495 + commentId := chi.URLParam(r, "commentId") 496 + comments, err := db.GetIssueComments( 497 + rp.db, 498 + db.FilterEq("id", commentId), 499 + ) 500 500 if err != nil { 501 - http.Error(w, "bad issue id", http.StatusBadRequest) 502 - log.Println("failed to parse issue id", err) 501 + l.Error("failed to fetch comment", "id", commentId) 502 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 503 + return 504 + } 505 + if len(comments) != 1 { 506 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 507 + http.Error(w, "invalid comment id", http.StatusBadRequest) 503 508 return 504 509 } 510 + comment := comments[0] 505 511 506 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 512 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 513 + LoggedInUser: user, 514 + RepoInfo: f.RepoInfo(user), 515 + Issue: issue, 516 + Comment: &comment, 517 + }) 518 + } 519 + 520 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 521 + l := rp.logger.With("handler", "DeleteIssueComment") 522 + user := rp.oauth.GetUser(r) 523 + f, err := rp.repoResolver.Resolve(r) 507 524 if err != nil { 508 - log.Println("failed to get issue", err) 509 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 525 + l.Error("failed to get repo and knot", "err", err) 510 526 return 511 527 } 512 528 513 - commentId := chi.URLParam(r, "comment_id") 514 - commentIdInt, err := strconv.Atoi(commentId) 515 - if err != nil { 516 - http.Error(w, "bad comment id", http.StatusBadRequest) 517 - log.Println("failed to parse issue id", err) 529 + issue, ok := r.Context().Value("issue").(*db.Issue) 530 + if !ok { 531 + l.Error("failed to get issue") 532 + rp.pages.Error404(w) 518 533 return 519 534 } 520 535 521 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 536 + commentId := chi.URLParam(r, "commentId") 537 + comments, err := db.GetIssueComments( 538 + rp.db, 539 + db.FilterEq("id", commentId), 540 + ) 522 541 if err != nil { 523 - http.Error(w, "bad comment id", http.StatusBadRequest) 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) 524 549 return 525 550 } 551 + comment := comments[0] 526 552 527 - if comment.OwnerDid != user.Did { 553 + if comment.Did != user.Did { 554 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 528 555 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 556 return 530 557 } ··· 536 563 537 564 // optimistic deletion 538 565 deleted := time.Now() 539 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 566 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 540 567 if err != nil { 541 - log.Println("failed to delete comment") 568 + l.Error("failed to delete comment", "err", err) 542 569 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 570 return 544 571 } ··· 566 593 comment.Deleted = &deleted 567 594 568 595 // htmx fragment of comment after deletion 569 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 596 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 570 597 LoggedInUser: user, 571 598 RepoInfo: f.RepoInfo(user), 572 599 Issue: issue, 573 - Comment: comment, 600 + Comment: &comment, 574 601 }) 575 602 } 576 603 ··· 600 627 return 601 628 } 602 629 603 - 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 + ) 604 640 if err != nil { 605 641 log.Println("failed to get issues", err) 606 642 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 617 653 } 618 654 619 655 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 656 + l := rp.logger.With("handler", "NewIssue") 620 657 user := rp.oauth.GetUser(r) 621 658 622 659 f, err := rp.repoResolver.Resolve(r) 623 660 if err != nil { 624 - log.Println("failed to get repo and knot", err) 661 + l.Error("failed to get repo and knot", "err", err) 625 662 return 626 663 } 627 664 ··· 650 687 return 651 688 } 652 689 653 - tx, err := rp.db.BeginTx(r.Context(), nil) 654 - if err != nil { 655 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 - return 657 - } 658 - 659 690 issue := &db.Issue{ 660 - RepoAt: f.RepoAt(), 661 - Rkey: tid.TID(), 662 - Title: title, 663 - Body: body, 664 - OwnerDid: user.Did, 665 - } 666 - err = db.NewIssue(tx, issue) 667 - if err != nil { 668 - log.Println("failed to create issue", err) 669 - rp.pages.Notice(w, "issues", "Failed to create issue.") 670 - return 691 + RepoAt: f.RepoAt(), 692 + Rkey: tid.TID(), 693 + Title: title, 694 + Body: body, 695 + Did: user.Did, 696 + Created: time.Now(), 671 697 } 698 + record := issue.AsRecord() 672 699 700 + // create an atproto record 673 701 client, err := rp.oauth.AuthorizedClient(r) 674 702 if err != nil { 675 - log.Println("failed to get authorized client", err) 703 + l.Error("failed to get authorized client", "err", err) 676 704 rp.pages.Notice(w, "issues", "Failed to create issue.") 677 705 return 678 706 } 679 - atUri := f.RepoAt().String() 680 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 707 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 681 708 Collection: tangled.RepoIssueNSID, 682 709 Repo: user.Did, 683 710 Rkey: issue.Rkey, 684 711 Record: &lexutil.LexiconTypeDecoder{ 685 - Val: &tangled.RepoIssue{ 686 - Repo: atUri, 687 - Title: title, 688 - Body: &body, 689 - }, 712 + Val: &record, 690 713 }, 691 714 }) 692 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 { 693 743 log.Println("failed to create issue", err) 694 744 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 745 return 696 746 } 697 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 = "" 698 756 rp.notifier.NewIssue(r.Context(), issue) 699 - 700 757 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 758 return 702 759 } 703 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
··· 898 898 RepoInfo repoinfo.RepoInfo 899 899 Active string 900 900 Issue *db.Issue 901 - Comments []db.Comment 901 + CommentList []db.CommentListItem 902 902 IssueOwnerHandle string 903 903 904 904 OrderedReactionKinds []db.ReactionKind ··· 944 944 LoggedInUser *oauth.User 945 945 RepoInfo repoinfo.RepoInfo 946 946 Issue *db.Issue 947 - Comment *db.Comment 947 + Comment *db.IssueComment 948 948 } 949 949 950 950 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 951 951 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 952 952 } 953 953 954 - type SingleIssueCommentParams struct { 954 + type ReplyIssueCommentPlaceholderParams struct { 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 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 962 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 961 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 962 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 963 + } 964 + 965 + type ReplyIssueCommentParams struct { 966 + LoggedInUser *oauth.User 967 + RepoInfo repoinfo.RepoInfo 968 + Issue *db.Issue 969 + Comment *db.IssueComment 970 + } 971 + 972 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 973 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 974 + } 975 + 976 + type IssueCommentBodyParams struct { 977 + LoggedInUser *oauth.User 978 + RepoInfo repoinfo.RepoInfo 979 + Issue *db.Issue 980 + Comment *db.IssueComment 981 + } 982 + 983 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 984 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 963 985 } 964 986 965 987 type RepoNewPullParams struct {
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['·']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['·']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 50 32 {{ end }} 51 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['·']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['·']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ end }}
+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 }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 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 }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ 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}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 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" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 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" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+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
··· 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
··· 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
··· 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>