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 (
blogPost struct {
*model.BlogPost
Author *model.Account
}
blogPostGroup struct {
blogPostCollection struct {
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -45,44 +47,36 @@ func serveBlogIndex(app *model.AppState) http.Handler {
return
}
collections := []*blogPostGroup{}
collectionPosts := []*blogPost{}
collectionYear := -1
collections := []*blogPostCollection{}
collection := blogPostCollection{
Posts: []*model.BlogPost{},
Year: -1,
}
for i, post := range posts {
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
if i == 0 {
collection.Year = post.PublishDate.Year()
}
if collectionYear == -1 {
collectionYear = post.CreatedAt.Year()
}
authoredPost := blogPost{
BlogPost: post,
Author: author,
}
if post.CreatedAt.Year() != collectionYear || i == len(posts) - 1 {
if i == len(posts) - 1 {
collectionPosts = append([]*blogPost{&authoredPost}, collectionPosts...)
if post.PublishDate.Year() != collection.Year {
clone := collection.Clone()
collections = append(collections, &clone)
collection = blogPostCollection{
Year: post.PublishDate.Year(),
Posts: []*model.BlogPost{},
}
collections = append(collections, &blogPostGroup{
Year: collectionYear,
Posts: slices.Clone(collectionPosts),
})
collectionPosts = []*blogPost{}
collectionYear = post.CreatedAt.Year()
}
collectionPosts = append(collectionPosts, &authoredPost)
collection.Posts = append(collection.Posts, post)
if i == len(posts) - 1 {
collections = append(collections, &collection)
}
}
type blogsData struct {
core.AdminPageData
TotalPosts int
Collections []*blogPostGroup
Collections []*blogPostCollection
}
err = templates.BlogsTemplate.Execute(w, blogsData{
@ -108,7 +102,11 @@ func serveBlogPost(app *model.AppState) http.Handler {
post, err := controller.GetBlogPost(app.DB, blogID)
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)
return
}

View file

@ -109,19 +109,8 @@ func adminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
var latestBlogPost *BlogPost = nil
if len(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,
}
}
var latestBlogPost *model.BlogPost = nil
if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] }
blogCount, err := controller.GetBlogPostCount(app.DB, false)
if err != nil {
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
Tracks []*model.Track
TrackCount int
BlogPost *BlogPost
LatestBlogPost *model.BlogPost
BlogCount int
}
@ -149,7 +138,7 @@ func adminIndexHandler(app *model.AppState) http.Handler {
ArtistCount: artistCount,
Tracks: tracks,
TrackCount: trackCount,
BlogPost: latestBlogPost,
LatestBlogPost: latestBlogPost,
BlogCount: blogCount,
})
if err != nil {

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<div class="blogpost">
<h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3>
<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>
</p>
<p class="description">{{.Description}}</p>

View file

@ -26,6 +26,9 @@
>{{.Post.Title}}</div>
</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>
<textarea
id="description"
@ -43,20 +46,47 @@
rows="30"
>{{.Post.Markdown}}</textarea>
<label for="bluesky-actor">Bluesky Author DID</label>
<input
type="text"
name="bluesky-actor"
id="bluesky-actor"
placeholder="did:plc:1234abcd..."
value="{{if .Post.BlueskyActorID}}{{.Post.BlueskyActorID}}{{end}}">
<label for="bluesky-post">Bluesky Post ID</label>
<input
type="text"
name="bluesky-post"
id="bluesky-post"
placeholder="3m109a03..."
value="{{if .Post.BlueskyPostID}}{{.Post.BlueskyPostID}}{{end}}">
<div class="social-post-details">
<div class="social-post-item">
<label for="bluesky-actor">Bluesky Author DID</label>
<input
type="text"
name="bluesky-actor"
id="bluesky-actor"
placeholder="did:plc:1234abcd..."
value="{{if .Post.Bluesky}}{{.Post.Bluesky.ActorDID}}{{end}}">
</div>
<div class="social-post-item">
<label for="bluesky-record">Bluesky Post ID</label>
<input
type="text"
name="bluesky-record"
id="bluesky-record"
placeholder="3m109a03..."
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>
<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>
<a class="button new" id="create-post">Create New</a>
</div>
{{if .BlogPost}}
{{block "blogpost" .BlogPost}}{{end}}
{{if .LatestBlogPost}}
{{block "blogpost" .LatestBlogPost}}{{end}}
{{else}}
<p>There are no blog posts.</p>
{{end}}

View file

@ -29,38 +29,23 @@ func ServeAllBlogs(app *model.AppState) http.Handler {
}
type (
BlogAuthor struct {
ID string `json:"id"`
Avatar string `json:"avatar"`
}
BlogPost struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Author BlogAuthor `json:"author"`
CreatedAt time.Time `json:"created_at"`
ShortBlogPost struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Author *model.BlogAuthor `json:"author"`
PublishDate time.Time `json:"publish_date"`
}
)
resPosts := []*BlogPost{}
resPosts := []*ShortBlogPost{}
for _, post := range posts {
author, err := controller.GetAccountByID(app.DB, post.AuthorID)
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{
resPosts = append(resPosts, &ShortBlogPost{
ID: post.ID,
Title: post.Title,
Description: post.Description,
Author: BlogAuthor{
ID: author.Username,
Avatar: author.AvatarURL.String,
},
CreatedAt: post.CreatedAt,
Author: &post.Author,
PublishDate: post.PublishDate,
})
}
@ -90,11 +75,13 @@ func ServeBlog(app *model.AppState) http.Handler {
return
}
if !blog.Visible && !privileged {
if blog == nil || (!blog.Visible && !privileged) {
http.NotFound(w, r)
return
}
blog.Author.ID = blog.Author.DisplayName
err = json.NewEncoder(w).Encode(blog)
if err != nil {
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.CreatedAt.Equal(time.Unix(0, 0)) {
blog.CreatedAt = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
if blog.PublishDate.Equal(time.Date(1, 1, 1, 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)
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)
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)
return
}
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.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
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)
}
})
@ -167,20 +159,64 @@ func UpdateBlog(app *model.AppState) http.Handler {
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 {
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)
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)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
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)
}
@ -190,7 +226,7 @@ func UpdateBlog(app *model.AppState) http.Handler {
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
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)
}
})
@ -201,17 +237,32 @@ func DeleteBlog(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id")
rowsAffected, err := controller.DeleteBlogPost(app.DB, blogID)
blog, err := controller.GetBlogPost(app.DB, blogID)
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)
return
}
if rowsAffected == 0 {
if blog == nil {
http.NotFound(w, r)
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)
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 (
"arimelody-web/model"
"database/sql"
"github.com/jmoiron/sqlx"
)
@ -9,37 +10,96 @@ import (
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
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 {
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
}
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
var blogs = []*model.BlogPost{}
query := "SELECT * FROM blogpost ORDER BY created_at"
if onlyVisible {
query = "SELECT * FROM blogpost WHERE visible=true ORDER BY created_at"
}
query := "SELECT post.id,post.title,post.publish_date," +
"post.description,post.visible," +
"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
if limit < 0 {
err = db.Select(&blogs, query)
rows, err = db.Query(query)
} 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 {
return nil, err
}
// for range 4 {
// blog := *blogs[len(blogs)-1]
// blog.CreatedAt = blog.CreatedAt.Add(time.Hour * -5000)
// blogs = append(blogs, &blog)
// }
for rows.Next() {
blog := model.BlogPost{}
err = rows.Scan(
&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
}
@ -57,54 +117,77 @@ func GetBlogPostCount(db *sqlx.DB, onlyVisible bool) (int, 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(
"INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
post.ID,
post.Title,
post.Description,
post.Visible,
post.AuthorID,
post.Markdown,
post.HTML,
post.BlueskyActorID,
post.BlueskyPostID,
"INSERT INTO blogpost (" +
"id,title,description,visible," +
"publish_date,author,markdown," +
"bluesky_actor,bluesky_record," +
"fediverse_account,fediverse_status) " +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
post.ID, post.Title, post.Description, post.Visible,
post.PublishDate, post.Author.ID, post.Markdown,
blueskyActor, blueskyRecord,
fediverseAccount, fediverseStatus,
)
return err
}
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(
"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",
postID,
post.ID,
post.Title,
post.Description,
post.Visible,
post.AuthorID,
post.Markdown,
post.HTML,
post.BlueskyActorID,
post.BlueskyPostID,
post.ID, post.Title, post.Description, post.Visible,
post.PublishDate, post.Author.ID, post.Markdown,
blueskyActor, blueskyRecord,
fediverseAccount, fediverseStatus,
)
return err
}
func DeleteBlogPost(db *sqlx.DB, postID string) (int64, error) {
result, err := db.Exec(
func DeleteBlogPost(db *sqlx.DB, postID string) error {
_, err := db.Exec(
"DELETE FROM blogpost "+
"WHERE id=$1",
postID,
)
if err != nil {
return 0, err
return err
}
rowsAffected, _ := result.RowsAffected()
return rowsAffected, nil
return nil
}

View file

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

View file

@ -133,12 +133,12 @@ CREATE TABLE arimelody.blogpost (
description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
modified_at TIMESTAMP,
author UUID NOT NULL,
markdown TEXT NOT NULL,
html TEXT NOT NULL,
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);

View file

@ -4,12 +4,12 @@ CREATE TABLE arimelody.blogpost (
description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
modified_at TIMESTAMP,
author UUID NOT NULL,
markdown TEXT NOT NULL,
html TEXT NOT NULL,
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_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -1,27 +1,38 @@
package model
import (
"database/sql"
"fmt"
"html/template"
"regexp"
"strings"
"time"
)
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 {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Visible bool `json:"visible" db:"visible"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ModifiedAt sql.NullTime `json:"modified_at" db:"modified_at"`
AuthorID string `json:"author" db:"author"`
Markdown string `json:"markdown" db:"markdown"`
HTML template.HTML `json:"html" db:"html"`
BlueskyActorID *string `json:"bluesky_actor" db:"bluesky_actor"`
BlueskyPostID *string `json:"bluesky_post" db:"bluesky_post"`
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Visible bool `json:"visible"`
PublishDate time.Time `json:"publish_date"`
Author BlogAuthor `json:"author"`
Markdown string `json:"markdown"`
Bluesky *BlueskyRecord `json:"bluesky"`
Fediverse *FediverseActivity `json:"fediverse"`
}
)
@ -36,16 +47,13 @@ func (b *BlogPost) TitleNormalised() 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 {
return b.CreatedAt.Format("2 January 2006, 15:04")
return b.PublishDate.Format("2 January 2006, 15:04")
}
func (b *BlogPost) PrintModifiedDate() string {
if !b.ModifiedAt.Valid {
return ""
}
return b.ModifiedAt.Time.Format("2 January 2006, 15:04")
func (b *BlogPost) TextPublishDate() string {
return b.PublishDate.Format("2006-01-02T15:04")
}

View file

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

View file

@ -28,12 +28,12 @@
<p>there are no posts! 🍃</p>
{{end}}
{{range .Collections}}
<h2 id="{{.Name}}" class="collection-name">{{.Name}}</h2>
<h2 id="{{.Year}}" class="collection-name">{{.Year}}</h2>
{{range .Posts}}
<article class="blog-post">
<h3 class="blog-title"><a href="/blog/{{.ID}}">{{.Title}}</a></h3>
<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>
</p>
{{if ne .Description ""}}

View file

@ -84,13 +84,17 @@
<article id="blog">
<header>
<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-date">posted {{.PrintDate}}{{if .ModifiedAt.Valid}} <span class="blog-modified-date">&bull; updated {{.PrintModifiedDate}}</span>{{end}}</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}}</p>
</header>
<hr>
{{.HTML}}
{{if .BlogHTML}}
{{.BlogHTML}}
{{else}}
<p class="no-content">No content.</p>
{{end}}
</article>
{{if ne .BlueskyURL ""}}

View file

@ -6,7 +6,6 @@ import (
"net/http"
"os"
"slices"
"strconv"
"strings"
"arimelody-web/controller"
@ -19,18 +18,18 @@ import (
)
type (
BlogView struct {
Collections []*BlogViewPostCollection
blogView struct {
Collections []*blogPostCollection
}
BlogViewPostCollection struct {
Name string
Posts []*BlogPostView
blogPostCollection struct {
Year int
Posts []*model.BlogPost
}
BlogPostView struct {
blogPostView struct {
*model.BlogPost
Author *model.Account
BlogHTML template.HTML
Comments []*model.ThreadViewPost
Likes 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{
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) {
dbPosts, err := controller.GetBlogPosts(app.DB, true, -1, 0)
posts, err := controller.GetBlogPosts(app.DB, true, -1, 0)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -62,43 +68,33 @@ func BlogHandler(app *model.AppState) http.Handler {
return
}
collections := []*BlogViewPostCollection{}
posts := []*BlogPostView{}
collectionYear := 0
for i, post := range dbPosts {
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
}
collections := []*blogPostCollection{}
collection := blogPostCollection{
Posts: []*model.BlogPost{},
Year: -1,
}
for i, post := range posts {
if i == 0 {
collectionYear = post.CreatedAt.Year()
collection.Year = post.PublishDate.Year()
}
if post.CreatedAt.Year() != collectionYear || i == len(dbPosts) - 1 {
if i == len(dbPosts) - 1 {
posts = append(posts, &BlogPostView{
BlogPost: post,
Author: author,
})
if post.PublishDate.Year() != collection.Year {
clone := collection.Clone()
collections = append(collections, &clone)
collection = blogPostCollection{
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{
BlogPost: post,
Author: author,
})
collection.Posts = append(collection.Posts, post)
if i == len(posts) - 1 {
collections = append(collections, &collection)
}
}
err = templates.BlogTemplate.Execute(w, BlogView{
err = templates.BlogTemplate.Execute(w, blogView{
Collections: collections,
})
if err != nil {
@ -119,10 +115,14 @@ func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler {
http.NotFound(w, r)
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)
return
}
if blog == nil {
http.NotFound(w, r)
return
}
if !blog.Visible {
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>"
mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
md := mdParser.Parse([]byte(blog.Markdown))
blog.HTML = template.HTML(markdown.Render(md, mdRenderer))
blogHTML := template.HTML(markdown.Render(md, mdRenderer))
comments := []*model.ThreadViewPost{}
likeCount := 0
boostCount := 0
var blueskyURL string
var blueskyPost *model.ThreadViewPost
if blog.BlueskyActorID != nil && blog.BlueskyPostID != nil {
blueskyPost, err = controller.FetchThreadViewPost(*blog.BlueskyActorID, *blog.BlueskyPostID)
if blog.Bluesky != nil {
blueskyPost, err = controller.FetchThreadViewPost(blog.Bluesky.ActorDID, blog.Bluesky.RecordID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err)
} else {
comments = append(comments, blueskyPost.Replies...)
likeCount += blueskyPost.Post.LikeCount
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,
Author: author,
BlogHTML: blogHTML,
Comments: comments,
Likes: likeCount,
Boosts: boostCount,