@@ -3,27 +3,25 @@ {{template "head" .}} </head> <body> {{template "body-pre" .}} {{.BodyTop}} {{template "issue" .}} {{template "change" .}} </body> </html> {{define "issue"}} {{define "change"}} <h1>{{.Change.Title}} <span class="gray">#{{.Change.ID}}</span></h1> <div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div> {{.Tabnav "Discussion"}} {{range .Items}} {{template "issue-item" .}} {{template "timeline-item" .}} {{end}} <div id="new-item-marker"></div> {{template "new-comment" .}} {{end}} {{define "issue-item"}} {{define "timeline-item"}} {{if eq .TemplateName "comment"}} {{template "comment" .IssueItem}} {{template "comment" .TimelineItem}} {{else if eq .TemplateName "event"}} {{render (event .IssueItem)}} {{render (event .TimelineItem)}} {{end}} {{end}}
@@ -3,11 +3,10 @@ {{template "scriptless-head" .}} </head> <body> {{template "body-pre" .}} {{.BodyTop}} {{template "create-issue" .}} {{render .Changes}} </body> </html> {{define "scriptless-head"}} @@ -17,17 +16,7 @@ {{.HeadPost}} {{end}} {{define "head"}} {{template "scriptless-head" .}} <script type="text/javascript"> var State = {{.State | jsonfmt}}; {{/*var State = {{.State}};*/}} </script> <script src="{{.BaseURI}}/assets/script.js" type="text/javascript"></script> {{end}} {{define "create-issue"}} {{if not .DisableUsers}} <div style="text-align: right;"><button class="btn btn-success btn-small" onclick="window.location = '{{.BaseURI}}/new';">Create Issue</button></div> {{end}} {{end}}
@@ -1,8 +1,8 @@ {{/* Dot is an issues.Comment. */}} {{define "comment"}} <div class="comment-edit-container"> <div> <div> <div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div> <div id="comment-{{.ID}}" style="display: flex;" class="list-entry"> <div class="list-entry-container list-entry-border"> <header class="list-entry-header" style="display: flex;"> @@ -24,13 +24,10 @@ </div> </div> </div> </div> </div> <div style="display: none;"> {{template "edit-comment" .}} </div> {{if (not state.DisableReactions)}} {{render (reactionsBar .Reactions (reactableID .ID))}} {{end}} </div> {{end}}
@@ -1,23 +0,0 @@ {{/* TODO: Dedup with new-comment, only buttons differ, so factor them out. */}} {{define "edit-comment"}} <div style="display: flex;" class="edit-container list-entry"> <div style="margin-right: 10px;">{{render (avatar .User)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <header class="list-entry-header tabs" style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </header> <div class="list-entry-body"> <textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" data-id="{{.ID}}" data-raw="{{.Body}}" tabindex=1></textarea> <div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button class="btn btn-success btn-small" onclick="EditComment({{`update` | json}}, this);" tabindex=1>Update comment</button> <button class="btn btn-danger btn-small" onclick="EditComment({{`cancel` | json}}, this);" tabindex=1>Cancel</button> </div> </div> </div> </div> {{end}}
@@ -1,18 +0,0 @@ {{/* TODO: Try to use issues.OpenState and issues.ClosedState constants. */}} {{define "toggle-button"}} {{if eq . "open"}} {{template "close-button"}} {{else if eq . "closed"}} {{template "reopen-button"}} {{else}} {{.}} {{end}} {{end}} {{define "close-button"}} <button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Close Issue" data-2-actions="Comment and close" onclick="ToggleIssueState('closed');" tabindex=1>Close Issue</button> {{end}} {{define "reopen-button"}} <button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Reopen Issue" data-2-actions="Reopen and comment" onclick="ToggleIssueState('open');" tabindex=1>Reopen Issue</button> {{end}}
@@ -1,28 +0,0 @@ {{define "new-comment"}} {{if .CurrentUser.ID}} <div id="new-comment-container" class="edit-container list-entry" style="display: flex;"> <div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <header class="list-entry-header tabs" style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </header> <div class="list-entry-body"> <textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" tabindex=1></textarea> <div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button class="btn btn-success btn-small" onclick="PostComment();" tabindex=1>Comment</button> {{if .Issue.Editable}}{{template "toggle-button" (print .Issue.State)}}{{end}} </div> </div> </div> </div> {{else}} <div class="event" style="margin-top: 20px; margin-bottom: 20px;"> <form method="post" action="/login/github" style="display: inline-block; margin-bottom: 0;"><input class="btn" type="submit" value="Sign in via GitHub"><input type="hidden" name="return" value="{{.BaseURI}}{{.ReqPath}}"></form> to comment. </div> {{end}} {{end}}
@@ -1,35 +0,0 @@ <html> <head> {{template "head" .}} </head> <body> {{template "body-pre" .}} {{.BodyTop}} {{template "new-issue" .}} </body> </html> {{define "new-issue"}} <div style="display: flex; margin-top: 20px;" class="edit-container list-entry"> <div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <header class="list-entry-header tabs-title"> <div><input id="title-editor" type="text" placeholder="Title" autofocus></div> <div style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </div> </header> <div class="list-entry-body"> <textarea class="comment-editor" style="min-height: 200px;" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);"></textarea> <div class="comment-preview markdown-body" style="padding: 10px; min-height: 200px; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button id="create-issue-button" class="btn btn-success btn-small" disabled="disabled" onclick="CreateNewIssue();">Create Issue</button> </div> </div> </div> </div> {{end}}
@@ -7,10 +7,10 @@ import ( type State struct { BaseURI string ReqPath string RepoSpec string IssueID uint64 `json:",omitempty"` // IssueID is the current issue ID, or 0 if not applicable (e.g., current page is /new). ChangeID uint64 `json:",omitempty"` // ChangeID is the current change ID, or 0 if not applicable (e.g., current page is /changes). CurrentUser users.User DisableReactions bool DisableUsers bool }
@@ -8,23 +8,23 @@ import ( "github.com/shurcooL/octiconssvg" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // Issues is a component that displays a page of changes, // Changes is a component that displays a page of changes, // with a navigation bar on top. type Issues struct { type Changes struct { IssuesNav IssuesNav Filter changes.StateFilter Entries []ChangeEntry } func (i Issues) Render() []*html.Node { func (i Changes) Render() []*html.Node { // TODO: Make this much nicer. // <div class="list-entry list-entry-border"> // {{render .IssuesNav}} // {{with .Issues}}{{range .}} // {{with .Entries}}{{range .}} // {{render .}} // {{end}}{{else}} // <div style="text-align: center; margin-top: 80px; margin-bottom: 80px;">There are no {{.Filter}} changes.</div> // {{end}} // </div>
@@ -8,18 +8,18 @@ import ( "github.com/shurcooL/octiconssvg" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // IssuesNav is a navigation component for displaying a header for a list of issues. // It contains tabs to switch between viewing open and closed issues. // IssuesNav is a navigation component for displaying a header for a list of changes. // It contains tabs to switch between viewing open and closed changes. type IssuesNav struct { OpenCount uint64 // Open issues count. ClosedCount uint64 // Closed issues count. OpenCount uint64 // Open changes count. ClosedCount uint64 // Closed changes count. Path string // URL path of current page (needed to generate correct links). Query url.Values // URL query of current page (needed to generate correct links). StateQueryKey string // Name of query key for controlling issue state filter. Constant, but provided externally. StateQueryKey string // Name of query key for controlling change state filter. Constant, but provided externally. } func (n IssuesNav) Render() []*html.Node { // TODO: Make this much nicer. // <header class="list-entry-header"> @@ -81,31 +81,31 @@ func (n IssuesNav) rawQuery(tabName string) string { return q.Encode() } // OpenIssuesTab is an "Open Issues Tab" component. type OpenIssuesTab struct { Count uint64 // Count of open issues. Count uint64 // Count of open changes. } func (t OpenIssuesTab) Render() []*html.Node { // TODO: Make this much nicer. // <span style="margin-right: 4px;">{{octicon "issue-opened"}}</span> // <span style="margin-right: 4px;">{{octicon "git-pull-request"}}</span> // {{.Count}} Open icon := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{ {Key: atom.Style.String(), Val: "margin-right: 4px;"}, }, FirstChild: octiconssvg.IssueOpened(), FirstChild: octiconssvg.GitPullRequest(), } text := htmlg.Text(fmt.Sprintf("%d Open", t.Count)) return []*html.Node{icon, text} } // ClosedIssuesTab is a "Closed Issues Tab" component. type ClosedIssuesTab struct { Count uint64 // Count of closed issues. Count uint64 // Count of closed changes. } func (t ClosedIssuesTab) Render() []*html.Node { // TODO: Make this much nicer. // <span style="margin-right: 4px;">{{octicon "check"}}</span>
@@ -5,51 +5,51 @@ import ( "time" "github.com/shurcooL/issues" ) // issueItem represents an issue item for display purposes. type issueItem struct { // IssueItem can be one of issues.Comment, issues.Event. IssueItem interface{} // timelineItem represents a timeline item for display purposes. type timelineItem struct { // TimelineItem can be one of issues.Comment, issues.Event. TimelineItem interface{} } func (i issueItem) TemplateName() string { switch i.IssueItem.(type) { func (i timelineItem) TemplateName() string { switch i.TimelineItem.(type) { case issues.Comment: return "comment" case issues.Event: return "event" default: panic(fmt.Errorf("unknown item type %T", i.IssueItem)) panic(fmt.Errorf("unknown item type %T", i.TimelineItem)) } } func (i issueItem) CreatedAt() time.Time { switch i := i.IssueItem.(type) { func (i timelineItem) CreatedAt() time.Time { switch i := i.TimelineItem.(type) { case issues.Comment: return i.CreatedAt case issues.Event: return i.CreatedAt default: panic(fmt.Errorf("unknown item type %T", i)) } } func (i issueItem) ID() uint64 { switch i := i.IssueItem.(type) { func (i timelineItem) ID() uint64 { switch i := i.TimelineItem.(type) { case issues.Comment: return i.ID case issues.Event: return i.ID default: panic(fmt.Errorf("unknown item type %T", i)) } } // byCreatedAtID implements sort.Interface. type byCreatedAtID []issueItem type byCreatedAtID []timelineItem func (s byCreatedAtID) Len() int { return len(s) } func (s byCreatedAtID) Less(i, j int) bool { if s[i].CreatedAt().Equal(s[j].CreatedAt()) { // If CreatedAt time is equal, fall back to ID as a tiebreaker.
@@ -1,86 +0,0 @@ package main import ( "bytes" "context" "log" "strconv" "github.com/shurcooL/github_flavored_markdown" "github.com/shurcooL/issues" "github.com/shurcooL/markdownfmt/markdown" "honnef.co/go/js/dom" ) func (f *frontend) EditComment(action string, this dom.HTMLElement) { container := getAncestorByClassName(this, "comment-edit-container") // HACK: Currently the child nodes are [text, div, text, div, text], but that isn't reliable. commentView := container.ChildNodes()[1].(dom.HTMLElement) editView := container.ChildNodes()[3].(dom.HTMLElement) commentEditor := editView.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement) switch action { case "edit": commentEditor.Value = commentEditor.GetAttribute("data-raw") commentView.Style().SetProperty("display", "none", "") editView.Style().SetProperty("display", "block", "") commentEditor.Focus() case "cancel", "update": switch action { case "cancel": if commentEditor.Value != commentEditor.GetAttribute("data-raw") { if !dom.GetWindow().Confirm("Are you sure you want to discard your unsaved changes?") { return } } commentEditor.Value = commentEditor.GetAttribute("data-raw") case "update": if commentEditor.Value != commentEditor.GetAttribute("data-raw") { fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil) fmted = bytes.TrimSpace(fmted) if len(fmted) == 0 { // Empty body isn't allowed. // TODO: Unless it's an issue description (initial comment). // TODO: Display error? Disable "Update comment" button? return } commentID, err := strconv.ParseUint(commentEditor.GetAttribute("data-id"), 10, 64) if err != nil { panic(err) } go func() { body := string(fmted) cr := issues.CommentRequest{ ID: commentID, Body: &body, } _, err := f.is.EditComment(context.Background(), issues.RepoSpec{URI: state.RepoSpec}, state.IssueID, cr) if err != nil { // TODO: Handle failure more visibly in the UI. log.Println("EditComment:", err) } }() commentEditor.SetAttribute("data-raw", string(fmted)) markdownBody := commentView.QuerySelector(".markdown-body").(*dom.HTMLDivElement) markdownBody.SetInnerHTML(string(github_flavored_markdown.Markdown(fmted))) } } commentView.Style().SetProperty("display", "block", "") editView.Style().SetProperty("display", "none", "") // TODO: switchWriteTab() (maybe without commentEditor.Focus() part). // TODO: Maybe without commentEditor.Focus() part? switchWriteTab(container, commentEditor) } } func getAncestorByClassName(el dom.Element, class string) dom.Element { for ; el != nil && !el.Class().Contains(class); el = el.ParentElement() { } return el }
@@ -3,336 +3,19 @@ // It's a Go package meant to be compiled with GOARCH=js // and executed in a browser, where the DOM is available. package main import ( "bytes" "context" "encoding/json" "fmt" "io/ioutil" "log" "net/http" "net/url" "strings" "dmitri.shuralyov.com/changes/app/common" "github.com/gopherjs/gopherjs/js" "github.com/shurcooL/frontend/reactionsmenu" "github.com/shurcooL/frontend/tabsupport" "github.com/shurcooL/github_flavored_markdown" "github.com/shurcooL/go/gopherjs_http/jsutil" "github.com/shurcooL/issues" "github.com/shurcooL/issuesapp/httpclient" "github.com/shurcooL/markdownfmt/markdown" "golang.org/x/oauth2" "honnef.co/go/js/dom" ) var document = dom.GetWindow().Document().(dom.HTMLDocument) var state common.State func main() { stateJSON := js.Global.Get("State").String() err := json.Unmarshal([]byte(stateJSON), &state) if err != nil { panic(err) } httpClient := httpClient() f := &frontend{is: httpclient.NewIssues(httpClient, "", "")} js.Global.Set("MarkdownPreview", jsutil.Wrap(MarkdownPreview)) js.Global.Set("SwitchWriteTab", jsutil.Wrap(SwitchWriteTab)) js.Global.Set("PasteHandler", jsutil.Wrap(PasteHandler)) js.Global.Set("CreateNewIssue", CreateNewIssue) js.Global.Set("ToggleIssueState", ToggleIssueState) js.Global.Set("PostComment", PostComment) js.Global.Set("EditComment", jsutil.Wrap(f.EditComment)) js.Global.Set("TabSupportKeyDownHandler", jsutil.Wrap(tabsupport.KeyDownHandler)) js.Global.Set("ToggleDetails", jsutil.Wrap(ToggleDetails)) switch readyState := document.ReadyState(); readyState { case "loading": document.AddEventListener("DOMContentLoaded", false, func(dom.Event) { go setup(f) }) case "interactive", "complete": setup(f) default: panic(fmt.Errorf("internal error: unexpected document.ReadyState value: %v", readyState)) } } func setup(f *frontend) { setupIssueToggleButton() if createIssueButton, ok := document.GetElementByID("create-issue-button").(dom.HTMLElement); ok { titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement) titleEditor.AddEventListener("input", false, func(_ dom.Event) { if strings.TrimSpace(titleEditor.Value) == "" { createIssueButton.SetAttribute("disabled", "disabled") } else { createIssueButton.RemoveAttribute("disabled") } }) } if !state.DisableReactions { reactionsService := IssuesReactions{Issues: f.is} reactionsmenu.Setup(state.RepoSpec, reactionsService, state.CurrentUser) } } // httpClient gives an *http.Client for making API requests. func httpClient() *http.Client { cookies := &http.Request{Header: http.Header{"Cookie": {document.Cookie()}}} if accessToken, err := cookies.Cookie("accessToken"); err == nil { // Authenticated client. src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: accessToken.Value}, ) return oauth2.NewClient(context.Background(), src) } // Not authenticated client. return http.DefaultClient } type frontend struct { is issues.Service } func setupIssueToggleButton() { if issueToggleButton := document.GetElementByID("issue-toggle-button"); issueToggleButton != nil { commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement) commentEditor.AddEventListener("input", false, func(_ dom.Event) { if strings.TrimSpace(commentEditor.Value) == "" { issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-1-action")) } else { issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-2-actions")) } }) } } func postJSON(url string, v interface{}) (*http.Response, error) { b, err := json.Marshal(v) if err != nil { return nil, err } req, err := http.NewRequest("POST", url, bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req) } func CreateNewIssue() { titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement) commentEditor := document.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement) title := strings.TrimSpace(titleEditor.Value) if title == "" { log.Println("cannot create issue with empty title") return } fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil) newIssue := issues.Issue{ Title: title, Comment: issues.Comment{ Body: string(bytes.TrimSpace(fmted)), }, } go func() { resp, err := postJSON("new", newIssue) if err != nil { log.Println(err) return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println(err) return } fmt.Printf("got reply: %v\n%q\n", resp.Status, string(body)) switch resp.StatusCode { case http.StatusOK: // Redirect. dom.GetWindow().Location().Href = string(body) } }() } func ToggleIssueState(issueState issues.State) { go func() { // Post comment first if there's text entered, and we're closing. if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" && issueState == issues.ClosedState { err := postComment() if err != nil { log.Println(err) return } } ir := issues.IssueRequest{ State: &issueState, } value, err := json.Marshal(ir) if err != nil { panic(err) } resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/edit", url.Values{"value": {string(value)}}) if err != nil { log.Println(err) return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println(err) return } data, err := url.ParseQuery(string(body)) if err != nil { log.Println(err) return } switch resp.StatusCode { case http.StatusOK: changeStateBadge := document.GetElementByID("change-state-badge") changeStateBadge.SetInnerHTML(data.Get("change-state-badge")) issueToggleButton := document.GetElementByID("issue-toggle-button") issueToggleButton.SetOuterHTML(data.Get("issue-toggle-button")) setupIssueToggleButton() for _, newEventData := range data["new-event"] { // Create event. newEvent := document.CreateElement("div").(*dom.HTMLDivElement) newItemMarker := document.GetElementByID("new-item-marker") newItemMarker.ParentNode().InsertBefore(newEvent, newItemMarker) newEvent.SetOuterHTML(newEventData) } } // Post comment after if there's text entered, and we're reopening. if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" && issueState == issues.OpenState { err := postComment() if err != nil { log.Println(err) return } } }() } func PostComment() { go func() { err := postComment() if err != nil { log.Println(err) } }() } // postComment posts the comment to the remote API. func postComment() error { commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement) fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil) if len(fmted) == 0 { return fmt.Errorf("cannot post empty comment") } value := string(bytes.TrimSpace(fmted)) resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/comment", url.Values{"value": {value}}) if err != nil { return err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return err } fmt.Printf("got reply: %v\n", resp.Status) switch resp.StatusCode { case http.StatusOK: // Create comment. newComment := document.CreateElement("div").(*dom.HTMLDivElement) newItemMarker := document.GetElementByID("new-item-marker") newItemMarker.ParentNode().InsertBefore(newComment, newItemMarker) newComment.SetOuterHTML(string(body)) // Reset new-comment component. commentEditor.Value = "" commentEditor.Underlying().Call("dispatchEvent", js.Global.Get("CustomEvent").New("input")) // Trigger "input" event listeners. switchWriteTab(document.GetElementByID("new-comment-container"), commentEditor) return nil default: return fmt.Errorf("did not get acceptable status code: %v", resp.Status) } } func MarkdownPreview(this dom.HTMLElement) { container := getAncestorByClassName(this, "edit-container") if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") { return } commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement) commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement) fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil) value := bytes.TrimSpace(fmted) if len(value) != 0 { commentPreview.SetInnerHTML(string(github_flavored_markdown.Markdown(value))) } else { commentPreview.SetInnerHTML(`<i class="gray">Nothing to preview.</i>`) } container.QuerySelector(".write-tab-link").(dom.Element).Class().Remove("active") container.QuerySelector(".preview-tab-link").(dom.Element).Class().Add("active") commentEditor.Style().SetProperty("display", "none", "") commentPreview.Style().SetProperty("display", "block", "") } func SwitchWriteTab(this dom.HTMLElement) { container := getAncestorByClassName(this, "edit-container") commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement) switchWriteTab(container, commentEditor) } func switchWriteTab(container dom.Element, commentEditor *dom.HTMLTextAreaElement) { if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") { commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement) container.QuerySelector(".write-tab-link").(dom.Element).Class().Add("active") container.QuerySelector(".preview-tab-link").(dom.Element).Class().Remove("active") commentEditor.Style().SetProperty("display", "block", "") commentPreview.Style().SetProperty("display", "none", "") } commentEditor.Focus() } func ToggleDetails(el dom.HTMLElement) { container := getAncestorByClassName(el, "commit-container").(dom.HTMLElement) details := container.QuerySelector("pre.commit-details").(dom.HTMLElement) @@ -342,5 +25,11 @@ func ToggleDetails(el dom.HTMLElement) { details.Style().SetProperty("display", "none", "") case "none": details.Style().SetProperty("display", "block", "") } } func getAncestorByClassName(el dom.Element, class string) dom.Element { for ; el != nil && !el.Class().Contains(class); el = el.ParentElement() { } return el }
@@ -1,44 +0,0 @@ package main import ( "context" "errors" "fmt" "github.com/shurcooL/issues" "github.com/shurcooL/reactions" ) // IssuesReactions implements reactions.Service on top of issues.Service, // specifically for use by issuesapp. // // The format of ID is "{{.issueID}}/{{.commentID}}". type IssuesReactions struct { Issues issues.Service } // Toggle toggles an issue. // id is "{{.issueID}}/{{.commentID}}". func (ir IssuesReactions) Toggle(ctx context.Context, uri string, id string, tr reactions.ToggleRequest) ([]reactions.Reaction, error) { var issueID, commentID uint64 _, err := fmt.Sscanf(id, "%d/%d", &issueID, &commentID) if err != nil { return nil, err } comment, err := ir.Issues.EditComment(ctx, issues.RepoSpec{URI: uri}, issueID, issues.CommentRequest{ ID: commentID, Reaction: &tr.Reaction, }) if err != nil { return nil, err } return comment.Reactions, nil } func (ir IssuesReactions) Get(_ context.Context, uri string, id string) ([]reactions.Reaction, error) { return nil, errors.New("IssuesReactions.Get: not implemented") } func (ir IssuesReactions) List(_ context.Context, uri string) (map[string][]reactions.Reaction, error) { return nil, errors.New("IssuesReactions.List: not implemented") }
@@ -1,104 +0,0 @@ package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "github.com/gopherjs/gopherjs/js" "honnef.co/go/js/dom" ) func PasteHandler(e dom.Event) { ce := e.(*dom.ClipboardEvent) items := ce.Get("clipboardData").Get("items") file, err := imagePNGFile(items) if err != nil { // No image to paste. return } // From this point, we're taking on the responsibility to handle this clipboard event. ce.PreventDefault() nameFunc, err := plainTextString(items) if err != nil { nameFunc = func() string { return "Image" } } go func() { b := blobToBytes(file) name := nameFunc() resp, err := http.Post("/api/usercontent", "image/png", bytes.NewReader(b)) if err != nil { log.Println(err) return } defer resp.Body.Close() var uploadResponse struct { URL string Error string } err = json.NewDecoder(resp.Body).Decode(&uploadResponse) if err != nil { log.Println(err) return } if uploadResponse.Error != "" { log.Println(uploadResponse.Error) return } insertText(ce.Target().(*dom.HTMLTextAreaElement), fmt.Sprintf("![%s](%s)\n\n", name, uploadResponse.URL)) }() } func insertText(t *dom.HTMLTextAreaElement, inserted string) { value, start, end := t.Value, t.SelectionStart, t.SelectionEnd t.Value = value[:start] + inserted + value[end:] t.SelectionStart, t.SelectionEnd = start+len(inserted), start+len(inserted) } // imagePNGFile tries to get an "image/png" file from items. func imagePNGFile(items *js.Object) (file *js.Object, err error) { for i := 0; i < items.Length(); i++ { item := items.Index(i) if item.Get("kind").String() != "file" || item.Get("type").String() != "image/png" { continue } return item.Call("getAsFile"), nil } return nil, fmt.Errorf("not found") } // plainTextString tries to get a "text/plain" string from items. // The returned func blocks until the string is available. func plainTextString(items *js.Object) (func() string, error) { for i := 0; i < items.Length(); i++ { item := items.Index(i) if item.Get("kind").String() != "string" || item.Get("type").String() != "text/plain" { continue } s := make(chan string) item.Call("getAsString", func(o *js.Object) { go func() { s <- o.String() }() }) return func() string { return <-s }, nil } return nil, fmt.Errorf("not found") } // blobToBytes converts a Blob to []byte. func blobToBytes(blob *js.Object) []byte { b := make(chan []byte) fileReader := js.Global.Get("FileReader").New() fileReader.Set("onload", func() { b <- js.Global.Get("Uint8Array").New(fileReader.Get("result")).Interface().([]byte) }) fileReader.Call("readAsArrayBuffer", blob) return <-b }
@@ -77,23 +77,23 @@ func New(service changes.Service, users users.Service, opt Options) http.Handler handler: h.ServeHTTP, users: users, } } // RepoSpecContextKey is a context key for the request's issues.RepoSpec. // That value specifies which repo the issues are to be displayed for. // RepoSpecContextKey is a context key for the request's repo spec. // That value specifies which repo the changes are to be displayed for. // The associated value will be of type string. var RepoSpecContextKey = &contextKey{"RepoSpec"} // BaseURIContextKey is a context key for the request's base URI. // That value specifies the base URI prefix to use for all absolute URLs. // The associated value will be of type string. var BaseURIContextKey = &contextKey{"BaseURI"} // Options for configuring issues app. // Options for configuring changes app. type Options struct { Notifications notifications.Service // If not nil, issues containing unread notifications are highlighted. Notifications notifications.Service // If not nil, changes containing unread notifications are highlighted. DisableReactions bool // Disable all support for displaying and toggling reactions. HeadPre, HeadPost template.HTML BodyPre string // An html/template definition of "body-pre" template. @@ -144,18 +144,18 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error { return nil } // Handle "/". if req.URL.Path == "/" { return h.IssuesHandler(w, req) return h.ChangesHandler(w, req) } // Handle "/{changeID}" and "/{changeID}/...". elems := strings.SplitN(req.URL.Path[1:], "/", 3) changeID, err := strconv.ParseUint(elems[0], 10, 64) if err != nil { return httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid issue ID %q: %v", elems[0], err)} return httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid change ID %q: %v", elems[0], err)} } switch { // "/{changeID}". case len(elems) == 1: return h.ChangeHandler(w, req, changeID) @@ -176,11 +176,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error { default: return httperror.HTTP{Code: http.StatusNotFound, Err: errors.New("no route")} } } func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error { func (h *handler) ChangesHandler(w http.ResponseWriter, req *http.Request) error { if req.Method != http.MethodGet { return httperror.Method{Allowed: []string{http.MethodGet}} } state, err := h.state(req, 0) if err != nil { @@ -194,22 +194,22 @@ func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error if err != nil { return err } openCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.OpenState)}) if err != nil { return fmt.Errorf("issues.Count(open): %v", err) return fmt.Errorf("changes.Count(open): %v", err) } closedCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.ClosedState)}) if err != nil { return fmt.Errorf("issues.Count(closed): %v", err) return fmt.Errorf("changes.Count(closed): %v", err) } var es []component.ChangeEntry for _, i := range is { es = append(es, component.ChangeEntry{Change: i, BaseURI: state.BaseURI}) } es = state.augmentUnread(req.Context(), es, h.is, h.Notifications) state.Changes = component.Issues{ state.Changes = component.Changes{ IssuesNav: component.IssuesNav{ OpenCount: openCount, ClosedCount: closedCount, Path: state.BaseURI + state.ReqPath, Query: req.URL.Query(), @@ -217,23 +217,23 @@ func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error }, Filter: filter, Entries: es, } w.Header().Set("Content-Type", "text/html; charset=utf-8") err = h.static.ExecuteTemplate(w, "issues.html.tmpl", &state) err = h.static.ExecuteTemplate(w, "changes.html.tmpl", &state) if err != nil { return fmt.Errorf("h.static.ExecuteTemplate: %v", err) } return nil } const ( // stateQueryKey is name of query key for controlling issue state filter. // stateQueryKey is name of query key for controlling change state filter. stateQueryKey = "state" ) // stateFilter parses the issue state filter from query, // stateFilter parses the change state filter from query, // returning an error if the value is unsupported. func stateFilter(query url.Values) (changes.StateFilter, error) { selectedTabName := query.Get(stateQueryKey) switch selectedTabName { case "": @@ -296,38 +296,38 @@ func (h *handler) ChangeHandler(w http.ResponseWriter, req *http.Request, change } state, err := h.state(req, changeID) if err != nil { return err } state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID) state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID) if err != nil { return err } cs, err := h.is.ListComments(req.Context(), state.RepoSpec, state.IssueID, nil) cs, err := h.is.ListComments(req.Context(), state.RepoSpec, state.ChangeID, nil) if err != nil { return fmt.Errorf("changes.ListComments: %v", err) } es, err := h.is.ListEvents(req.Context(), state.RepoSpec, state.IssueID, nil) es, err := h.is.ListEvents(req.Context(), state.RepoSpec, state.ChangeID, nil) if err != nil { return fmt.Errorf("changes.ListEvents: %v", err) } var items []issueItem var items []timelineItem for _, comment := range cs { items = append(items, issueItem{comment}) items = append(items, timelineItem{comment}) } for _, event := range es { items = append(items, issueItem{event}) items = append(items, timelineItem{event}) } sort.Sort(byCreatedAtID(items)) state.Items = items // Call loadTemplates to set updated reactionsBar, reactableID, etc., template functions. t, err := loadTemplates(state.State, h.Options.BodyPre) if err != nil { return fmt.Errorf("loadTemplates: %v", err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") err = t.ExecuteTemplate(w, "issue.html.tmpl", &state) err = t.ExecuteTemplate(w, "change.html.tmpl", &state) if err != nil { return fmt.Errorf("t.ExecuteTemplate: %v", err) } return nil } @@ -338,15 +338,15 @@ func (h *handler) ChangeCommitsHandler(w http.ResponseWriter, req *http.Request, } state, err := h.state(req, changeID) if err != nil { return err } state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID) state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID) if err != nil { return err } cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.IssueID) cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.ChangeID) if err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") err = h.static.ExecuteTemplate(w, "change-commits.html.tmpl", &state) @@ -373,17 +373,17 @@ func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, c } state, err := h.state(req, changeID) if err != nil { return err } state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID) state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID) if err != nil { return err } var commit commitMessage if commitID != "" { cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.IssueID) cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.ChangeID) if err != nil { return err } i := commitIndex(cs, commitID) if i == -1 { @@ -406,11 +406,11 @@ func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, c } var opt *changes.GetDiffOptions if commitID != "" { opt = &changes.GetDiffOptions{Commit: commitID} } rawDiff, err := h.is.GetDiff(req.Context(), state.RepoSpec, state.IssueID, opt) rawDiff, err := h.is.GetDiff(req.Context(), state.RepoSpec, state.ChangeID, opt) if err != nil { return err } fileDiffs, err := diff.ParseMultiFileDiff(rawDiff) if err != nil { @@ -452,18 +452,18 @@ func (h *handler) state(req *http.Request, changeID uint64) (state, error) { // TODO: Caller still does a lot of work outside to calculate req.URL.Path by // subtracting BaseURI from full original req.URL.Path. We should be able // to compute it here internally by using req.RequestURI and BaseURI. reqPath := req.URL.Path if reqPath == "/" { reqPath = "" // This is needed so that absolute URL for root view, i.e., /issues, is "/issues" and not "/issues/" because of "/issues" + "/". reqPath = "" // This is needed so that absolute URL for root view, i.e., /changes, is "/changes" and not "/changes/" because of "/changes" + "/". } b := state{ State: common.State{ BaseURI: req.Context().Value(BaseURIContextKey).(string), ReqPath: reqPath, RepoSpec: req.Context().Value(RepoSpecContextKey).(string), IssueID: changeID, ChangeID: changeID, }, } b.HeadPre = h.HeadPre b.HeadPost = h.HeadPost if h.BodyTop != nil { @@ -498,35 +498,35 @@ type state struct { HeadPre, HeadPost template.HTML BodyTop template.HTML common.State Changes component.Issues Changes component.Changes Change changes.Change Items []issueItem Items []timelineItem } func (s state) Tabnav(selected string) template.HTML { // Render the tabnav. return template.HTML(htmlg.RenderComponentsString(tabnav{ Tabs: []tab{ { Content: iconText{Icon: octiconssvg.CommentDiscussion, Text: "Discussion"}, URL: fmt.Sprintf("%s/%d", s.BaseURI, s.IssueID), URL: fmt.Sprintf("%s/%d", s.BaseURI, s.ChangeID), Selected: selected == "Discussion", }, { Content: contentCounter{ Content: iconText{Icon: octiconssvg.GitCommit, Text: "Commits"}, Count: s.Change.Commits, }, URL: fmt.Sprintf("%s/%d/commits", s.BaseURI, s.IssueID), URL: fmt.Sprintf("%s/%d/commits", s.BaseURI, s.ChangeID), Selected: selected == "Commits", }, { Content: iconText{Icon: octiconssvg.Diff, Text: "Files"}, URL: fmt.Sprintf("%s/%d/files", s.BaseURI, s.IssueID), URL: fmt.Sprintf("%s/%d/files", s.BaseURI, s.ChangeID), Selected: selected == "Files", }, }, })) } @@ -546,11 +546,11 @@ func loadTemplates(state common.State, bodyPre string) (*template.Template, erro "reactionPosition": func(emojiID reactions.EmojiID) string { return reactions.Position(":" + string(emojiID) + ":") }, "equalUsers": func(a, b users.User) bool { return a.UserSpec == b.UserSpec }, "reactableID": func(commentID uint64) string { return fmt.Sprintf("%d/%d", state.IssueID, commentID) return fmt.Sprintf("%d/%d", state.ChangeID, commentID) }, "reactionsBar": func(reactions []reactions.Reaction, reactableID string) htmlg.Component { return reactionscomponent.ReactionsBar{ Reactions: reactions, CurrentUser: state.CurrentUser,