diff --git a/src/hmns3/hmns3.go b/src/hmns3/hmns3.go
index 91db660..a39a5e6 100644
--- a/src/hmns3/hmns3.go
+++ b/src/hmns3/hmns3.go
@@ -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)
diff --git a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go
index 325b201..ed1bfc1 100644
--- a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go
+++ b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go
@@ -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,
diff --git a/src/migration/seed.go b/src/migration/seed.go
index 0abad6e..e4bc8df 100644
--- a/src/migration/seed.go
+++ b/src/migration/seed.go
@@ -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
}
diff --git a/src/models/education.go b/src/models/education.go
index afa4f28..26a4bb9 100644
--- a/src/models/education.go
+++ b/src/models/education.go
@@ -1,6 +1,8 @@
package models
-import "time"
+import (
+ "time"
+)
type EduArticle struct {
ID int `db:"id"`
diff --git a/src/templates/mapping.go b/src/templates/mapping.go
index 24a17ff..d964115 100644
--- a/src/templates/mapping.go
+++ b/src/templates/mapping.go
@@ -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
diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html
index 4d9b452..3519996 100644
--- a/src/templates/src/editor.html
+++ b/src/templates/src/editor.html
@@ -81,21 +81,22 @@
{{ end }}
{{ if .ShowEduOptions }}
+ {{/* Hope you have a .Article field! */}}
-
+
{{ end }}
diff --git a/src/templates/src/education_article.html b/src/templates/src/education_article.html
index 5d4c4c9..033dad6 100644
--- a/src/templates/src/education_article.html
+++ b/src/templates/src/education_article.html
@@ -1,5 +1,5 @@
{{ template "base.html" . }}
{{ define "content" }}
- {{ .Content }}
+ {{ .Article.Content }}
{{ end }}
diff --git a/src/templates/types.go b/src/templates/types.go
index 3977704..292ef61 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -390,6 +390,7 @@ type EduArticle struct {
Slug string
Description string
Published bool
+ Type string
Url string
EditUrl string
diff --git a/src/utils/utils.go b/src/utils/utils.go
index b8d73f7..6df2c0d 100644
--- a/src/utils/utils.go
+++ b/src/utils/utils.go
@@ -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)
}
diff --git a/src/website/education.go b/src/website/education.go
index 1e39f96..b6b387e 100644
--- a/src/website/education.go
+++ b/src/website/education.go
@@ -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")
+ }
+}