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()
}
utils.Must0(os.MkdirAll(dir, fs.ModePerm))
utils.Must(os.MkdirAll(dir, fs.ModePerm))
s := server{
log: logging.ExtractLogger(ctx).With().
@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) {
bucket, key := bucketKey(r)
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 != "" {
file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
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 (
id SERIAL NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
type INT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,

View File

@ -64,7 +64,7 @@ func BareMinimumSeed() *models.Project {
fmt.Println("Creating HMN project...")
hmn := seedProject(ctx, tx, seedHMN, nil)
utils.Must0(tx.Commit(ctx))
utils.Must(tx.Commit(ctx))
return hmn
}
@ -166,7 +166,7 @@ func SampleSeed() {
// Finally, set sequence numbers to things that won't conflict
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 {
@ -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)),
input.ShowEmail,
)
utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password"))
utils.Must(auth.SetPassword(ctx, conn, input.Username, "password"))
return user
}

View File

@ -1,6 +1,8 @@
package models
import "time"
import (
"time"
)
type EduArticle struct {
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{
Title: a.Title,
Slug: a.Slug,
@ -505,9 +505,15 @@ func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.Edu
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 {
res.Content = template.HTML(currentVersion.ContentHTML)
if a.CurrentVersion != nil {
res.Content = template.HTML(a.CurrentVersion.ContentHTML)
}
return res

View File

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

View File

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

View File

@ -390,6 +390,7 @@ type EduArticle struct {
Slug string
Description string
Published bool
Type string
Url 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.
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
func Must0(err error) {
func Must(err error) {
if err != nil {
panic(err)
}

View File

@ -1,10 +1,11 @@
package website
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"time"
"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/parsing"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
)
func EducationIndex(c *RequestContext) ResponseData {
@ -46,28 +48,10 @@ func EducationGlossary(c *RequestContext) ResponseData {
func EducationArticle(c *RequestContext) ResponseData {
type articleData struct {
templates.BaseData
Content template.HTML
Article templates.EduArticle
}
type articleResult struct {
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,
)
article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], models.EduArticleTypeArticle)
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
@ -76,7 +60,7 @@ func EducationArticle(c *RequestContext) ResponseData {
tmpl := articleData{
BaseData: getBaseData(c, "Handmade Education", nil),
Content: template.HTML(article.CurrentVersion.ContentHTML),
Article: templates.EducationArticleToTemplate(article),
}
var res ResponseData
@ -93,7 +77,7 @@ func EducationAdmin(c *RequestContext) ResponseData {
var tmplArticles []templates.EduArticle
var tmplGlossaryTerms []templates.EduArticle
for _, a := range articles {
tmpl := templates.EducationArticleToTemplate(&a.Article, &a.CurrentVersion)
tmpl := templates.EducationArticleToTemplate(&a.Article)
switch a.Article.Type {
case models.EduArticleTypeArticle:
tmplArticles = append(tmplArticles, tmpl)
@ -122,6 +106,7 @@ func EducationAdmin(c *RequestContext) ResponseData {
func EducationArticleNew(c *RequestContext) ResponseData {
type adminData struct {
editorData
Article map[string]interface{}
}
tmpl := adminData{
@ -140,82 +125,69 @@ func EducationArticleNewSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusBadRequest, err)
}
var redirectUrl string
art, ver := getEduArticleFromForm(form)
title := form.Get("title")
slug := form.Get("slug")
description := form.Get("description")
var eduType models.EduArticleType
switch form.Get("type") {
case "article":
eduType = models.EduArticleTypeArticle
redirectUrl = hmnurl.BuildEducationArticle(slug)
case "glossary":
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)
dupe := 0 < db.MustQueryOneScalar[int](c, c.Conn,
`
SELECT COUNT(*) FROM education_article
WHERE slug = $1
`,
art.Slug,
)
if dupe {
return c.RejectRequest("A resource already exists with that slug.")
}
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 {
// TODO
panic("not implemented yet")
type adminData struct {
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 {
// TODO
panic("not implemented yet")
form, err := c.GetFormValues()
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 {
@ -228,6 +200,37 @@ func EducationArticleDeleteSubmit(c *RequestContext) ResponseData {
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(
urlContext *hmnurl.UrlContext,
currentUser *models.User,
@ -244,5 +247,103 @@ func getEditorDataForEduArticle(
ShowEduOptions: true,
}
if article != nil {
result.PostTitle = article.Title
result.EditInitialContents = article.CurrentVersion.ContentRaw
}
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")
}
}