forked from tangled.org/core
this repo has no description

appview: allow editing issues

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

oppi.li d7caef6a 045b2783

verified
+31 -26
appview/db/issues.go
···
}
}
type CommentListItem struct {
Self *IssueComment
Replies []*IssueComment
···
return &comment, nil
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
return err
}
-
// check if issue already exists
-
var existingRowId, existingIssueId sql.NullInt64
-
err = tx.QueryRow(`
-
select rowid, issue_id from issues
-
where did = ? and rkey = ?
-
`, issue.Did, issue.Rkey).Scan(&existingRowId, &existingIssueId)
-
switch {
-
case err == sql.ErrNoRows:
-
return createNewIssue(tx, issue)
-
case err != nil:
return err
-
default:
-
// Case 3: Issue exists - update it
-
return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64))
}
}
···
return row.Scan(&issue.Id, &issue.IssueId)
}
-
func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error {
// update existing issue
_, err := tx.Exec(`
-
update issues
-
set title = ?, body = ?
where did = ? and rkey = ?
-
`, issue.Title, issue.Body, issue.Did, issue.Rkey)
-
if err != nil {
-
return err
-
}
-
-
// set the values from existing record
-
issue.Id = existingRowId
-
issue.IssueId = existingIssueId
-
return nil
}
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
···
}
}
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
+
type CommentListItem struct {
Self *IssueComment
Replies []*IssueComment
···
return &comment, nil
}
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
return err
}
+
issues, err := GetIssues(
+
tx,
+
FilterEq("did", issue.Did),
+
FilterEq("rkey", issue.Rkey),
+
)
switch {
case err != nil:
return err
+
case len(issues) == 0:
+
return createNewIssue(tx, issue)
+
case len(issues) != 1: // should be unreachable
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
default:
+
// if content is identical, do not edit
+
existingIssue := issues[0]
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
+
return nil
+
}
+
+
issue.Id = existingIssue.Id
+
issue.IssueId = existingIssue.IssueId
+
return updateIssue(tx, issue)
}
}
···
return row.Scan(&issue.Id, &issue.IssueId)
}
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
// update existing issue
_, err := tx.Exec(`
+
update issues
+
set title = ?, body = ?, edited = ?
where did = ? and rkey = ?
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
+
return err
}
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+3 -9
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
-
"strings"
"time"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/serververify"
"tangled.sh/tangled.sh/core/appview/validator"
"tangled.sh/tangled.sh/core/idresolver"
···
issue := db.IssueFromRecord(did, rkey, record)
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
-
return fmt.Errorf("title is empty after HTML sanitization")
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
}
tx, err := ddb.BeginTx(ctx, nil)
···
}
defer tx.Rollback()
-
err = db.NewIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "err", err)
return err
···
"encoding/json"
"fmt"
"log/slog"
"time"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/serververify"
"tangled.sh/tangled.sh/core/appview/validator"
"tangled.sh/tangled.sh/core/idresolver"
···
issue := db.IssueFromRecord(did, rkey, record)
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
+
return fmt.Errorf("failed to validate issue: %w", err)
}
tx, err := ddb.BeginTx(ctx, nil)
···
}
defer tx.Rollback()
+
err = db.PutIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "err", err)
return err
+130 -23
appview/issues/issues.go
···
"log/slog"
"net/http"
"slices"
-
"strings"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/appview/validator"
···
Reactions: reactionCountMap,
UserReacted: userReactions,
})
}
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
-
-
if title == "" || body == "" {
-
rp.pages.Notice(w, "issues", "Title and body are required")
-
return
-
}
-
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
-
return
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
-
return
-
}
-
issue := &db.Issue{
RepoAt: f.RepoAt(),
Rkey: tid.TID(),
-
Title: title,
-
Body: body,
Did: user.Did,
Created: time.Now(),
}
record := issue.AsRecord()
// create an atproto record
···
}
defer rollback()
-
err = db.NewIssue(tx, issue)
if err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
···
"log/slog"
"net/http"
"slices"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/appview/validator"
···
Reactions: reactionCountMap,
UserReacted: userReactions,
})
+
}
+
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssue")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
})
+
case http.MethodPost:
+
noticeId := "issues"
+
newIssue := issue
+
newIssue.Title = r.FormValue("title")
+
newIssue.Body = r.FormValue("body")
+
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
+
return
+
}
+
+
newRecord := newIssue.AsRecord()
+
+
// edit an atproto record
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
+
return
+
}
+
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
+
if err != nil {
+
l.Error("failed to get record", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
+
return
+
}
+
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Repo: user.Did,
+
Rkey: newIssue.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &newRecord,
+
},
+
})
+
if err != nil {
+
l.Error("failed to edit record on PDS", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
+
return
+
}
+
+
// modify on DB -- TODO: transact this cleverly
+
tx, err := rp.db.Begin()
+
if err != nil {
+
l.Error("failed to edit issue on DB", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.PutIssue(tx, newIssue)
+
if err != nil {
+
log.Println("failed to edit issue", err)
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
l.Error("failed to edit issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
+
return
+
}
+
+
rp.pages.HxRefresh(w)
+
}
+
}
+
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteIssue")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
})
+
case http.MethodPost:
+
}
}
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
issue := &db.Issue{
RepoAt: f.RepoAt(),
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
Did: user.Did,
Created: time.Now(),
}
+
+
if err := rp.validator.ValidateIssue(issue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
+
return
+
}
+
record := issue.AsRecord()
// create an atproto record
···
}
defer rollback()
+
err = db.PutIssue(tx, issue)
if err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
+3
appview/issues/router.go
···
r.Get("/reply", i.ReplyIssueComment)
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
})
r.Post("/close", i.CloseIssue)
r.Post("/reopen", i.ReopenIssue)
})
···
r.Get("/reply", i.ReplyIssueComment)
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
})
+
r.Get("/edit", i.EditIssue)
+
r.Post("/edit", i.EditIssue)
+
r.Delete("/", i.DeleteIssue)
r.Post("/close", i.CloseIssue)
r.Post("/reopen", i.ReopenIssue)
})
+19 -11
appview/pages/pages.go
···
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
-
State string
}
type ThreadReactionFragmentParams struct {
···
return p.executePlain("repo/fragments/reaction", w, params)
}
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
-
params.Active = "issues"
-
if params.Issue.Open {
-
params.State = "open"
-
} else {
-
params.State = "closed"
-
}
-
return p.executeRepo("repo/issues/issue", w, params)
-
}
-
type RepoNewIssueParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Active string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
return p.executeRepo("repo/issues/new", w, params)
}
···
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
+
}
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues/issue", w, params)
+
}
+
+
type EditIssueParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Action string
+
}
+
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
+
params.Action = "edit"
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
}
type ThreadReactionFragmentParams struct {
···
return p.executePlain("repo/fragments/reaction", w, params)
}
type RepoNewIssueParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue // existing issue if any -- passed when editing
Active string
+
Action string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
+
params.Action = "create"
return p.executeRepo("repo/issues/new", w, params)
}
+1 -7
appview/pages/templates/repo/issues/fragments/commentList.html
···
{{ end }}
</div>
-
{{ if $root.LoggedInUser }}
-
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
-
{{ else }}
-
<div class="p-2 border-t border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400">
-
<a class="underline" href="/login">login</a> to reply to this discussion
-
</div>
-
{{ end }}
</div>
{{ end }}
···
{{ end }}
</div>
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
</div>
{{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
{{ define "edit" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
-
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/edit"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}">
{{ i "pencil" "size-3" }}
···
{{ define "delete" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
-
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/"
hx-confirm="Are you sure you want to delete your comment?"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}"
···
{{ define "edit" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}">
{{ i "pencil" "size-3" }}
···
{{ define "delete" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
hx-confirm="Are you sure you want to delete your comment?"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}"
+6 -6
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
{{ template "timestamp" . }}
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
{{ if and $isCommentOwner (not .Comment.Deleted) }}
-
{{ template "edit" . }}
-
{{ template "delete" . }}
{{ end }}
</div>
{{ end }}
···
</a>
{{ end }}
-
{{ define "edit" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
-
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/edit"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}">
{{ i "pencil" "size-3" }}
</a>
{{ end }}
-
{{ define "delete" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
-
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/"
hx-confirm="Are you sure you want to delete your comment?"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}"
···
{{ template "timestamp" . }}
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
{{ template "editIssueComment" . }}
+
{{ template "deleteIssueComment" . }}
{{ end }}
</div>
{{ end }}
···
</a>
{{ end }}
+
{{ define "editIssueComment" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}">
{{ i "pencil" "size-3" }}
</a>
{{ end }}
+
{{ define "deleteIssueComment" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
hx-confirm="Are you sure you want to delete your comment?"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}"
+2 -2
appview/pages/templates/repo/issues/fragments/newComment.html
···
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
<button
id="close-button"
type="button"
···
}
});
</script>
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
<button
type="button"
class="btn flex items-center gap-2"
···
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
<button
id="close-button"
type="button"
···
}
});
</script>
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
<button
type="button"
class="btn flex items-center gap-2"
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
···
+
{{ define "repo/issues/fragments/putIssue" }}
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
+
<form
+
{{ if eq .Action "edit" }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
{{ else }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
+
{{ end }}
+
hx-swap="none"
+
hx-indicator="#spinner">
+
<div class="flex flex-col gap-2">
+
<div>
+
<label for="title">title</label>
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
+
</div>
+
<div>
+
<label for="body">body</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y"
+
placeholder="Describe your issue. Markdown is supported."
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+
</div>
+
<div class="flex justify-between">
+
<div id="issues" class="error"></div>
+
<div class="flex gap-2 items-center">
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
type="button"
+
{{ if .Issue }}
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
+
{{ else }}
+
href="/{{ .RepoInfo.FullName }}/issues"
+
{{ end }}
+
>
+
{{ i "x" "w-4 h-4" }}
+
cancel
+
</a>
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ if eq .Action "edit" }}
+
{{ i "pencil" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ else }}
+
{{ i "circle-plus" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ end }}
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
</div>
+
</form>
+
{{ end }}
+6 -2
appview/pages/templates/repo/issues/fragments/replyComment.html
···
id="reply-form-{{ .Comment.Id }}"
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
<textarea
···
class="w-full p-2"
placeholder="Leave a reply..."
autofocus
-
rows="3"></textarea>
<input
type="text"
···
<button
id="reply-{{ .Comment.Id }}"
type="submit"
-
hx-disabled-elt="#reply-{{ .Comment.Id }}"
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
···
id="reply-form-{{ .Comment.Id }}"
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
hx-on::after-request="if(event.detail.successful) this.reset()"
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
>
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
<textarea
···
class="w-full p-2"
placeholder="Leave a reply..."
autofocus
+
rows="3"
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
+
hx-target="#reply-form-{{ .Comment.Id }}"
+
hx-get="#"
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
<input
type="text"
···
<button
id="reply-{{ .Comment.Id }}"
type="submit"
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+7 -5
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
-
<img
-
src="{{ tinyAvatar .LoggedInUser.Did }}"
-
alt=""
-
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
-
/>
<input
class="w-full py-2 border-none focus:outline-none"
placeholder="Leave a reply..."
···
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
+
{{ if .LoggedInUser }}
+
<img
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
+
alt=""
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
+
/>
+
{{ end }}
<input
class="w-full py-2 border-none focus:outline-none"
placeholder="Leave a reply..."
+86 -44
appview/pages/templates/repo/issues/issue.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-2">
-
<h1 class="text-2xl">
-
{{ .Issue.Title | description }}
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
-
</h1>
-
</header>
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
{{ if eq .State "open" }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "circle-dot" }}
{{ end }}
-
<section class="mt-2">
-
<div class="inline-flex items-center gap-2">
-
<div id="state"
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
-
<span class="text-white">{{ .State }}</span>
-
</div>
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
-
opened by
-
{{ template "user/fragments/picHandleLink" .Issue.Did }}
-
<span class="select-none before:content-['\00B7']"></span>
-
{{ template "repo/fragments/time" .Issue.Created }}
-
</span>
-
</div>
-
{{ if .Issue.Body }}
-
<article id="body" class="mt-4 prose dark:prose-invert">
-
{{ .Issue.Body | markdown }}
-
</article>
-
{{ end }}
-
<div class="flex items-center gap-2 mt-2">
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
-
{{ range $kind := .OrderedReactionKinds }}
-
{{
-
template "repo/fragments/reaction"
-
(dict
-
"Kind" $kind
-
"Count" (index $.Reactions $kind)
-
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Issue.AtUri)
-
}}
-
{{ end }}
-
</div>
-
</section>
{{ end }}
{{ define "repoAfter" }}
···
{{ end }}
{{ define "repoContent" }}
+
<section id="issue-{{ .Issue.IssueId }}">
+
{{ template "issueHeader" .Issue }}
+
{{ template "issueInfo" . }}
+
{{ if .Issue.Body }}
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
+
{{ end }}
+
{{ template "issueReactions" . }}
+
</section>
+
{{ end }}
+
+
{{ define "issueHeader" }}
+
<header class="pb-2">
+
<h1 class="text-2xl">
+
{{ .Title | description }}
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
+
</h1>
+
</header>
+
{{ end }}
+
+
{{ define "issueInfo" }}
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ if eq .Issue.State "open" }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ end }}
+
<div class="inline-flex items-center gap-2">
+
<div id="state"
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
+
<span class="text-white">{{ .Issue.State }}</span>
+
</div>
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
+
opened by
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
+
<span class="select-none before:content-['\00B7']"></span>
+
{{ if .Issue.Edited }}
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
+
{{ else }}
+
{{ template "repo/fragments/time" .Issue.Created }}
+
{{ end }}
+
</span>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ template "issueActions" . }}
{{ end }}
+
</div>
+
{{ end }}
+
{{ define "issueActions" }}
+
{{ template "editIssue" . }}
+
{{ template "deleteIssue" . }}
+
{{ end }}
+
+
{{ define "editIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
hx-swap="innerHTML"
+
hx-target="#issue-{{.Issue.IssueId}}">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
{{ define "deleteIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/delete"
+
hx-confirm="Are you sure you want to delete your issue?"
+
hx-swap="innerHTML"
+
hx-target="#comment-body-{{.Issue.IssueId}}"
+
>
+
{{ i "trash-2" "size-3" }}
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+
{{ define "issueReactions" }}
+
<div class="flex items-center gap-2 mt-2">
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
+
{{ range $kind := .OrderedReactionKinds }}
+
{{
+
template "repo/fragments/reaction"
+
(dict
+
"Kind" $kind
+
"Count" (index $.Reactions $kind)
+
"IsReacted" (index $.UserReacted $kind)
+
"ThreadAt" $.Issue.AtUri)
+
}}
+
{{ end }}
+
</div>
{{ end }}
{{ define "repoAfter" }}
+1 -33
appview/pages/templates/repo/issues/new.html
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
-
class="space-y-6"
-
hx-swap="none"
-
hx-indicator="#spinner"
-
>
-
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title">title</label>
-
<input type="text" name="title" id="title" class="w-full" />
-
</div>
-
<div>
-
<label for="body">body</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y"
-
placeholder="Describe your issue. Markdown is supported."
-
></textarea>
-
</div>
-
<div>
-
<button type="submit" class="btn-create flex items-center gap-2">
-
{{ i "circle-plus" "w-4 h-4" }}
-
create issue
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
</div>
-
<div id="issues" class="error"></div>
-
</form>
{{ end }}
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
+
{{ template "repo/issues/fragments/putIssue" . }}
{{ end }}
+21 -3
appview/validator/issue.go
···
"strings"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
)
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
···
}
}
-
sanitizer := markup.NewSanitizer()
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
return fmt.Errorf("body is empty after HTML sanitization")
}
···
"strings"
"tangled.sh/tangled.sh/core/appview/db"
)
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
···
}
}
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
+
if issue.Title == "" {
+
return fmt.Errorf("issue title is empty")
+
}
+
+
if issue.Body == "" {
+
return fmt.Errorf("issue body is empty")
+
}
+
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
+
return fmt.Errorf("title is empty after HTML sanitization")
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
return fmt.Errorf("body is empty after HTML sanitization")
}
+8 -3
appview/validator/validator.go
···
package validator
-
import "tangled.sh/tangled.sh/core/appview/db"
type Validator struct {
-
db *db.DB
}
func New(db *db.DB) *Validator {
return &Validator{
-
db: db,
}
}
···
package validator
+
import (
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
)
type Validator struct {
+
db *db.DB
+
sanitizer markup.Sanitizer
}
func New(db *db.DB) *Validator {
return &Validator{
+
db: db,
+
sanitizer: markup.NewSanitizer(),
}
}
+1 -1
input.css
···
}
label {
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
@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;
···
}
label {
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
@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;