~evanj/cms

98f52d2f534845b89becd8c6dbbaaca924730beb — Evan M Jones 1 year, 7 months ago 736fa6b
WIP(Reference): Reference type almost complete.
M internal/c/c.go => internal/c/c.go +5 -1
@@ 112,7 112,11 @@ func (c *Controller) HTML(w http.ResponseWriter, r *http.Request, tmpl *template
		return
	}

	// JSON response.
	c.JSON(w, r, data)
}

// TODO: You know why this is bad, change it.
func (c *Controller) JSON(w http.ResponseWriter, r *http.Request, data interface{}) {
	bytes, err := json.Marshal(data)
	if err != nil {
		c.log.Println(err)

M internal/c/content/content.go => internal/c/content/content.go +45 -0
@@ 6,6 6,7 @@ import (
	"io"
	"log"
	"net/http"
	"strconv"
	"strings"

	"git.sr.ht/~evanj/cms/internal/c"


@@ 38,6 39,7 @@ type dber interface {
	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
	ContentSearch(space space.Space, ct contenttype.ContentType, query string, page int) ([]content.Content, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error)
}



@@ 305,6 307,46 @@ func (c *Content) delete(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

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

	page, err := strconv.Atoi(r.URL.Query().Get("page"))
	if err != nil || page < 1 {
		page = 1
	}
	page-- // Show one to user but start counting at zero for us.

	user, err := c.GetCookieUser(w, r)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "must be logged in")
		return
	}

	space, err := c.db.SpaceGet(user, spaceID)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to find required space")
	}

	ct, err := c.db.ContentTypeGet(space, contenttypeID)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to find required contenttype")
	}

	list, err := c.db.ContentSearch(space, ct, query, page)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to find desired content")
		return
	}

	c.JSON(w, r, list)
}

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


@@ 316,6 358,9 @@ func (c *Content) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	case "/content/delete":
		c.delete(w, r)
		return
	case "/content/search":
		c.search(w, r)
		return
	}

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

M internal/c/contenttype/contenttype.go => internal/c/contenttype/contenttype.go +36 -1
@@ 33,6 33,7 @@ type dber interface {
	ContentTypeNew(space space.Space, name string, params []db.ContentTypeNewParam) (contenttype.ContentType, error)
	ContentTypeGet(space space.Space, contenttypeID string) (contenttype.ContentType, error)
	ContentTypeDelete(space space.Space, ct contenttype.ContentType) error
	ContentTypeSearch(space space.Space, query string, page int) ([]contenttype.ContentType, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error)
}



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



@@ 185,6 186,37 @@ func (c *ContentType) delete(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func (c *ContentType) search(w http.ResponseWriter, r *http.Request) {
	spaceID := r.FormValue("space")
	query := r.FormValue("query")

	page, err := strconv.Atoi(r.URL.Query().Get("page"))
	if err != nil || page < 1 {
		page = 1
	}
	page-- // Show one to user but start counting at zero for us.

	user, err := c.GetCookieUser(w, r)
	if err != nil {
		c.Error(w, r, http.StatusBadRequest, "must be logged in to create contenttype")
		return
	}

	space, err := c.db.SpaceGet(user, spaceID)
	if err != nil {
		c.Error(w, r, http.StatusInternalServerError, "failed to find desired space")
		return
	}

	list, err := c.db.ContentTypeSearch(space, query, page)
	if err != nil {
		c.Error(w, r, http.StatusInternalServerError, "failed to find desired contenttype")
		return
	}

	c.JSON(w, r, list)
}

func (c *ContentType) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/contenttype/new":


@@ 193,6 225,9 @@ func (c *ContentType) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	case "/contenttype/delete":
		c.delete(w, r)
		return
	case "/contenttype/search":
		c.search(w, r)
		return
	}

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

M internal/m/valuetype/valuetype.go => internal/m/valuetype/valuetype.go +2 -1
@@ 9,8 9,9 @@ const (
	InputMarkdown ValueTypeEnum = "InputMarkdown"
	File          ValueTypeEnum = "File"
	Date          ValueTypeEnum = "Date"
	Reference     ValueTypeEnum = "Reference"

	// Possible fields for the future.
	// Reference     ValueTypeEnum = "Reference"
	// ReferenceList ValueTypeEnum = "ReferenceList"
	// FileList      ValueTypeEnum = "FileList"
)

M internal/s/db/content.go => internal/s/db/content.go +41 -0
@@ 45,6 45,21 @@ var (
		WHERE ID = ?;
	`

	queryContentListByNameAndContentType = `
		SELECT DISTINCT cms_content.ID, cms_content.CONTENTTYPE_ID, cms_value_string_small.VALUE as VALUE
		FROM cms_content 

		JOIN cms_value
		ON cms_value.CONTENT_ID = cms_content.ID

		JOIN cms_value_string_small  
		ON cms_value.VALUE_ID = cms_value_string_small.ID

		WHERE cms_content.CONTENTTYPE_ID = ? 
		AND cms_value_string_small.VALUE LIKE ?
		LIMIT ? OFFSET ?;
	`

	queryContentListByContentType = `
		SELECT ID, CONTENTTYPE_ID 
		FROM cms_content 


@@ 324,6 339,32 @@ func (db *DB) ContentPerContentType(space space.Space, ct contenttype.ContentTyp
	return ret, nil
}

func (db *DB) ContentSearch(space space.Space, ct contenttype.ContentType, query string, page int) ([]content.Content, error) {
	s := fmt.Sprintf("%%%s%%", query)

	var ret []content.Content
	rows, err := db.Query(queryContentListByNameAndContentType, ct.ID(), s, perPage, perPage*page)
	if err != nil {
		db.log.Println(err)
		return nil, err
	}

	for rows.Next() {
		var content Content
		var fake ContentValue

		if err := rows.Scan(&content.ContentID, &content.ContentParentTypeID, &fake.FieldValue); err != nil {
			return nil, err
		}

		content.ContentValues = append(content.ContentValues, fake)

		ret = append(ret, &content)
	}

	return ret, nil
}

func (db *DB) ContentGet(space space.Space, ct contenttype.ContentType, contentID string) (content.Content, error) {
	var content Content
	if err := db.QueryRow(queryContentGetByID, contentID).Scan(&content.ContentID, &content.ContentParentTypeID); err != nil {

M internal/s/db/contenttype.go => internal/s/db/contenttype.go +29 -7
@@ 25,13 25,14 @@ type ContentTypeNewParam struct {
}

var (
	queryCreateContentType           = `INSERT INTO cms_contenttype (NAME, SPACE_ID) VALUES (?, ?);`
	queryDeleteContentType           = `DELETE FROM cms_contenttype WHERE ID = ?;`
	queryFindContentTypeByID         = `SELECT ID, NAME FROM cms_contenttype WHERE ID = ?`
	queryFindContentTypeByIDAndSpace = `SELECT ID, NAME FROM cms_contenttype WHERE ID = ? AND SPACE_ID = ?`
	queryFindContentTypesBySpace     = `SELECT ID, NAME FROM cms_contenttype WHERE SPACE_ID = ? LIMIT ? OFFSET ?`
	queryCreateContentTypeConnection = `INSERT INTO cms_contenttype_to_valuetype (NAME, CONTENTTYPE_ID, VALUETYPE_ID) VALUES (?, ?, ( SELECT ID FROM cms_valuetype WHERE VALUE = ? ));`
	queryFindValueTypes              = `SELECT cms_contenttype_to_valuetype.ID, NAME, VALUE FROM cms_contenttype_to_valuetype JOIN cms_valuetype ON VALUETYPE_ID = cms_valuetype.ID WHERE CONTENTTYPE_ID = ? ORDER BY cms_contenttype_to_valuetype.ID ASC;`
	queryCreateContentType             = `INSERT INTO cms_contenttype (NAME, SPACE_ID) VALUES (?, ?);`
	queryDeleteContentType             = `DELETE FROM cms_contenttype WHERE ID = ?;`
	queryFindContentTypeByID           = `SELECT ID, NAME FROM cms_contenttype WHERE ID = ?;`
	queryFindContentTypeByIDAndSpace   = `SELECT ID, NAME FROM cms_contenttype WHERE ID = ? AND SPACE_ID = ?;`
	queryFindContentTypeByNameAndSpace = `SELECT ID, NAME FROM cms_contenttype WHERE NAME LIKE ? AND SPACE_ID = ? LIMIT ? OFFSET ?;`
	queryFindContentTypesBySpace       = `SELECT ID, NAME FROM cms_contenttype WHERE SPACE_ID = ? LIMIT ? OFFSET ?;`
	queryCreateContentTypeConnection   = `INSERT INTO cms_contenttype_to_valuetype (NAME, CONTENTTYPE_ID, VALUETYPE_ID) VALUES (?, ?, ( SELECT ID FROM cms_valuetype WHERE VALUE = ? ));`
	queryFindValueTypes                = `SELECT cms_contenttype_to_valuetype.ID, NAME, VALUE FROM cms_contenttype_to_valuetype JOIN cms_valuetype ON VALUETYPE_ID = cms_valuetype.ID WHERE CONTENTTYPE_ID = ? ORDER BY cms_contenttype_to_valuetype.ID ASC;`
)

func (db *DB) ContentTypeNew(space space.Space, name string, params []ContentTypeNewParam) (contenttype.ContentType, error) {


@@ 123,6 124,27 @@ func (db *DB) ContentTypeGet(space space.Space, contenttypeID string) (contentty
	return &ct, nil
}

// TODO: Consolidate with other list function here. They are the same except for
// the query used.
func (db *DB) ContentTypeSearch(space space.Space, query string, page int) ([]contenttype.ContentType, error) {
	var ret []contenttype.ContentType
	rows, err := db.Query(queryFindContentTypeByNameAndSpace, fmt.Sprintf("%%%s%%", query), space.ID(), perPage, perPage*page)
	if err != nil {
		db.log.Println(err)
		return ret, err
	}

	for rows.Next() {
		var ct ContentType
		if err := rows.Scan(&ct.ContentTypeID, &ct.ContentTypeName); err != nil {
			return nil, err
		}
		ret = append(ret, &ct)
	}

	return ret, nil
}

func (db *DB) ContentTypeDelete(space space.Space, ct contenttype.ContentType) error {
	t, err := db.Begin()
	if err != nil {

M internal/s/db/db.go => internal/s/db/db.go +29 -75
@@ 44,34 44,27 @@ func New(log *log.Logger, typ, creds string, sec securer) (*DB, error) {

func (db *DB) CreateTables() error {
	var _ interface{}
	var err error

	// user
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_user (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) UNIQUE NOT NULL,
			HASH varchar(256) NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// space
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,
			DESCRIPTION varchar(256) NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// user to space
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_user_to_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			USER_ID INTEGER NOT NULL,


@@ 80,12 73,9 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		return err
	}

	// contenttype
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_contenttype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,


@@ 94,25 84,19 @@ func (db *DB) CreateTables() error {
			CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, NAME)
		);
	`)
	if err != nil {
		return err
	}

	// valuetype
	// This will never be created by users.
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE varchar(256) UNIQUE NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// contenttype to valuetype
	// TODO: Make name + contenttype_id unique.
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_contenttype_to_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,


@@ 122,100 106,69 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(VALUETYPE_ID) REFERENCES cms_valuetype(ID)
		);
	`)
	if err != nil {
		return err
	}

	// content
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_content (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENTTYPE_ID INTEGER NOT NULL,
			FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		return err
	}

	// content_to_value
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_value (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENT_ID INTEGER NOT NULL,
			CONTENTTYPE_TO_VALUETYPE_ID INTEGER NOT NULL,
			VALUE_ID INTEGER NOT NULL, -- Should be a foreign key but impossible to make it from three tables.
			VALUE_ID INTEGER NOT NULL, -- Should be a foreign key but impossible to make it for two+ tables.
			FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE,
			FOREIGN KEY(CONTENTTYPE_TO_VALUETYPE_ID) REFERENCES cms_contenttype_to_valuetype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		return err
	}

	// value StringSmall, File
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_value_string_small ( 
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE VARCHAR(256) NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// value StringBig, InputHTML, InputMarkdown
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_value_string_big (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE TEXT NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// value Date
	_, err = db.Exec(`
	_, _ = db.Exec(`
		CREATE TABLE cms_value_date (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE DATE NOT NULL
		);
	`)
	if err != nil {
		return err
	}

	// Only valuetypes cms supports.
	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	if err != nil {
		return err
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)
	if err != nil {
		return err
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputHTML)
	if err != nil {
		return err
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputMarkdown)
	if err != nil {
		return err
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.File)
	if err != nil {
		return err
	}
	// value Date
	_, _ = db.Exec(`
		CREATE TABLE cms_value_reference (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE DATE NOT NULL,
			FOREIGN KEY(VALUE) REFERENCES cms_content(ID)
		);
	`)

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Date)
	if err != nil {
		return err
	}
	// Only valuetypes cms supports.
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputHTML)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputMarkdown)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.File)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Date)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Reference)

	return nil
}


@@ 223,6 176,7 @@ func (db *DB) CreateTables() error {
func (db *DB) EnsureSetup() error {
	err := db.CreateTables()
	if err != nil && strings.Contains(err.Error(), "Table ") && strings.Contains(err.Error(), "already exists") {
		db.log.Println(err)
		err = nil
	}
	return err

M internal/s/tmpl/css/main.css => internal/s/tmpl/css/main.css +69 -3
@@ 1,9 1,35 @@
body {
}

main { 
header > h1 { 
  max-width: 700px;
  margin: 0 auto;
  margin-left: auto;
  margin-right: auto;
}

form { 
  max-width: 100%%;
}

dialog { 
  padding: 0;
  width: 300px; 
  max-width: 100%%;
}

dialog menu { 
  width: 100%%;
  padding: 0;
  margin: 0;
}

dialog menu > div { 
  padding: 16px;
}

dialog menu span,
dialog menu input { 
  width: 100%%;
}

input,


@@ 12,9 38,16 @@ textarea {
  box-sizing: border-box;
}

input[type=button],
select { 
  display: inline-block;
}

a,
input, 
button {
button[type=button],
button[type=submit],
summary {
  cursor: pointer;
}



@@ 51,3 84,36 @@ details > form {
  margin-bottom: 16px;
}

/* For search dropdown. */

.algolia-autocomplete {
  width: 100%%;
}

.algolia-autocomplete .aa-input, .algolia-autocomplete .aa-hint {
  width: 100%%;
}

.algolia-autocomplete .aa-hint {
  color: #999;
}

.algolia-autocomplete .aa-dropdown-menu {
  width: 100%%;
  background: white;
  border: solid black 3px;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion {
  cursor: pointer;
  padding: 5px 4px;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor {
  background-color: #B2D7FF;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion em {
  font-weight: bold;
  font-style: normal;
}

M internal/s/tmpl/css/mvp.css => internal/s/tmpl/css/mvp.css +4 -1
@@ 210,6 210,7 @@ button {
    padding: 1rem 2rem;
}

input[type=submit]:hover,
button:hover {
    cursor: pointer;
    filter: brightness(var(--hover-brightness));


@@ 217,6 218,7 @@ button:hover {

a b,
a strong,
input[type=submit],
button {
    background-color: var(--color);
    border: 2px solid var(--color);


@@ 275,6 277,7 @@ textarea {
}

input,
select,
textarea {
    border: 1px solid var(--color-bg-secondary);
    border-radius: var(--border-radius);


@@ 344,4 347,4 @@ blockquote footer {
    padding: 1.5rem 0;
}

/* Custom styles */
\ No newline at end of file
/* Custom styles */

M internal/s/tmpl/html/_header.html => internal/s/tmpl/html/_header.html +16 -2
@@ 1,4 1,18 @@
<header>
  <h1>CMS</h1>
  <p>An old-school CMS for most.</p>
  <nav>
    <a href="/">CMS</a>
    <ul>
      {{ if .Space }}
      <li><a href="/">Home</a></li>
      {{ end }}
      {{ if .ContentType }}
      <li><a href="/space/{{ .Space.ID }}">{{ .Space.Name }}</a></li>
      {{ end }}
      {{ if .Content }}
      <li><a href="/contenttype/{{ .Space.ID}}/{{ .ContentType.ID }}">{{ .ContentType.Name }}</a></li>
      {{ end }}
      <li><a href="//git.sr.ht/~evanj/cms">Source</a></li>
    </ul>
  </nav>
  <h1>A <u>minimalist</u> content management infrastructure for <mark>most</mark>.</h1>
</header>

M internal/s/tmpl/html/content.html => internal/s/tmpl/html/content.html +3 -3
@@ 11,11 11,11 @@
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>{{ .Space.Name }} > {{ .ContentType.Name }} > {{ (.Content.MustValueByName "name").Value }}</h1>
      <h1>{{ .Space.Name }}, {{ .ContentType.Name }}, {{ (.Content.MustValueByName "name").Value }}</h1>

      <details>
        <summary>Update Content</summary>


@@ 73,7 73,7 @@
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>

M internal/s/tmpl/html/contenttype.html => internal/s/tmpl/html/contenttype.html +21 -4
@@ 11,11 11,11 @@
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>{{ .Space.Name }} > {{ .ContentType.Name }}</h1>
      <h1>{{ .Space.Name }}, {{ .ContentType.Name }}</h1>

      <details>
        <summary>Create a {{ .ContentType.Name }} Content</summary>


@@ 52,6 52,22 @@
              <input required type=date name="{{ .Type }}-{{ .Name }}" placeholder="{{ .Name }}" />
            {{ end }}

            {{ if eq .Type "Reference" }}
              <input class='output-ref' required type=hidden name="{{ .Type }}-{{ .Name }}" />
              <input class='input-ref' type=button value=Open />
              <dialog>
                <menu>
                  <div>
                    <center>
                      <p>Search for content to use as reference.</p>
                    </center>
                    <input autofocus class='input-contenttype' type=text placeholder='Search by content type' />
                    <input disabled class='input-content' type=text placeholder='Search by content name' />
                  </div>
                </menu>
              </dialog>
            {{ end }}

          {{ end }}

          <input type=submit value=Create />


@@ 86,8 102,9 @@
    <hr/>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
  <script src='//unpkg.com/tinymce@5.2.0/tinymce.min.js'></script>
  <script src='https://unpkg.com/autocomplete.js@0.37.1/dist/autocomplete.min.js'></script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>

M internal/s/tmpl/html/index.html => internal/s/tmpl/html/index.html +1 -3
@@ 11,12 11,10 @@
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>Home</h1>

      {{ if .User }}

        <details>

M internal/s/tmpl/html/space.html => internal/s/tmpl/html/space.html +4 -2
@@ 11,7 11,7 @@
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>



@@ 33,9 33,11 @@
              <option disabled value="InputMarkdown">Markdown</option>
              <option disabled value="File">File</option>
              <option disabled value="Date">Date</option>
              <option disabled value="Reference">Reference</option>
            </select>
            <input disabled type=button value='Remove Field' />
          </div>
          <button id='add-fieldbtn'>Add Another Field</button>
          <input type=button id='add-fieldbtn' value='Add Another Field' />
          <input type=submit value=Create />
        </form>
      </details>

M internal/s/tmpl/js/content.js => internal/s/tmpl/js/content.js +110 -0
@@ 43,4 43,114 @@
    }
  });

  // REFERENCE
  var refs = document.querySelectorAll("form > dialog")
  var menus = document.querySelectorAll("form > dialog > menu")
  var refbtns = document.querySelectorAll(".input-ref")
  var tobtns = document.querySelectorAll(".output-ref")
  for (i = 0; i < refs.length; i++) { 
    (function(btn, menu, dialog, output) { 
      var chosenContentTypeID

      // OPEN
      btn.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
        dialog.showModal()
      })

      // CLOSE
      dialog.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
        dialog.close()
      })

      // STOP
      menu.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
      })

      // INPUTS EVENTS AND RESULTS
      var inputs = dialog.querySelectorAll('input')
      var contenttype = inputs[0]
      var content = inputs[1]

      var opts = {
        autoselect: true,
        autoselectOnBlur: true, 
        tabAutocomplete: true,
        hint: false,
        // clearOnSelected: true
      }

      function getopts(url, transform, displayKey) { 
        var contenttypeAbort = function() {}
        return {
          displayKey: displayKey,
          source: function(query, cb) { 
            cb([])
            contenttypeAbort()
            var req = new XMLHttpRequest()
            contenttypeAbort = function() { req.abort() } 
            req.onreadystatechange = function() {
              if (this.readyState != 4) {
                return
              }

              if (this.status != 200) {
                alert(this.responseText)
                cb([])
                return
              }

              try { 
                cb(transform(JSON.parse(this.responseText)))
              }
              catch(e) { 
                alert(e.toString())
              }
            }
            req.open('GET', url() + query, true)
            req.send()
          }
        }
      }

      var contenttypeOpts = getopts(
        function() { return '/contenttype/search?space={{ .Space.ID }}&query='; }, 
        function(data) { return data },
        'ContentTypeName'
      )

      window.autocomplete(contenttype, opts, [contenttypeOpts]).on('autocomplete:selected', onContentTypeSelected)
      function onContentTypeSelected(e, item, dataset, ctx) {
        chosenContentTypeID = item.ContentTypeID
        content.disabled = false
      }

      var contentOpts = getopts(
        function() { return '/content/search?space={{ .Space.ID }}&contenttype=' + chosenContentTypeID + '&query='; }, 
        function(data) { 
          // Big hack.
          data = data ? data : []
          for (i = 0; i < data.length; i++) {
            Object.assign(data[i], data[i].ContentValues[0])
          }
          return data
        },
        'FieldValue'
      )

      window.autocomplete(content, opts, [contentOpts]).on('autocomplete:selected', onContentSelected)
      function onContentSelected(e, item, dataset, ctx) {
        output.value = item.ContentID
        btn.value = item.FieldValue
        dialog.close()
      }

    })(refbtns[i], menus[i], refs[i], tobtns[i])
  }

})();

M internal/s/tmpl/js/space.js => internal/s/tmpl/js/space.js +2 -1
@@ 18,8 18,9 @@
          <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>
        <input type=button id='remove-fieldbtn_${i}' value='Remove Field' />
      </div>
    `
    addFieldBtn.parentNode.insertBefore(el, addFieldBtn)

M internal/s/tmpl/tmpls_embed.go => internal/s/tmpl/tmpls_embed.go +231 -19
@@ 10,9 10,35 @@ func init() {
	tmpls["css/main.css"] = `body {
}

main { 
header > h1 { 
  max-width: 700px;
  margin: 0 auto;
  margin-left: auto;
  margin-right: auto;
}

form { 
  max-width: 100%;
}

dialog { 
  padding: 0;
  width: 300px; 
  max-width: 100%;
}

dialog menu { 
  width: 100%;
  padding: 0;
  margin: 0;
}

dialog menu > div { 
  padding: 16px;
}

dialog menu span,
dialog menu input { 
  width: 100%;
}

input,


@@ 21,9 47,16 @@ textarea {
  box-sizing: border-box;
}

input[type=button],
select { 
  display: inline-block;
}

a,
input, 
button {
button[type=button],
button[type=submit],
summary {
  cursor: pointer;
}



@@ 60,6 93,39 @@ details > form {
  margin-bottom: 16px;
}

/* For search dropdown. */

.algolia-autocomplete {
  width: 100%;
}

.algolia-autocomplete .aa-input, .algolia-autocomplete .aa-hint {
  width: 100%;
}

.algolia-autocomplete .aa-hint {
  color: #999;
}

.algolia-autocomplete .aa-dropdown-menu {
  width: 100%;
  background: white;
  border: solid black 3px;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion {
  cursor: pointer;
  padding: 5px 4px;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor {
  background-color: #B2D7FF;
}

.algolia-autocomplete .aa-dropdown-menu .aa-suggestion em {
  font-weight: bold;
  font-style: normal;
}
`

	tmpls["css/mvp.css"] = `:root {


@@ 274,6 340,7 @@ button {
    padding: 1rem 2rem;
}

input[type=submit]:hover,
button:hover {
    cursor: pointer;
    filter: brightness(var(--hover-brightness));


@@ 281,6 348,7 @@ button:hover {

a b,
a strong,
input[type=submit],
button {
    background-color: var(--color);
    border: 2px solid var(--color);


@@ 339,6 407,7 @@ textarea {
}

input,
select,
textarea {
    border: 1px solid var(--color-bg-secondary);
    border-radius: var(--border-radius);


@@ 408,7 477,8 @@ blockquote footer {
    padding: 1.5rem 0;
}

/* Custom styles */`
/* Custom styles */
`

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


@@ 421,8 491,22 @@ blockquote footer {
`

	tmpls["html/_header.html"] = `<header>
  <h1>CMS</h1>
  <p>An old-school CMS for most.</p>
  <nav>
    <a href="/">CMS</a>
    <ul>
      {{ if .Space }}
      <li><a href="/">Home</a></li>
      {{ end }}
      {{ if .ContentType }}
      <li><a href="/space/{{ .Space.ID }}">{{ .Space.Name }}</a></li>
      {{ end }}
      {{ if .Content }}
      <li><a href="/contenttype/{{ .Space.ID}}/{{ .ContentType.ID }}">{{ .ContentType.Name }}</a></li>
      {{ end }}
      <li><a href="//git.sr.ht/~evanj/cms">Source</a></li>
    </ul>
  </nav>
  <h1>A <u>minimalist</u> content management infrastructure for <mark>most</mark>.</h1>
</header>
`



@@ 439,11 523,11 @@ blockquote footer {
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>{{ .Space.Name }} > {{ .ContentType.Name }} > {{ (.Content.MustValueByName "name").Value }}</h1>
      <h1>{{ .Space.Name }}, {{ .ContentType.Name }}, {{ (.Content.MustValueByName "name").Value }}</h1>

      <details>
        <summary>Update Content</summary>


@@ 501,7 585,7 @@ blockquote footer {
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>


@@ 520,11 604,11 @@ blockquote footer {
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>{{ .Space.Name }} > {{ .ContentType.Name }}</h1>
      <h1>{{ .Space.Name }}, {{ .ContentType.Name }}</h1>

      <details>
        <summary>Create a {{ .ContentType.Name }} Content</summary>


@@ 561,6 645,22 @@ blockquote footer {
              <input required type=date name="{{ .Type }}-{{ .Name }}" placeholder="{{ .Name }}" />
            {{ end }}

            {{ if eq .Type "Reference" }}
              <input class='output-ref' required type=hidden name="{{ .Type }}-{{ .Name }}" />
              <input class='input-ref' type=button value=Open />
              <dialog>
                <menu>
                  <div>
                    <center>
                      <p>Search for content to use as reference.</p>
                    </center>
                    <input autofocus class='input-contenttype' type=text placeholder='Search by content type' />
                    <input disabled class='input-content' type=text placeholder='Search by content name' />
                  </div>
                </menu>
              </dialog>
            {{ end }}

          {{ end }}

          <input type=submit value=Create />


@@ 595,8 695,9 @@ blockquote footer {
    <hr/>
    {{ template "html/_footer.html" }}
  </main>
  <script src="//unpkg.com/tinymce@5.2.0/tinymce.min.js"></script>
  <script>{{ template "js/content.js" }}</script>
  <script src='//unpkg.com/tinymce@5.2.0/tinymce.min.js'></script>
  <script src='https://unpkg.com/autocomplete.js@0.37.1/dist/autocomplete.min.js'></script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>


@@ 615,12 716,10 @@ blockquote footer {
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>

      <h1>Home</h1>

      {{ if .User }}

        <details>


@@ 697,7 796,7 @@ blockquote footer {
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" }}
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>



@@ 719,9 818,11 @@ blockquote footer {
              <option disabled value="InputMarkdown">Markdown</option>
              <option disabled value="File">File</option>
              <option disabled value="Date">Date</option>
              <option disabled value="Reference">Reference</option>
            </select>
            <input disabled type=button value='Remove Field' />
          </div>
          <button id='add-fieldbtn'>Add Another Field</button>
          <input type=button id='add-fieldbtn' value='Add Another Field' />
          <input type=submit value=Create />
        </form>
      </details>


@@ 800,6 901,116 @@ blockquote footer {
    }
  });

  // REFERENCE
  var refs = document.querySelectorAll("form > dialog")
  var menus = document.querySelectorAll("form > dialog > menu")
  var refbtns = document.querySelectorAll(".input-ref")
  var tobtns = document.querySelectorAll(".output-ref")
  for (i = 0; i < refs.length; i++) { 
    (function(btn, menu, dialog, output) { 
      var chosenContentTypeID

      // OPEN
      btn.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
        dialog.showModal()
      })

      // CLOSE
      dialog.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
        dialog.close()
      })

      // STOP
      menu.addEventListener('click', function(e) { 
        e.stopPropagation()
        e.preventDefault()
      })

      // INPUTS EVENTS AND RESULTS
      var inputs = dialog.querySelectorAll('input')
      var contenttype = inputs[0]
      var content = inputs[1]

      var opts = {
        autoselect: true,
        autoselectOnBlur: true, 
        tabAutocomplete: true,
        hint: false,
        // clearOnSelected: true
      }

      function getopts(url, transform, displayKey) { 
        var contenttypeAbort = function() {}
        return {
          displayKey: displayKey,
          source: function(query, cb) { 
            cb([])
            contenttypeAbort()
            var req = new XMLHttpRequest()
            contenttypeAbort = function() { req.abort() } 
            req.onreadystatechange = function() {
              if (this.readyState != 4) {
                return
              }

              if (this.status != 200) {
                alert(this.responseText)
                cb([])
                return
              }

              try { 
                cb(transform(JSON.parse(this.responseText)))
              }
              catch(e) { 
                alert(e.toString())
              }
            }
            req.open('GET', url() + query, true)
            req.send()
          }
        }
      }

      var contenttypeOpts = getopts(
        function() { return '/contenttype/search?space={{ .Space.ID }}&query='; }, 
        function(data) { return data },
        'ContentTypeName'
      )

      window.autocomplete(contenttype, opts, [contenttypeOpts]).on('autocomplete:selected', onContentTypeSelected)
      function onContentTypeSelected(e, item, dataset, ctx) {
        chosenContentTypeID = item.ContentTypeID
        content.disabled = false
      }

      var contentOpts = getopts(
        function() { return '/content/search?space={{ .Space.ID }}&contenttype=' + chosenContentTypeID + '&query='; }, 
        function(data) { 
          // Big hack.
          data = data ? data : []
          for (i = 0; i < data.length; i++) {
            Object.assign(data[i], data[i].ContentValues[0])
          }
          return data
        },
        'FieldValue'
      )

      window.autocomplete(content, opts, [contentOpts]).on('autocomplete:selected', onContentSelected)
      function onContentSelected(e, item, dataset, ctx) {
        output.value = item.ContentID
        btn.value = item.FieldValue
        dialog.close()
      }

    })(refbtns[i], menus[i], refs[i], tobtns[i])
  }

})();
`



@@ 823,8 1034,9 @@ blockquote footer {
          <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>
        <input type=button id='remove-fieldbtn_${i}' value='Remove Field' />
      </div>
    ` + "`" + `
    addFieldBtn.parentNode.insertBefore(el, addFieldBtn)

M makefile => makefile +1 -1
@@ 9,7 9,7 @@ vendor: go.mod go.sum
	go mod vendor

build:
	go build -o $(BIN)
	go build -ldflags="-s -w" -o $(BIN)

gen: 
	go generate ./...