diff --git a/controller/blog.go b/controller/blog.go new file mode 100644 index 0000000..7fb201e --- /dev/null +++ b/controller/blog.go @@ -0,0 +1,83 @@ +package controller + +import ( + "arimelody-web/model" + + "github.com/jmoiron/sqlx" +) + +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) + if err != nil { + return nil, err + } + + 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" + } + + var err error + if limit < 0 { + err = db.Select(&blogs, query) + } else { + err = db.Select(&blogs, 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) + // } + + return blogs, nil +} + +func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error { + _, 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, + ) + + return err +} + +func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error { + _, 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 " + + "WHERE id=$1", + postID, + post.ID, + post.Title, + post.Description, + post.Visible, + post.AuthorID, + post.Markdown, + post.HTML, + post.BlueskyActorID, + post.BlueskyPostID, + ) + + return err +} diff --git a/controller/migrator.go b/controller/migrator.go index 4b99b9c..ac3c419 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -8,7 +8,7 @@ import ( "github.com/jmoiron/sqlx" ) -const DB_VERSION int = 4 +const DB_VERSION int = 5 func CheckDBVersionAndMigrate(db *sqlx.DB) { db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") @@ -49,6 +49,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) { ApplyMigration(db, "003-fail-lock") oldDBVersion = 4 + case 4: + ApplyMigration(db, "004-blog") + oldDBVersion = 5 + } } diff --git a/model/blog.go b/model/blog.go index 7f0c87b..d341154 100644 --- a/model/blog.go +++ b/model/blog.go @@ -1,6 +1,7 @@ package model import ( + "database/sql" "fmt" "html/template" "regexp" @@ -9,20 +10,22 @@ import ( ) type ( - Blog struct { - Title string `db:"title"` - Description string `db:"description"` - Visible bool `db:"visible"` - Date time.Time `db:"date"` - AuthorID string `db:"author"` - Markdown string `db:"markdown"` - HTML template.HTML `db:"html"` - BlueskyActorID string `db:"bsky_actor"` - BlueskyPostID string `db:"bsky_post"` + BlogPost struct { + ID string `db:"id"` + Title string `db:"title"` + Description string `db:"description"` + Visible bool `db:"visible"` + CreatedAt time.Time `db:"created_at"` + ModifiedAt sql.NullTime `db:"modified_at"` + AuthorID string `db:"author"` + Markdown string `db:"markdown"` + HTML template.HTML `db:"html"` + BlueskyActorID *string `db:"bluesky_actor"` + BlueskyPostID *string `db:"bluesky_post"` } ) -func (b *Blog) TitleNormalised() string { +func (b *BlogPost) TitleNormalised() string { rgx := regexp.MustCompile(`[^a-z0-9\-]`) return rgx.ReplaceAllString( strings.ReplaceAll( @@ -32,10 +35,17 @@ func (b *Blog) TitleNormalised() string { ) } -func (b *Blog) GetMonth() string { - return fmt.Sprintf("%02d", int(b.Date.Month())) +func (b *BlogPost) GetMonth() string { + return fmt.Sprintf("%02d", int(b.CreatedAt.Month())) } -func (b *Blog) PrintDate() string { - return b.Date.Format("02 January 2006") +func (b *BlogPost) PrintDate() string { + return b.CreatedAt.Format("2 January 2006, 03:04") +} + +func (b *BlogPost) PrintModifiedDate() string { + if !b.ModifiedAt.Valid { + return "" + } + return b.ModifiedAt.Time.Format("2 January 2006, 03:04") } diff --git a/model/bluesky.go b/model/bluesky.go index dd8391e..f04d9db 100644 --- a/model/bluesky.go +++ b/model/bluesky.go @@ -55,7 +55,7 @@ type ( func (record *Record) CreatedAtPrint() (string, error) { t, err := record.CreatedAtTime() if err != nil { return "", err } - return t.Format("15:04, 2 February 2006"), nil + return t.Format("2 Jan 2006, 15:04"), nil } func (record *Record) CreatedAtTime() (time.Time, error) { diff --git a/public/script/blog.js b/public/script/blog.js index 4f96ab6..73d34a3 100644 --- a/public/script/blog.js +++ b/public/script/blog.js @@ -1,28 +1,15 @@ -document.addEventListener('readystatechange', () => { - document.querySelectorAll('.comment-hover').forEach((/** @type {HTMLDivElement} */ comment) => { - /** @type {HTMLLinkElement} */ - const commentBody = comment.querySelector('a.comment-body'); +import { hijackClickEvent } from "./main.js"; - comment.querySelectorAll('a').forEach((/** @type {HTMLLinkElement} */ element) => { - element.addEventListener('click', event => { - event.stopPropagation(); - }); - }); - - comment.addEventListener('click', () => { - commentBody.click(); - }); - - comment.style.cursor = 'pointer'; - comment.role = 'link'; - }); - - document.getElementById('blog-copy-link').addEventListener('click', event => { - event.preventDefault(); - if (navigator.clipboard === undefined) { - console.error("clipboard is not supported by this browser!"); - return; - } - navigator.clipboard.writeText(location.protocol + "//" + location.host + location.pathname); - }); +document.querySelectorAll('article.blog-post').forEach(element => { + const link = element.querySelector('.blog-title a'); + hijackClickEvent(element, link); +}); +document.querySelectorAll('article.blog-post').forEach(element => { + const link = element.querySelector('.blog-title a'); + hijackClickEvent(element, link); +}); + +document.getElementById('load-more').addEventListener('click', event => { + event.preventDefault(); + alert('ok'); }); diff --git a/public/script/blogpost.js b/public/script/blogpost.js new file mode 100644 index 0000000..fac626e --- /dev/null +++ b/public/script/blogpost.js @@ -0,0 +1,16 @@ +import { hijackClickEvent } from "./main.js"; + +document.querySelectorAll('.comment-hover').forEach((/** @type {HTMLDivElement} */ comment) => { + /** @type {HTMLLinkElement} */ + const commentDate = comment.querySelector('.comment-date'); + hijackClickEvent(comment, commentDate); +}); + +document.getElementById('blog-copy-link').addEventListener('click', event => { + event.preventDefault(); + if (navigator.clipboard === undefined) { + console.error("clipboard is not supported by this browser!"); + return; + } + navigator.clipboard.writeText(location.protocol + "//" + location.host + location.pathname); +}); diff --git a/public/style/blog.css b/public/style/blog.css index 7f57d68..07b8089 100644 --- a/public/style/blog.css +++ b/public/style/blog.css @@ -1,249 +1,70 @@ -:root { - --like: rgb(223, 104, 104); - --repost: rgb(162, 223, 73); - --bluesky: rgb(16, 131, 254); - --mastodon: rgb(86, 58, 204); -} - -main { - width: min(calc(100% - 4rem), 1200px); - margin: 0 auto 1rem auto; -} - -#blog-sidebar { - position: fixed; - width: 3em; - padding: 3em; - transform: translate(-9em, -1em); - overflow: clip; - opacity: .5; - transition: opacity .2s; -} -#blog-sidebar:hover { - opacity: 1; -} - -#blog-sidebar ul { - margin: 0; - padding: .3em; - list-style: none; - display: flex; - flex-direction: column; - gap: .3em; +article.blog-post { + margin-bottom: 1rem; + padding: 1.5rem; + border: 1px solid #8882; border-radius: 4px; - border: 1px solid var(--on-background); - box-shadow: 4px 4px 4px #0001; -} - -#blog-sidebar a { - width: 35px; - height: 35px; - display: block; - padding: .2em; - border-radius: 2px; + background-color: #ffffff08; + transition: background-color .1s; text-decoration: none; -} -#blog-sidebar a:hover { - background: #0001; -} -#blog-sidebar a:active { - background: #0002; + cursor: pointer; } -#blog-sidebar a img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; +.blog-post h2:hover, +.blog-post p:hover { + background: none; } -#blog-sidebar span { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - font-size: 1.5em; +.blog-title { + margin: 0; +} +.blog-title a { + display: inherit; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; } -.blog p:hover, -.comment p:hover { - background: inherit; +.blog-meta { + margin: 0; } -article.blog { - font-family: 'Lora', serif; - font-size: 24px; +.blog-author { + margin: 0; + font-size: .8em; +} +.blog-author img { + width: 1.3em; + height: 1.3em; + display: inline-block; + transform: translate(0, 4px); + border-radius: 4px; } .blog-date { - margin-top: -1em; - opacity: .75; -} - -.blog p { - line-height: 1.5em; -} - -.blog sub { - opacity: .75; -} - -.blog pre { - max-height: 15em; - padding: .5em; - font-size: .9em; - border: 1px solid #8884; - border-radius: 2px; - overflow: scroll; - background: var(--background-alt); -} - -.blog p code { - padding: .2em .3em; - font-size: .9em; - border: 1px solid #8884; - border-radius: 2px; - background: var(--background-alt); -} - -.blog img { - max-height: 50%; - max-width: 100%; - - display: block; -} - -.blog i.end-mark { - width: 1.2em; - height: 1.2em; - margin-top: -.2em; - - display: inline-block; - transform: translateY(.2em); - - background: url("/img/aridoodle.png"); - background-size: contain; - background-repeat: no-repeat; - background-position: center; -} - - - -/* COMMENTS */ - -#interactions { - margin: 1em 0; - display: flex; - flex-direction: row; - gap: .5em; - flex-wrap: wrap; -} - -.btn { - display: inline-block; - padding: .4em .6em; - border: 1px solid var(--on-background); - border-radius: 2px; - color: inherit; - text-decoration: none; - font-weight: 600; -} - -#interactions .likes, -#interactions .reposts { - padding: 0 .5em; - min-width: fit-content; - font-family: monospace; - text-align: center; - line-height: 2em; - border: 1px solid var(--on-background); - border-radius: 2px; - text-wrap: nowrap; -} - -#interactions .likes { - border-color: var(--on-background); -} -#interactions .reposts { - border-color: var(--on-background); -} - -.comment-callout { - padding-bottom: 1em; - text-align: center; - border-radius: 2px; - border-bottom: 1px solid #8884; -} - -.bluesky { - color: var(--bluesky); -} - -.mastodon { - color: var(--mastodon); -} - -.comment { - font-family: 'Inter', 'Arial', sans-serif; - font-size: 1em; -} - -.comment .comment-hover { - padding: 1em; - transition: background-color .1s; -} - -.comment .comment-hover:hover { - background-color: #8881; -} - -.comment .comment-header a { - display: flex; - gap: .5em; - font-weight: 600; - color: var(--primary); - text-decoration: none; - align-items: center; - overflow: hidden; - text-overflow: ellipsis; -} - -.comment .comment-header a .display-name { - overflow: inherit; - text-overflow: inherit; -} - -.comment .comment-header a .handle { - opacity: .5; - font-family: monospace; - font-size: .9em; - overflow: inherit; - text-overflow: inherit; -} - -.comment .comment-header img.avatar { - width: 1.5em; - height: 1.5em; -} - -.comment .comment-body { - color: inherit; - text-decoration: none; -} - -.comment p.comment-text { - margin: .5em 0; - white-space: break-spaces; -} - -.comment .comment-footer { margin: 0; font-size: .8em; - opacity: .5; + opacity: .75; } -.comment .comment-replies { - margin-left: 1em; - border-left: 2px solid #8884; +.blog-description { + margin: .5em 0 0 0; + display: -webkit-box; + font-size: .8em; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +#load-more { + margin: 0 auto; + padding: .5em 2em; + display: block; + font-family: inherit; + font-size: inherit; +} + +@media screen and (max-width: 1500px) { + #blog-sidebar { + display: none; + } } diff --git a/public/style/blogpost.css b/public/style/blogpost.css new file mode 100644 index 0000000..47145fe --- /dev/null +++ b/public/style/blogpost.css @@ -0,0 +1,314 @@ +:root { + --like: rgb(223, 104, 104); + --boost: rgb(162, 223, 73); + --bluesky: rgb(16, 131, 254); + --mastodon: rgb(86, 58, 204); +} + +main { + width: min(calc(100% - 4rem), 1200px); + margin: 0 auto 1rem auto; +} + +#blog-sidebar { + position: fixed; + width: 3em; + padding: 3em; + transform: translate(-9em, -1em); + overflow: clip; + opacity: .5; + transition: opacity .2s; +} +#blog-sidebar:hover { + opacity: 1; +} + +#blog-sidebar ul { + margin: 0; + padding: .3em; + list-style: none; + display: flex; + flex-direction: column; + gap: .3em; + border-radius: 4px; + border: 1px solid var(--on-background); + box-shadow: 4px 4px 4px #0001; +} + +#blog-sidebar a { + width: 35px; + height: 35px; + display: block; + padding: .2em; + border-radius: 2px; + text-decoration: none; +} +#blog-sidebar a:hover { + background: #0001; +} +#blog-sidebar a:active { + background: #0002; +} + +#blog-sidebar a img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +#blog-sidebar span { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.5em; +} + +#blog-sidebar hr { + margin: 0; +} + +.blog p:hover, +.comment p:hover { + background: inherit; +} + +article.blog { + font-size: 22px; +} + +.blog h1 { + margin-bottom: 0; + font-size: 1.8em; +} + +.blog-author { + margin: .2em 0; +} +.blog .blog-author img { + width: 1.3em; + height: 1.3em; + display: inline-block; + transform: translate(0, 6px); + border-radius: 4px; +} + +.blog-date { + margin: .5em 0; + font-size: .7em; +} +.blog-modified-date { + font-style: italic; + opacity: .75; +} + +#blog-content { + font-family: 'Lora', serif; +} + +.blog p { + line-height: 1.5em; +} + +.blog sub { + opacity: .75; +} + +.blog pre { + max-height: 15em; + padding: .5em; + font-size: .9em; + border: 1px solid #8884; + border-radius: 2px; + overflow: scroll; + background: var(--background-alt); +} + +.blog p code { + padding: .2em .3em; + font-size: .9em; + border: 1px solid #8884; + border-radius: 2px; + background: var(--background-alt); +} + +.blog img { + max-height: 50%; + max-width: 100%; + + display: block; +} + +.blog i.end-mark { + width: 1.2em; + height: 1.2em; + margin-top: -.2em; + + display: inline-block; + transform: translateY(.2em); + + background: url("/img/aridoodle.png"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + + + +/* COMMENTS */ + +#interactions { + margin: 1em 0; + display: flex; + flex-direction: row; + gap: .5em; + flex-wrap: wrap; + align-items: center; +} + +.btn { + display: inline-block; + padding: .4em .6em; + border: 1px solid var(--on-background); + border-radius: 2px; + color: inherit; + text-decoration: none; + font-weight: 600; +} + +#interactions button { + min-width: fit-content; + padding: 0 .75em 0 .5em; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: .5em; + + font-family: monospace; + font-size: inherit; + text-align: center; + line-height: 2em; + text-wrap: nowrap; + + color: inherit; + background: none; + border: 1px solid var(--on-background); + border-radius: 4px; +} +#interactions button:hover { + background: #0001; +} +#interactions button:active { + background: #0002; +} + +#interactions img { + width: 1.5em; + height: 1.5em; + display: inline-block; +} + +.comment-callout { + margin: 0 0 0 1em; +} +.comment-callout:hover { + background: none; +} + +.bluesky { + color: var(--bluesky); +} + +.mastodon { + color: var(--mastodon); +} + +.comment { + font-family: 'Inter', 'Arial', sans-serif; + font-size: 1em; +} + +.comment .comment-hover { + padding: 1em; + transition: background-color .1s; + cursor: pointer; +} + +.comment .comment-hover:hover { + background-color: #8881; +} + +.comment .comment-header a { + display: flex; + gap: .5em; + font-weight: 600; + color: var(--primary); + text-decoration: none; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.comment .comment-header a .display-name { + overflow: inherit; + text-overflow: inherit; +} + +.comment .comment-header a .handle { + opacity: .5; + font-family: monospace; + font-size: .9em; + overflow: inherit; + text-overflow: inherit; +} + +.comment .comment-header img.avatar { + width: 1.5em; + height: 1.5em; + border-radius: 4px; +} + +.comment .comment-body { + color: inherit; + text-decoration: none; +} + +.comment p.comment-text { + margin: .5em 0; + white-space: break-spaces; +} + +.comment .comment-footer { + margin: 0; + font-size: .8em; +} + +.comment .comment-footer .comment-footer-static { + opacity: .5; +} + +.comment .comment-replies { + margin-left: 1em; + border-left: 2px solid #8884; +} + +.comment .comment-date { + transition: opacity .2s; +} + +@media screen and (prefers-color-scheme: dark) { + #blog-sidebar a:hover { + background: #fff2; + } + #blog-sidebar a:active { + background: #fff4; + } + + .comment-date { + opacity: .5; + } +} diff --git a/public/style/main.css b/public/style/main.css index 7d73f24..0a4bc95 100644 --- a/public/style/main.css +++ b/public/style/main.css @@ -38,7 +38,7 @@ a:hover { text-decoration: underline; } -a.link-button { +.link-button { padding: .3em .5em; border: 1px solid var(--links); color: var(--links); @@ -51,7 +51,7 @@ a.link-button { opacity: 0; } -a.link-button:hover { +.link-button:hover { color: #eee; border-color: #eee; background-color: var(--links) !important; diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql index 90385ac..42dbd63 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -127,17 +127,39 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM +CREATE TABLE arimelody.blogpost ( + id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + 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 +); +ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id); + + + -- -- Foreign keys -- +-- Account ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +-- Music ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE; + +-- Blog +ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE; diff --git a/schema-migration/004-blog.sql b/schema-migration/004-blog.sql new file mode 100644 index 0000000..6e613f0 --- /dev/null +++ b/schema-migration/004-blog.sql @@ -0,0 +1,15 @@ +CREATE TABLE arimelody.blogpost ( + id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + 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 +); +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; diff --git a/templates/templates.go b/templates/templates.go index 7e344fb..9cfbd23 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -33,3 +33,10 @@ var BlogTemplate = template.Must(template.ParseFiles( filepath.Join("view", "prideflag.html"), filepath.Join("view", "blog.html"), )) +var BlogPostTemplate = template.Must(template.ParseFiles( + filepath.Join("view", "layout.html"), + filepath.Join("view", "header.html"), + filepath.Join("view", "footer.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("view", "blogpost.html"), +)) diff --git a/view/blog.go b/view/blog.go index 73095b0..3567818 100644 --- a/view/blog.go +++ b/view/blog.go @@ -5,7 +5,9 @@ import ( "html/template" "net/http" "os" - "time" + "slices" + "strconv" + "strings" "arimelody-web/controller" "arimelody-web/model" @@ -16,14 +18,26 @@ import ( "github.com/gomarkdown/markdown/parser" ) -type BlogView struct { - *model.Blog - Comments []*model.ThreadViewPost - Likes int - Reposts int - BlueskyURL string - MastodonURL string -} +type ( + BlogView struct { + Collections []*BlogViewPostCollection + } + + BlogViewPostCollection struct { + Name string + Posts []*BlogPostView + } + + BlogPostView struct { + *model.BlogPost + Author *model.Account + Comments []*model.ThreadViewPost + Likes int + Boosts int + BlueskyURL string + MastodonURL string + } +) var mdRenderer = html.NewRenderer(html.RendererOptions{ Flags: html.CommonFlags | html.HrefTargetBlank, @@ -31,41 +45,105 @@ var mdRenderer = html.NewRenderer(html.RendererOptions{ func BlogHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - blog := model.Blog{ - Title: "hello world!", - Description: "lorem ipsum yadda yadda something boobies babababababababa", - Visible: true, - Date: time.Now(), - AuthorID: "ari", - Markdown: -` -**i'm ari!** (she/her) 🏳️‍⚧️🏳️‍🌈💫🦆🇮🇪 + if strings.Count(r.URL.Path, "/") > 1 { + http.NotFound(w, r) + return + } -welcome to my blog! + if len(r.URL.Path) > 1 { + ServeBlogPost(app, r.URL.Path[1:]).ServeHTTP(w, r) + return + } -i'm a musician, developer, streamer, youtuber, and probably a bunch of other things i forgot to mention! + dbPosts, err := controller.GetBlogPosts(app.DB, true, -1, 0) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + 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 + } -## code block test + if i == 0 { + collectionYear = post.CreatedAt.Year() + } -~~~ c -#include + if post.CreatedAt.Year() != collectionYear || i == len(dbPosts) - 1 { + if i == len(dbPosts) - 1 { + posts = append(posts, &BlogPostView{ + BlogPost: post, + Author: author, + }) + } + postsCopy := slices.Clone(posts) + collections = append(collections, &BlogViewPostCollection{ + Name: strconv.Itoa(collectionYear), + Posts: postsCopy, + }) + posts = []*BlogPostView{} + collectionYear = post.CreatedAt.Year() + } -int main(int argc, char *argv[]) { - printf("hello world!~\n"); - return 0; -} -~~~ + posts = append(posts, &BlogPostView{ + BlogPost: post, + Author: author, + }) + } -## aridoodle - -this is `+"`"+`aridoodle`+"`"+`. please take care of her. - -![aridoodle](/img/aridoodle.png) -`, - BlueskyActorID: "did:plc:yct6cvgfipngizry5umzkxr3", - BlueskyPostID: "3llsudsx7pc2u", + err = templates.BlogTemplate.Execute(w, BlogView{ + Collections: collections, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } + }) +} + +func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blog, err := controller.GetBlogPost(app.DB, blogPostID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if !blog.Visible { + session, err := controller.GetSessionFromRequest(app, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if session == nil || session.Account == nil { + http.NotFound(w, r) + return + } + } + + 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 += " " @@ -74,23 +152,32 @@ this is `+"`"+`aridoodle`+"`"+`. please take care of her. blog.HTML = template.HTML(markdown.Render(md, mdRenderer)) comments := []*model.ThreadViewPost{} - blueskyPost, err := controller.FetchThreadViewPost(blog.BlueskyActorID, blog.BlueskyPostID) - 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 := 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 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) + } + } - err = templates.BlogTemplate.Execute(w, BlogView{ - Blog: &blog, - Comments: blueskyPost.Replies, - Likes: blueskyPost.Post.LikeCount, - Reposts: blueskyPost.Post.RepostCount, - BlueskyURL: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blog.BlueskyActorID, blog.BlueskyPostID), - MastodonURL: "#", + err = templates.BlogPostTemplate.Execute(w, BlogPostView{ + BlogPost: blog, + Author: author, + Comments: comments, + Likes: likeCount, + Boosts: boostCount, + BlueskyURL: blueskyURL, }) if err != nil { - fmt.Fprintf(os.Stderr, "Error rendering blog post: %v\n", err) + fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/view/blog.html b/view/blog.html index ea10bbe..d1bf56d 100644 --- a/view/blog.html +++ b/view/blog.html @@ -1,15 +1,15 @@ {{define "head"}} -{{.Title}} - ari melody 💫 +blog - ari melody 💫 - + - + - - + + - + @@ -18,138 +18,34 @@ {{define "content"}}
-
- -
-
-
-

{{.Title}}

-

Posted by {{.AuthorID}} @ {{.PrintDate}}

+

# blog

+

thoughts from your local SPACEGIRL 💫

-
- {{.HTML}} +
-
- - {{if ne .BlueskyURL ""}} -
- -
- - 🔁 {{.Reposts}} -
- -

- join the conversation on - Bluesky 🦋 - - -

- -
- {{range .Comments}} - {{template "comment" .}} - {{end}} -
+
+ {{if eq (len .Collections) 0}} +

there are no posts! 🍃

+ {{end}} + {{range .Collections}} +

{{.Name}}

+ {{range .Posts}} +
+

{{.Title}}

+

+ {{.Author.Username}}'s avatar {{.Author.Username}} + • {{.PrintDate}} +

+ {{if ne .Description ""}} +

{{.Description}}

+ {{end}} +
+ {{end}} {{end}} - -
+ + + +
{{end}} - -{{define "comment"}} -
-
- - -
-

{{.Post.Record.Text}}

- {{if .Post.HasImage}} -

- {{range .Post.Embed.Media.Images}} - [image] - {{end}} -

- {{end}} -
- - -
- -
- {{range .Replies}} - {{template "comment" .}} - {{end}} -
-
-{{end}} - diff --git a/view/blogpost.html b/view/blogpost.html new file mode 100644 index 0000000..6126eda --- /dev/null +++ b/view/blogpost.html @@ -0,0 +1,178 @@ +{{define "head"}} +{{.Title}} - ari melody 💫 + + + + + + + + + + + + + + +{{end}} + +{{define "content"}} +
+
+ +
+
+
+
+

# {{.Title}}

+

by {{.Author.Username}} {{.Author.Username}}'s avatar

+

posted {{.PrintDate}}{{if .ModifiedAt.Valid}} • updated {{.PrintModifiedDate}}{{end}}

+
+ +
+ +
+ {{.HTML}} +
+
+ + {{if ne .BlueskyURL ""}} +
+ +
+ + + +

+ join the conversation on + Bluesky 🦋 + + +

+
+ +
+ {{range .Comments}} + {{template "comment" .}} + {{end}} +
+ {{end}} + + +
+
+{{end}} + +{{define "comment"}} +
+
+ +
+
+

{{.Post.Record.Text}}

+ {{if .Post.HasImage}} +

+ {{range .Post.Embed.Media.Images}} + [image] + {{end}} +

+ {{end}} +
+ +
+
+ +
+ {{range .Replies}} + {{template "comment" .}} + {{end}} +
+
+{{end}} diff --git a/view/header.html b/view/header.html index 03a8384..2457197 100644 --- a/view/header.html +++ b/view/header.html @@ -21,11 +21,11 @@ home
  • - music + music
  • - blog + blog