Support changing instance page titles

Now admins can choose a title for their About and Privacy pages; now
editable through the instance page editor.

This adds `title` and `content_type` fields to the `appcontent` table,
requiring a migration by running `writefreely --migrate`

The content_type field specifies that items we're currently storing in
this table are all "page"s; queries for fetching these have been updated
to filter for this type. In the future, this field will be used to
indicate when an item is a stylesheet (ref T563) or other supported
type.

Ref T566
pull/95/head
Matt Baer 6 years ago
parent 4cad074b44
commit 9cb0f80921
  1. 23
      admin.go
  2. 2
      app.go
  3. 24
      database.go
  4. 14
      migrations/drivers.go
  5. 1
      migrations/migrations.go
  6. 35
      migrations/v2.go
  7. 17
      pages.go
  8. 4
      pages/about.tmpl
  9. 4
      pages/privacy.tmpl
  10. 10
      templates/user/admin/pages.tmpl
  11. 45
      templates/user/admin/view-page.tmpl

@ -11,6 +11,7 @@
package writefreely package writefreely
import ( import (
"database/sql"
"fmt" "fmt"
"github.com/gogits/gogs/pkg/tool" "github.com/gogits/gogs/pkg/tool"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -81,7 +82,7 @@ type inspectedCollection struct {
type instanceContent struct { type instanceContent struct {
ID string ID string
Type string Type string
Title string Title sql.NullString
Content string Content string
Updated time.Time Updated time.Time
} }
@ -249,19 +250,26 @@ func handleViewAdminPages(app *app, u *User, w http.ResponseWriter, r *http.Requ
// Add in default pages // Add in default pages
var hasAbout, hasPrivacy bool var hasAbout, hasPrivacy bool
for _, c := range p.Pages { for i, c := range p.Pages {
if hasAbout && hasPrivacy { if hasAbout && hasPrivacy {
break break
} }
if c.ID == "about" { if c.ID == "about" {
hasAbout = true hasAbout = true
if !c.Title.Valid {
p.Pages[i].Title = defaultAboutTitle(app.cfg)
}
} else if c.ID == "privacy" { } else if c.ID == "privacy" {
hasPrivacy = true hasPrivacy = true
if !c.Title.Valid {
p.Pages[i].Title = defaultPrivacyTitle()
}
} }
} }
if !hasAbout { if !hasAbout {
p.Pages = append(p.Pages, &instanceContent{ p.Pages = append(p.Pages, &instanceContent{
ID: "about", ID: "about",
Title: defaultAboutTitle(app.cfg),
Content: defaultAboutPage(app.cfg), Content: defaultAboutPage(app.cfg),
Updated: defaultPageUpdatedTime, Updated: defaultPageUpdatedTime,
}) })
@ -269,6 +277,7 @@ func handleViewAdminPages(app *app, u *User, w http.ResponseWriter, r *http.Requ
if !hasPrivacy { if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{ p.Pages = append(p.Pages, &instanceContent{
ID: "privacy", ID: "privacy",
Title: defaultPrivacyTitle(),
Content: defaultPrivacyPolicy(app.cfg), Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime, Updated: defaultPageUpdatedTime,
}) })
@ -308,7 +317,13 @@ func handleViewAdminPage(app *app, u *User, w http.ResponseWriter, r *http.Reque
if err != nil { if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)} return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
} }
p.UserPage = NewUserPage(app, r, u, p.Content.ID, nil) title := "New page"
if p.Content != nil {
title = "Edit " + p.Content.ID
} else {
p.Content = &instanceContent{}
}
p.UserPage = NewUserPage(app, r, u, title, nil)
showUserPage(w, "view-page", p) showUserPage(w, "view-page", p)
return nil return nil
@ -325,7 +340,7 @@ func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Req
// Update page // Update page
m := "" m := ""
err := app.db.UpdateDynamicContent(id, r.FormValue("content")) err := app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
if err != nil { if err != nil {
m = "?m=" + err.Error() m = "?m=" + err.Error()
} }

@ -115,6 +115,7 @@ func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error {
func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error { func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error {
p := struct { p := struct {
page.StaticPage page.StaticPage
ContentTitle string
Content template.HTML Content template.HTML
PlainContent string PlainContent string
Updated string Updated string
@ -141,6 +142,7 @@ func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *te
if err != nil { if err != nil {
return err return err
} }
p.ContentTitle = c.Title.String
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "")) p.Content = template.HTML(applyMarkdown([]byte(c.Content), ""))
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
if !c.Updated.IsZero() { if !c.Updated.IsZero() {

@ -116,7 +116,7 @@ type writestore interface {
CreateInvitedUser(inviteID string, userID int64) error CreateInvitedUser(inviteID string, userID int64) error
GetDynamicContent(id string) (*instanceContent, error) GetDynamicContent(id string) (*instanceContent, error)
UpdateDynamicContent(id, content string) error UpdateDynamicContent(id, title, content, contentType string) error
GetAllUsers(page uint) (*[]User, error) GetAllUsers(page uint) (*[]User, error)
GetAllUsersCount() int64 GetAllUsersCount() int64
GetUserLastPostTime(id int64) (*time.Time, error) GetUserLastPostTime(id int64) (*time.Time, error)
@ -2263,7 +2263,17 @@ func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error {
} }
func (db *datastore) GetInstancePages() ([]*instanceContent, error) { func (db *datastore) GetInstancePages() ([]*instanceContent, error) {
rows, err := db.Query("SELECT id, content, updated FROM appcontent") return db.GetAllDynamicContent("page")
}
func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) {
where := ""
params := []interface{}{}
if t != "" {
where = " WHERE content_type = ?"
params = append(params, t)
}
rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...)
if err != nil { if err != nil {
log.Error("Failed selecting from appcontent: %v", err) log.Error("Failed selecting from appcontent: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."} return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."}
@ -2273,7 +2283,7 @@ func (db *datastore) GetInstancePages() ([]*instanceContent, error) {
pages := []*instanceContent{} pages := []*instanceContent{}
for rows.Next() { for rows.Next() {
c := &instanceContent{} c := &instanceContent{}
err = rows.Scan(&c.ID, &c.Content, &c.Updated) err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type)
if err != nil { if err != nil {
log.Error("Failed scanning row: %v", err) log.Error("Failed scanning row: %v", err)
break break
@ -2292,7 +2302,7 @@ func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) {
c := &instanceContent{ c := &instanceContent{
ID: id, ID: id,
} }
err := db.QueryRow("SELECT content, updated FROM appcontent WHERE id = ?", id).Scan(&c.Content, &c.Updated) err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type)
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, nil return nil, nil
@ -2303,12 +2313,12 @@ func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) {
return c, nil return c, nil
} }
func (db *datastore) UpdateDynamicContent(id, content string) error { func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error {
var err error var err error
if db.driverName == driverSQLite { if db.driverName == driverSQLite {
_, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, content, updated) VALUES (?, ?, "+db.now()+")", id, content) _, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType)
} else { } else {
_, err = db.Exec("INSERT INTO appcontent (id, content, updated) VALUES (?, ?, "+db.now()+") "+db.upsert("id")+" content = ?, updated = "+db.now(), id, content, content) _, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content)
} }
if err != nil { if err != nil {
log.Error("Unable to INSERT appcontent for '%s': %v", id, err) log.Error("Unable to INSERT appcontent for '%s': %v", id, err)

@ -47,6 +47,13 @@ func (db *datastore) typeChar(l int) string {
return fmt.Sprintf("CHAR(%d)", l) return fmt.Sprintf("CHAR(%d)", l)
} }
func (db *datastore) typeVarChar(l int) string {
if db.driverName == driverSQLite {
return "TEXT"
}
return fmt.Sprintf("VARCHAR(%d)", l)
}
func (db *datastore) typeBool() string { func (db *datastore) typeBool() string {
if db.driverName == driverSQLite { if db.driverName == driverSQLite {
return "INTEGER" return "INTEGER"
@ -58,6 +65,13 @@ func (db *datastore) typeDateTime() string {
return "DATETIME" return "DATETIME"
} }
func (db *datastore) collateMultiByte() string {
if db.driverName == driverSQLite {
return ""
}
return " COLLATE utf8_bin"
}
func (db *datastore) engine() string { func (db *datastore) engine() string {
if db.driverName == driverSQLite { if db.driverName == driverSQLite {
return "" return ""

@ -56,6 +56,7 @@ func (m *migration) Migrate(db *datastore) error {
var migrations = []Migration{ var migrations = []Migration{
New("support user invites", supportUserInvites), // -> V1 (v0.8.0) New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
} }
// CurrentVer returns the current migration version the application is on // CurrentVer returns the current migration version the application is on

@ -0,0 +1,35 @@
/*
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package migrations
func supportInstancePages(db *datastore) error {
t, err := db.Begin()
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN title ` + db.typeVarChar(255) + db.collateMultiByte() + ` NULL`)
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN content_type ` + db.typeVarChar(36) + ` DEFAULT 'page' NOT NULL`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

@ -11,6 +11,7 @@
package writefreely package writefreely
import ( import (
"database/sql"
"github.com/writeas/writefreely/config" "github.com/writeas/writefreely/config"
"time" "time"
) )
@ -25,12 +26,20 @@ func getAboutPage(app *app) (*instanceContent, error) {
if c == nil { if c == nil {
c = &instanceContent{ c = &instanceContent{
ID: "about", ID: "about",
Type: "page",
Content: defaultAboutPage(app.cfg), Content: defaultAboutPage(app.cfg),
} }
} }
if !c.Title.Valid {
c.Title = defaultAboutTitle(app.cfg)
}
return c, nil return c, nil
} }
func defaultAboutTitle(cfg *config.Config) sql.NullString {
return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true}
}
func getPrivacyPage(app *app) (*instanceContent, error) { func getPrivacyPage(app *app) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("privacy") c, err := app.db.GetDynamicContent("privacy")
if err != nil { if err != nil {
@ -39,13 +48,21 @@ func getPrivacyPage(app *app) (*instanceContent, error) {
if c == nil { if c == nil {
c = &instanceContent{ c = &instanceContent{
ID: "privacy", ID: "privacy",
Type: "page",
Content: defaultPrivacyPolicy(app.cfg), Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime, Updated: defaultPageUpdatedTime,
} }
} }
if !c.Title.Valid {
c.Title = defaultPrivacyTitle()
}
return c, nil return c, nil
} }
func defaultPrivacyTitle() sql.NullString {
return sql.NullString{String: "Privacy Policy", Valid: true}
}
func defaultAboutPage(cfg *config.Config) string { func defaultAboutPage(cfg *config.Config) string {
if cfg.App.Federation { if cfg.App.Federation {
return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub.` return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub.`

@ -1,9 +1,9 @@
{{define "head"}}<title>About {{.SiteName}}</title> {{define "head"}}<title>{{.ContentTitle}} &mdash; {{.SiteName}}</title>
<meta name="description" content="{{.PlainContent}}"> <meta name="description" content="{{.PlainContent}}">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<div class="content-container snug"> <div class="content-container snug">
<h1>About {{.SiteName}}</h1> <h1>{{.ContentTitle}}</h1>
{{.Content}} {{.Content}}

@ -1,8 +1,8 @@
{{define "head"}}<title>{{.SiteName}} Privacy Policy</title> {{define "head"}}<title>{{.ContentTitle}} &mdash; {{.SiteName}}</title>
<meta name="description" content="{{.PlainContent}}"> <meta name="description" content="{{.PlainContent}}">
{{end}} {{end}}
{{define "content"}}<div class="content-container snug"> {{define "content"}}<div class="content-container snug">
<h1>Privacy Policy</h1> <h1>{{.ContentTitle}}</h1>
<p style="font-style:italic">Last updated {{.Updated}}</p> <p style="font-style:italic">Last updated {{.Updated}}</p>
{{.Content}} {{.Content}}

@ -1,6 +1,12 @@
{{define "pages"}} {{define "pages"}}
{{template "header" .}} {{template "header" .}}
<style>
table.classy.export .disabled, table.classy.export a {
text-transform: initial;
}
</style>
<div class="snug content-container"> <div class="snug content-container">
{{template "admin-header" .}} {{template "admin-header" .}}
@ -8,12 +14,12 @@
<table class="classy export" style="width:100%"> <table class="classy export" style="width:100%">
<tr> <tr>
<th>Pages</th> <th>Page</th>
<th>Last Modified</th> <th>Last Modified</th>
</tr> </tr>
{{range .Pages}} {{range .Pages}}
<tr> <tr>
<td><a href="/admin/page/{{.ID}}">{{.ID}}</a></td> <td><a href="/admin/page/{{.ID}}">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.ID}}{{end}}</a></td>
<td style="text-align:right">{{.UpdatedFriendly}}</td> <td style="text-align:right">{{.UpdatedFriendly}}</td>
</tr> </tr>
{{end}} {{end}}

@ -1,22 +1,53 @@
{{define "view-page"}} {{define "view-page"}}
{{template "header" .}} {{template "header" .}}
<style>
label {
display: block;
margin-top: 1em;
padding: 0 0 1em;
color: #666;
}
.content-desc {
font-size: 0.95em;
}
.page-desc {
margin: 0 0 0.5em;
}
textarea + .content-desc {
margin: 0.5em 0 1em;
font-style: italic;
}
input[type=text] {
/* Match textarea color. TODO: y is it like this thooo */
border-color: #ccc;
}
</style>
<div class="snug content-container"> <div class="snug content-container">
{{template "admin-header" .}} {{template "admin-header" .}}
<h2 id="posts-header">{{.Content.ID}} page</h2> <h2 id="posts-header">{{.Content.ID}} page</h2>
{{if .Message}}<p>{{.Message}}</p>{{end}}
{{if eq .Content.ID "about"}} {{if eq .Content.ID "about"}}
<p>Describe what your instance is <a href="/about" target="page">about</a>. <em>Accepts Markdown</em>.</p> <p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
{{else if eq .Content.ID "privacy"}} {{else if eq .Content.ID "privacy"}}
<p>Outline your <a href="/privacy" target="page">privacy policy</a>. <em>Accepts Markdown</em>.</p> <p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
{{else}}
<p><em>Accepts Markdown and HTML</em>.</p>
{{end}} {{end}}
{{if .Message}}<p>{{.Message}}</p>{{end}}
<form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)"> <form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)">
<textarea id="about-editor" class="section codable norm edit-page" name="content">{{.Content.Content}}</textarea> <label for="title">
Title
</label>
<input type="text" name="title" id="title" value="{{.Content.Title.String}}" />
<label for="content">
Content
</label>
<textarea id="content" class="section codable norm edit-page" name="content">{{.Content.Content}}</textarea>
<p class="content-desc">Accepts Markdown and HTML.</p>
<input type="submit" value="Save" /> <input type="submit" value="Save" />
</form> </form>

Loading…
Cancel
Save