huge blog refactor

tidying up data structures; improvements to blog admin UI/UX, etc.
This commit is contained in:
ari melody 2025-11-08 12:54:31 +00:00
parent eaa2f6587d
commit 0c2aaa0b38
Signed by: ari
GPG key ID: CF99829C92678188
18 changed files with 432 additions and 239 deletions

View file

@ -23,17 +23,19 @@ func Handler(app *model.AppState) http.Handler {
} }
type ( type (
blogPost struct { blogPostCollection struct {
*model.BlogPost
Author *model.Account
}
blogPostGroup struct {
Year int Year int
Posts []*blogPost Posts []*model.BlogPost
} }
) )
func (c *blogPostCollection) Clone() blogPostCollection {
return blogPostCollection{
Year: c.Year,
Posts: slices.Clone(c.Posts),
}
}
func serveBlogIndex(app *model.AppState) http.Handler { func serveBlogIndex(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
@ -45,44 +47,36 @@ func serveBlogIndex(app *model.AppState) http.Handler {
return return
} }
collections := []*blogPostGroup{} collections := []*blogPostCollection{}
collectionPosts := []*blogPost{} collection := blogPostCollection{
collectionYear := -1 Posts: []*model.BlogPost{},
Year: -1,
}
for i, post := range posts { for i, post := range posts {
author, err := controller.GetAccountByID(app.DB, post.AuthorID) if i == 0 {
if err != nil { collection.Year = post.PublishDate.Year()
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", post.ID, err)
continue
} }
if collectionYear == -1 { if post.PublishDate.Year() != collection.Year {
collectionYear = post.CreatedAt.Year() clone := collection.Clone()
collections = append(collections, &clone)
collection = blogPostCollection{
Year: post.PublishDate.Year(),
Posts: []*model.BlogPost{},
}
} }
authoredPost := blogPost{ collection.Posts = append(collection.Posts, post)
BlogPost: post,
Author: author,
}
if post.CreatedAt.Year() != collectionYear || i == len(posts) - 1 {
if i == len(posts) - 1 { if i == len(posts) - 1 {
collectionPosts = append([]*blogPost{&authoredPost}, collectionPosts...) collections = append(collections, &collection)
} }
collections = append(collections, &blogPostGroup{
Year: collectionYear,
Posts: slices.Clone(collectionPosts),
})
collectionPosts = []*blogPost{}
collectionYear = post.CreatedAt.Year()
}
collectionPosts = append(collectionPosts, &authoredPost)
} }
type blogsData struct { type blogsData struct {
core.AdminPageData core.AdminPageData
TotalPosts int TotalPosts int
Collections []*blogPostGroup Collections []*blogPostCollection
} }
err = templates.BlogsTemplate.Execute(w, blogsData{ err = templates.BlogsTemplate.Execute(w, blogsData{
@ -108,7 +102,11 @@ func serveBlogPost(app *model.AppState) http.Handler {
post, err := controller.GetBlogPost(app.DB, blogID) post, err := controller.GetBlogPost(app.DB, blogID)
if err != nil { if err != nil {
fmt.Printf("can't find blog with ID %s\n", blogID) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if post == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View file

@ -109,19 +109,8 @@ func adminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
var latestBlogPost *BlogPost = nil var latestBlogPost *model.BlogPost = nil
if len(blogPosts) > 0 { if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] }
author, err := controller.GetAccountByID(app.DB, blogPosts[0].AuthorID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest blog post author %s: %v\n", blogPosts[0].AuthorID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
latestBlogPost = &BlogPost{
BlogPost: blogPosts[0],
Author: author,
}
}
blogCount, err := controller.GetBlogPostCount(app.DB, false) blogCount, err := controller.GetBlogPostCount(app.DB, false)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err)
@ -137,7 +126,7 @@ func adminIndexHandler(app *model.AppState) http.Handler {
ArtistCount int ArtistCount int
Tracks []*model.Track Tracks []*model.Track
TrackCount int TrackCount int
BlogPost *BlogPost LatestBlogPost *model.BlogPost
BlogCount int BlogCount int
} }
@ -149,7 +138,7 @@ func adminIndexHandler(app *model.AppState) http.Handler {
ArtistCount: artistCount, ArtistCount: artistCount,
Tracks: tracks, Tracks: tracks,
TrackCount: trackCount, TrackCount: trackCount,
BlogPost: latestBlogPost, LatestBlogPost: latestBlogPost,
BlogCount: blogCount, BlogCount: blogCount,
}) })
if err != nil { if err != nil {

View file

@ -1,4 +1,5 @@
.blog-collection { .blog-collection {
margin-bottom: 1em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .5em; gap: .5em;

View file

@ -5,7 +5,7 @@ input[type="text"] {
border: none; border: none;
border-radius: 4px; border-radius: 4px;
outline: none; outline: none;
color: inherit; color: var(--fg-3);
background-color: var(--bg-1); background-color: var(--bg-1);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@ -67,6 +67,17 @@ input[type="text"] {
background-color: var(--bg-3); background-color: var(--bg-3);
} }
#blogpost #publish-date {
padding: .4em .5em;
font-family: inherit;
font-size: inherit;
border-radius: 4px;
border: none;
background-color: var(--bg-1);
color: var(--fg-3);
box-shadow: var(--shadow-sm);
}
#blogpost textarea { #blogpost textarea {
width: calc(100% - 2em); width: calc(100% - 2em);
margin: 0; margin: 0;
@ -95,6 +106,12 @@ input[type="text"] {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
#blogpost .social-post-details {
margin: 1em 0 1em 0;
display: flex;
gap: 1em;
}
#blogpost .blog-actions { #blogpost .blog-actions {
margin-top: 1em; margin-top: 1em;
} }

View file

@ -1,9 +1,12 @@
const blogID = document.getElementById("blogpost").dataset.id; const blogID = document.getElementById("blogpost").dataset.id;
const titleInput = document.getElementById("title"); const titleInput = document.getElementById("title");
const publishDateInput = document.getElementById("publish-date");
const descInput = document.getElementById("description"); const descInput = document.getElementById("description");
const mdInput = document.getElementById("markdown"); const mdInput = document.getElementById("markdown");
const blueskyActorInput = document.getElementById("bluesky-actor"); const blueskyActorInput = document.getElementById("bluesky-actor");
const blueskyPostInput = document.getElementById("bluesky-post"); const blueskyRecordInput = document.getElementById("bluesky-record");
const fediverseAccountInput = document.getElementById("fediverse-account");
const fediverseStatusInput = document.getElementById("fediverse-status");
const visInput = document.getElementById("visibility"); const visInput = document.getElementById("visibility");
const saveBtn = document.getElementById("save"); const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete"); const deleteBtn = document.getElementById("delete");
@ -12,11 +15,18 @@ saveBtn.addEventListener("click", () => {
fetch("/api/v1/blog/" + blogID, { fetch("/api/v1/blog/" + blogID, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
title: titleInput.value, title: titleInput.innerText,
publish_date: publishDateInput.value + ":00Z",
description: descInput.value, description: descInput.value,
markdown: mdInput.value, markdown: mdInput.value,
bluesky_actor: blueskyActorInput.value, bluesky: {
bluesky_post: blueskyPostInput.value, actor: blueskyActorInput.value,
record: blueskyRecordInput.value,
},
fediverse: {
account: fediverseAccountInput.value,
status: fediverseStatusInput.value,
},
visible: visInput.value === "true", visible: visInput.value === "true",
}), }),
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
@ -53,7 +63,9 @@ deleteBtn.addEventListener("click", () => {
}); });
}); });
[titleInput, descInput, mdInput, blueskyActorInput, blueskyPostInput, visInput].forEach(input => { [titleInput, publishDateInput, descInput, mdInput, visInput,
blueskyActorInput, blueskyRecordInput,
fediverseAccountInput, fediverseStatusInput].forEach(input => {
input.addEventListener("change", () => { input.addEventListener("change", () => {
saveBtn.disabled = false; saveBtn.disabled = false;
}); });

View file

@ -2,7 +2,7 @@
<div class="blogpost"> <div class="blogpost">
<h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3> <h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3>
<p class="meta"> <p class="meta">
<span class="author"><img src="/img/favicon.png" alt="{{.Author.Username}}'s avatar" width="32" height="32"/> {{.Author.Username}}</span> <span class="author"><img src="/img/favicon.png" alt="" width="32" height="32"/> {{.Author.DisplayName}}</span>
<span class="date">&bull; {{.PrintDate}}</span> <span class="date">&bull; {{.PrintDate}}</span>
</p> </p>
<p class="description">{{.Description}}</p> <p class="description">{{.Description}}</p>

View file

@ -26,6 +26,9 @@
>{{.Post.Title}}</div> >{{.Post.Title}}</div>
</h2> </h2>
<label for="publish-date">Publish Date</label>
<input type="datetime-local" name="publish-date" id="publish-date" value="{{.Post.TextPublishDate}}">
<label for="description">Description</label> <label for="description">Description</label>
<textarea <textarea
id="description" id="description"
@ -43,20 +46,47 @@
rows="30" rows="30"
>{{.Post.Markdown}}</textarea> >{{.Post.Markdown}}</textarea>
<div class="social-post-details">
<div class="social-post-item">
<label for="bluesky-actor">Bluesky Author DID</label> <label for="bluesky-actor">Bluesky Author DID</label>
<input <input
type="text" type="text"
name="bluesky-actor" name="bluesky-actor"
id="bluesky-actor" id="bluesky-actor"
placeholder="did:plc:1234abcd..." placeholder="did:plc:1234abcd..."
value="{{if .Post.BlueskyActorID}}{{.Post.BlueskyActorID}}{{end}}"> value="{{if .Post.Bluesky}}{{.Post.Bluesky.ActorDID}}{{end}}">
<label for="bluesky-post">Bluesky Post ID</label> </div>
<div class="social-post-item">
<label for="bluesky-record">Bluesky Post ID</label>
<input <input
type="text" type="text"
name="bluesky-post" name="bluesky-record"
id="bluesky-post" id="bluesky-record"
placeholder="3m109a03..." placeholder="3m109a03..."
value="{{if .Post.BlueskyPostID}}{{.Post.BlueskyPostID}}{{end}}"> value="{{if .Post.Bluesky}}{{.Post.Bluesky.RecordID}}{{end}}">
</div>
</div>
<div class="social-post-details">
<div class="social-post-item">
<label for="fediverse-account">Fediverse Account</label>
<input
type="text"
name="fediverse-account"
id="fediverse-account"
placeholder="@me@my.fediverse.place"
value="{{if .Post.Fediverse}}{{.Post.Fediverse.AccountID}}{{end}}">
</div>
<div class="social-post-item">
<label for="fediverse-status">Fediverse Status ID</label>
<input
type="text"
name="fediverse-status"
id="fediverse-status"
placeholder="never consistent ¯\_(ツ)_/¯"
value="{{if .Post.Fediverse}}{{.Post.Fediverse.StatusID}}{{end}}">
</div>
</div>
<label for="visibility">Visibility</label> <label for="visibility">Visibility</label>
<select name="visibility" id="visibility"> <select name="visibility" id="visibility">

View file

@ -59,8 +59,8 @@
<h2><a href="/admin/blogs/">Latest Blog Post</a> <small>({{.BlogCount}} total)</small></h2> <h2><a href="/admin/blogs/">Latest Blog Post</a> <small>({{.BlogCount}} total)</small></h2>
<a class="button new" id="create-post">Create New</a> <a class="button new" id="create-post">Create New</a>
</div> </div>
{{if .BlogPost}} {{if .LatestBlogPost}}
{{block "blogpost" .BlogPost}}{{end}} {{block "blogpost" .LatestBlogPost}}{{end}}
{{else}} {{else}}
<p>There are no blog posts.</p> <p>There are no blog posts.</p>
{{end}} {{end}}

View file

@ -29,38 +29,23 @@ func ServeAllBlogs(app *model.AppState) http.Handler {
} }
type ( type (
BlogAuthor struct { ShortBlogPost struct {
ID string `json:"id"`
Avatar string `json:"avatar"`
}
BlogPost struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Author BlogAuthor `json:"author"` Author *model.BlogAuthor `json:"author"`
CreatedAt time.Time `json:"created_at"` PublishDate time.Time `json:"publish_date"`
} }
) )
resPosts := []*BlogPost{} resPosts := []*ShortBlogPost{}
for _, post := range posts { for _, post := range posts {
author, err := controller.GetAccountByID(app.DB, post.AuthorID) resPosts = append(resPosts, &ShortBlogPost{
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch author for blog post %s: %v\n", post.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
resPosts = append(resPosts, &BlogPost{
ID: post.ID, ID: post.ID,
Title: post.Title, Title: post.Title,
Description: post.Description, Description: post.Description,
Author: BlogAuthor{ Author: &post.Author,
ID: author.Username, PublishDate: post.PublishDate,
Avatar: author.AvatarURL.String,
},
CreatedAt: post.CreatedAt,
}) })
} }
@ -90,11 +75,13 @@ func ServeBlog(app *model.AppState) http.Handler {
return return
} }
if !blog.Visible && !privileged { if blog == nil || (!blog.Visible && !privileged) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
blog.Author.ID = blog.Author.DisplayName
err = json.NewEncoder(w).Encode(blog) err = json.NewEncoder(w).Encode(blog)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog post %s: %v\n", blogID, err) fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog post %s: %v\n", blogID, err)
@ -122,11 +109,13 @@ func CreateBlog(app *model.AppState) http.Handler {
if blog.Title == "" { blog.Title = blog.ID } if blog.Title == "" { blog.Title = blog.ID }
if !blog.CreatedAt.Equal(time.Unix(0, 0)) { if blog.PublishDate.Equal(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)) {
blog.CreatedAt = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) blog.PublishDate = time.Date(
time.Now().Year(), time.Now().Month(), time.Now().Day(),
time.Now().Hour(), time.Now().Minute(), 0, 0, time.UTC)
} }
blog.AuthorID = session.Account.ID blog.Author.ID = session.Account.ID
err = controller.CreateBlogPost(app.DB, &blog) err = controller.CreateBlogPost(app.DB, &blog)
if err != nil { if err != nil {
@ -134,18 +123,21 @@ func CreateBlog(app *model.AppState) http.Handler {
http.Error(w, fmt.Sprintf("Post %s already exists", blog.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Post %s already exists", blog.ID), http.StatusBadRequest)
return return
} }
fmt.Printf("WARN: Failed to create blog %s: %s\n", blog.ID, err) fmt.Printf("WARN: Failed to create blog %s: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" created by \"%s\".", blog.ID, session.Account.Username) app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" created by \"%s\".", blog.ID, session.Account.Username)
blog.Author.ID = session.Account.Username
blog.Author.DisplayName = session.Account.Username
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog) err = json.NewEncoder(w).Encode(blog)
if err != nil { if err != nil {
fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %s\n", blog.ID, err) fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -167,20 +159,64 @@ func UpdateBlog(app *model.AppState) http.Handler {
return return
} }
err = json.NewDecoder(r.Body).Decode(blog) type (
BlueskyRecord struct {
ActorID string `json:"actor"`
RecordID string `json:"record"`
}
FediverseStatus struct {
AccountID string `json:"account"`
StatusID string `json:"status"`
}
UpdatedBlog struct {
Title string `json:"title"`
PublishDate time.Time `json:"publish_date"`
Description string `json:"description"`
Markdown string `json:"markdown"`
Bluesky BlueskyRecord `json:"bluesky"`
Fediverse FediverseStatus `json:"fediverse"`
Visible bool `json:"visible"`
}
)
var updatedBlog UpdatedBlog
err = json.NewDecoder(r.Body).Decode(&updatedBlog)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to update blog %s: %s\n", blog.ID, err) fmt.Printf("WARN: Failed to update blog %s: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
blog.Title = updatedBlog.Title
blog.PublishDate = updatedBlog.PublishDate
blog.Description = updatedBlog.Description
blog.Markdown = updatedBlog.Markdown
if len(updatedBlog.Bluesky.ActorID) > 0 && len(updatedBlog.Bluesky.RecordID) > 0 {
blog.Bluesky = &model.BlueskyRecord{
ActorDID: updatedBlog.Bluesky.ActorID,
RecordID: updatedBlog.Bluesky.RecordID,
}
} else {
blog.Bluesky = nil
}
if len(updatedBlog.Fediverse.AccountID) > 0 && len(updatedBlog.Fediverse.StatusID) > 0 {
blog.Fediverse = &model.FediverseActivity{
AccountID: updatedBlog.Fediverse.AccountID,
StatusID: updatedBlog.Fediverse.StatusID,
}
} else {
blog.Fediverse = nil
}
blog.Visible = updatedBlog.Visible
err = controller.UpdateBlogPost(app.DB, blogID, blog) err = controller.UpdateBlogPost(app.DB, blogID, blog)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Failed to update release %s: %s\n", blogID, err) fmt.Printf("WARN: Failed to update release %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -190,7 +226,7 @@ func UpdateBlog(app *model.AppState) http.Handler {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog) err = json.NewEncoder(w).Encode(blog)
if err != nil { if err != nil {
fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %s\n", blog.ID, err) fmt.Printf("WARN: Blog post %s updated, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -201,17 +237,32 @@ func DeleteBlog(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id") blogID := r.PathValue("id")
rowsAffected, err := controller.DeleteBlogPost(app.DB, blogID) blog, err := controller.GetBlogPost(app.DB, blogID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to delete blog post %s: %s\n", blogID, err) fmt.Printf("WARN: Failed to fetch blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if rowsAffected == 0 { if blog == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
err = controller.DeleteBlogPost(app.DB, blogID)
if err != nil {
fmt.Printf("WARN: Failed to delete blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" deleted by \"%s\".", blogID, session.Account.Username) app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" deleted by \"%s\".", blogID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
if err != nil {
fmt.Printf("WARN: Blog post %s deleted, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}) })
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"database/sql"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@ -9,37 +10,96 @@ import (
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) { func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
var blog = model.BlogPost{} var blog = model.BlogPost{}
err := db.Get(&blog, "SELECT * FROM blogpost WHERE id=$1", id) rows, err := db.Query(
"SELECT post.id,post.title,post.description,post.visible," +
"post.publish_date,post.author,post.markdown," +
"post.bluesky_actor,post.bluesky_record," +
"post.fediverse_account,post.fediverse_status," +
"author.id,author.username,author.avatar_url " +
"FROM blogpost AS post " +
"JOIN account AS author ON post.author=author.id " +
"WHERE post.id=$1",
id,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
blueskyActor := sql.NullString{}
blueskyRecord := sql.NullString{}
fediverseAccount := sql.NullString{}
fediverseStatus := sql.NullString{}
if !rows.Next() {
return nil, nil
}
err = rows.Scan(
&blog.ID, &blog.Title, &blog.Description, &blog.Visible,
&blog.PublishDate, &blog.Author.ID, &blog.Markdown,
&blueskyActor, &blueskyRecord,
&fediverseAccount, &fediverseStatus,
&blog.Author.ID, &blog.Author.DisplayName, &blog.Author.AvatarURL,
)
if err != nil {
return nil, err
}
if blueskyActor.Valid && blueskyRecord.Valid {
blog.Bluesky = &model.BlueskyRecord{
ActorDID: blueskyActor.String,
RecordID: blueskyRecord.String,
}
}
if fediverseAccount.Valid && fediverseStatus.Valid {
blog.Fediverse = &model.FediverseActivity{
AccountID: fediverseAccount.String,
StatusID: fediverseStatus.String,
}
}
return &blog, nil return &blog, nil
} }
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) { func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
var blogs = []*model.BlogPost{} var blogs = []*model.BlogPost{}
query := "SELECT * FROM blogpost ORDER BY created_at" query := "SELECT post.id,post.title,post.publish_date," +
if onlyVisible { "post.description,post.visible," +
query = "SELECT * FROM blogpost WHERE visible=true ORDER BY created_at" "author.id,author.username,author.avatar_url " +
} "FROM blogpost AS post " +
"JOIN account AS author ON post.author=author.id"
if onlyVisible { query += " WHERE visible=true" }
query += " ORDER BY publish_date DESC"
var rows *sql.Rows
var err error var err error
if limit < 0 { if limit < 0 {
err = db.Select(&blogs, query) rows, err = db.Query(query)
} else { } else {
err = db.Select(&blogs, query + " LIMIT $1 OFFSET $2", limit, offset) rows, err = db.Query(query + " LIMIT $1 OFFSET $2", limit, offset)
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
// for range 4 { for rows.Next() {
// blog := *blogs[len(blogs)-1] blog := model.BlogPost{}
// blog.CreatedAt = blog.CreatedAt.Add(time.Hour * -5000) err = rows.Scan(
// blogs = append(blogs, &blog) &blog.ID,
// } &blog.Title,
&blog.PublishDate,
&blog.Description,
&blog.Visible,
&blog.Author.ID,
&blog.Author.DisplayName,
&blog.Author.AvatarURL,
)
if err != nil {
return nil, err
}
blogs = append(blogs, &blog)
}
return blogs, nil return blogs, nil
} }
@ -57,54 +117,77 @@ func GetBlogPostCount(db *sqlx.DB, onlyVisible bool) (int, error) {
} }
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error { func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
var blueskyActor *string
var blueskyRecord *string
if post.Bluesky != nil {
blueskyActor = &post.Bluesky.ActorDID
blueskyRecord = &post.Bluesky.RecordID
}
var fediverseAccount *string
var fediverseStatus *string
if post.Fediverse != nil {
fediverseAccount = &post.Fediverse.AccountID
fediverseStatus = &post.Fediverse.StatusID
}
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " + "INSERT INTO blogpost (" +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", "id,title,description,visible," +
post.ID, "publish_date,author,markdown," +
post.Title, "bluesky_actor,bluesky_record," +
post.Description, "fediverse_account,fediverse_status) " +
post.Visible, "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
post.AuthorID, post.ID, post.Title, post.Description, post.Visible,
post.Markdown, post.PublishDate, post.Author.ID, post.Markdown,
post.HTML, blueskyActor, blueskyRecord,
post.BlueskyActorID, fediverseAccount, fediverseStatus,
post.BlueskyPostID,
) )
return err return err
} }
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error { func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
var blueskyActor string
var blueskyRecord string
if post.Bluesky != nil {
blueskyActor = post.Bluesky.ActorDID
blueskyRecord = post.Bluesky.RecordID
}
var fediverseAccount string
var fediverseStatus string
if post.Fediverse != nil {
fediverseAccount = post.Fediverse.AccountID
fediverseStatus = post.Fediverse.StatusID
}
_, err := db.Exec( _, err := db.Exec(
"UPDATE blogpost SET " + "UPDATE blogpost SET " +
"id=$2,title=$3,description=$4,visible=$5,author=$6,markdown=$7,html=$8,bluesky_actor=$9,bluesky_post=$10,modified_at=CURRENT_TIMESTAMP " + "id=$2,title=$3,description=$4,visible=$5," +
"publish_date=$6,author=$7,markdown=$8," +
"bluesky_actor=$9,bluesky_record=$10," +
"fediverse_account=$11,fediverse_status=$12 " +
"WHERE id=$1", "WHERE id=$1",
postID, postID,
post.ID, post.ID, post.Title, post.Description, post.Visible,
post.Title, post.PublishDate, post.Author.ID, post.Markdown,
post.Description, blueskyActor, blueskyRecord,
post.Visible, fediverseAccount, fediverseStatus,
post.AuthorID,
post.Markdown,
post.HTML,
post.BlueskyActorID,
post.BlueskyPostID,
) )
return err return err
} }
func DeleteBlogPost(db *sqlx.DB, postID string) (int64, error) { func DeleteBlogPost(db *sqlx.DB, postID string) error {
result, err := db.Exec( _, err := db.Exec(
"DELETE FROM blogpost "+ "DELETE FROM blogpost "+
"WHERE id=$1", "WHERE id=$1",
postID, postID,
) )
if err != nil { if err != nil {
return 0, err return err
} }
rowsAffected, _ := result.RowsAffected() return nil
return rowsAffected, nil
} }

View file

@ -11,8 +11,8 @@ import (
const BSKY_API_BASE = "https://public.api.bsky.app" const BSKY_API_BASE = "https://public.api.bsky.app"
func FetchThreadViewPost(actorID string, postID string) (*model.ThreadViewPost, error) { func FetchThreadViewPost(actorID string, recordID string) (*model.ThreadViewPost, error) {
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, postID) uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, recordID)
req, err := http.NewRequest( req, err := http.NewRequest(
http.MethodGet, http.MethodGet,

View file

@ -133,12 +133,12 @@ CREATE TABLE arimelody.blogpost (
description TEXT NOT NULL, description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE, visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
modified_at TIMESTAMP,
author UUID NOT NULL, author UUID NOT NULL,
markdown TEXT NOT NULL, markdown TEXT NOT NULL,
html TEXT NOT NULL,
bluesky_actor TEXT, bluesky_actor TEXT,
bluesky_post TEXT bluesky_record TEXT,
fediverse_account TEXT,
fediverse_status TEXT
); );
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id); ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);

View file

@ -4,12 +4,12 @@ CREATE TABLE arimelody.blogpost (
description TEXT NOT NULL, description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE, visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
modified_at TIMESTAMP,
author UUID NOT NULL, author UUID NOT NULL,
markdown TEXT NOT NULL, markdown TEXT NOT NULL,
html TEXT NOT NULL,
bluesky_actor TEXT, bluesky_actor TEXT,
bluesky_post TEXT bluesky_record TEXT,
fediverse_account TEXT,
fediverse_status TEXT
); );
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id); ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -1,27 +1,38 @@
package model package model
import ( import (
"database/sql"
"fmt" "fmt"
"html/template"
"regexp" "regexp"
"strings" "strings"
"time" "time"
) )
type ( type (
BlueskyRecord struct {
ActorDID string `json:"actor"`
RecordID string `json:"record"`
}
FediverseActivity struct {
AccountID string `json:"account"`
StatusID string `json:"status"`
}
BlogAuthor struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
BlogPost struct { BlogPost struct {
ID string `json:"id" db:"id"` ID string `json:"id"`
Title string `json:"title" db:"title"` Title string `json:"title"`
Description string `json:"description" db:"description"` Description string `json:"description"`
Visible bool `json:"visible" db:"visible"` Visible bool `json:"visible"`
CreatedAt time.Time `json:"created_at" db:"created_at"` PublishDate time.Time `json:"publish_date"`
ModifiedAt sql.NullTime `json:"modified_at" db:"modified_at"` Author BlogAuthor `json:"author"`
AuthorID string `json:"author" db:"author"` Markdown string `json:"markdown"`
Markdown string `json:"markdown" db:"markdown"` Bluesky *BlueskyRecord `json:"bluesky"`
HTML template.HTML `json:"html" db:"html"` Fediverse *FediverseActivity `json:"fediverse"`
BlueskyActorID *string `json:"bluesky_actor" db:"bluesky_actor"`
BlueskyPostID *string `json:"bluesky_post" db:"bluesky_post"`
} }
) )
@ -36,16 +47,13 @@ func (b *BlogPost) TitleNormalised() string {
} }
func (b *BlogPost) GetMonth() string { func (b *BlogPost) GetMonth() string {
return fmt.Sprintf("%02d", int(b.CreatedAt.Month())) return fmt.Sprintf("%02d", int(b.PublishDate.Month()))
} }
func (b *BlogPost) PrintDate() string { func (b *BlogPost) PrintDate() string {
return b.CreatedAt.Format("2 January 2006, 15:04") return b.PublishDate.Format("2 January 2006, 15:04")
} }
func (b *BlogPost) PrintModifiedDate() string { func (b *BlogPost) TextPublishDate() string {
if !b.ModifiedAt.Valid { return b.PublishDate.Format("2006-01-02T15:04")
return ""
}
return b.ModifiedAt.Time.Format("2 January 2006, 15:04")
} }

View file

@ -121,6 +121,11 @@ article#blog p {
line-height: 1.5em; line-height: 1.5em;
} }
article#blog p.no-content {
font-style: italic;
opacity: .66;
}
article#blog sub { article#blog sub {
opacity: .75; opacity: .75;
} }

View file

@ -28,12 +28,12 @@
<p>there are no posts! 🍃</p> <p>there are no posts! 🍃</p>
{{end}} {{end}}
{{range .Collections}} {{range .Collections}}
<h2 id="{{.Name}}" class="collection-name">{{.Name}}</h2> <h2 id="{{.Year}}" class="collection-name">{{.Year}}</h2>
{{range .Posts}} {{range .Posts}}
<article class="blog-post"> <article class="blog-post">
<h3 class="blog-title"><a href="/blog/{{.ID}}">{{.Title}}</a></h3> <h3 class="blog-title"><a href="/blog/{{.ID}}">{{.Title}}</a></h3>
<p class="blog-meta"> <p class="blog-meta">
<span class="blog-author"><img src="/img/favicon.png" alt="{{.Author.Username}}'s avatar" width="32" height="32"/> {{.Author.Username}}</span> <span class="blog-author"><img src="/img/favicon.png" alt="" width="32" height="32"/> {{.Author.DisplayName}}</span>
<span class="blog-date">&bull; {{.PrintDate}}</span> <span class="blog-date">&bull; {{.PrintDate}}</span>
</p> </p>
{{if ne .Description ""}} {{if ne .Description ""}}

View file

@ -84,13 +84,17 @@
<article id="blog"> <article id="blog">
<header> <header>
<h1 class="typeout"># {{.Title}}</h1> <h1 class="typeout"># {{.Title}}</h1>
<p class="blog-author">by <a href="/blog?author={{.Author.Username}}">{{.Author.Username}} <img src="/img/favicon.png" alt="" aria-hidden="true" width="32" height="32"/></a></p> <p class="blog-author">by <a href="/blog?author={{.Author.DisplayName}}">{{.Author.DisplayName}} <img src="/img/favicon.png" alt="" aria-hidden="true" width="32" height="32"/></a></p>
<p class="blog-date">posted {{.PrintDate}}{{if .ModifiedAt.Valid}} <span class="blog-modified-date">&bull; updated {{.PrintModifiedDate}}</span>{{end}}</p> <p class="blog-date">posted {{.PrintDate}}</p>
</header> </header>
<hr> <hr>
{{.HTML}} {{if .BlogHTML}}
{{.BlogHTML}}
{{else}}
<p class="no-content">No content.</p>
{{end}}
</article> </article>
{{if ne .BlueskyURL ""}} {{if ne .BlueskyURL ""}}

View file

@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"os" "os"
"slices" "slices"
"strconv"
"strings" "strings"
"arimelody-web/controller" "arimelody-web/controller"
@ -19,18 +18,18 @@ import (
) )
type ( type (
BlogView struct { blogView struct {
Collections []*BlogViewPostCollection Collections []*blogPostCollection
} }
BlogViewPostCollection struct { blogPostCollection struct {
Name string Year int
Posts []*BlogPostView Posts []*model.BlogPost
} }
BlogPostView struct { blogPostView struct {
*model.BlogPost *model.BlogPost
Author *model.Account BlogHTML template.HTML
Comments []*model.ThreadViewPost Comments []*model.ThreadViewPost
Likes int Likes int
Boosts int Boosts int
@ -39,6 +38,13 @@ type (
} }
) )
func (c *blogPostCollection) Clone() blogPostCollection {
return blogPostCollection{
Year: c.Year,
Posts: slices.Clone(c.Posts),
}
}
var mdRenderer = html.NewRenderer(html.RendererOptions{ var mdRenderer = html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.HrefTargetBlank, Flags: html.CommonFlags | html.HrefTargetBlank,
}) })
@ -51,7 +57,7 @@ func BlogHandler(app *model.AppState) http.Handler {
}) })
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
dbPosts, err := controller.GetBlogPosts(app.DB, true, -1, 0) posts, err := controller.GetBlogPosts(app.DB, true, -1, 0)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -62,43 +68,33 @@ func BlogHandler(app *model.AppState) http.Handler {
return return
} }
collections := []*BlogViewPostCollection{} collections := []*blogPostCollection{}
posts := []*BlogPostView{} collection := blogPostCollection{
collectionYear := 0 Posts: []*model.BlogPost{},
for i, post := range dbPosts { Year: -1,
author, err := controller.GetAccountByID(app.DB, post.AuthorID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", post.ID, err)
continue
} }
for i, post := range posts {
if i == 0 { if i == 0 {
collectionYear = post.CreatedAt.Year() collection.Year = post.PublishDate.Year()
} }
if post.CreatedAt.Year() != collectionYear || i == len(dbPosts) - 1 { if post.PublishDate.Year() != collection.Year {
if i == len(dbPosts) - 1 { clone := collection.Clone()
posts = append(posts, &BlogPostView{ collections = append(collections, &clone)
BlogPost: post, collection = blogPostCollection{
Author: author, Year: post.PublishDate.Year(),
}) Posts: []*model.BlogPost{},
} }
postsCopy := slices.Clone(posts)
collections = append(collections, &BlogViewPostCollection{
Name: strconv.Itoa(collectionYear),
Posts: postsCopy,
})
posts = []*BlogPostView{}
collectionYear = post.CreatedAt.Year()
} }
posts = append(posts, &BlogPostView{ collection.Posts = append(collection.Posts, post)
BlogPost: post,
Author: author, if i == len(posts) - 1 {
}) collections = append(collections, &collection)
}
} }
err = templates.BlogTemplate.Execute(w, BlogView{ err = templates.BlogTemplate.Execute(w, blogView{
Collections: collections, Collections: collections,
}) })
if err != nil { if err != nil {
@ -119,10 +115,14 @@ func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogPostID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if blog == nil {
http.NotFound(w, r)
return
}
if !blog.Visible { if !blog.Visible {
session, err := controller.GetSessionFromRequest(app, r) session, err := controller.GetSessionFromRequest(app, r)
@ -138,37 +138,32 @@ func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler {
} }
} }
author, err := controller.GetAccountByID(app.DB, blog.AuthorID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", blog.ID, err)
}
// blog.Markdown += " <i class=\"end-mark\"></i>" // blog.Markdown += " <i class=\"end-mark\"></i>"
mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
md := mdParser.Parse([]byte(blog.Markdown)) md := mdParser.Parse([]byte(blog.Markdown))
blog.HTML = template.HTML(markdown.Render(md, mdRenderer)) blogHTML := template.HTML(markdown.Render(md, mdRenderer))
comments := []*model.ThreadViewPost{} comments := []*model.ThreadViewPost{}
likeCount := 0 likeCount := 0
boostCount := 0 boostCount := 0
var blueskyURL string var blueskyURL string
var blueskyPost *model.ThreadViewPost var blueskyPost *model.ThreadViewPost
if blog.BlueskyActorID != nil && blog.BlueskyPostID != nil { if blog.Bluesky != nil {
blueskyPost, err = controller.FetchThreadViewPost(*blog.BlueskyActorID, *blog.BlueskyPostID) blueskyPost, err = controller.FetchThreadViewPost(blog.Bluesky.ActorDID, blog.Bluesky.RecordID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err)
} else { } else {
comments = append(comments, blueskyPost.Replies...) comments = append(comments, blueskyPost.Replies...)
likeCount += blueskyPost.Post.LikeCount likeCount += blueskyPost.Post.LikeCount
boostCount += blueskyPost.Post.RepostCount boostCount += blueskyPost.Post.RepostCount
blueskyURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blueskyPost.Post.Author.Handle, *blog.BlueskyPostID) blueskyURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blueskyPost.Post.Author.Handle, blog.Bluesky.RecordID)
} }
} }
err = templates.BlogPostTemplate.Execute(w, BlogPostView{ err = templates.BlogPostTemplate.Execute(w, blogPostView{
BlogPost: blog, BlogPost: blog,
Author: author, BlogHTML: blogHTML,
Comments: comments, Comments: comments,
Likes: likeCount, Likes: likeCount,
Boosts: boostCount, Boosts: boostCount,