Add admin page editor UX

This commit is contained in:
Noah 2017-12-23 14:48:47 -08:00
parent 3f7b800423
commit 9eaca9c98b
11 changed files with 272 additions and 8 deletions

View File

@ -2,8 +2,13 @@ package core
import ( import (
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"net/url"
"os"
"path/filepath"
"strconv" "strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/caches/null" "github.com/kirsle/blog/core/caches/null"
@ -18,6 +23,7 @@ func (b *Blog) AdminRoutes(r *mux.Router) {
adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false) adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false)
r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/" r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/"
adminRouter.HandleFunc("/settings", b.SettingsHandler) adminRouter.HandleFunc("/settings", b.SettingsHandler)
adminRouter.HandleFunc("/editor", b.EditorHandler)
adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler) adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler)
r.PathPrefix("/admin").Handler(negroni.New( r.PathPrefix("/admin").Handler(negroni.New(
negroni.HandlerFunc(b.LoginRequired), negroni.HandlerFunc(b.LoginRequired),
@ -30,6 +36,141 @@ func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) {
b.RenderTemplate(w, r, "admin/index", nil) b.RenderTemplate(w, r, "admin/index", nil)
} }
// FileTree holds information about files in the document roots.
type FileTree struct {
UserRoot bool // false = CoreRoot
Files []Filepath
}
// EditorHandler lets you edit web pages from the frontend.
func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) {
// Editing a page?
file := strings.Trim(r.FormValue("file"), "/")
if len(file) > 0 {
var (
fp string
fromCore = r.FormValue("from") == "core"
saving = r.FormValue("action") == "save"
deleting = r.FormValue("action") == "delete"
body = []byte{}
)
// Are they saving?
if saving {
fp = filepath.Join(b.UserRoot, file)
body = []byte(r.FormValue("body"))
err := ioutil.WriteFile(fp, body, 0644)
if err != nil {
b.Flash(w, r, "Error saving: %s", err)
} else {
b.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!")
return
}
} else if deleting {
fp = filepath.Join(b.UserRoot, file)
err := os.Remove(fp)
if err != nil {
b.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err)
} else {
b.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!")
return
}
} else {
// Where is the file from?
if fromCore {
fp = filepath.Join(b.DocumentRoot, file)
} else {
fp = filepath.Join(b.UserRoot, file)
}
// Check the file. If not found, check from the core root.
f, err := os.Stat(fp)
if os.IsNotExist(err) {
fp = filepath.Join(b.DocumentRoot, file)
fromCore = true
f, err = os.Stat(fp)
}
// If it exists, load it.
if !os.IsNotExist(err) && !f.IsDir() {
body, err = ioutil.ReadFile(fp)
if err != nil {
b.Flash(w, r, "Error reading %s: %s", fp, err)
}
}
// Default HTML boilerplate for .gohtml templates.
if len(body) == 0 && strings.HasSuffix(fp, ".gohtml") {
body = []byte("{{ define \"title\" }}Untitled Page{{ end }}\n" +
"{{ define \"content\" }}\n<h1>Untitled Page</h1>\n\n{{ end }}")
}
}
v := NewVars(map[interface{}]interface{}{
"File": file,
"Path": fp,
"Body": string(body),
"FromCore": fromCore,
})
b.RenderTemplate(w, r, "admin/editor", v)
return
}
// Otherwise listing the index view.
b.editorFileList(w, r)
}
// editorFileList handles the index view of /admin/editor.
func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) {
// Listing the file tree?
trees := []FileTree{}
for i, root := range []string{b.UserRoot, b.DocumentRoot} {
tree := FileTree{
UserRoot: i == 0,
Files: []Filepath{},
}
filepath.Walk(root, func(path string, f os.FileInfo, err error) error {
abs, _ := filepath.Abs(path)
rel, _ := filepath.Rel(root, path)
// Skip hidden files and directories.
if f.IsDir() || rel == "." || strings.HasPrefix(rel, ".private") || strings.HasPrefix(rel, "admin/") {
return nil
}
// Only text files.
ext := strings.ToLower(filepath.Ext(path))
okTypes := []string{
".html", ".gohtml", ".md", ".markdown", ".js", ".css", ".jsx",
}
ok := false
for _, ft := range okTypes {
if ext == ft {
ok = true
break
}
}
if !ok {
return nil
}
tree.Files = append(tree.Files, Filepath{
Absolute: abs,
Relative: rel,
Basename: filepath.Base(path),
})
return nil
})
trees = append(trees, tree)
}
v := NewVars(map[interface{}]interface{}{
"FileTrees": trees,
})
b.RenderTemplate(w, r, "admin/filelist", v)
}
// SettingsHandler lets you configure the app from the frontend. // SettingsHandler lets you configure the app from the frontend.
func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) {
v := NewVars() v := NewVars()

View File

@ -70,7 +70,7 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
// Look up the author username. // Look up the author username.
if c.UserID > 0 { if c.UserID > 0 {
if _, ok := userMap[c.UserID]; !ok { if _, ok := userMap[c.UserID]; !ok {
if user, err := users.Load(c.UserID); err == nil { if user, err2 := users.Load(c.UserID); err2 == nil {
userMap[c.UserID] = user userMap[c.UserID] = user
} }
} }
@ -220,7 +220,11 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
} }
// Append their comment. // Append their comment.
t.Post(c) err := t.Post(c)
if err != nil {
b.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err)
return
}
b.NotifyComment(c) b.NotifyComment(c)
// Are they subscribing to future comments? // Are they subscribing to future comments?
@ -236,6 +240,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
log.Info("t: %v", t.Comments)
return return
} }
} }

View File

@ -164,7 +164,11 @@ func (db *DB) ListAll(path string) ([]string, error) {
func (db *DB) makePath(path string) error { func (db *DB) makePath(path string) error {
parts := strings.Split(path, string(filepath.Separator)) parts := strings.Split(path, string(filepath.Separator))
parts = parts[:len(parts)-1] // pop off the filename parts = parts[:len(parts)-1] // pop off the filename
directory := "/" + filepath.Join(parts...) directory, err := filepath.Abs(filepath.Join(parts...))
if err != nil {
log.Error("[JsonDB] Couldn't get abs path to %s", path)
return err
}
if _, err := os.Stat(directory); err != nil { if _, err := os.Stat(directory); err != nil {
log.Debug("[JsonDB] Create directory: %s", directory) log.Debug("[JsonDB] Create directory: %s", directory)

View File

@ -23,6 +23,7 @@ type Vars struct {
LoggedIn bool LoggedIn bool
CurrentUser *users.User CurrentUser *users.User
CSRF string CSRF string
Editable bool // page is editable
Request *http.Request Request *http.Request
RequestTime time.Time RequestTime time.Time
RequestDuration time.Duration RequestDuration time.Duration
@ -125,6 +126,9 @@ func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, wi
"RenderIndex": b.RenderIndex, "RenderIndex": b.RenderIndex,
"RenderPost": b.RenderPost, "RenderPost": b.RenderPost,
"RenderTags": b.RenderTags, "RenderTags": b.RenderTags,
"TemplateName": func() string {
return filepath.URI
},
} }
if functions != nil { if functions != nil {
for name, fn := range functions { for name, fn := range functions {
@ -181,6 +185,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
vars.RequestDuration = time.Now().Sub(vars.RequestTime) vars.RequestDuration = time.Now().Sub(vars.RequestTime)
vars.CSRF = b.GenerateCSRFToken(w, r, session) vars.CSRF = b.GenerateCSRFToken(w, r, session)
vars.Editable = !strings.HasPrefix(path, "admin/")
w.Header().Set("Content-Type", "text/html; encoding=UTF-8") w.Header().Set("Content-Type", "text/html; encoding=UTF-8")
b.RenderPartialTemplate(w, path, vars, true, template.FuncMap{ b.RenderPartialTemplate(w, path, vars, true, template.FuncMap{

View File

@ -3,4 +3,15 @@
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
{{ .Message }} {{ .Message }}
{{ if .CurrentUser.Admin }}
<p>
<strong>Admin:</strong> create a
<a href="/admin/editor?file={{ .Request.URL.Path }}.md">Markdown</a>
or
<a href="/admin/editor?file={{ .Request.URL.Path }}.gohtml">HTML</a>
page here.
</p>
{{ end }}
{{ end }} {{ end }}

View File

@ -78,6 +78,12 @@
{{ end }} {{ end }}
{{ template "content" . }} {{ template "content" . }}
{{ if and .CurrentUser.Admin .Editable }}
<p class="mt-4">
<strong>Admin:</strong> [<a href="/admin/editor?file={{ TemplateName }}">edit this page</a>]
</p>
{{ end }}
</div> </div>
<div class="col-3"> <div class="col-3">
@ -95,7 +101,7 @@
<h4 class="cart-title">Control Center</h4> <h4 class="cart-title">Control Center</h4>
<p> <p>
Logged in as: <a href="/u/{{ .CurrentUser.Username }}">{{ .CurrentUser.Username }}</a> Logged in as: {{ .CurrentUser.Username }}
</p> </p>
<ul class="list-unstyled"> <ul class="list-unstyled">

10
root/about.md Normal file
View File

@ -0,0 +1,10 @@
# About Blog
This is a simple web blog and content management system written in Go.
## Features
* Web blog
* Draft, Private Posts
* Page editor
* You can edit any page from the front-end.

48
root/admin/editor.gohtml Normal file
View File

@ -0,0 +1,48 @@
{{ define "title" }}Page Editor{{ end }}
{{ define "content" }}
<h1>Edit: {{ .Data.File }}</h1>
{{ if .Data.FromCore }}
<p>
<strong>Note:</strong> this page is from the blog core root, so changes
made will be saved to your user root instead.
</p>
{{ end }}
<form action="/admin/editor" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<input type="hidden" name="save" value="true">
<div class="form-group">
<label for="file">
Filepath:
</label>
<input type="text"
size="40"
id="file"
name="file"
value="{{ .Data.File }}"
placeholder="path/to/file.gohtml"
class="form-control"
required>
</div>
<div class="form-group">
<label for="body">
Content:
</label>
<textarea
cols="40"
rows="12"
id="body"
name="body"
class="form-control"
required>{{ .Data.Body }}</textarea>
</div>
<p>
<button type="submit" name="action" value="save" class="btn btn-primary">Save Page</button>
<button type="submit" name="action" value="delete" class="btn btn-danger" onClick="return window.confirm('Are you sure?')">Delete Page</button>
</p>
</form>
{{ end }}

View File

@ -0,0 +1,34 @@
{{ define "title" }}Page Editor{{ end }}
{{ define "content" }}
<h1>Page Editor</h1>
{{ range .Data.FileTrees }}
{{ if .UserRoot }}
<h2>User Root</h2>
<p>
These are your custom web files that override those in the CoreRoot.
</p>
<ul>
{{ range .Files }}
<li><a href="/admin/editor?file={{ .Relative }}">{{ .Relative }}</a></li>
{{ end }}
</ul>
{{ else }}
<h2>Core Root</h2>
<p>
These are the blog's built-in web files. If you edit them, your
changes will be saved into your User Root to override the file
from the Core Root.
</p>
<ul>
{{ range .Files }}
<li><a href="/admin/editor?file={{ .Relative }}&from=core">{{ .Relative }}</a></li>
{{ end }}
</ul>
{{ end }}
{{ end }}
</form>
{{ end }}

View File

@ -23,7 +23,7 @@
(updated {{ $p.Updated.Format "January 2, 2006" }}) (updated {{ $p.Updated.Format "January 2, 2006" }})
</span> </span>
{{ end }} {{ end }}
by <a href="/u/{{ $a.Username }}">{{ or $a.Name $a.Username }}</a> by {{ or $a.Name $a.Username }}
</div> </div>
<div class="markdown mb-4"> <div class="markdown mb-4">

View File

@ -2,16 +2,16 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="markdown col-12 col-lg-1 mb-1"> <div class="markdown col-12 col-lg-2 mb-1">
<img src="{{ .Avatar }}" <img src="{{ .Avatar }}"
width="96" width="96"
height="96" height="96"
alt="Avatar image"> alt="Avatar image">
</div> </div>
<div class="markdown col-12 col-lg-11"> <div class="markdown col-12 col-lg-10">
<div class="comment-meta"> <div class="comment-meta">
{{ if and .UserID .Username }} {{ if and .UserID .Username }}
<a href="/u/{{ .Username }}"><strong>{{ or .Name "Anonymous" }}</strong></a> <strong>{{ or .Name "Anonymous" }}</strong> (@{{ .Username }})
{{ else }} {{ else }}
<strong>{{ or .Name "Anonymous" }}</strong> <strong>{{ or .Name "Anonymous" }}</strong>
{{ end }} {{ end }}