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:
34
content/posts/urchin.md
Normal file
34
content/posts/urchin.md
Normal 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...
|
||||
|
||||
@@ -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}}
|
||||
|
||||
77
layouts/partials/mastodon-comments.html
Normal file
77
layouts/partials/mastodon-comments.html
Normal 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()">
|
||||
>> LOAD COMMENTS{{ if gt $count 0 }} ({{ $count }}){{ end }} <<
|
||||
</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 -}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
213
static/css/mastodon-comments.css
Normal file
213
static/css/mastodon-comments.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
static/images/posters/urchin.jpg
Normal file
BIN
static/images/posters/urchin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
141
static/js/mastodon-comments.js
Normal file
141
static/js/mastodon-comments.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
3
static/js/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user