Mastodon Comments Activate. I'll need to actually post about my blog to mastodon to use it but I'll start soon I think.

This commit is contained in:
2025-12-24 20:43:35 -06:00
parent 87b70ad855
commit 2fdff679f5
9 changed files with 692 additions and 37 deletions

34
content/posts/urchin.md Normal file
View File

@@ -0,0 +1,34 @@
---
title: 'Urchin'
date: 2025-12-25T01:53:05Z
draft: true
series: "Frank's Couch"
summary: ""
imdb: "tt35715953"
poster: "/images/posters/urchin.jpg"
tags:
- gucci
- ghost theater
- marcel
- amc-south
- amc-lakeline
- anticipated
- no-expectations
- had pizza
---
{{< imdbposter >}}
| Date watched | December 14, 2025 |
|---------------------|-------------------|
| Show Time | |
| Theater | |
| Theater Number | |
| Pizza | |
| Tickets | |
| Letterboxd Rating | ***** (5.0) |
| Crew | |
{{< /imdbposter >}}
Write your review here...

View File

@@ -9,6 +9,8 @@
{{ if or (.Site.Params.remark42) (.Site.Config.Services.Disqus.Shortname) }}
{{ partial "post/comments.html" . }}
{{ end }}
{{/* Mastodon comments - shows if mastodon_id is set in front matter */}}
{{ partial "mastodon-comments.html" . }}
{{- if .Site.Params.goatcounter }}
{{ partial "analytics.html" . -}}
{{- end}}

View File

@@ -0,0 +1,77 @@
{{/*
Mastodon Comments Partial
Displays comments from a Mastodon post. Requires mastodon_id in front matter.
Comment count is fetched at build time; full comments load on button click.
Inspired by: https://andreas.scherbaum.la/post/2024-05-23_client-side-comments-with-mastodon-on-a-static-hugo-website/
And the vibes of: I Saw the TV Glow
*/}}
{{- $host := "tilde.zone" -}}
{{- $username := "mnw" -}}
{{- if .Params.mastodon_id -}}
{{- $id := .Params.mastodon_id -}}
{{/* Fetch comment count at build time */}}
{{- $count := 0 -}}
{{- $apiUrl := printf "https://%s/api/v1/statuses/%s/context" $host $id -}}
{{- with resources.GetRemote $apiUrl -}}
{{- if .Err -}}
{{/* API error - show 0 */}}
{{- else -}}
{{- $data := .Content | transform.Unmarshal -}}
{{- if $data.descendants -}}
{{- $count = len $data.descendants -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Build blocklist from front matter */}}
{{- $blocked := slice -}}
{{- if .Params.mastodon_blocked -}}
{{- $blocked = .Params.mastodon_blocked -}}
{{- end -}}
<div class="mastodon-comments-section">
<pre class="comments-header">/* ================================================== */
/* COMMENTS */
/* via the fediverse / tilde.zone */
/* ================================================== */</pre>
<noscript>
<pre class="comments-error">
ERROR: JavaScript required to load comments.
Enable JS or view discussion directly at:
https://{{ $host }}/@{{ $username }}/{{ $id }}
</pre>
</noscript>
<p class="comments-intro">
++ TRANSMISSION RECEIVED ++<br>
Reply to <a href="https://{{ $host }}/@{{ $username }}/{{ $id }}" rel="nofollow">this post on Mastodon</a> to join the discussion.
</p>
<div id="mastodon-comments-list">
<button type="button" id="load-comments-btn" onclick="loadMastodonComments()">
&gt;&gt; LOAD COMMENTS{{ if gt $count 0 }} ({{ $count }}){{ end }} &lt;&lt;
</button>
</div>
<p class="comments-note">
<small>// comments loaded from {{ $host }} when you click the button</small>
</p>
</div>
<link rel="stylesheet" href="{{ "css/mastodon-comments.css" | relURL }}">
<script src="{{ "js/purify.min.js" | relURL }}"></script>
<script src="{{ "js/mastodon-comments.js" | relURL }}"></script>
<script>
var mastodonHost = '{{ $host }}';
var mastodonUser = '{{ $username }}';
var mastodonId = '{{ $id }}';
var blockedToots = [{{ range $blocked }}'{{ . }}',{{ end }}];
</script>
{{- end -}}

View File

@@ -28,20 +28,16 @@
{{- end -}}
{{- if or $year $runtime $director -}}
<div style="font-size: 0.85em; color: #666; margin-top: 8px;">
{{- if or $year $runtime -}}
<div>
{{- if $year -}}{{ $year }}{{- end -}}
{{- if and $year $runtime -}} · {{- end -}}
{{- if $runtime -}}{{ $runtime }} min{{- end -}}
</div>
{{- end -}}
{{- if $director -}}
<div>
Directed by {{- if reflect.IsSlice $director -}}
{{ delimit $director ", " }}
{{- else -}}
{{ $director }}
{{- end -}}
Directed by {{ if reflect.IsSlice $director }}{{ delimit $director ", " }}{{ else }}{{ $director }}{{ end }}
</div>
{{- end -}}
{{- if or $year $runtime -}}
<div>
{{- if $year }}{{ $year }}{{ end -}}
{{- if and $year $runtime }} · {{ end -}}
{{- if $runtime }}{{ $runtime }} min{{ end -}}
</div>
{{- end -}}
</div>

View File

@@ -6,13 +6,18 @@ Usage:
python scripts/import_letterboxd.py # Interactive mode - pick from recent
python scripts/import_letterboxd.py --latest # Import most recent entry
python scripts/import_letterboxd.py --list # Just list recent entries
python scripts/import_letterboxd.py --theater # Skip to theater questions
python scripts/import_letterboxd.py --home # Skip to home video questions
The script will prompt for viewing details (theater vs home) and pre-fill
the front matter table accordingly.
"""
import argparse
import os
import re
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import xml.etree.ElementTree as ET
@@ -129,7 +134,124 @@ def rating_to_stars(rating):
return f"{stars} ({rating})"
def create_draft_post(movie, tmdb_details, poster_url):
def prompt_viewing_details():
"""Prompt user for viewing location details."""
print("\nWhere did you watch this?")
print(" 1. Theater")
print(" 2. Home")
while True:
choice = input("Enter 1 or 2: ").strip()
if choice == "1":
return prompt_theater_details()
elif choice == "2":
return prompt_home_details()
else:
print("Please enter 1 or 2")
def prompt_theater_details():
"""Prompt for theater-specific details."""
print("\nWhich theater?")
theaters = [
("1", "Gucci", "gucci"),
("2", "Ghost Theater", "ghost-theater"),
("3", "Marcel", "marcel"),
("4", "AMC South", "amc-south"),
("5", "AMC Lakeline", "amc-lakeline"),
("6", "Other", None),
]
for num, name, _ in theaters:
print(f" {num}. {name}")
theater_name = ""
theater_tag = None
while True:
choice = input("Enter number: ").strip()
for num, name, tag in theaters:
if choice == num:
if name == "Other":
theater_name = input("Theater name: ").strip()
else:
theater_name = name
theater_tag = tag
break
if theater_name:
break
print("Please enter a valid number")
show_time = input("Show time (e.g. 7:30pm): ").strip()
theater_num = input("Theater number: ").strip()
pizza = input("Pizza? (Yes/No): ").strip() or ""
tickets = input("Tickets (e.g. 'At Box Office', 'A-List'): ").strip()
crew = input("Crew (e.g. 'Me, Coach T, Science Bro'): ").strip()
return {
"type": "theater",
"theater": theater_name,
"theater_tag": theater_tag,
"show_time": show_time,
"theater_num": theater_num,
"pizza": pizza,
"tickets": tickets,
"crew": crew,
}
def prompt_home_details():
"""Prompt for home viewing details."""
location = input("Location (e.g. 'Living Room', 'Woodrow Apt'): ").strip() or "Home"
show_time = input("Show time (optional, e.g. 'evening'): ").strip()
pizza = input("Pizza? (Yes/No): ").strip() or "No"
# Media format
print("\nMedia format?")
media_options = [
("1", "Online"),
("2", "BluRay"),
("3", "DVD"),
("4", "VHS"),
]
for num, name in media_options:
print(f" {num}. {name}")
media = "Online"
media_choice = input("Enter number (default 1): ").strip()
for num, name in media_options:
if media_choice == num:
media = name
break
# Screen type
print("\nScreen?")
screen_options = [
("1", "4k TV"),
("2", "4k Computer"),
("3", "1080p Computer"),
("4", "Cell Phone"),
("5", "Someone Elses TV"),
]
for num, name in screen_options:
print(f" {num}. {name}")
screen = "4k TV"
screen_choice = input("Enter number (default 1): ").strip()
for num, name in screen_options:
if screen_choice == num:
screen = name
break
return {
"type": "home",
"theater": "Home Video",
"theater_tag": "homevideo",
"show_time": show_time,
"theater_num": location,
"pizza": pizza,
"media": media,
"screen": screen,
}
def create_draft_post(movie, tmdb_details, poster_url, viewing_details=None):
"""Create a Hugo draft post for the movie."""
slug = slugify(movie["title"])
filename = f"{slug}.md"
@@ -140,7 +262,7 @@ def create_draft_post(movie, tmdb_details, poster_url):
return None
# Format the date for Hugo
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Format watched date nicely
watched = movie["watched_date"]
@@ -156,6 +278,52 @@ def create_draft_post(movie, tmdb_details, poster_url):
imdb_id = tmdb_details.get("imdb_id", "")
rating_display = rating_to_stars(movie["rating"])
# Use viewing details if provided, otherwise use empty defaults
if viewing_details:
show_time = viewing_details.get("show_time", "")
theater = viewing_details.get("theater", "")
theater_num = viewing_details.get("theater_num", "")
pizza = viewing_details.get("pizza", "")
is_home = viewing_details.get("type") == "home"
# Build tags based on viewing type
tags = []
if viewing_details.get("theater_tag"):
tags.append(viewing_details["theater_tag"])
tags.extend(["no-expectations"])
if pizza.lower() == "yes":
tags.append("had pizza")
tags_yaml = "\n".join(f" - {tag}" for tag in tags)
# Different last two rows for home vs theater
if is_home:
row5_label = "Media"
row5_value = viewing_details.get("media", "")
row7_label = "Screen"
row7_value = viewing_details.get("screen", "")
else:
row5_label = "Tickets"
row5_value = viewing_details.get("tickets", "")
row7_label = "Crew"
row7_value = viewing_details.get("crew", "")
else:
show_time = ""
theater = ""
theater_num = ""
pizza = ""
row5_label = "Tickets"
row5_value = ""
row7_label = "Crew"
row7_value = ""
tags_yaml = """ - gucci
- ghost-theater
- marcel
- amc-south
- amc-lakeline
- anticipated
- no-expectations
- had pizza"""
# Build the frontmatter and content
content = f'''---
title: '{movie["title"]}'
@@ -166,26 +334,25 @@ summary: ""
imdb: "{imdb_id}"
poster: "{poster_url or ''}"
tags:
- gucci
- ghost theater
- marcel
- amc-south
- amc-lakeline
- anticipated
- no-expectations
- had pizza
{tags_yaml}
# Mastodon comments: After posting about this on Mastodon, add the post ID below.
# Get the ID from the end of the toot URL, e.g. https://tilde.zone/@mnw/123456789
# mastodon_id: ""
# To block a reply from showing, add its full URL to this list:
# mastodon_blocked:
# - "https://tilde.zone/@someone/123456789"
---
{{{{< imdbposter >}}}}
| Date watched | {watched_display} |
| Date watched | {watched_display:<17} |
|---------------------|-------------------|
| Show Time | |
| Theater | |
| Theater Number | |
| Pizza | |
| Tickets | |
| Letterboxd Rating | {rating_display} |
| Crew | |
| Show Time | {show_time:<17} |
| Theater | {theater:<17} |
| Theater Number | {theater_num:<17} |
| Pizza | {pizza:<17} |
| {row5_label:<19} | {row5_value:<17} |
| Letterboxd Rating | {rating_display:<17} |
| {row7_label:<19} | {row7_value:<17} |
{{{{< /imdbposter >}}}}
@@ -209,12 +376,25 @@ def display_movies(movies, limit=10):
print()
def import_movie(movie):
"""Import a single movie: fetch details, download poster, create post."""
def import_movie(movie, viewing_mode=None):
"""Import a single movie: fetch details, download poster, create post.
Args:
movie: Movie data from Letterboxd RSS
viewing_mode: 'theater', 'home', or None (will prompt)
"""
print(f"\nImporting: {movie['title']} ({movie['year']})")
# Get viewing details
if viewing_mode == "theater":
viewing_details = prompt_theater_details()
elif viewing_mode == "home":
viewing_details = prompt_home_details()
else:
viewing_details = prompt_viewing_details()
# Get TMDB details
print(" Fetching TMDB details...")
print("\n Fetching TMDB details...")
tmdb = get_tmdb_details(movie["tmdb_id"])
# Download poster
@@ -226,7 +406,7 @@ def import_movie(movie):
# Create draft post
print(" Creating draft post...")
filepath = create_draft_post(movie, tmdb, poster_url)
filepath = create_draft_post(movie, tmdb, poster_url, viewing_details)
if filepath:
print(f"\nDone! Edit your draft at: {filepath.relative_to(PROJECT_ROOT)}")
@@ -241,8 +421,17 @@ def main():
parser.add_argument("--latest", action="store_true", help="Import most recent entry")
parser.add_argument("--list", action="store_true", help="Just list recent entries")
parser.add_argument("--count", type=int, default=10, help="Number of entries to show")
parser.add_argument("--theater", action="store_true", help="Skip viewing prompt, go straight to theater questions")
parser.add_argument("--home", action="store_true", help="Skip viewing prompt, go straight to home questions")
args = parser.parse_args()
# Determine viewing mode from flags
viewing_mode = None
if args.theater:
viewing_mode = "theater"
elif args.home:
viewing_mode = "home"
print("Fetching Letterboxd RSS feed...")
try:
root = fetch_rss()
@@ -260,7 +449,7 @@ def main():
sys.exit(0)
if args.latest:
import_movie(movies[0])
import_movie(movies[0], viewing_mode)
sys.exit(0)
# Interactive mode
@@ -272,7 +461,7 @@ def main():
sys.exit(0)
idx = int(choice) - 1
if 0 <= idx < len(movies):
import_movie(movies[idx])
import_movie(movies[idx], viewing_mode)
else:
print("Invalid selection")
sys.exit(1)

View File

@@ -0,0 +1,213 @@
/**
* Mastodon Comments Stylesheet
*
* Teletype / Fax Machine / I Saw the TV Glow aesthetic
* Works with various Hugo themes
*/
.mastodon-comments-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px dashed #888;
font-family: 'Courier New', Courier, monospace;
}
.comments-header {
margin: 0 0 1.5rem 0;
padding: 0;
font-size: 0.85rem;
color: #888;
background: none;
border: none;
white-space: pre;
overflow-x: auto;
}
.comments-intro {
font-size: 0.9rem;
margin-bottom: 1.5rem;
letter-spacing: 0.05em;
}
.comments-intro a {
text-decoration: underline;
}
.comments-note {
margin-top: 1rem;
color: #666;
font-size: 0.8rem;
}
#mastodon-comments-list {
min-height: 50px;
}
#load-comments-btn {
font-family: 'Courier New', Courier, monospace;
font-size: 1rem;
padding: 0.75rem 1.5rem;
background: transparent;
border: 2px solid currentColor;
cursor: pointer;
letter-spacing: 0.1em;
transition: all 0.2s ease;
}
#load-comments-btn:hover {
background: #333;
color: #fff;
}
.loading,
.no-comments,
.comments-received,
.comments-error {
padding: 1rem;
margin: 1rem 0;
background: #f5f5f5;
border-left: 4px solid #888;
font-size: 0.85rem;
white-space: pre-wrap;
}
.comments-error {
border-left-color: #c00;
color: #900;
}
.comments-received {
border-left-color: #080;
color: #060;
}
/* Individual comment styling */
.mastodon-comment {
margin: 1.5rem 0;
padding: 1rem;
background: #fafafa;
border: 1px solid #ddd;
}
.mastodon-comment .comment-header pre {
margin: 0 0 0.75rem 0;
padding: 0;
font-size: 0.75rem;
color: #666;
background: none;
border: none;
white-space: pre;
}
.mastodon-comment .comment-author {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.mastodon-comment .avatar {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid #ccc;
}
.mastodon-comment .author-info {
display: flex;
flex-direction: column;
}
.mastodon-comment .display-name {
font-weight: bold;
text-decoration: none;
}
.mastodon-comment .display-name:hover {
text-decoration: underline;
}
.mastodon-comment .handle {
font-size: 0.8rem;
color: #666;
}
.mastodon-comment .emoji {
height: 18px;
width: 18px;
vertical-align: middle;
}
.mastodon-comment .comment-content {
margin: 1rem 0;
line-height: 1.5;
}
.mastodon-comment .comment-content p {
margin: 0.5rem 0;
}
.mastodon-comment .comment-content a {
word-break: break-all;
}
.mastodon-comment .comment-attachments {
margin: 1rem 0;
}
.mastodon-comment .comment-attachments img,
.mastodon-comment .comment-attachments video {
max-width: 100%;
max-height: 300px;
border: 1px solid #ccc;
}
.mastodon-comment .comment-meta {
font-size: 0.8rem;
color: #666;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px dashed #ccc;
}
.mastodon-comment .comment-meta a {
text-decoration: none;
color: #666;
}
.mastodon-comment .comment-meta a:hover {
text-decoration: underline;
}
.mastodon-comment .replies {
color: #888;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.mastodon-comment {
background: #1a1a1a;
border-color: #444;
}
.mastodon-comment .comment-header pre {
color: #888;
}
.mastodon-comment .handle,
.mastodon-comment .comment-meta,
.mastodon-comment .comment-meta a {
color: #999;
}
.loading,
.no-comments,
.comments-received {
background: #222;
}
#load-comments-btn:hover {
background: #eee;
color: #000;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,141 @@
/**
* Mastodon Comments Loader
*
* Fetches and displays replies to a Mastodon post.
* Uses DOMPurify for HTML sanitization.
*
* Inspired by Andreas Scherbaum's implementation.
* Aesthetic inspired by I Saw the TV Glow.
*/
var commentsLoaded = false;
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatDate(dateStr) {
var d = new Date(dateStr);
var year = d.getFullYear();
var month = String(d.getMonth() + 1).padStart(2, '0');
var day = String(d.getDate()).padStart(2, '0');
var hours = String(d.getHours()).padStart(2, '0');
var mins = String(d.getMinutes()).padStart(2, '0');
return year + '-' + month + '-' + day + ' ' + hours + ':' + mins;
}
function getUserHandle(account) {
var handle = '@' + account.acct;
if (account.acct.indexOf('@') === -1) {
var domain = new URL(account.url);
handle += '@' + domain.hostname;
}
return handle;
}
function renderComment(toot, depth) {
// Skip blocked toots
if (blockedToots.includes(toot.url)) {
return '';
}
// Process display name with custom emojis
var displayName = escapeHtml(toot.account.display_name || toot.account.username);
toot.account.emojis.forEach(function(emoji) {
var emojiImg = '<img src="' + escapeHtml(emoji.static_url) + '" alt=":' + emoji.shortcode + ':" class="emoji" height="18" width="18">';
displayName = displayName.replace(':' + emoji.shortcode + ':', emojiImg);
});
var indent = depth > 0 ? ' style="margin-left: ' + (depth * 20) + 'px; border-left: 2px dashed #666;"' : '';
var html = '<div class="mastodon-comment"' + indent + '>';
html += '<div class="comment-header">';
html += '<pre>';
html += '/* ---------------------------------------- */\n';
html += '/* FROM: ' + getUserHandle(toot.account).padEnd(32) + ' */\n';
html += '/* DATE: ' + formatDate(toot.created_at).padEnd(32) + ' */\n';
html += '/* ---------------------------------------- */</pre>';
html += '</div>';
html += '<div class="comment-author">';
html += '<img src="' + escapeHtml(toot.account.avatar_static) + '" alt="" class="avatar">';
html += '<div class="author-info">';
html += '<a href="' + escapeHtml(toot.account.url) + '" rel="nofollow" class="display-name">' + displayName + '</a>';
html += '<span class="handle">' + escapeHtml(getUserHandle(toot.account)) + '</span>';
html += '</div>';
html += '</div>';
html += '<div class="comment-content">' + toot.content + '</div>';
// Media attachments
if (toot.media_attachments && toot.media_attachments.length > 0) {
html += '<div class="comment-attachments">';
toot.media_attachments.forEach(function(attachment) {
if (attachment.type === 'image') {
html += '<a href="' + escapeHtml(attachment.url) + '" rel="nofollow"><img src="' + escapeHtml(attachment.preview_url) + '" alt="' + escapeHtml(attachment.description || 'attachment') + '"></a>';
} else if (attachment.type === 'video' || attachment.type === 'gifv') {
html += '<video controls ' + (attachment.type === 'gifv' ? 'autoplay loop muted' : '') + '><source src="' + escapeHtml(attachment.url) + '"></video>';
}
});
html += '</div>';
}
html += '<div class="comment-meta">';
html += '<a href="' + escapeHtml(toot.url) + '" rel="nofollow" class="comment-link">[VIEW ORIGINAL]</a>';
if (toot.replies_count > 0) {
html += ' <span class="replies">[' + toot.replies_count + ' REPLIES]</span>';
}
html += '</div>';
html += '</div>';
return html;
}
function renderComments(toots, parentId, depth) {
var html = '';
var replies = toots
.filter(function(toot) { return toot.in_reply_to_id === parentId; })
.sort(function(a, b) { return a.created_at.localeCompare(b.created_at); });
replies.forEach(function(toot) {
html += renderComment(toot, depth);
html += renderComments(toots, toot.id, depth + 1);
});
return html;
}
function loadMastodonComments() {
if (commentsLoaded) return;
var container = document.getElementById('mastodon-comments-list');
container.innerHTML = '<pre class="loading">\n++ ESTABLISHING CONNECTION TO ' + mastodonHost.toUpperCase() + ' ++\n++ PLEASE STAND BY ++\n</pre>';
var apiUrl = 'https://' + mastodonHost + '/api/v1/statuses/' + mastodonId + '/context';
fetch(apiUrl)
.then(function(response) {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(function(data) {
if (data.descendants && data.descendants.length > 0) {
var html = '<pre class="comments-received">\n++ TRANSMISSION COMPLETE ++\n++ ' + data.descendants.length + ' COMMENT(S) RECEIVED ++\n</pre>';
html += renderComments(data.descendants, mastodonId, 0);
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
} else {
container.innerHTML = '<pre class="no-comments">\n++ NO COMMENTS RECEIVED ++\n++ BE THE FIRST TO RESPOND ++\n</pre>';
}
commentsLoaded = true;
})
.catch(function(error) {
container.innerHTML = '<pre class="comments-error">\n++ TRANSMISSION ERROR ++\n++ FAILED TO LOAD COMMENTS ++\n++ ERROR: ' + escapeHtml(error.message) + ' ++\n</pre>';
});
}

3
static/js/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long