Add editing of education resources

This commit is contained in:
Ben Visness 2022-07-19 22:24:03 -05:00
parent a17ac21f49
commit ccd63e7a2e
10 changed files with 217 additions and 106 deletions

View File

@ -29,7 +29,7 @@ func StartServer(ctx context.Context) jobs.Job {
return jobs.Noop() return jobs.Noop()
} }
utils.Must0(os.MkdirAll(dir, fs.ModePerm)) utils.Must(os.MkdirAll(dir, fs.ModePerm))
s := server{ s := server{
log: logging.ExtractLogger(ctx).With(). log: logging.ExtractLogger(ctx).With().
@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) {
bucket, key := bucketKey(r) bucket, key := bucketKey(r)
w.Header().Set("Location", fmt.Sprintf("/%s", bucket)) w.Header().Set("Location", fmt.Sprintf("/%s", bucket))
utils.Must0(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm)) utils.Must(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm))
if key != "" { if key != "" {
file := utils.Must1(os.Create(filepath.Join(dir, bucket, key))) file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
io.Copy(file, r.Body) io.Copy(file, r.Body)

View File

@ -37,7 +37,7 @@ func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error {
CREATE TABLE education_article ( CREATE TABLE education_article (
id SERIAL NOT NULL PRIMARY KEY, id SERIAL NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL, description TEXT NOT NULL,
type INT NOT NULL, type INT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE, published BOOLEAN NOT NULL DEFAULT FALSE,

View File

@ -64,7 +64,7 @@ func BareMinimumSeed() *models.Project {
fmt.Println("Creating HMN project...") fmt.Println("Creating HMN project...")
hmn := seedProject(ctx, tx, seedHMN, nil) hmn := seedProject(ctx, tx, seedHMN, nil)
utils.Must0(tx.Commit(ctx)) utils.Must(tx.Commit(ctx))
return hmn return hmn
} }
@ -166,7 +166,7 @@ func SampleSeed() {
// Finally, set sequence numbers to things that won't conflict // Finally, set sequence numbers to things that won't conflict
utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);")) utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);"))
utils.Must0(tx.Commit(ctx)) utils.Must(tx.Commit(ctx))
} }
func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User { func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User {
@ -198,7 +198,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)), utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)),
input.ShowEmail, input.ShowEmail,
) )
utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password")) utils.Must(auth.SetPassword(ctx, conn, input.Username, "password"))
return user return user
} }

View File

@ -1,6 +1,8 @@
package models package models
import "time" import (
"time"
)
type EduArticle struct { type EduArticle struct {
ID int `db:"id"` ID int `db:"id"`

View File

@ -492,7 +492,7 @@ func TagToTemplate(t *models.Tag) Tag {
} }
} }
func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.EduArticleVersion) EduArticle { func EducationArticleToTemplate(a *models.EduArticle) EduArticle {
res := EduArticle{ res := EduArticle{
Title: a.Title, Title: a.Title,
Slug: a.Slug, Slug: a.Slug,
@ -505,9 +505,15 @@ func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.Edu
Content: "NO CONTENT HERE FOLKS YOU DID A BUG", Content: "NO CONTENT HERE FOLKS YOU DID A BUG",
} }
switch a.Type {
case models.EduArticleTypeArticle:
res.Type = "article"
case models.EduArticleTypeGlossary:
res.Type = "glossary"
}
if currentVersion != nil { if a.CurrentVersion != nil {
res.Content = template.HTML(currentVersion.ContentHTML) res.Content = template.HTML(a.CurrentVersion.ContentHTML)
} }
return res return res

View File

@ -81,21 +81,22 @@
{{ end }} {{ end }}
{{ if .ShowEduOptions }} {{ if .ShowEduOptions }}
{{/* Hope you have a .Article field! */}}
<div class="mb2"> <div class="mb2">
<label for="slug">Slug:</label> <label for="slug">Slug:</label>
<input name="slug" maxlength="255" type="text" id="slug" required /> <input name="slug" maxlength="255" type="text" id="slug" required value="{{ .Article.Slug }}" />
</div> </div>
<div class="mb2"> <div class="mb2">
<label for="type">Type:</label> <label for="type">Type:</label>
<select name="type" id="type"> <select name="type" id="type">
<option value="article">Article</option> <option value="article" {{ if eq .Article.Type "article" }}selected{{ end }}>Article</option>
<option value="glossary">Glossary Term</option> <option value="glossary" {{ if eq .Article.Type "glossary" }}selected{{ end }}>Glossary Term</option>
</select> </select>
</div> </div>
<div class="mb2"> <div class="mb2">
<label for="description">Description:</label> <label for="description">Description:</label>
<div> <div>
<textarea name="description" id="slug" required></textarea> <textarea name="description" id="slug" required>{{ .Article.Description }}</textarea>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@ -1,5 +1,5 @@
{{ template "base.html" . }} {{ template "base.html" . }}
{{ define "content" }} {{ define "content" }}
{{ .Content }} {{ .Article.Content }}
{{ end }} {{ end }}

View File

@ -390,6 +390,7 @@ type EduArticle struct {
Slug string Slug string
Description string Description string
Published bool Published bool
Type string
Url string Url string
EditUrl string EditUrl string

View File

@ -22,7 +22,7 @@ func OrDefault[T comparable](v T, def T) T {
// Takes an (error) return and panics if there is an error. // Takes an (error) return and panics if there is an error.
// Helps avoid `if err != nil` in scripts. Use sparingly in real code. // Helps avoid `if err != nil` in scripts. Use sparingly in real code.
func Must0(err error) { func Must(err error) {
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -1,10 +1,11 @@
package website package website
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/url"
"time" "time"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
@ -13,6 +14,7 @@ import (
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
) )
func EducationIndex(c *RequestContext) ResponseData { func EducationIndex(c *RequestContext) ResponseData {
@ -46,28 +48,10 @@ func EducationGlossary(c *RequestContext) ResponseData {
func EducationArticle(c *RequestContext) ResponseData { func EducationArticle(c *RequestContext) ResponseData {
type articleData struct { type articleData struct {
templates.BaseData templates.BaseData
Article templates.EduArticle
Content template.HTML
} }
type articleResult struct { article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], models.EduArticleTypeArticle)
Article models.EduArticle `db:"a"`
CurrentVersion models.EduArticleVersion `db:"v"`
}
article, err := db.QueryOne[articleResult](c, c.Conn,
`
SELECT $columns
FROM
education_article AS a
JOIN education_article_version AS v ON a.current_version = v.id
WHERE
slug = $1
AND type = $2
`,
c.PathParams["slug"],
models.EduArticleTypeArticle,
)
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)
} else if err != nil { } else if err != nil {
@ -76,7 +60,7 @@ func EducationArticle(c *RequestContext) ResponseData {
tmpl := articleData{ tmpl := articleData{
BaseData: getBaseData(c, "Handmade Education", nil), BaseData: getBaseData(c, "Handmade Education", nil),
Content: template.HTML(article.CurrentVersion.ContentHTML), Article: templates.EducationArticleToTemplate(article),
} }
var res ResponseData var res ResponseData
@ -93,7 +77,7 @@ func EducationAdmin(c *RequestContext) ResponseData {
var tmplArticles []templates.EduArticle var tmplArticles []templates.EduArticle
var tmplGlossaryTerms []templates.EduArticle var tmplGlossaryTerms []templates.EduArticle
for _, a := range articles { for _, a := range articles {
tmpl := templates.EducationArticleToTemplate(&a.Article, &a.CurrentVersion) tmpl := templates.EducationArticleToTemplate(&a.Article)
switch a.Article.Type { switch a.Article.Type {
case models.EduArticleTypeArticle: case models.EduArticleTypeArticle:
tmplArticles = append(tmplArticles, tmpl) tmplArticles = append(tmplArticles, tmpl)
@ -122,6 +106,7 @@ func EducationAdmin(c *RequestContext) ResponseData {
func EducationArticleNew(c *RequestContext) ResponseData { func EducationArticleNew(c *RequestContext) ResponseData {
type adminData struct { type adminData struct {
editorData editorData
Article map[string]interface{}
} }
tmpl := adminData{ tmpl := adminData{
@ -140,82 +125,69 @@ func EducationArticleNewSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusBadRequest, err) return c.ErrorResponse(http.StatusBadRequest, err)
} }
var redirectUrl string art, ver := getEduArticleFromForm(form)
title := form.Get("title") dupe := 0 < db.MustQueryOneScalar[int](c, c.Conn,
slug := form.Get("slug") `
description := form.Get("description") SELECT COUNT(*) FROM education_article
var eduType models.EduArticleType WHERE slug = $1
switch form.Get("type") { `,
case "article": art.Slug,
eduType = models.EduArticleTypeArticle )
redirectUrl = hmnurl.BuildEducationArticle(slug) if dupe {
case "glossary": return c.RejectRequest("A resource already exists with that slug.")
eduType = models.EduArticleTypeGlossary
redirectUrl = hmnurl.BuildEducationGlossary(slug)
default:
panic(fmt.Errorf("unknown education article type: %s", form.Get("type")))
}
published := form.Get("published") != ""
body := form.Get("body")
// TODO: Education-specific Markdown
bodyRendered := parsing.ParseMarkdown(body, parsing.ForumRealMarkdown)
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
{
dupe := 0 < db.MustQueryOneScalar[int](c, tx,
`
SELECT COUNT(*) FROM education_article
WHERE slug = $1 AND type = $2
`,
slug, eduType,
)
if dupe {
return c.RejectRequest("A resource already exists with that slug and type.")
}
articleID := db.MustQueryOneScalar[int](c, tx,
`
INSERT INTO education_article (title, slug, description, published, type, current_version)
VALUES ($1, $2, $3, $4, $5, -1)
RETURNING id
`,
title, slug, description, published, eduType,
)
versionID := db.MustQueryOneScalar[int](c, tx,
`
INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html)
VALUES ($1, $2, $3, $4, $5 )
RETURNING id
`,
articleID, time.Now(), c.CurrentUser.ID, body, bodyRendered,
)
tx.Exec(c,
`UPDATE education_article SET current_version = $1 WHERE id = $2`,
versionID, articleID,
)
}
err = tx.Commit(c)
if err != nil {
panic(err)
} }
return c.Redirect(redirectUrl, http.StatusSeeOther) createEduArticle(c, art, ver)
res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther)
res.AddFutureNotice("success", "Created new education article.")
return res
} }
func EducationArticleEdit(c *RequestContext) ResponseData { func EducationArticleEdit(c *RequestContext) ResponseData {
// TODO type adminData struct {
panic("not implemented yet") editorData
Article templates.EduArticle
}
article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0)
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
panic(err)
}
tmpl := adminData{
editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "Edit Education Article", nil), article),
Article: templates.EducationArticleToTemplate(article),
}
tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleEdit(c.PathParams["slug"])
var res ResponseData
res.MustWriteTemplate("editor.html", tmpl, c.Perf)
return res
} }
func EducationArticleEditSubmit(c *RequestContext) ResponseData { func EducationArticleEditSubmit(c *RequestContext) ResponseData {
// TODO form, err := c.GetFormValues()
panic("not implemented yet") if err != nil {
return c.ErrorResponse(http.StatusBadRequest, err)
}
_, err = fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0)
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
panic(err)
}
art, ver := getEduArticleFromForm(form)
updateEduArticle(c, c.PathParams["slug"], art, ver)
res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther)
res.AddFutureNotice("success", "Edited education article.")
return res
} }
func EducationArticleDelete(c *RequestContext) ResponseData { func EducationArticleDelete(c *RequestContext) ResponseData {
@ -228,6 +200,37 @@ func EducationArticleDeleteSubmit(c *RequestContext) ResponseData {
panic("not implemented yet") panic("not implemented yet")
} }
func fetchEduArticle(ctx context.Context, dbConn db.ConnOrTx, slug string, t models.EduArticleType) (*models.EduArticle, error) {
type eduArticleResult struct {
Article models.EduArticle `db:"a"`
CurrentVersion models.EduArticleVersion `db:"v"`
}
var qb db.QueryBuilder
qb.Add(
`
SELECT $columns
FROM
education_article AS a
JOIN education_article_version AS v ON a.current_version = v.id
WHERE
slug = $?
`,
slug,
)
if t != 0 {
qb.Add(`AND type = $?`, t)
}
res, err := db.QueryOne[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...)
if err != nil {
return nil, err
}
res.Article.CurrentVersion = &res.CurrentVersion
return &res.Article, nil
}
func getEditorDataForEduArticle( func getEditorDataForEduArticle(
urlContext *hmnurl.UrlContext, urlContext *hmnurl.UrlContext,
currentUser *models.User, currentUser *models.User,
@ -244,5 +247,103 @@ func getEditorDataForEduArticle(
ShowEduOptions: true, ShowEduOptions: true,
} }
if article != nil {
result.PostTitle = article.Title
result.EditInitialContents = article.CurrentVersion.ContentRaw
}
return result return result
} }
func getEduArticleFromForm(form url.Values) (art models.EduArticle, ver models.EduArticleVersion) {
art.Title = form.Get("title")
art.Slug = form.Get("slug")
art.Description = form.Get("description")
switch form.Get("type") {
case "article":
art.Type = models.EduArticleTypeArticle
case "glossary":
art.Type = models.EduArticleTypeGlossary
default:
panic(fmt.Errorf("unknown education article type: %s", form.Get("type")))
}
art.Published = form.Get("published") != ""
// TODO: Education-specific Markdown
ver.ContentRaw = form.Get("body")
ver.ContentHTML = parsing.ParseMarkdown(ver.ContentRaw, parsing.ForumRealMarkdown)
return
}
func createEduArticle(c *RequestContext, art models.EduArticle, ver models.EduArticleVersion) {
tx := utils.Must1(c.Conn.Begin(c))
defer tx.Rollback(c)
{
articleID := db.MustQueryOneScalar[int](c, tx,
`
INSERT INTO education_article (title, slug, description, published, type, current_version)
VALUES ($1, $2, $3, $4, $5, -1)
RETURNING id
`,
art.Title, art.Slug, art.Description, art.Published, art.Type,
)
versionID := db.MustQueryOneScalar[int](c, tx,
`
INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html)
VALUES ($1, $2, $3, $4, $5 )
RETURNING id
`,
articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML,
)
tx.Exec(c,
`UPDATE education_article SET current_version = $1 WHERE id = $2`,
versionID, articleID,
)
}
utils.Must(tx.Commit(c))
}
func updateEduArticle(c *RequestContext, slug string, art models.EduArticle, ver models.EduArticleVersion) {
tx := utils.Must1(c.Conn.Begin(c))
defer tx.Rollback(c)
{
articleID := db.MustQueryOneScalar[int](c, tx,
`SELECT id FROM education_article WHERE slug = $1`,
slug,
)
versionID := db.MustQueryOneScalar[int](c, tx,
`
INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html)
VALUES ($1, $2, $3, $4, $5 )
RETURNING id
`,
articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML,
)
tx.Exec(c,
`
UPDATE education_article
SET
title = $1, slug = $2, description = $3, published = $4, type = $5,
current_version = $6
WHERE
id = $7
`,
art.Title, art.Slug, art.Description, art.Published, art.Type,
versionID,
articleID,
)
}
utils.Must(tx.Commit(c))
}
func eduArticleURL(a *models.EduArticle) string {
switch a.Type {
case models.EduArticleTypeArticle:
return hmnurl.BuildEducationArticle(a.Slug)
case models.EduArticleTypeGlossary:
return hmnurl.BuildEducationGlossary(a.Slug)
default:
panic("unknown education article type")
}
}