From 8c71e80b2a0b2f8124d02942968f8b710398a4d2 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Thu, 20 Nov 2025 16:10:31 +0800 Subject: [PATCH] Add Mastodon feed to right sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Display latest 5 Mastodon posts (toots) in sidebar - Include original posts and boosts, exclude replies - Show medium-length previews (150-200 chars) - Styled to match existing blog design with purple Mastodon branding - Section title: "微網誌 (Microblog)" - Relative timestamps in Chinese ("2小時前") - Links to original posts on Mastodon - Loading skeletons for better UX - Graceful error handling (fails silently if API unavailable) - Respects dark mode Implementation: - Created lib/mastodon.ts with utility functions: - Parse Mastodon URL format - Strip HTML from content - Smart text truncation - Relative time formatting in Chinese - API functions to fetch account and statuses - Created components/mastodon-feed.tsx: - Client component with useEffect for data fetching - Fetches directly from Mastodon public API - Handles boosts/reblogs with indicator - Shows media attachment indicators - Matches existing card styling patterns - Updated components/right-sidebar.tsx: - Added MastodonFeed between profile and hot tags - Maintains consistent spacing and layout Usage: Set NEXT_PUBLIC_MASTODON_URL in .env.local to enable Format: https://your.instance/@yourhandle --- components/mastodon-feed.tsx | 170 +++++++++++++++++++++++++++++++++++ components/right-sidebar.tsx | 4 + lib/mastodon.ts | 158 ++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 components/mastodon-feed.tsx create mode 100644 lib/mastodon.ts diff --git a/components/mastodon-feed.tsx b/components/mastodon-feed.tsx new file mode 100644 index 0000000..2f5ecf4 --- /dev/null +++ b/components/mastodon-feed.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faMastodon } from '@fortawesome/free-brands-svg-icons'; +import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { siteConfig } from '@/lib/config'; +import { + parseMastodonUrl, + stripHtml, + truncateText, + formatRelativeTime, + fetchAccountId, + fetchStatuses, + type MastodonStatus +} from '@/lib/mastodon'; + +export function MastodonFeed() { + const [statuses, setStatuses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + const loadStatuses = async () => { + const mastodonUrl = siteConfig.social.mastodon; + + if (!mastodonUrl) { + setLoading(false); + return; + } + + try { + // Parse the Mastodon URL + const parsed = parseMastodonUrl(mastodonUrl); + if (!parsed) { + setError(true); + setLoading(false); + return; + } + + const { instance, username } = parsed; + + // Fetch account ID + const accountId = await fetchAccountId(instance, username); + if (!accountId) { + setError(true); + setLoading(false); + return; + } + + // Fetch statuses (5 posts, exclude replies, include boosts) + const fetchedStatuses = await fetchStatuses(instance, accountId, 5); + setStatuses(fetchedStatuses); + } catch (err) { + console.error('Error loading Mastodon feed:', err); + setError(true); + } finally { + setLoading(false); + } + }; + + loadStatuses(); + }, []); + + // Don't render if no Mastodon URL is configured + if (!siteConfig.social.mastodon) { + return null; + } + + // Don't render if there's an error (fail silently) + if (error) { + return null; + } + + return ( +
+ {/* Header */} +
+ + 微網誌 +
+ + {/* Content */} + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : statuses.length === 0 ? ( +

+ 暫無動態 +

+ ) : ( +
+ {statuses.map((status) => { + // Handle boosts (reblogs) + const displayStatus = status.reblog || status; + const content = stripHtml(displayStatus.content); + const truncated = truncateText(content, 180); + const relativeTime = formatRelativeTime(status.created_at); + const hasMedia = displayStatus.media_attachments.length > 0; + + return ( + + ); + })} +
+ )} + + {/* Footer link */} + {!loading && statuses.length > 0 && ( + + 查看更多 + + + )} +
+ ); +} diff --git a/components/right-sidebar.tsx b/components/right-sidebar.tsx index a81f4a5..af497c3 100644 --- a/components/right-sidebar.tsx +++ b/components/right-sidebar.tsx @@ -6,6 +6,7 @@ import { faFire, faArrowRight } from '@fortawesome/free-solid-svg-icons'; import { siteConfig } from '@/lib/config'; import { getAllTagsWithCount } from '@/lib/posts'; import { allPages } from 'contentlayer2/generated'; +import { MastodonFeed } from './mastodon-feed'; export function RightSidebar() { const tags = getAllTagsWithCount().slice(0, 5); @@ -91,6 +92,9 @@ export function RightSidebar() { + {/* Mastodon Feed */} + + {tags.length > 0 && (

diff --git a/lib/mastodon.ts b/lib/mastodon.ts new file mode 100644 index 0000000..e75f3f9 --- /dev/null +++ b/lib/mastodon.ts @@ -0,0 +1,158 @@ +/** + * Mastodon API utilities for fetching and processing toots + */ + +export interface MastodonStatus { + id: string; + content: string; + created_at: string; + url: string; + reblog: MastodonStatus | null; + account: { + username: string; + display_name: string; + avatar: string; + }; + media_attachments: Array<{ + type: string; + url: string; + preview_url: string; + }>; +} + +/** + * Parse Mastodon URL to extract instance domain and username + * @param url - Mastodon profile URL (e.g., "https://mastodon.social/@username") + * @returns Object with instance and username, or null if invalid + */ +export function parseMastodonUrl(url: string): { instance: string; username: string } | null { + try { + const urlObj = new URL(url); + const instance = urlObj.hostname; + const pathMatch = urlObj.pathname.match(/^\/@?([^/]+)/); + + if (!pathMatch) return null; + + const username = pathMatch[1]; + return { instance, username }; + } catch { + return null; + } +} + +/** + * Strip HTML tags from content and decode HTML entities + * @param html - HTML content from Mastodon post + * @returns Plain text content + */ +export function stripHtml(html: string): string { + // Remove HTML tags + let text = html.replace(//gi, '\n'); + text = text.replace(/<\/p>

/gi, '\n\n'); + text = text.replace(/<[^>]+>/g, ''); + + // Decode common HTML entities + text = text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); + + return text.trim(); +} + +/** + * Truncate text smartly, avoiding cutting words in half + * @param text - Text to truncate + * @param maxLength - Maximum length (default: 180) + * @returns Truncated text with ellipsis if needed + */ +export function truncateText(text: string, maxLength: number = 180): string { + if (text.length <= maxLength) return text; + + // Find the last space before maxLength + const truncated = text.substring(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + + // If there's a space, cut at the space; otherwise use maxLength + const cutPoint = lastSpace > maxLength * 0.8 ? lastSpace : maxLength; + + return text.substring(0, cutPoint).trim() + '...'; +} + +/** + * Format timestamp as relative time in Chinese + * @param dateString - ISO date string + * @returns Relative time string (e.g., "2小時前") + */ +export function formatRelativeTime(dateString: string): string { + const now = new Date(); + const date = new Date(dateString); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return '剛剛'; + if (diffMin < 60) return `${diffMin}分鐘前`; + if (diffHour < 24) return `${diffHour}小時前`; + if (diffDay < 7) return `${diffDay}天前`; + if (diffDay < 30) return `${Math.floor(diffDay / 7)}週前`; + if (diffDay < 365) return `${Math.floor(diffDay / 30)}個月前`; + + return `${Math.floor(diffDay / 365)}年前`; +} + +/** + * Fetch user's Mastodon account ID from username + * @param instance - Mastodon instance domain + * @param username - Username without @ + * @returns Account ID or null if not found + */ +export async function fetchAccountId(instance: string, username: string): Promise { + try { + const response = await fetch( + `https://${instance}/api/v1/accounts/lookup?acct=${username}`, + { cache: 'no-store' } + ); + + if (!response.ok) return null; + + const account = await response.json(); + return account.id; + } catch (error) { + console.error('Error fetching Mastodon account:', error); + return null; + } +} + +/** + * Fetch user's recent statuses from Mastodon + * @param instance - Mastodon instance domain + * @param accountId - Account ID + * @param limit - Number of statuses to fetch (default: 5) + * @returns Array of statuses or empty array on error + */ +export async function fetchStatuses( + instance: string, + accountId: string, + limit: number = 5 +): Promise { + try { + const response = await fetch( + `https://${instance}/api/v1/accounts/${accountId}/statuses?limit=${limit}&exclude_replies=true`, + { cache: 'no-store' } + ); + + if (!response.ok) return []; + + const statuses = await response.json(); + return statuses; + } catch (error) { + console.error('Error fetching Mastodon statuses:', error); + return []; + } +}