~evanj/cms

e11cc3b6fe001e603f9a0562f0ca2b6dd5785785 — Evan M Jones 8 months ago 93b7c37
Feat(updating+json): Updating contents works. Can now deliver json
responses as well.
M internal/c/c.go => internal/c/c.go +47 -6
@@ 2,11 2,13 @@ package c

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"strings"

	"git.sr.ht/~evanj/cms/internal/m/user"
)


@@ 23,6 25,7 @@ type Controller struct {
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
}



@@ 33,7 36,8 @@ func New(log *log.Logger, db dber) *Controller {
	}
}

func (c *Controller) GetCookieUser(w http.ResponseWriter, r *http.Request) (user.User, error) {
// TODO: You know why this is bad, change it.
func (c *Controller) GetCookieUser2(w http.ResponseWriter, r *http.Request) (user.User, error) {
	cookie, err := r.Cookie(KeyUserLogin)
	if err != nil {
		return nil, err


@@ 47,6 51,25 @@ func (c *Controller) GetCookieUser(w http.ResponseWriter, r *http.Request) (user
	return user, nil
}

func (c *Controller) GetCookieUser(w http.ResponseWriter, r *http.Request) (user.User, error) {
	user, err := c.GetCookieUser2(w, r)
	if err != nil {
		// No user in cookie, lets check in basic auth.

		u, p, ok := r.BasicAuth()
		if !ok {
			return nil, fmt.Errorf("no user available")
		}

		user, err = c.db.UserGet(u, p)
		if err != nil {
			return nil, err
		}
	}

	return user, nil
}

func (c *Controller) SetCookieUser(w http.ResponseWriter, r *http.Request, user user.User) {
	var tok string
	if user != nil {


@@ 74,14 97,32 @@ func (c *Controller) Error(w http.ResponseWriter, r *http.Request, code int, str
	fmt.Fprintf(w, str)
}

// TODO: You know why this is bad, change it.
func (c *Controller) HTML(w http.ResponseWriter, r *http.Request, tmpl *template.Template, data interface{}) {
	buf := bytes.Buffer{}
	if err := tmpl.Execute(&buf, data); err != nil {
	// HTML response.
	if strings.Contains(r.Header.Get("Accept"), "text/html") {
		buf := bytes.Buffer{}
		if err := tmpl.Execute(&buf, data); err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusInternalServerError, "failed to build html response")
			return
		}

		w.WriteHeader(http.StatusOK)
		w.Header().Add("Content-Type", "text/html")
		io.Copy(w, &buf)
		return
	}

	// JSON response.
	bytes, err := json.Marshal(data)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to build html response")
		c.Error(w, r, http.StatusInternalServerError, "failed to build json response")
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Header().Add("Content-Type", "text/html")
	io.Copy(w, &buf)
	w.Header().Add("Content-Type", "application/json")
	w.Write(bytes)
}

M internal/c/content/content.go => internal/c/content/content.go +136 -1
@@ 13,6 13,7 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"
)


@@ 29,11 30,14 @@ type Content struct {
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
	SpaceGet(user user.User, spaceID string) (space.Space, error)
	ContentTypeGet(space space.Space, contenttypeID string) (contenttype.ContentType, error)
	ContentNew(space space.Space, ct contenttype.ContentType, params []db.ContentNewParam) (content.Content, error)
	ContentGet(space space.Space, ct contenttype.ContentType, contentID string) (content.Content, error)
	ContentUpdate(space space.Space, ct contenttype.ContentType, content content.Content, params []db.ContentUpdateParam) (content.Content, error)
	ContentDelete(space space.Space, ct contenttype.ContentType, content content.Content) error
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error)
}



@@ 50,6 54,30 @@ func New(log *log.Logger, db dber, e3 e3er) *Content {
	}
}

func (c *Content) tree(w http.ResponseWriter, r *http.Request, spaceID, contenttypeID, contentID string) (user.User, space.Space, contenttype.ContentType, content.Content, error) {
	user, err := c.GetCookieUser(w, r)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("must be logged in")
	}

	space, err := c.db.SpaceGet(user, spaceID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("failed to find required space")
	}

	ct, err := c.db.ContentTypeGet(space, contenttypeID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("failed to find required contenttype")
	}

	content, err := c.db.ContentGet(space, ct, contentID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("failed to find required content")
	}

	return user, space, ct, content, nil
}

func (c *Content) create(w http.ResponseWriter, r *http.Request) {
	spaceID := r.FormValue("space")
	contenttypeID := r.FormValue("contenttype")


@@ 121,7 149,7 @@ func (c *Content) create(w http.ResponseWriter, r *http.Request) {
func (c *Content) serve(w http.ResponseWriter, r *http.Request, spaceID, contenttypeID, contentID string) {
	user, err := c.GetCookieUser(w, r)
	if err != nil {
		c.Error(w, r, http.StatusBadRequest, "must be logged in to create content")
		c.Error(w, r, http.StatusBadRequest, "must be logged in")
		return
	}



@@ 152,11 180,118 @@ func (c *Content) serve(w http.ResponseWriter, r *http.Request, spaceID, content
	})
}

func (c *Content) update(w http.ResponseWriter, r *http.Request) {
	spaceID := r.FormValue("space")
	contenttypeID := r.FormValue("contenttype")
	contentID := r.FormValue("content")

	user, space, ct, content, err := c.tree(w, r, spaceID, contenttypeID, contentID)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	var params []db.ContentUpdateParam
	for key := range r.Form {
		if key == "space" || key == "contenttype" || key == "content" {
			continue
		}

		parts := strings.Split(key, "-")
		if len(parts) < 2 {
			c.Error(w, r, http.StatusInternalServerError, "invalid name field for value")
			return
		}

		typ, id, val := parts[0], parts[1], r.FormValue(key)
		if typ == valuetype.File && strings.TrimSpace(val) == "" {
			// Don't update empty file fields.
			continue
		}

		params = append(params, db.ContentUpdateParam{
			ID:    id,
			Value: val,
		})
	}

	// TODO: Upload concurrently.
	_, _, _ = r.FormFile("") // Dummy read, loads internal r.MulitpartForm state.
	for key := range r.MultipartForm.File {
		file, header, err := r.FormFile(key)
		if err != nil {
			c.Error(w, r, http.StatusInternalServerError, "failed to retreive file")
			return
		}

		// TODO: Change public to false
		url, err := c.e3.Upload(r.Context(), true, header.Filename, file)
		if err != nil {
			c.log.Println("failed to upload file", err)
			c.Error(w, r, http.StatusInternalServerError, "failed to upload file")
			return
		}

		parts := strings.Split(key, "-")
		if len(parts) < 2 {
			c.Error(w, r, http.StatusInternalServerError, "invalid name field for value")
			return
		}

		_, id := parts[0], parts[1]

		params = append(params, db.ContentUpdateParam{
			ID:    id,
			Value: url,
		})
	}

	content, err = c.db.ContentUpdate(space, ct, content, params)
	if err != nil {
		c.Error(w, r, http.StatusInternalServerError, "failed to find update content")
		return
	}

	url := fmt.Sprintf("/content/%s/%s/%s", space.ID(), ct.ID(), content.ID())
	c.log.Println("successfully updated content for user", user.Name(), "in space", space.Name(), "for contenttype", ct.Name(), "redirecting to", url)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func (c *Content) delete(w http.ResponseWriter, r *http.Request) {
	spaceID := r.FormValue("space")
	contenttypeID := r.FormValue("contenttype")
	contentID := r.FormValue("content")

	user, space, ct, content, err := c.tree(w, r, spaceID, contenttypeID, contentID)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	if err := c.db.ContentDelete(space, ct, content); err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to delete content")
		return
	}

	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ct.ID())
	c.log.Println("successfully deleted content for user", user.Name(), "in space", space.Name(), "for contenttype", ct.Name(), "redirecting to", url)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func (c *Content) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/content/new":
		c.create(w, r)
		return
	case "/content/update":
		c.update(w, r)
		return
	case "/content/delete":
		c.delete(w, r)
		return
	}

	parts := strings.Split(r.URL.Path, "/")

M internal/c/contenttype/contenttype.go => internal/c/contenttype/contenttype.go +1 -0
@@ 27,6 27,7 @@ type ContentType struct {
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
	SpaceGet(user user.User, spaceID string) (space.Space, error)
	ContentTypeNew(space space.Space, name string, params []db.ContentTypeNewParam) (contenttype.ContentType, error)

M internal/c/ping/ping.go => internal/c/ping/ping.go +1 -0
@@ 14,6 14,7 @@ type Ping struct {
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
}


M internal/c/space/space.go => internal/c/space/space.go +1 -0
@@ 25,6 25,7 @@ type Space struct {
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
	SpaceNew(user user.User, name, desc string) (space.Space, error)
	SpaceGet(user user.User, spaceID string) (space.Space, error)

M internal/m/content/content.go => internal/m/content/content.go +3 -0
@@ 1,11 1,14 @@
package content

import (
	"encoding/json"

	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/value"
)

type Content interface {
	json.Marshaler
	ID() string
	Type() contenttype.ContentType
	Values() []value.Value

M internal/m/contenttype/contenttype.go => internal/m/contenttype/contenttype.go +6 -1
@@ 1,8 1,13 @@
package contenttype

import "git.sr.ht/~evanj/cms/internal/m/valuetype"
import (
	"encoding/json"

	"git.sr.ht/~evanj/cms/internal/m/valuetype"
)

type ContentType interface {
	json.Marshaler
	ID() string
	Name() string
	Fields() []valuetype.ValueType

M internal/m/space/space.go => internal/m/space/space.go +3 -0
@@ 1,6 1,9 @@
package space

import "encoding/json"

type Space interface {
	json.Marshaler
	ID() string
	Name() string
	Desc() string

M internal/m/user/user.go => internal/m/user/user.go +3 -0
@@ 1,6 1,9 @@
package user

import "encoding/json"

type User interface {
	json.Marshaler
	ID() string
	Name() string
	Token() string

M internal/m/value/value.go => internal/m/value/value.go +1 -0
@@ 1,6 1,7 @@
package value

type Value interface {
	ID() string
	Type() string
	Name() string
	Value() string

M internal/m/valuetype/valuetype.go => internal/m/valuetype/valuetype.go +3 -0
@@ 10,6 10,9 @@ const (
	File          ValueTypeEnum = "File"
	Date          ValueTypeEnum = "Date"
	Reference     ValueTypeEnum = "Reference"
	// Possible fields for the future.
	// FileList          ValueTypeEnum = "FileList"
	// ReferenceList ValueTypeEnum = "ReferenceList"
)

type ValueType interface {

M internal/s/db/content.go => internal/s/db/content.go +62 -6
@@ 1,6 1,7 @@
package db

import (
	"encoding/json"
	"fmt"

	"git.sr.ht/~evanj/cms/internal/m/content"


@@ 18,6 19,7 @@ type Content struct {
}

type ContentValue struct {
	id    string
	typ   string // StringSmall
	name  string // Title of a blog post
	value string // "My First Blog Post!"


@@ 29,6 31,15 @@ const (
		VALUES (?);
	`

	queryContentDelete = `
		-- Delete attached values.
		DELETE FROM cms_value
		WHERE CONTENT_ID = ?;
		-- Delete content itself.
		DELETE FROM cms_content 
		WHERE ID = ?;
	`

	queryContentGetByID = `
		SELECT ID, CONTENTTYPE_ID 
		FROM cms_content 


@@ 46,8 57,14 @@ const (
		VALUES (?, (SELECT ID FROM cms_contenttype_to_valuetype WHERE CONTENTTYPE_ID = ? AND NAME = ? LIMIT 1), ?);
	`

	queryValueUpdate = `
		UPDATE cms_value 
		SET value = ?
		WHERE ID = ? 
	`

	queryValueGetByID = `
		SELECT cms_valuetype.VALUE as TYPE, cms_contenttype_to_valuetype.NAME, cms_value.VALUE
		SELECT cms_value.ID as ID, cms_valuetype.VALUE as TYPE, cms_contenttype_to_valuetype.NAME, cms_value.VALUE
		FROM cms_value 
		JOIN cms_contenttype_to_valuetype 
		ON cms_contenttype_to_valuetype.ID = cms_value.CONTENTTYPE_TO_VALUETYPE_ID


@@ 57,13 74,14 @@ const (
	`

	queryValueListByContent = `
		SELECT cms_valuetype.VALUE as TYPE, cms_contenttype_to_valuetype.NAME, cms_value.VALUE
		SELECT cms_value.ID as ID, cms_valuetype.VALUE as TYPE, cms_contenttype_to_valuetype.NAME, cms_value.VALUE
		FROM cms_value 
		JOIN cms_contenttype_to_valuetype 
		ON cms_contenttype_to_valuetype.ID = cms_value.CONTENTTYPE_TO_VALUETYPE_ID
		JOIN cms_valuetype
		ON cms_valuetype.ID = cms_contenttype_to_valuetype.VALUETYPE_ID
		WHERE CONTENT_ID = ?;
		WHERE CONTENT_ID = ?
		ORDER BY cms_contenttype_to_valuetype.ID ASC;
	`
)



@@ 106,7 124,7 @@ func (db *DB) ContentNew(space space.Space, ct contenttype.ContentType, params [
		}

		var value ContentValue
		if err := db.QueryRow(queryValueGetByID, valueID).Scan(&value.typ, &value.name, &value.value); err != nil {
		if err := db.QueryRow(queryValueGetByID, valueID).Scan(&value.id, &value.typ, &value.name, &value.value); err != nil {
			db.log.Println("db.QueryRow", err)
			return nil, fmt.Errorf("failed to find value created")
		}


@@ 121,6 139,26 @@ func (db *DB) ContentNew(space space.Space, ct contenttype.ContentType, params [
	return &content, nil
}

type ContentUpdateParam struct {
	ID    string
	Value string
}

func (db *DB) ContentUpdate(space space.Space, ct contenttype.ContentType, content content.Content, params []ContentUpdateParam) (content.Content, error) {
	for _, item := range params {
		if _, err := db.Exec(queryValueUpdate, item.Value, item.ID); err != nil {
			db.log.Println("db.Exec", err)
			return nil, fmt.Errorf("failed to create update content value '%s'", item.Value)
		}
	}
	return db.ContentGet(space, ct, content.ID())
}

func (db *DB) ContentDelete(space space.Space, ct contenttype.ContentType, content content.Content) error {
	_, err := db.Exec(queryContentDelete, content.ID(), content.ID())
	return err
}

func (db *DB) ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error) {
	var ret []content.Content
	rows, err := db.Query(queryContentListByContentType, ct.ID(), perPage, perPage*page)


@@ 143,7 181,7 @@ func (db *DB) ContentPerContentType(space space.Space, ct contenttype.ContentTyp

		for rows.Next() {
			var value ContentValue
			if err := rows.Scan(&value.typ, &value.name, &value.value); err != nil {
			if err := rows.Scan(&value.id, &value.typ, &value.name, &value.value); err != nil {
				return nil, err
			}
			content.values = append(content.values, value)


@@ 170,7 208,7 @@ func (db *DB) ContentGet(space space.Space, ct contenttype.ContentType, contentI

	for rows.Next() {
		var value ContentValue
		if err := rows.Scan(&value.typ, &value.name, &value.value); err != nil {
		if err := rows.Scan(&value.id, &value.typ, &value.name, &value.value); err != nil {
			return nil, fmt.Errorf("failed to scan values(s)")
		}
		content.values = append(content.values, value)


@@ 192,6 230,7 @@ func (c *Content) Values() []value.Value {
	var ret []value.Value
	for _, item := range c.values {
		ret = append(ret, &ContentValue{
			item.id,
			item.typ,
			item.name,
			item.value,


@@ 217,6 256,23 @@ func (c *Content) MustValueByName(name string) (ret value.Value) {
	return
}

func (c *Content) MarshalJSON() ([]byte, error) {
	fields := make(map[string]string)
	for _, item := range c.Values() {
		fields[item.Name()] = item.Value()
	}

	values := make(map[string]interface{})
	values["id"] = c.ID()
	values["fields"] = fields

	return json.Marshal(values)
}

func (c *ContentValue) ID() string {
	return c.id
}

func (c *ContentValue) Type() string {
	return c.typ
}

M internal/s/db/contenttype.go => internal/s/db/contenttype.go +14 -0
@@ 1,6 1,7 @@
package db

import (
	"encoding/json"
	"fmt"

	"git.sr.ht/~evanj/cms/internal/m/contenttype"


@@ 159,6 160,19 @@ func (ct *ContentType) Fields() []valuetype.ValueType {
	return ret
}

func (c *ContentType) MarshalJSON() ([]byte, error) {
	fields := make(map[string]string)
	for _, item := range c.Fields() {
		fields[item.Name()] = item.Type()
	}

	values := make(map[string]interface{})
	values["id"] = c.ID()
	values["fields"] = fields

	return json.Marshal(values)
}

func (f *ContentTypeField) ID() string {
	return f.id
}

M internal/s/db/space.go => internal/s/db/space.go +9 -0
@@ 1,6 1,7 @@
package db

import (
	"encoding/json"
	"fmt"

	"git.sr.ht/~evanj/cms/internal/m/space"


@@ 100,3 101,11 @@ func (s *Space) Name() string {
func (s *Space) Desc() string {
	return s.desc
}

func (c *Space) MarshalJSON() ([]byte, error) {
	values := make(map[string]string)
	values["id"] = c.ID()
	values["name"] = c.Name()
	values["desc"] = c.Desc()
	return json.Marshal(values)
}

M internal/s/db/user.go => internal/s/db/user.go +8 -0
@@ 1,6 1,7 @@
package db

import (
	"encoding/json"
	"fmt"

	"git.sr.ht/~evanj/cms/internal/m/user"


@@ 123,3 124,10 @@ func (u *User) Name() string {
func (u *User) Token() string {
	return u.token
}

func (u *User) MarshalJSON() ([]byte, error) {
	values := make(map[string]string)
	values["id"] = u.ID()
	values["name"] = u.Name()
	return json.Marshal(values)
}

A internal/s/tmpl/css/main.css => internal/s/tmpl/css/main.css +34 -0
@@ 0,0 1,34 @@
body {
}

main { 
  max-width: 700px;
  margin: 0 auto;
}

input,
textarea { 
  display: block;
  box-sizing: border-box;
}

input, 
button {
  cursor: pointer;
}

textarea { 
  width: 100%%;
}

.input-html,
.input-markdown { 
  display: block !important; /* tinymce hides this. */
  width: 0;
  height: 0;
  resize: none;
  overflow: hidden;
  position: relative;
  left: 50%%;
  top: 100px;
}

A internal/s/tmpl/html/_footer.html => internal/s/tmpl/html/_footer.html +3 -0
@@ 0,0 1,3 @@
<footer>
  <center>© 2015-2020 Evan Jones</center>
</footer>

A internal/s/tmpl/html/_head.html => internal/s/tmpl/html/_head.html +0 -0

A internal/s/tmpl/html/_header.html => internal/s/tmpl/html/_header.html +4 -0
@@ 0,0 1,4 @@
<header>
  <h1>CMS</h1>
  <p>An old-school CMS for most.</p>
</header>

M internal/s/tmpl/html/content.html => internal/s/tmpl/html/content.html +43 -19
@@ 8,13 8,7 @@
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form textarea, form button { display: block; margin: 5px 0; }
    form textarea { width: 100%%; resize: vertical; min-height: 150px;}
    form div input, form div textarea, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>


@@ 42,29 36,59 @@
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <input required type=hidden name=content value="{{ .Content.ID }}" />

        <br/>

        {{ range .Content.Values }}

          <strong>type:</strong> {{ .Type }}
          <br/>
          <strong>name:</strong> {{ .Name }}
          <br/>
          <strong>value:</strong> {{ .Value }}
          <br/>
          <br/>
          <label>{{ .Name }}</label>

          {{ if eq .Type "StringSmall" }}
            <input value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

          {{ if eq .Type "StringBig" }}
            <textarea required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "InputHTML" }}
            <textarea class='input-html' value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "InputMarkdown" }}
            <textarea class='input-markdown' value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "File" }}
            <input value="{{ .Value }}" type=file name="{{ .Type }}-{{ .ID }}" multiple=false />
          {{ end }}

          {{ if eq .Type "Date" }}
            <input value="{{ .Value }}" required type=date name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

          {{ if eq .Type "Reference" }}
            <input value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

        {{ end }}

        <input type=submit value=Go />
      </form>

      <form method=POST action='/content/delete' enctype='multipart/form-data'>
        <legend>Delete Content:</legend>
        <input required type=hidden name=space value="{{ .Space.ID }}" />
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <input required type=hidden name=content value="{{ .Content.ID }}" />
        <input type=submit value=Go />
      </form>


    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script src="//unpkg.com/tinymce@5.2.0/plugins/textpattern/plugin.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
</body>

</html>

M internal/s/tmpl/html/contenttype.html => internal/s/tmpl/html/contenttype.html +10 -15
@@ 8,13 8,7 @@
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form textarea, form button { display: block; margin: 5px 0; }
    form textarea { width: 100%%; resize: vertical; min-height: 150px;}
    form div input, form div textarea, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>


@@ 38,9 32,12 @@
        <legend>Create a <strong>{{ .ContentType.Name }}</strong>:</legend>
        <input required type=hidden name=space value="{{ .Space.ID }}" />
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <br/>

        {{ range .ContentType.Fields }}

          <label>{{ .Name }}</label>

          {{ if eq .Type "StringSmall" }}
            <input required type=text name="{{ .Name }}" placeholder="{{ .Name }}" />
          {{ end }}


@@ 50,13 47,11 @@
          {{ end }}

          {{ if eq .Type "InputHTML" }}
            TODO: HTML
            <textarea required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
            <textarea class='input-html' required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
          {{ end }}

          {{ if eq .Type "InputMarkdown" }}
            TODO: Markdown
            <textarea required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
            <textarea class='input-markdown' required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
          {{ end }}

          {{ if eq .Type "File" }}


@@ 68,7 63,6 @@
          {{ end }}

          {{ if eq .Type "Reference" }}
            TODO: Reference
            <input required type=text name="{{ .Name }}" placeholder="{{ .Name }}" />
          {{ end }}



@@ 97,10 91,11 @@

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script src="//unpkg.com/tinymce@5.2.0/plugins/textpattern/plugin.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
</body>

</html>

M internal/s/tmpl/html/index.html => internal/s/tmpl/html/index.html +3 -11
@@ 8,16 8,10 @@
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input { display: block; margin: 10px 0; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>
      <h1>CMS</h1>
      <p>An old-school CMS for most.</p>
    </header>
    {{ template "html/_header.html" }}
    <hr/>
    <article>



@@ 71,9 65,7 @@

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
</body>


M internal/s/tmpl/html/space.html => internal/s/tmpl/html/space.html +4 -50
@@ 8,18 8,10 @@
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form button { display: block; margin: 5px 0; }
    form div input, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>
      <h1>CMS</h1>
      <p>An old-school CMS for most.</p>
    </header>
    {{ template "html/_header.html" }}
    <hr/>
    <article>



@@ 63,47 55,9 @@

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>

  <script>
    (function() { 
      var addFieldBtn = document.getElementById('add-fieldbtn')
      var i = 1
      addFieldBtn.addEventListener('click', function(e) { 
        i++
        e.preventDefault()
        e.stopPropagation()
        var el = document.createElement('div')
        el.innerHTML = `
          <div>
            <input required type=text name="field_name_${i}" placeholder="field name" />
            <select required name="field_type_${i}">
              <option disabled selected value>Field Type</option>
              <option value="StringSmall">String Small</option>
              <option value="StringBig">String Big</option>
              <option value="InputHTML">HTML</option>
              <option value="InputMarkdown">Markdown</option>
              <option value="File">File</option>
              <option value="Date">Date</option>
              <option value="Reference">Reference</option>
            </select>
            <button id='remove-fieldbtn_${i}'>Remove Field</button>
          </div>
        `
        addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
        var removeFieldBtn = document.getElementById(`remove-fieldbtn_${i}`)
        removeFieldBtn.addEventListener('click', function(e) { 
          i--
          e.preventDefault()
          e.stopPropagation()
          el.parentNode.removeChild(el)
        })
      })
    })();
  </script>
  <script>{{ template "js/space.js" }}</script>
</body>

</html>

A internal/s/tmpl/js/content.js => internal/s/tmpl/js/content.js +42 -0
@@ 0,0 1,42 @@
// Setup inputs for content create/update.
(function() { 

  // HTML
  tinymce.init({ 
    selector: 'textarea.input-html',
    statusbar: false,
    setup: function(item) { 
      item.on('change', function() { 
        item.targetElm.value = item.getContent()
      })
    }
  })

  // MARKDOWN
  tinymce.init({
    selector: "textarea.input-markdown",
    plugin: 'textpattern',
    menubar: false,
    toolbar: 'undo redo',
    statusbar: false,
    textpattern_patterns: [
      {start: '*', end: '*', format: 'italic'},
      {start: '**', end: '**', format: 'bold'},
      {start: '#', format: 'h1'},
      {start: '##', format: 'h2'},
      {start: '###', format: 'h3'},
      {start: '####', format: 'h4'},
      {start: '#####', format: 'h5'},
      {start: '######', format: 'h6'},
      {start: '1. ', cmd: 'InsertOrderedList'},
      {start: '* ', cmd: 'InsertUnorderedList'},
      {start: '- ', cmd: 'InsertUnorderedList'}
    ],
    setup: function(item) { 
      item.on('change', function() { 
        item.targetElm.value = item.getContent()
      })
    }
  });

})();

A internal/s/tmpl/js/space.js => internal/s/tmpl/js/space.js +35 -0
@@ 0,0 1,35 @@
// Add more fields to space create.
(function() { 
  var addFieldBtn = document.getElementById('add-fieldbtn')
  var i = 1
  addFieldBtn.addEventListener('click', function(e) { 
    i++
    e.preventDefault()
    e.stopPropagation()
    var el = document.createElement('div')
    el.innerHTML = `
      <div>
        <input required type=text name="field_name_${i}" placeholder="field name" />
        <select required name="field_type_${i}">
          <option disabled selected value>Field Type</option>
          <option value="StringSmall">String Small</option>
          <option value="StringBig">String Big</option>
          <option value="InputHTML">HTML</option>
          <option value="InputMarkdown">Markdown</option>
          <option value="File">File</option>
          <option value="Date">Date</option>
          <option value="Reference">Reference</option>
        </select>
        <button id='remove-fieldbtn_${i}'>Remove Field</button>
      </div>
    `
    addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
    var removeFieldBtn = document.getElementById(`remove-fieldbtn_${i}`)
    removeFieldBtn.addEventListener('click', function(e) { 
      i--
      e.preventDefault()
      e.stopPropagation()
      el.parentNode.removeChild(el)
    })
  })
})();

M internal/s/tmpl/tmpl.go => internal/s/tmpl/tmpl.go +13 -3
@@ 1,9 1,19 @@
package tmpl

import "html/template"
import (
	"html/template"
)

//go:generate embed -pattern html/* -id tmpls
//go:generate embed -pattern */* -id tmpls

var all *template.Template

func MustParse(name string) *template.Template {
	return template.Must(template.New(name).Parse(Must(name)))
	if all == nil {
		all = template.New("cms")
		for key, val := range tmpls {
			all = template.Must(all.New(key).Parse(val))
		}
	}
	return all.Lookup(name)
}

M internal/s/tmpl/tmpls_embed.go => internal/s/tmpl/tmpls_embed.go +191 -96
@@ 1,4 1,4 @@
// Code generated by "embed -pattern html/* -id tmpls"; DO NOT EDIT.
// Code generated by "embed -pattern */* -id tmpls"; DO NOT EDIT.

package tmpl



@@ 7,6 7,55 @@ var tmpls map[string]string
func init() {
	tmpls = make(map[string]string)

	tmpls["css/main.css"] = `body {
}

main { 
  max-width: 700px;
  margin: 0 auto;
}

input,
textarea { 
  display: block;
  box-sizing: border-box;
}

input, 
button {
  cursor: pointer;
}

textarea { 
  width: 100%;
}

.input-html,
.input-markdown { 
  display: block !important; /* tinymce hides this. */
  width: 0;
  height: 0;
  resize: none;
  overflow: hidden;
  position: relative;
  left: 50%;
  top: 100px;
}
`

	tmpls["html/_footer.html"] = `<footer>
  <center>© 2015-2020 Evan Jones</center>
</footer>
`

	tmpls["html/_head.html"] = ``

	tmpls["html/_header.html"] = `<header>
  <h1>CMS</h1>
  <p>An old-school CMS for most.</p>
</header>
`

	tmpls["html/content.html"] = `<!DOCTYPE html>
<html lang=en>



@@ 17,13 66,7 @@ func init() {
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form textarea, form button { display: block; margin: 5px 0; }
    form textarea { width: 100%; resize: vertical; min-height: 150px;}
    form div input, form div textarea, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>


@@ 51,29 94,59 @@ func init() {
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <input required type=hidden name=content value="{{ .Content.ID }}" />

        <br/>

        {{ range .Content.Values }}

          <strong>type:</strong> {{ .Type }}
          <br/>
          <strong>name:</strong> {{ .Name }}
          <br/>
          <strong>value:</strong> {{ .Value }}
          <br/>
          <br/>
          <label>{{ .Name }}</label>

          {{ if eq .Type "StringSmall" }}
            <input value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

          {{ if eq .Type "StringBig" }}
            <textarea required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "InputHTML" }}
            <textarea class='input-html' value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "InputMarkdown" }}
            <textarea class='input-markdown' value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}">{{ .Value }}</textarea>
          {{ end }}

          {{ if eq .Type "File" }}
            <input value="{{ .Value }}" type=file name="{{ .Type }}-{{ .ID }}" multiple=false />
          {{ end }}

          {{ if eq .Type "Date" }}
            <input value="{{ .Value }}" required type=date name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

          {{ if eq .Type "Reference" }}
            <input value="{{ .Value }}" required type=text name="{{ .Type }}-{{ .ID }}" placeholder="{{ .Name }}" />
          {{ end }}

        {{ end }}

        <input type=submit value=Go />
      </form>

      <form method=POST action='/content/delete' enctype='multipart/form-data'>
        <legend>Delete Content:</legend>
        <input required type=hidden name=space value="{{ .Space.ID }}" />
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <input required type=hidden name=content value="{{ .Content.ID }}" />
        <input type=submit value=Go />
      </form>


    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script src="//unpkg.com/tinymce@5.2.0/plugins/textpattern/plugin.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
</body>

</html>


@@ 89,13 162,7 @@ func init() {
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form textarea, form button { display: block; margin: 5px 0; }
    form textarea { width: 100%; resize: vertical; min-height: 150px;}
    form div input, form div textarea, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>


@@ 119,9 186,12 @@ func init() {
        <legend>Create a <strong>{{ .ContentType.Name }}</strong>:</legend>
        <input required type=hidden name=space value="{{ .Space.ID }}" />
        <input required type=hidden name=contenttype value="{{ .ContentType.ID }}" />
        <br/>

        {{ range .ContentType.Fields }}

          <label>{{ .Name }}</label>

          {{ if eq .Type "StringSmall" }}
            <input required type=text name="{{ .Name }}" placeholder="{{ .Name }}" />
          {{ end }}


@@ 131,13 201,11 @@ func init() {
          {{ end }}

          {{ if eq .Type "InputHTML" }}
            TODO: HTML
            <textarea required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
            <textarea class='input-html' required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
          {{ end }}

          {{ if eq .Type "InputMarkdown" }}
            TODO: Markdown
            <textarea required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
            <textarea class='input-markdown' required type=text name="{{ .Name }}" placeholder="{{ .Name }}" ></textarea>
          {{ end }}

          {{ if eq .Type "File" }}


@@ 149,7 217,6 @@ func init() {
          {{ end }}

          {{ if eq .Type "Reference" }}
            TODO: Reference
            <input required type=text name="{{ .Name }}" placeholder="{{ .Name }}" />
          {{ end }}



@@ 178,10 245,11 @@ func init() {

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script src="//unpkg.com/tinymce@5.2.0/plugins/textpattern/plugin.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
</body>

</html>


@@ 197,16 265,10 @@ func init() {
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input { display: block; margin: 10px 0; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>
      <h1>CMS</h1>
      <p>An old-school CMS for most.</p>
    </header>
    {{ template "html/_header.html" }}
    <hr/>
    <article>



@@ 260,9 322,7 @@ func init() {

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>
</body>



@@ 279,18 339,10 @@ func init() {
</head>

<body>
  <style>
    main { max-width: 600px; margin: 0 auto; }
    form input, form button { display: block; margin: 5px 0; }
    form div input, form div button { display: inline-block; }
    form > input { margin-top: 10px; }
  </style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    <header>
      <h1>CMS</h1>
      <p>An old-school CMS for most.</p>
    </header>
    {{ template "html/_header.html" }}
    <hr/>
    <article>



@@ 334,52 386,95 @@ func init() {

    </article>
    <hr/>
    <footer>
      <center>© 2015-2020 Evan Jones</center>
    </footer>
    {{ template "html/_footer.html" }}
  </main>

  <script>
    (function() { 
      var addFieldBtn = document.getElementById('add-fieldbtn')
      var i = 1
      addFieldBtn.addEventListener('click', function(e) { 
        i++
        e.preventDefault()
        e.stopPropagation()
        var el = document.createElement('div')
        el.innerHTML = ` + "`" + `
          <div>
            <input required type=text name="field_name_${i}" placeholder="field name" />
            <select required name="field_type_${i}">
              <option disabled selected value>Field Type</option>
              <option value="StringSmall">String Small</option>
              <option value="StringBig">String Big</option>
              <option value="InputHTML">HTML</option>
              <option value="InputMarkdown">Markdown</option>
              <option value="File">File</option>
              <option value="Date">Date</option>
              <option value="Reference">Reference</option>
            </select>
            <button id='remove-fieldbtn_${i}'>Remove Field</button>
          </div>
        ` + "`" + `
        addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
        var removeFieldBtn = document.getElementById(` + "`" + `remove-fieldbtn_${i}` + "`" + `)
        removeFieldBtn.addEventListener('click', function(e) { 
          i--
          e.preventDefault()
          e.stopPropagation()
          el.parentNode.removeChild(el)
        })
      })
    })();
  </script>
  <script>{{ template "js/space.js" }}</script>
</body>

</html>
`

	tmpls["js/content.js"] = `// Setup inputs for content create/update.
(function() { 

  // HTML
  tinymce.init({ 
    selector: 'textarea.input-html',
    statusbar: false,
    setup: function(item) { 
      item.on('change', function() { 
        item.targetElm.value = item.getContent()
      })
    }
  })

  // MARKDOWN
  tinymce.init({
    selector: "textarea.input-markdown",
    plugin: 'textpattern',
    menubar: false,
    toolbar: 'undo redo',
    statusbar: false,
    textpattern_patterns: [
      {start: '*', end: '*', format: 'italic'},
      {start: '**', end: '**', format: 'bold'},
      {start: '#', format: 'h1'},
      {start: '##', format: 'h2'},
      {start: '###', format: 'h3'},
      {start: '####', format: 'h4'},
      {start: '#####', format: 'h5'},
      {start: '######', format: 'h6'},
      {start: '1. ', cmd: 'InsertOrderedList'},
      {start: '* ', cmd: 'InsertUnorderedList'},
      {start: '- ', cmd: 'InsertUnorderedList'}
    ],
    setup: function(item) { 
      item.on('change', function() { 
        item.targetElm.value = item.getContent()
      })
    }
  });

})();
`

	tmpls["js/space.js"] = `// Add more fields to space create.
(function() { 
  var addFieldBtn = document.getElementById('add-fieldbtn')
  var i = 1
  addFieldBtn.addEventListener('click', function(e) { 
    i++
    e.preventDefault()
    e.stopPropagation()
    var el = document.createElement('div')
    el.innerHTML = ` + "`" + `
      <div>
        <input required type=text name="field_name_${i}" placeholder="field name" />
        <select required name="field_type_${i}">
          <option disabled selected value>Field Type</option>
          <option value="StringSmall">String Small</option>
          <option value="StringBig">String Big</option>
          <option value="InputHTML">HTML</option>
          <option value="InputMarkdown">Markdown</option>
          <option value="File">File</option>
          <option value="Date">Date</option>
          <option value="Reference">Reference</option>
        </select>
        <button id='remove-fieldbtn_${i}'>Remove Field</button>
      </div>
    ` + "`" + `
    addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
    var removeFieldBtn = document.getElementById(` + "`" + `remove-fieldbtn_${i}` + "`" + `)
    removeFieldBtn.addEventListener('click', function(e) { 
      i--
      e.preventDefault()
      e.stopPropagation()
      el.parentNode.removeChild(el)
    })
  })
})();
`

}

func Get(name string) (string, bool) {

M makefile => makefile +1 -1
@@ 20,7 20,7 @@ test:
dev: dev-server dev-client

dev-client:
	find . -type f -name '*.html' | entr -r sh -c "make gen"
	find . -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) | entr -r sh -c "make gen"

dev-server:
	find . -type f -name '*.go' | entr -r sh -c "clear && make build && ./$(BIN)"