时光沉淀

基于Gotosocial/Mastodon给博客增加一个说说页面

· Views · By 浪子

以本主题为例,在主题目录下新建gts.php,修改以下代码
Gotosocial必须有以下三个参数
GTS_INSTANCE USER_ID ACCESS_TOKEN
Mastodon则不需要ACCESS_TOKEN

<?php
/**
* 说说
*
* @package custom
*/
if (!defined('__TYPECHO_ROOT_DIR__')) exit;

// GoToSocial API 配置
define('GTS_INSTANCE', 'social.sgcd.net');  // 你的 GoToSocial 实例域名
define('USER_ID', '01N805GS5HM673X9J1TZQZPVHX');  // 你的用户 ID
define('ACCESS_TOKEN', ' ');  // 如果不需要认证,留空即可
define('ITEMS_PER_PAGE', 20);  // 每页显示的条目数
define('MAX_PAGES', 25);  // 最大缓存页数
define('API_BASE_URL', 'https://' . GTS_INSTANCE . '/api/v1');
define('CACHE_DIR', __TYPECHO_THEME_DIR__ . '/cache');
define('CACHE_LIFETIME', 3600); // 缓存生存时间(秒)

class GoToSocialFetcher {
    private $accessToken;
    private $baseUrl;
    private $cacheFile;
    
    public function __construct() {
        $this->accessToken = ACCESS_TOKEN;
        $this->baseUrl = API_BASE_URL;
        $this->cacheFile = CACHE_DIR . '/timeline_cache.json';
        
        if (!file_exists(CACHE_DIR)) {
            mkdir(CACHE_DIR, 0777, true);
        }
    }
    
    private function getCache() {
        if (file_exists($this->cacheFile)) {
            $cacheData = json_decode(file_get_contents($this->cacheFile), true);
            if ($cacheData && time() - $cacheData['timestamp'] < CACHE_LIFETIME) {
                return $cacheData['data'];
            }
        }
        return null;
    }
    
    private function setCache($data) {
        $cacheData = [
            'timestamp' => time(),
            'data' => $data
        ];
        file_put_contents($this->cacheFile, json_encode($cacheData));
    }
    
public function fetchTimeline() {
    $cachedData = $this->getCache();
    if ($cachedData !== null) {
        return $cachedData;
    }
    
    $toots = [];
    $lastId = null;
    
    for ($i = 0; $i < MAX_PAGES; $i++) {
        try {
            $url = $this->baseUrl . "/accounts/" . USER_ID . "/statuses?limit=" . ITEMS_PER_PAGE;
            if ($lastId) {
                $url .= "&max_id=" . $lastId;
            }
            
            // 初始化 CURL 选项
            $ch = curl_init();
            $headers = [
                'Accept: application/json',
                'User-Agent: PHP/GoToSocialFetcher'
            ];
            
            // 只有在设置了 token 且不为空时才添加认证头
            if (!empty($this->accessToken) && $this->accessToken !== 'your-access-token-here') {
                $headers[] = 'Authorization: Bearer ' . $this->accessToken;
            }
            
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2
            ]);

            $response = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            
            if ($response === false) {
                throw new Exception('CURL Error: ' . curl_error($ch));
            }
            
            if ($httpCode !== 200) {
                throw new Exception('API returned status code: ' . $httpCode);
            }
            
            curl_close($ch);
            
            $data = json_decode($response, true);
            
            if (json_last_error() !== JSON_ERROR_NONE) {
                throw new Exception('JSON decode error: ' . json_last_error_msg());
            }
            
            if (empty($data)) {
                break;
            }
            
            foreach ($data as $toot) {
                if (empty($toot['reblog']) && empty($toot['in_reply_to_id'])) {
                    $toots[] = $toot;
                }
            }
            
            if (!empty($data)) {
                $lastId = end($data)['id'];
            }
            
        } catch (Exception $e) {
            error_log('Error fetching timeline: ' . $e->getMessage());
            throw $e;
        }
    }
    
    $this->setCache($toots);
    return $toots;
}

    public function renderTimeline($page = 1) {
        try {
            $toots = $this->fetchTimeline();
            
            $totalItems = count($toots);
            $totalPages = ceil($totalItems / ITEMS_PER_PAGE);
            $page = max(1, min($page, $totalPages));
            $offset = ($page - 1) * ITEMS_PER_PAGE;
            
            $pageToots = array_slice($toots, $offset, ITEMS_PER_PAGE);
            
            if (!empty($pageToots)) {
                foreach ($pageToots as $toot) {
                    echo $this->renderToot($toot);
                }
                
                if ($totalPages > 1) {
                    echo $this->renderPagination($page, $totalPages);
                }
            } else {
                echo '<div class="empty-talks">暂时没有说说</div>';
            }
            
        } catch (Exception $e) {
            echo '<div class="error">Error: ' . htmlspecialchars($e->getMessage()) . '</div>';
        }
    }
    
    private function renderToot($toot) {
        $html = '<article class="post">';
        $html .= '<div class="post-header">';
        $html .= '<img src="' . htmlspecialchars($toot['account']['avatar']) . '" alt="Avatar" class="avatar">';
        $html .= '<div class="post-meta">';
        $html .= '<h2 class="display-name">' . 
                 htmlspecialchars($toot['account']['display_name']) . 
                 ' <span class="username">@' . htmlspecialchars($toot['account']['username']) . '</span></h2>';
        $html .= '<time datetime="' . $toot['created_at'] . '">' . date('Y-m-d H:i', strtotime($toot['created_at'])) . '</time>';
        $html .= '</div></div>';
        
        $html .= '<div class="post-content">';
        $html .= $toot['content'];
        
        if (!empty($toot['media_attachments'])) {
            $html .= '<div class="media-attachments">';
            foreach ($toot['media_attachments'] as $media) {
                if ($media['type'] === 'image') {
                    $html .= '<img src="' . htmlspecialchars($media['url']) . '" alt="Media" class="attachment">';
                }
            }
            $html .= '</div>';
        }
        $html .= '</div>';
        
        $html .= '<div class="post-footer">';
        $html .= '<span class="interactions">';
        $html .= '<span>🔁 ' . $toot['reblogs_count'] . '</span>';
        $html .= '<span>⭐ ' . $toot['favourites_count'] . '</span>';
        $html .= '</span></div>';
        $html .= '</article>';
        
        return $html;
    }
    
    private function renderPagination($currentPage, $totalPages) {
        $html = '<div class="pagination flex justify-between items-center my-8">';
        
        $prevClass = $currentPage == 1 ? ' opacity-50 cursor-not-allowed' : '';
        $html .= '<a href="?page=' . max(1, $currentPage - 1) . '" class="prev px-6 py-4 bg-black text-white rounded-full text-sm shadow-lg transition-all duration-100' . $prevClass . '">上一页</a>';
        
        $nextClass = $currentPage == $totalPages ? ' opacity-50 cursor-not-allowed' : '';
        $html .= '<a href="?page=' . min($totalPages, $currentPage + 1) . '" class="next px-6 py-4 bg-black text-white rounded-full text-sm shadow-lg transition-all duration-100' . $nextClass . '">下一页</a>';
        
        $html .= '</div>';
        return $html;
    }
}

$this->need('header.php');
?>

<main class="prose prose-neutral relative mx-auto min-h-[calc(100%-10rem)] max-w-3xl px-8 pt-20 pb-32 dark:prose-invert">
    <article>
        <header class="mb-20">
            <h1 class="!my-0 pb-2.5">说说</h1>
        </header>
        <section class="talks-container">
            <?php 
            $page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
            $fetcher = new GoToSocialFetcher();
            $fetcher->renderTimeline($page);
            ?>
        </section>
    </article>
</main>
    <style>
 
        .timeline {
            background: #fff;
            border-radius: 8px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        
        .post {
            padding: 20px;
            border-bottom: 1px solid #eee;
        }
        
        .post:last-child {
            border-bottom: none;
        }
        
        .post-header {
            display: flex;
            align-items: center;
            margin-bottom: 15px;
        }
        
        .avatar {
            width: 48px;
            height: 48px;
            border-radius: 50%;
            margin-right: 15px;
        }
        
        .post-meta {
            flex: 1;
        }
        
        .display-name {
            font-size: 1.1em;
            font-weight: bold;
            margin: 0;
            display: flex;
            align-items: center;
            gap: 8px; /* 添加显示名称和用户名之间的间距 */
        }
        
        .username {
            color: #666;
            font-size: 0.85em;
            font-weight: normal;
        }
        
        .post-content {
            margin: 15px 0;
        }
        
        .media-attachments {
            margin-top: 15px;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 10px;
        }
        
        .attachment {
            max-width: 100%;
            border-radius: 4px;
        }
        
        .post-footer {
            margin-top: 15px;
            color: #666;
        }
        
        .interactions span {
            margin-right: 20px;
        }
        
        .error {
            background-color: #fee;
            color: #c00;
            padding: 15px;
            border-radius: 4px;
            margin: 20px 0;
        }
        
        .no-posts {
            text-align: center;
            padding: 40px;
            color: #666;
        }
        
        @media (max-width: 640px) {
            .display-name {
                font-size: 1em;
                gap: 6px;
            }
            
            .username {
                font-size: 0.8em;
            }
            .container {
                padding: 10px;
            }
            
            .pagination {
                padding: 0 10px;
            }
            
            .post {
                padding: 15px;
            }
            
            .avatar {
                width: 40px;
                height: 40px;
            }
        }
    </style>
<?php $this->need('footer.php'); ?>

评论

0 条评论

暂无评论

Using $this when not in object context

Error: Using $this when not in object context in /app/data/plugins/FediverseSync/template/comment.php:3 Stack trace: #0 /app/data/plugins/FediverseSync/Plugin.php(989): require_once() #1 [internal function]: FediverseSync_Plugin::renderComments(Object(Widget\Archive), NULL) #2 /app/var/Typecho/Plugin.php(446): call_user_func_array(Array, Array) #3 /app/var/Widget/Archive.php(1274): Typecho\Plugin->__call('Widget_Archive:...', Array) #4 /app/data/themes/paper-main/footer.php(23): Widget\Archive->footer() #5 /app/var/Widget/Archive.php(1336): require('/app/data/theme...') #6 /app/data/themes/paper-main/post.php(51): Widget\Archive->need('footer.php') #7 /app/var/Widget/Archive.php(1418): require_once('/app/data/theme...') #8 /app/var/Typecho/Router.php(99): Widget\Archive->render() #9 /app/index.php(23): Typecho\Router::dispatch() #10 {main}