From 500906548044f5049f29ef812134bdcf43d826cf Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 19 Nov 2017 21:49:19 -0800 Subject: [PATCH] Blog edit and preview page --- core/admin.go | 35 +++++++++ core/app.go | 1 + core/auth.go | 4 +- core/blog.go | 58 +++++++++++++++ core/forms/settings.go | 31 ++++++++ core/initial-setup.go | 4 +- core/markdown.go | 9 +++ core/models/posts/posts.go | 66 ++++++++++++++++ core/responses.go | 20 +++++ core/templates.go | 26 +++++-- root/.layout.gohtml | 8 +- root/admin/settings.gohtml | 88 +++------------------- root/blog/edit.gohtml | 149 +++++++++++++++++++++++++++++++++++++ root/bluez/theme.css | 6 ++ 14 files changed, 416 insertions(+), 89 deletions(-) create mode 100644 core/blog.go create mode 100644 core/forms/settings.go create mode 100644 core/markdown.go create mode 100644 core/models/posts/posts.go create mode 100644 root/blog/edit.gohtml diff --git a/core/admin.go b/core/admin.go index f3874ae..80df389 100644 --- a/core/admin.go +++ b/core/admin.go @@ -2,8 +2,10 @@ package core import ( "net/http" + "strconv" "github.com/gorilla/mux" + "github.com/kirsle/blog/core/forms" "github.com/kirsle/blog/core/models/settings" "github.com/urfave/negroni" ) @@ -32,5 +34,38 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { // Get the current settings. settings, _ := settings.Load() v.Data["s"] = settings + + if r.Method == http.MethodPost { + redisPort, _ := strconv.Atoi(r.FormValue("redis-port")) + redisDB, _ := strconv.Atoi(r.FormValue("redis-db")) + form := &forms.Settings{ + Title: r.FormValue("title"), + AdminEmail: r.FormValue("admin-email"), + RedisEnabled: r.FormValue("redis-enabled") == "true", + RedisHost: r.FormValue("redis-host"), + RedisPort: redisPort, + RedisDB: redisDB, + RedisPrefix: r.FormValue("redis-prefix"), + } + + // Copy form values into the settings struct for display, in case of + // any validation errors. + settings.Site.Title = form.Title + settings.Site.AdminEmail = form.AdminEmail + settings.Redis.Enabled = form.RedisEnabled + settings.Redis.Host = form.RedisHost + settings.Redis.Port = form.RedisPort + settings.Redis.DB = form.RedisDB + settings.Redis.Prefix = form.RedisPrefix + err := form.Validate() + if err != nil { + v.Error = err + } else { + // Save the settings. + settings.Save() + b.FlashAndReload(w, r, "Settings have been saved!") + return + } + } b.RenderTemplate(w, r, "admin/settings", v) } diff --git a/core/app.go b/core/app.go index 956cb08..d346e52 100644 --- a/core/app.go +++ b/core/app.go @@ -57,6 +57,7 @@ func New(documentRoot, userRoot string) *Blog { r.HandleFunc("/login", blog.LoginHandler) r.HandleFunc("/logout", blog.LogoutHandler) blog.AdminRoutes(r) + blog.BlogRoutes(r) r.PathPrefix("/").HandlerFunc(blog.PageHandler) r.NotFoundHandler = http.HandlerFunc(blog.PageHandler) diff --git a/core/auth.go b/core/auth.go index aa7d175..16fcf39 100644 --- a/core/auth.go +++ b/core/auth.go @@ -26,7 +26,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { Form: forms.Setup{}, } - if r.Method == "POST" { + if r.Method == http.MethodPost { form := &forms.Login{ Username: r.FormValue("username"), Password: r.FormValue("password"), @@ -42,7 +42,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { vars.Error = errors.New("bad username or password") } else { // Login OK! - vars.Flash = "Login OK!" + b.Flash(w, r, "Login OK!") b.Login(w, r, user) // A next URL given? TODO: actually get to work diff --git a/core/blog.go b/core/blog.go new file mode 100644 index 0000000..65f6dbc --- /dev/null +++ b/core/blog.go @@ -0,0 +1,58 @@ +package core + +import ( + "html/template" + "net/http" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/models/posts" + "github.com/urfave/negroni" +) + +// BlogRoutes attaches the blog routes to the app. +func (b *Blog) BlogRoutes(r *mux.Router) { + // Login-required routers. + loginRouter := mux.NewRouter() + loginRouter.HandleFunc("/blog/edit", b.EditBlog) + r.PathPrefix("/blog").Handler( + negroni.New( + negroni.HandlerFunc(b.LoginRequired), + negroni.Wrap(loginRouter), + ), + ) + + adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false) + r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/" + adminRouter.HandleFunc("/settings", b.SettingsHandler) + adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler) + r.PathPrefix("/admin").Handler(negroni.New( + negroni.HandlerFunc(b.LoginRequired), + negroni.Wrap(adminRouter), + )) +} + +// EditBlog is the blog writing and editing page. +func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { + v := NewVars(map[interface{}]interface{}{ + "preview": "", + }) + post := posts.New() + + if r.Method == http.MethodPost { + // Parse from form values. + post.LoadForm(r) + + // Previewing, or submitting? + switch r.FormValue("submit") { + case "preview": + v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body)) + case "submit": + if err := post.Validate(); err != nil { + v.Error = err + } + } + } + + v.Data["post"] = post + b.RenderTemplate(w, r, "blog/edit", v) +} diff --git a/core/forms/settings.go b/core/forms/settings.go new file mode 100644 index 0000000..877e9fe --- /dev/null +++ b/core/forms/settings.go @@ -0,0 +1,31 @@ +package forms + +import ( + "errors" + "net/mail" +) + +// Settings are the user-facing admin settings. +type Settings struct { + Title string + AdminEmail string + RedisEnabled bool + RedisHost string + RedisPort int + RedisDB int + RedisPrefix string +} + +// Validate the form. +func (f Settings) Validate() error { + if len(f.Title) == 0 { + return errors.New("website title is required") + } + if f.AdminEmail != "" { + _, err := mail.ParseAddress(f.AdminEmail) + if err != nil { + return err + } + } + return nil +} diff --git a/core/initial-setup.go b/core/initial-setup.go index 86ef3c7..d97adc1 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -15,7 +15,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { Form: forms.Setup{}, } - if r.Method == "POST" { + if r.Method == http.MethodPost { form := forms.Setup{ Username: r.FormValue("username"), Password: r.FormValue("password"), @@ -49,7 +49,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { // All set! b.Login(w, r, user) - b.Redirect(w, "/admin") + b.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.") return } } diff --git a/core/markdown.go b/core/markdown.go new file mode 100644 index 0000000..f1babfa --- /dev/null +++ b/core/markdown.go @@ -0,0 +1,9 @@ +package core + +import "github.com/shurcooL/github_flavored_markdown" + +// RenderMarkdown renders markdown to HTML. +func (b *Blog) RenderMarkdown(input string) string { + output := github_flavored_markdown.Markdown([]byte(input)) + return string(output) +} diff --git a/core/models/posts/posts.go b/core/models/posts/posts.go new file mode 100644 index 0000000..3503fcc --- /dev/null +++ b/core/models/posts/posts.go @@ -0,0 +1,66 @@ +package posts + +import ( + "errors" + "net/http" + "strconv" + "strings" +) + +// Post holds information for a blog post. +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Fragment string `json:"fragment"` + ContentType string `json:"contentType"` + Body string `json:"body"` + Privacy string `json:"privacy"` + Sticky bool `json:"sticky"` + EnableComments bool `json:"enableComments"` + Tags []string `json:"tags"` +} + +// New creates a blank post with sensible defaults. +func New() *Post { + return &Post{ + ContentType: "markdown", + Privacy: "public", + EnableComments: true, + } +} + +// LoadForm populates the post from form values. +func (p *Post) LoadForm(r *http.Request) { + id, _ := strconv.Atoi(r.FormValue("id")) + + p.ID = id + p.Title = r.FormValue("title") + p.Fragment = r.FormValue("fragment") + p.ContentType = r.FormValue("content-type") + p.Body = r.FormValue("body") + p.Privacy = r.FormValue("privacy") + p.Sticky = r.FormValue("sticky") == "true" + p.EnableComments = r.FormValue("enable-comments") == "true" + + // Ingest the tags. + tags := strings.Split(r.FormValue("tags"), ",") + p.Tags = []string{} + for _, tag := range tags { + p.Tags = append(p.Tags, strings.TrimSpace(tag)) + } +} + +// Validate makes sure the required fields are all present. +func (p *Post) Validate() error { + if p.Title == "" { + return errors.New("title is required") + } + if p.ContentType != "markdown" && p.ContentType != "markdown+html" && + p.ContentType != "html" { + return errors.New("invalid setting for ContentType") + } + if p.Privacy != "public" && p.Privacy != "draft" && p.Privacy != "private" { + return errors.New("invalid setting for Privacy") + } + return nil +} diff --git a/core/responses.go b/core/responses.go index c282741..85a51d9 100644 --- a/core/responses.go +++ b/core/responses.go @@ -1,9 +1,29 @@ package core import ( + "fmt" "net/http" ) +// Flash adds a flash message to the user's session. +func (b *Blog) Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { + session := b.Session(r) + session.AddFlash(fmt.Sprintf(message, args...)) + session.Save(r, w) +} + +// FlashAndRedirect flashes and redirects in one go. +func (b *Blog) FlashAndRedirect(w http.ResponseWriter, r *http.Request, location, message string, args ...interface{}) { + b.Flash(w, r, message, args...) + b.Redirect(w, location) +} + +// FlashAndReload flashes and sends a redirect to the same path. +func (b *Blog) FlashAndReload(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { + b.Flash(w, r, message, args...) + b.Redirect(w, r.URL.Path) +} + // Redirect sends an HTTP redirect response. func (b *Blog) Redirect(w http.ResponseWriter, location string) { log.Error("Redirect: %s", location) diff --git a/core/templates.go b/core/templates.go index 722b4fe..07f7b8a 100644 --- a/core/templates.go +++ b/core/templates.go @@ -23,7 +23,7 @@ type Vars struct { // Common template variables. Message string - Flash string + Flashes []string Error error Data map[interface{}]interface{} Form forms.Form @@ -44,7 +44,7 @@ func NewVars(data ...map[interface{}]interface{}) *Vars { } // LoadDefaults combines template variables with default, globally available vars. -func (v *Vars) LoadDefaults(r *http.Request) { +func (v *Vars) LoadDefaults(b *Blog, w http.ResponseWriter, r *http.Request) { // Get the site settings. s, err := settings.Load() if err != nil { @@ -57,6 +57,16 @@ func (v *Vars) LoadDefaults(r *http.Request) { v.Title = s.Site.Title v.Path = r.URL.Path + // Add any flashed messages from the endpoint controllers. + session := b.Session(r) + if flashes := session.Flashes(); len(flashes) > 0 { + for _, flash := range flashes { + _ = flash + v.Flashes = append(v.Flashes, flash.(string)) + } + session.Save(r, w) + } + ctx := r.Context() if user, ok := ctx.Value(userKey).(*users.User); ok { if user.ID > 0 { @@ -68,7 +78,7 @@ func (v *Vars) LoadDefaults(r *http.Request) { // TemplateVars is an interface that describes the template variable struct. type TemplateVars interface { - LoadDefaults(*http.Request) + LoadDefaults(*Blog, http.ResponseWriter, *http.Request) } // RenderTemplate responds with an HTML template. @@ -87,9 +97,15 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin return err } + // Useful template functions. + log.Error("HERE!!!") + t := template.New(filepath.Absolute).Funcs(template.FuncMap{ + "StringsJoin": strings.Join, + }) + // Parse the template files. The layout comes first because it's the wrapper // and allows the filepath template to set the page title. - t, err := template.ParseFiles(layout.Absolute, filepath.Absolute) + t, err = t.ParseFiles(layout.Absolute, filepath.Absolute) if err != nil { log.Error(err.Error()) return err @@ -99,7 +115,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin if vars == nil { vars = &Vars{} } - vars.LoadDefaults(r) + vars.LoadDefaults(b, w, r) w.Header().Set("Content-Type", "text/html; encoding=UTF-8") err = t.ExecuteTemplate(w, "layout", vars) diff --git a/root/.layout.gohtml b/root/.layout.gohtml index 4e99c0e..0241055 100644 --- a/root/.layout.gohtml +++ b/root/.layout.gohtml @@ -1,5 +1,5 @@ {{ define "title" }}Untitled{{ end }} -{{ define "scripts" }}Default Scripts{{ end }} +{{ define "scripts" }}{{ end }} {{ define "layout" }} @@ -59,9 +59,9 @@ {{ end }} - {{ if .Flash }} + {{ range .Flashes }}
- {{ .Flash }} + {{ . }}
{{ end }} @@ -159,7 +159,7 @@ - + {{ template "scripts" or "" }} diff --git a/root/admin/settings.gohtml b/root/admin/settings.gohtml index 871ca50..2cb5aa8 100644 --- a/root/admin/settings.gohtml +++ b/root/admin/settings.gohtml @@ -1,45 +1,16 @@ {{ define "title" }}Website Settings{{ end }} {{ define "content" }}
-
-
- -
- +
{{ with .Data.s }} -
+

The Basics

@@ -47,9 +18,9 @@
For getting notifications about comments, etc. -
@@ -72,22 +43,11 @@ Enable Redis
- -
- - (optional) - -
-
@@ -95,7 +55,7 @@
@@ -104,7 +64,7 @@ 0-15
@@ -113,42 +73,18 @@ (optional)
- - -
- -
- -
-
- -
- - (optional) - + + Cancel
+
{{ end }}
{{ end }} -{{ define "scripts" }} - -{{ end }} diff --git a/root/blog/edit.gohtml b/root/blog/edit.gohtml new file mode 100644 index 0000000..507ec0a --- /dev/null +++ b/root/blog/edit.gohtml @@ -0,0 +1,149 @@ +{{ define "title" }}Update Blog{{ end }} +{{ define "content" }} +
+{{ if .Data.preview }} +
+
+ Preview +
+
+ {{ .Data.preview }} +
+
+{{ end }} + +{{ with .Data.post }} +
+
+

Update Blog

+ + {{ . }} + +
+ + +
+ +
+ + + You can leave this blank if this is a new post. It will pick a + default value based on the title. + + +
+ +
+ + +
+ +
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+ +
+ + +
+
+
+{{ end }} + +
+{{ end }} diff --git a/root/bluez/theme.css b/root/bluez/theme.css index f6fb809..28b8683 100644 --- a/root/bluez/theme.css +++ b/root/bluez/theme.css @@ -30,6 +30,12 @@ h6, .h6 { .form-group label { font-weight: bold; } +label.form-check-label { + font-weight: normal; +} +button { + cursor: pointer; +} /* * Top nav