分享 503 0

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

    以本主题为例,在主题目录下新建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'); ?>