Blog edit and preview page

pull/4/head
Noah 2017-11-19 21:49:19 -08:00
parent 5b051391d6
commit 5009065480
14 changed files with 416 additions and 89 deletions

View File

@ -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)
}

View File

@ -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)

View File

@ -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

58
core/blog.go Normal file
View File

@ -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)
}

31
core/forms/settings.go Normal file
View File

@ -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
}

View File

@ -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
}
}

9
core/markdown.go Normal file
View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -1,5 +1,5 @@
{{ define "title" }}Untitled{{ end }}
{{ define "scripts" }}Default Scripts{{ end }}
{{ define "scripts" }}{{ end }}
{{ define "layout" }}
<!DOCTYPE html>
@ -59,9 +59,9 @@
</div>
{{ end }}
{{ if .Flash }}
{{ range .Flashes }}
<div class="alert alert-success">
{{ .Flash }}
{{ . }}
</div>
{{ end }}
@ -159,7 +159,7 @@
</div>
</footer>
<script type="text/javascript" src="/js/vue.min.js"></script>
<script type="text/javascript" src="/js/bootstrap.min.js"></script>
{{ template "scripts" or "" }}
</body>

View File

@ -1,45 +1,16 @@
{{ define "title" }}Website Settings{{ end }}
{{ define "content" }}
<form action="/admin/settings" method="POST">
<div id="settings-app" class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link" href="#site"
:class="{ active: currentTab === 'site'}"
v-on:click="currentTab = 'site'">
Settings
</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" href="#db"
:class="{ active: currentTab === 'db'}"
v-on:click="currentTab = 'db'">
Database
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#security"
:class="{ active: currentTab === 'security'}"
v-on:click="currentTab = 'security'">
Security
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Hello</a>
</li> -->
</ul>
</div>
<div class="card">
{{ with .Data.s }}
<div class="card-body" v-if="currentTab === 'site'">
<div class="card-body">
<h3>The Basics</h3>
<div class="form-group">
<label for="title">Title</label>
<input type="text"
class="form-control"
name="title" id="title"
name="title"
value="{{ .Site.Title }}"
placeholder="Website Title">
</div>
@ -47,9 +18,9 @@
<div class="form-group">
<label for="admin-email">Admin Email</label>
<small class="text-muted">For getting notifications about comments, etc.</small>
<input type="email"
<input type="text"
class="form-control"
name="admin-email" id="admin-email"
name="admin-email"
value="{{ .Site.AdminEmail }}"
placeholder="name@domain.com">
</div>
@ -72,22 +43,11 @@
Enable Redis
</label>
</div>
<div class="form-group">
<label for="redis-prefix">Key Prefix</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
</div>
<div class="form-group">
<label for="redis-host">Redis Host</label>
<input type="text"
class="form-control"
name="redis-host" id="redis-host"
name="redis-host"
value="{{ .Redis.Host }}"
placeholder="localhost">
</div>
@ -95,7 +55,7 @@
<label for="redis-port">Port</label>
<input type="text"
class="form-control"
name="redis-port" id="redis-port"
name="redis-port"
value="{{ .Redis.Port }}"
placeholder="6379">
</div>
@ -104,7 +64,7 @@
<small class="text-muted">0-15</small>
<input type="text"
class="form-control"
name="redis-db" id="redis-db"
name="redis-db"
value="{{ .Redis.DB }}"
placeholder="0">
</div>
@ -113,42 +73,18 @@
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
name="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
</div>
</div>
<div class="card-body" v-if="currentTab === 'db'">
</div>
<div class="card-body" v-if="currentTab === 'security'">
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-input"
name="redis-enabled"
value="true"
{{ if .Redis.Enabled }}checked{{ end }}>
Enable Redis
</label>
</div>
<div class="form-group">
<label for="redis-prefix">Key Prefix</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
<button type="submit" class="btn btn-primary">Save Settings</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</div>
</div>
{{ end }}
</div>
</form>
{{ end }}
{{ define "scripts" }}
<script type="text/javascript" src="/admin/settings.js"></script>
{{ end }}

149
root/blog/edit.gohtml Normal file
View File

@ -0,0 +1,149 @@
{{ define "title" }}Update Blog{{ end }}
{{ define "content" }}
<form action="/blog/edit" method="POST">
{{ if .Data.preview }}
<div class="card">
<div class="card-title">
Preview
</div>
<div class="card-body">
{{ .Data.preview }}
</div>
</div>
{{ end }}
{{ with .Data.post }}
<div class="card">
<div class="card-body">
<h3>Update Blog</h3>
{{ . }}
<div class="form-group">
<label for="title">Title</label>
<input type="text"
class="form-control"
name="title"
value="{{ .Title }}"
placeholder="Post Title"
autocomplete="off">
</div>
<div class="form-group">
<label for="fragment">URL Fragment</label>
<small class="text-muted">
You can leave this blank if this is a new post. It will pick a
default value based on the title.
</small>
<input type="text"
class="form-control"
name="fragment"
value="{{ .Fragment }}"
placeholder="url-fragment-for-blog-entry"
autocomplete="false">
</div>
<div class="form-group">
<label for="body">Body</label>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input type="radio"
class="form-check-input"
name="content-type"
value="markdown"
{{ if eq .ContentType "markdown" }}checked{{ end }}
> Markdown
</label>
</div>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input type="radio"
class="form-check-input"
name="content-type"
value="markdown+html"
{{ if eq .ContentType "markdown+html" }}checked{{ end }}
> Markdown + HTML
</label>
</div>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input type="radio"
class="form-check-input"
name="content-type"
value="html"
{{ if eq .ContentType "html" }}checked{{ end }}
> Raw HTML
</label>
</div>
<textarea class="form-control"
cols="80"
rows="12"
name="body"
placeholder="Post body goes here">{{ .Body }}</textarea>
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input type="text"
class="form-control"
name="tags"
placeholder="Comma, Separated, List"
value="{{ StringsJoin .Tags ", " }}"
autocomplete="off">
</div>
<div class="form-group">
<label for="privacy">Privacy</label>
<select name="privacy" class="form-control">
<option value="public"{{ if eq .Privacy "public" }} selected{{ end }}>Public: everybody can see this post</option>
<option value="private"{{ if eq .Privacy "private" }} selected{{ end }}>Private: only site admins can see this post</option>
<option value="draft"{{ if eq .Privacy "draft" }} selected{{ end }}>Draft: don't show this post on the blog anywhere</option>
</select>
</div>
<div class="form-group">
<label>Options</label>
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-label"
name="sticky"
value="true"
{{ if .Sticky }}checked{{ end }}
> Make this post sticky (always on top)
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-label"
name="enable-comments"
value="true"
{{ if .EnableComments }}checked{{ end }}
> Enable comments on this post
</label>
</div>
</div>
<div class="form-group">
<button type="submit"
class="btn btn-success"
name="submit"
value="preview">
Preview
</button>
<button type="submit"
class="btn btn-primary"
name="submit"
value="post">
Publish
</button>
</div>
</div>
</div>
{{ end }}
</form>
{{ end }}

View File

@ -30,6 +30,12 @@ h6, .h6 {
.form-group label {
font-weight: bold;
}
label.form-check-label {
font-weight: normal;
}
button {
cursor: pointer;
}
/*
* Top nav