From 835dd344ca9b611d61af222e7b20d7dbd0ba5be0 Mon Sep 17 00:00:00 2001 From: ari melody Date: Wed, 2 Apr 2025 23:49:20 +0100 Subject: [PATCH] style improvements and bluesky comments! --- controller/bluesky.go | 48 +++++++++++++ model/blog.go | 2 + model/bluesky.go | 82 ++++++++++++++++++++++ public/style/blog.css | 157 ++++++++++++++++++++++++++++++++++++++++-- view/blog.go | 59 ++++++++++++++-- view/blog.html | 56 ++++++++++++++- view/music.go | 2 +- 7 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 controller/bluesky.go create mode 100644 model/bluesky.go diff --git a/controller/bluesky.go b/controller/bluesky.go new file mode 100644 index 0000000..0e382df --- /dev/null +++ b/controller/bluesky.go @@ -0,0 +1,48 @@ +package controller + +import ( + "arimelody-web/model" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" +) + +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) + + req, err := http.NewRequest( + http.MethodGet, + strings.Join([]string{BSKY_API_BASE, "xrpc", "app.bsky.feed.getPostThread"}, "/"), + nil, + ) + if err != nil { panic(err) } + + req.URL.RawQuery = url.Values{ + "uri": { uri }, + }.Encode() + req.Header.Set("User-Agent", "ari melody [https://arimelody.me]") + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + + res, err := client.Do(req) + if err != nil { + return nil, errors.New(fmt.Sprintf("Failed to call Bluesky API: %v", err)) + } + + type Data struct { + Thread model.ThreadViewPost `json:"thread"` + } + data := Data{} + err = json.NewDecoder(res.Body).Decode(&data) + if err != nil { + return nil, errors.New(fmt.Sprintf("Invalid response from server: %v", err)) + } + + return &data.Thread, nil +} diff --git a/model/blog.go b/model/blog.go index 89f37a0..7f0c87b 100644 --- a/model/blog.go +++ b/model/blog.go @@ -17,6 +17,8 @@ type ( AuthorID string `db:"author"` Markdown string `db:"markdown"` HTML template.HTML `db:"html"` + BlueskyActorID string `db:"bsky_actor"` + BlueskyPostID string `db:"bsky_post"` } ) diff --git a/model/bluesky.go b/model/bluesky.go new file mode 100644 index 0000000..dd8391e --- /dev/null +++ b/model/bluesky.go @@ -0,0 +1,82 @@ +package model + +import ( + "fmt" + "strings" + "time" +) + +type ( + Record struct { + Type string `json:"$type"` + CreatedAt string `json:"createdAt"` + Text string `json:"text"` + } + + Profile struct { + DID string `json:"did"` + Handle string `json:"handle"` + Avatar string `json:"avatar"` + DisplayName string `json:"displayName"` + CreatedAt string `json:"createdAt"` + } + + PostImage struct { + Thumbnail string `json:"thumb"` + Fullsize string `json:"fullsize"` + Alt string `json:"alt"` + } + + EmbedMedia struct { + Images []PostImage `json:"images"` + } + + Embed struct { + Media EmbedMedia `json:"media"` + } + + Post struct { + Author Profile `json:"author"` + Record Record `json:"record"` + ReplyCount int `json:"replyCount"` + RepostCount int `json:"repostCount"` + LikeCount int `json:"likeCount"` + QuoteCount int `json:"quoteCount"` + Embed *Embed `json:"embed"` + URI string `json:"uri"` + } + + ThreadViewPost struct { + Post Post `json:"post"` + Replies []*ThreadViewPost `json:"replies"` + } +) + +func (record *Record) CreatedAtPrint() (string, error) { + t, err := record.CreatedAtTime() + if err != nil { return "", err } + return t.Format("15:04, 2 February 2006"), nil +} + +func (record *Record) CreatedAtTime() (time.Time, error) { + return time.Parse("2006-01-02T15:04:05Z", record.CreatedAt) +} + +func (post *Post) HasImage() bool { + return post.Embed != nil && len(post.Embed.Media.Images) > 0 +} + +func (post *Post) PostID() string { + return strings.TrimPrefix( + post.URI, + fmt.Sprintf("at://%s/app.bsky.feed.post/", post.Author.DID), + ) +} + +func (post *Post) BskyURL() string { + return fmt.Sprintf( + "https://bsky.app/profile/%s/post/%s", + post.Author.DID, + post.PostID(), + ) +} diff --git a/public/style/blog.css b/public/style/blog.css index a333120..7f424d5 100644 --- a/public/style/blog.css +++ b/public/style/blog.css @@ -1,7 +1,20 @@ +: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), 900px); margin: 0 auto 1rem auto; } +.blog p:hover, +.comment p:hover { + background: inherit; +} + article.blog { font-family: 'Lora', serif; } @@ -15,16 +28,29 @@ article.blog { line-height: 1.5em; } -.blog p:hover { - background: inherit; -} - .blog sub { opacity: .75; } +.blog pre { + max-height: 15em; + padding: .5em; + font-size: .9em; + border: 1px solid #8884; + border-radius: 2px; + overflow: scroll; + background: #060606; +} + +.blog p code { + padding: .2em .3em; + font-size: .9em; + border: 1px solid #8884; + border-radius: 2px; + background: #060606; +} + .blog img { - margin: 0 auto; max-height: 50%; max-width: 100%; @@ -44,3 +70,124 @@ article.blog { 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); +} + +.btn.bluesky, +.btn.mastodon { + font-family: monospace; + text-wrap: nowrap; +} + +.btn.bluesky { + color: var(--bluesky); + border-color: var(--bluesky); +} + +.btn.mastodon { + color: var(--mastodon); + border-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 .comment-date { + margin: 0; + font-size: .8em; + opacity: .5; +} + +.comment .comment-replies { + margin-left: 1em; + border-left: 2px solid #8884; +} diff --git a/view/blog.go b/view/blog.go index 85da400..9ab52d2 100644 --- a/view/blog.go +++ b/view/blog.go @@ -1,10 +1,13 @@ package view import ( + "fmt" "html/template" "net/http" + "os" "time" + "arimelody-web/controller" "arimelody-web/model" "arimelody-web/templates" @@ -20,16 +23,14 @@ 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!~", + Title: "hello world!", Description: "lorem ipsum yadda yadda something boobies babababababababa", Visible: true, Date: time.Now(), AuthorID: "ari", Markdown: ` -# hello, world! - -i'm ari! +**i'm ari!** she/her 🏳️‍⚧️🏳️‍🌈💫🦆🇮🇪 @@ -72,7 +73,26 @@ thank you for stopping by- i hope you have a lovely rest of your day! 💫 - impact meme - OpenTerminal - Silver.js + +### code block test + +~~~ c +#include + +int main(int argc, char *argv[]) { + printf("hello world!~\n"); + return 0; +} +~~~ + +### aridoodle + +this is `+"`"+`aridoodle`+"`"+`. please take care of her. + +![aridoodle](/img/aridoodle.png) `, + BlueskyActorID: "did:plc:yct6cvgfipngizry5umzkxr3", + BlueskyPostID: "3llsudsx7pc2u", } // blog.Markdown += " " @@ -81,6 +101,35 @@ thank you for stopping by- i hope you have a lovely rest of your day! 💫 md := mdParser.Parse([]byte(blog.Markdown)) blog.HTML = template.HTML(markdown.Render(md, mdRenderer)) - templates.BlogTemplate.Execute(w, &blog) + 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...) + } + + type BlogView struct { + *model.Blog + Comments []*model.ThreadViewPost + Likes int + Reposts int + BlueskyURL string + MastodonURL string + } + + err = templates.BlogTemplate.Execute(w, BlogView{ + Blog: &blog, + Comments: blueskyPost.Replies, + Likes: 10, + Reposts: 10, + BlueskyURL: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blog.BlueskyActorID, blog.BlueskyPostID), + MastodonURL: "#", + }) + if err != nil { + fmt.Fprintf(os.Stderr, "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 60f3c4b..f858252 100644 --- a/view/blog.html +++ b/view/blog.html @@ -24,11 +24,63 @@
{{.HTML}} -
- +
+ +
+ + 🔁 {{.Reposts}} + Bluesky 🦋 + + Mastodon 🐘 +
+ +
+ {{range .Comments}} + {{template "comment" .}} + {{end}} +
+ {{end}} + +{{define "comment"}} + +{{end}} + diff --git a/view/music.go b/view/music.go index 2d40ef0..3c27b6e 100644 --- a/view/music.go +++ b/view/music.go @@ -89,7 +89,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler { err := templates.MusicGatewayTemplate.Execute(w, response) if err != nil { - fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "Error rendering music gateway for %s: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }