基于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'); ?>
评论
暂无评论