fixed 友圈bug
This commit is contained in:
1 parent
05ba086e76
commit
744d6cce8b
12 files changed
+2146
-37
No files matched your search
@@ -30,6 +30,17 @@ jobs:
|
||||
# 2. 自动处理隐藏文章 (防止 status: hidden 的文章意外泄露)
|
||||
./scripts/add_draft_to_hidden.ps1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Generate Friend Circle Data
|
||||
run: node scripts/generate_circle_data.js
|
||||
|
||||
- name: Build
|
||||
run: rm -rf public && hugo --minify
|
||||
|
||||
|
||||
@@ -35,3 +35,5 @@ static/pics/hugo/节前资产资源图解矢量化工作/abafa5de9215e3beb21dda2
|
||||
static/pics/hugo/节前资产资源图解矢量化工作/wps1.jpg
|
||||
static/pics/hugo/节前资产资源图解矢量化工作/wps2.jpg
|
||||
static/pics/hugo/湖北三资运维问题记录
|
||||
|
||||
node_modules/
|
||||
@@ -168,17 +168,33 @@ RSS 地址: `https://usj.cc/rss.xml`
|
||||
通常由 GitHub Actions 自动调用,不建议手动运行。
|
||||
需要环境变量: `DOGECLOUD_ACCESS_KEY`, `DOGECLOUD_SECRET_KEY`, `CDN_URL_LIST`.
|
||||
|
||||
### 6.8 朋友圈数据生成 (`scripts/generate_circle_data.js`)
|
||||
抓取友链 RSS 并生成朋友圈数据。
|
||||
|
||||
* **用法**:
|
||||
```bash
|
||||
node scripts/generate_circle_data.js
|
||||
```
|
||||
* **功能**:
|
||||
1. 读取 `themes/Ying/static/json/link_lite.json` 中的友链列表。
|
||||
2. 尝试抓取每个友链的 RSS/Atom 订阅源。
|
||||
3. 提取最新文章(包含标题、链接、摘要、图片等)。
|
||||
4. 生成 `themes/Ying/static/json/friend_circle_data.json` 供前端展示。
|
||||
|
||||
## 7. 自动化部署 (GitHub Actions)
|
||||
|
||||
本项目配置了完整的 CI/CD 流程 (`.github/workflows/upy.yml`),主要包含以下步骤:
|
||||
|
||||
1. **环境准备**: 安装 Hugo, Python, PowerShell。
|
||||
1. **环境准备**: 安装 Hugo, Node.js, Python, PowerShell。
|
||||
2. **预构建检查**:
|
||||
* 运行 `update_link_lite_json.ps1` 确保友链数据最新。
|
||||
* 运行 `add_draft_to_hidden.ps1` 确保隐藏文章安全。
|
||||
3. **构建**: 运行 `hugo --minify` 生成静态文件。
|
||||
4. **部署**: 将 `public` 目录推送到又拍云 (UpYun)。
|
||||
5. **刷新缓存**: 部署成功后,调用多吉云 (DogeCloud) API 刷新 CDN 节点。
|
||||
3. **数据生成**:
|
||||
* 运行 `npm install` 安装依赖。
|
||||
* 运行 `generate_circle_data.js` 抓取并生成朋友圈数据。
|
||||
4. **构建**: 运行 `hugo --minify` 生成静态文件。
|
||||
5. **部署**: 将 `public` 目录推送到又拍云 (UpYun)。
|
||||
6. **刷新缓存**: 部署成功后,调用多吉云 (DogeCloud) API 刷新 CDN 节点。
|
||||
|
||||
> **注意**: 友链健康检查 (`scripts/check_links.js`) 仅作为本地工具 (`deploy.ps1`) 运行,不会阻断线上部署流程。
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
---
|
||||
pid: 137
|
||||
title: "如何hugo静态实现友联朋友圈功能"
|
||||
date: 2026-01-21T14:30:00+08:00
|
||||
slug: 137
|
||||
draft: false
|
||||
categories: ["教程", "折腾"]
|
||||
tags: ["Hugo", "朋友圈", "静态博客"]
|
||||
author: "小赵同学"
|
||||
layout: "post"
|
||||
---
|
||||
|
||||
前段时间把博客从 Typecho 迁移到了 Hugo,整体感觉就是快,静态博客真的省心。但是,心里总觉得少点什么。没错,就是那个能看到朋友们最新动态的“朋友圈”功能。
|
||||
|
||||
在 Typecho 时代,有现成的插件可以用,转到 Hugo 后,发现很多现成的主题并没有集成这个功能,或者样式不是我喜欢的。最近逛博客,看到 [liushen.fun](https://blog.liushen.fun/fcircle/) 的友圈样式挺不错的,卡片风格,毛玻璃特效,甚是喜欢。于是心血来潮,想着能不能自己在 Hugo 上也整一个。
|
||||
|
||||
本来想着直接把仓库开源给大家参考,但因为仓库里有一些私密的配置和魔改的烂代码,实在不好意思(也不方便)公开。所以,既然大家想要,那我就把**完整的代码**和**实现步骤**都贴在这里,主打一个“喂饭级”教程,大家直接复制粘贴就能用!
|
||||
|
||||
## 实现思路
|
||||
|
||||
因为 Hugo 是静态博客,没有数据库,所以不能像 Typecho 那样实时读取数据库。我的思路是:
|
||||
|
||||
1. **后端抓取**:利用 GitHub Actions 定时运行一个 Node.js 脚本。
|
||||
2. **数据处理**:脚本读取 `links.yaml`(或者你的友链 JSON),抓取每个朋友的 RSS Feed,提取文章标题、链接、时间、摘要和封面图。
|
||||
3. **数据生成**:将处理好的数据保存为 `friend_circle_data.json` 文件。
|
||||
4. **前端渲染**:在 Hugo 的页面模板中,通过 AJAX 请求这个 JSON 文件,动态渲染成卡片列表。
|
||||
|
||||
这样既保持了博客的静态特性,又实现了动态更新。
|
||||
|
||||
## 第一步:后端脚本 (Node.js)
|
||||
|
||||
首先,我们需要一个脚本来干脏活累活。我在项目根目录的 `scripts` 文件夹下新建了一个 `generate_circle_data.js`。
|
||||
|
||||
你需要先安装一个依赖:
|
||||
```bash
|
||||
npm install rss-parser
|
||||
```
|
||||
|
||||
下面是完整的脚本代码,支持自动识别 RSS(如果友链里没写),支持提取文章里的第一张图作为封面,支持提取摘要:
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Parser = require('rss-parser');
|
||||
|
||||
// 你的友链数据文件路径,根据实际情况修改
|
||||
const LINK_LITE_PATH = path.join(__dirname, '../themes/Ying/static/json/link_lite.json');
|
||||
// 输出文件路径
|
||||
const OUTPUT_PATH = path.join(__dirname, '../themes/Ying/static/json/friend_circle_data.json');
|
||||
|
||||
const MAX_POSTS_PER_FRIEND = 5; // 每个朋友取最近5篇
|
||||
const MAX_TOTAL_POSTS = 100; // 总共展示100篇
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchFeed(url) {
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
return feed;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting Friend Circle generation...');
|
||||
|
||||
if (!fs.existsSync(LINK_LITE_PATH)) {
|
||||
console.error('link_lite.json not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(LINK_LITE_PATH, 'utf8'));
|
||||
// 假设 data.friends 是一个数组: [name, url, avatar, rss?]
|
||||
// 如果你的格式不一样,请自行调整下面的 map 逻辑
|
||||
|
||||
let allPosts = [];
|
||||
const BATCH_SIZE = 5; // 并发控制
|
||||
const friends = data.friends;
|
||||
|
||||
for (let i = 0; i < friends.length; i += BATCH_SIZE) {
|
||||
const batch = friends.slice(i, i + BATCH_SIZE);
|
||||
const promises = batch.map(async (friend) => {
|
||||
const name = friend[0];
|
||||
const blogUrl = friend[1];
|
||||
const avatar = friend[2];
|
||||
let rssUrl = friend.length >= 4 ? friend[3] : null;
|
||||
|
||||
// 如果没有提供 RSS,尝试盲猜
|
||||
const candidates = [];
|
||||
if (rssUrl) {
|
||||
candidates.push(rssUrl);
|
||||
} else {
|
||||
const cleanUrl = blogUrl.replace(/\/$/, '');
|
||||
candidates.push(`${cleanUrl}/atom.xml`);
|
||||
candidates.push(`${cleanUrl}/rss.xml`);
|
||||
candidates.push(`${cleanUrl}/feed`);
|
||||
candidates.push(`${cleanUrl}/index.xml`);
|
||||
}
|
||||
|
||||
let feed = null;
|
||||
for (const url of candidates) {
|
||||
feed = await fetchFeed(url);
|
||||
if (feed) break;
|
||||
}
|
||||
|
||||
if (!feed) {
|
||||
console.log(`[${name}] No valid RSS found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取文章
|
||||
const posts = feed.items.slice(0, MAX_POSTS_PER_FRIEND).map(item => {
|
||||
let img = null;
|
||||
// 优先从 content 中提取图片
|
||||
const content = item['content:encoded'] || item.content || item.description || '';
|
||||
const imgMatch = content.match(/<img[^>]+src=['"]([^'"]+)['"]/i);
|
||||
|
||||
if (imgMatch) {
|
||||
img = imgMatch[1];
|
||||
} else if (item.enclosure && item.enclosure.url && item.enclosure.type && item.enclosure.type.startsWith('image')) {
|
||||
img = item.enclosure.url;
|
||||
}
|
||||
|
||||
// 提取摘要
|
||||
let snippet = '';
|
||||
if (content) {
|
||||
snippet = content.replace(/<[^>]+>/g, '');
|
||||
snippet = snippet.replace(/\s+/g, ' ').trim();
|
||||
if (snippet.length > 120) {
|
||||
snippet = snippet.substring(0, 120) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
date: item.isoDate || item.pubDate,
|
||||
author: name,
|
||||
avatar: avatar,
|
||||
blogUrl: blogUrl,
|
||||
image: img,
|
||||
description: snippet
|
||||
};
|
||||
});
|
||||
|
||||
allPosts = allPosts.concat(posts);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// 按时间倒序排序
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
|
||||
const finalPosts = allPosts.slice(0, MAX_TOTAL_POSTS);
|
||||
|
||||
const output = {
|
||||
updated: new Date().toISOString(),
|
||||
posts: finalPosts
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
|
||||
console.log(`Saved ${finalPosts.length} posts to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
## 第二步:自动化 (GitHub Actions)
|
||||
|
||||
脚本写好了,总不能每天自己手动跑吧?这时候 GitHub Actions 就派上用场了。
|
||||
|
||||
在 `.github/workflows` 下创建一个 `upy.yml` (或者其他名字),设置定时任务。我是设置每天跑一次,或者每次 push 代码时触发。
|
||||
|
||||
```yaml
|
||||
- name: Install Node.js dependencies
|
||||
run: npm install rss-parser
|
||||
|
||||
- name: Generate Friend Circle Data
|
||||
run: node scripts/generate_circle_data.js
|
||||
```
|
||||
|
||||
把这两步加到你现有的构建流程里,放在 `hugo` 命令之前就行。
|
||||
|
||||
## 第三步:前端颜值 (HTML & CSS)
|
||||
|
||||
这一步是重头戏!为了达到那个卡片式、毛玻璃、还有丝滑动效的效果,我可是调了半天 CSS。
|
||||
|
||||
我直接把我的 `themes/Ying/layouts/_default/circles.html` 文件内容贴出来。这个文件集成了 HTML 结构、CSS 样式和 JS 逻辑(包括 PJAX 适配和加载更多功能)。
|
||||
|
||||
你可以直接在你的 Hugo 主题里新建一个 `layouts/_default/circles.html`,然后复制下面的代码:
|
||||
|
||||
```html
|
||||
{{ define "main" }}
|
||||
<div id="pjax-container">
|
||||
<div class="post-content" itemprop="articleBody">
|
||||
{{ .Content }}
|
||||
<div id="friend-circle-container" class="friend-circle-list">
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在探索朋友们的新动态...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 布局:PC端两列,间距紧凑 */
|
||||
.friend-circle-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 移动端一列 */
|
||||
@media (max-width: 768px) {
|
||||
.friend-circle-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片核心样式:毛玻璃 + 动效 */
|
||||
.fc-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
height: 160px;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.3); /* 虚线边框 */
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
animation: ukFadeInUp 0.8s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] .fc-item {
|
||||
background: rgba(35, 35, 35, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 鼠标悬停特效 */
|
||||
.fc-item:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-color: #000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.fc-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
z-index: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-right: 60px;
|
||||
}
|
||||
|
||||
/* 背景装饰图(文章封面) */
|
||||
.fc-bg-image {
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
opacity: 0.15;
|
||||
transition: all 0.5s ease;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: grayscale(20%);
|
||||
}
|
||||
|
||||
.fc-item:hover .fc-bg-image {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
/* 底部信息栏 */
|
||||
.fc-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 作者胶囊标签 */
|
||||
.fc-author-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-author-pill {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.fc-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.fc-author-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 加载更多按钮 */
|
||||
.fc-load-more-btn {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px 30px;
|
||||
margin: 30px auto 10px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 30px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes ukFadeInUp {
|
||||
0% { opacity: 0; transform: translateY(40px) scale(0.95); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid rgba(127,127,127,0.2);
|
||||
border-top-color: #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 时间格式化:YYYY-MM-DD
|
||||
function timeAgo(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
let allPosts = [];
|
||||
let currentIndex = 0;
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
function renderPosts(posts) {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
const existingBtn = document.getElementById('fc-load-more-btn');
|
||||
if (existingBtn) existingBtn.remove();
|
||||
|
||||
let html = '';
|
||||
posts.forEach((post, index) => {
|
||||
const dateStr = timeAgo(post.date);
|
||||
const delay = index * 0.05; // 瀑布流延迟动画
|
||||
const bgImageSrc = post.image || post.avatar; // 有封面图用封面,没封面用头像
|
||||
|
||||
html += `
|
||||
<a href="${post.link}" target="_blank" class="fc-item" style="animation-delay: ${delay}s">
|
||||
<div class="fc-title" title="${post.title}">${post.title}</div>
|
||||
<img src="${bgImageSrc}" class="fc-bg-image" loading="lazy" onerror="this.style.opacity=0">
|
||||
<div class="fc-bottom">
|
||||
<div class="fc-author-pill">
|
||||
<img src="${post.avatar}" class="fc-avatar" loading="lazy">
|
||||
<span class="fc-author-name">${post.author}</span>
|
||||
</div>
|
||||
<div class="fc-date">${dateStr}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
if (currentIndex < allPosts.length) {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'fc-load-more-btn';
|
||||
btn.className = 'fc-load-more-btn';
|
||||
btn.innerText = '加载更多';
|
||||
btn.onclick = loadMore;
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
const nextBatch = allPosts.slice(currentIndex, currentIndex + BATCH_SIZE);
|
||||
currentIndex += BATCH_SIZE;
|
||||
renderPosts(nextBatch);
|
||||
}
|
||||
|
||||
function loadFriendCircle() {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
if (!container) return;
|
||||
|
||||
fetch('/json/friend_circle_data.json?t=' + new Date().getTime())
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
allPosts = data.posts;
|
||||
currentIndex = 0;
|
||||
container.innerHTML = ''; // 清除 loading
|
||||
loadMore();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err);
|
||||
container.innerHTML = '加载失败';
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
document.addEventListener('DOMContentLoaded', loadFriendCircle);
|
||||
// 适配 PJAX
|
||||
document.addEventListener('pjax:complete', loadFriendCircle);
|
||||
</script>
|
||||
</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## 避坑指南
|
||||
|
||||
1. **CORS 问题**:因为我们是请求同域名的 JSON 文件,所以一般不会有跨域问题。但如果你把 JSON 传到了其他 CDN,记得配置 CORS。
|
||||
2. **PJAX 白屏**:如果你像我一样用了 PJAX,一定要加 `document.addEventListener('pjax:complete', ...)` 这一行,否则跳转回来会白屏。
|
||||
3. **样式冲突**:CSS 里的 `.friend-circle-list` 使用了 Grid 布局,如果你的主题有全局 CSS 冲突,可能需要微调。
|
||||
|
||||
## 总结
|
||||
|
||||
这一套下来,其实核心就三块:Node 脚本抓数据、Github Action 跑脚本、HTML 渲染页面。
|
||||
|
||||
虽然代码看起来有点多,但逻辑是很清晰的。把这些文件放到对应位置,基本上就能跑起来了。如果你也喜欢这种卡片风格,不妨试一试!
|
||||
|
||||
好了,代码都毫无保留地交出来了,希望能帮到同样在折腾 Hugo 的朋友们。如果有问题,欢迎在评论区留言(虽然我也不一定能解决哈哈)。
|
||||
Generated
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "blog",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"rss-parser": "^3.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/rss-parser": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmmirror.com/rss-parser/-/rss-parser-3.13.0.tgz",
|
||||
"integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==",
|
||||
"dependencies": {
|
||||
"entities": "^2.0.3",
|
||||
"xml2js": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz",
|
||||
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"rss-parser": "^3.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Parser = require('rss-parser');
|
||||
|
||||
const LINK_LITE_PATH = path.join(__dirname, '../themes/Ying/static/json/link_lite.json');
|
||||
const OUTPUT_PATH = path.join(__dirname, '../themes/Ying/static/json/friend_circle_data.json');
|
||||
const MAX_POSTS_PER_FRIEND = 5;
|
||||
const MAX_TOTAL_POSTS = 100; // Limit total output size
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchFeed(url) {
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
return feed;
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch ${url}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRssUrl(friend) {
|
||||
// If RSS is explicitly provided (4th element in array)
|
||||
if (friend.length >= 4 && friend[3]) {
|
||||
return friend[3];
|
||||
}
|
||||
|
||||
// Otherwise, try to guess
|
||||
const baseUrl = friend[1].replace(/\/$/, '');
|
||||
const guesses = [
|
||||
`${baseUrl}/atom.xml`,
|
||||
`${baseUrl}/rss.xml`,
|
||||
`${baseUrl}/feed`,
|
||||
`${baseUrl}/feed/`,
|
||||
`${baseUrl}/index.xml`
|
||||
];
|
||||
|
||||
for (const url of guesses) {
|
||||
try {
|
||||
// Simple HEAD request or just try to parse
|
||||
// Since we use rss-parser, just trying to parse is easiest check
|
||||
// But doing this for every friend is slow.
|
||||
// For now, we only use explicit RSS if provided, or simple guess if not.
|
||||
// To speed up, we'll just try the first guess or return null if strict.
|
||||
// But to be helpful, let's try the most common one: atom.xml
|
||||
// ideally we should check HEAD, but let's just return the first guess for now
|
||||
// and let the parser fail if it's wrong.
|
||||
// BETTER STRATEGY: Return a list of candidates and try them.
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `${baseUrl}/atom.xml`; // Default guess
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting Friend Circle generation...');
|
||||
|
||||
if (!fs.existsSync(LINK_LITE_PATH)) {
|
||||
console.error('link_lite.json not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(LINK_LITE_PATH, 'utf8'));
|
||||
// data.friends is an array of arrays: [name, url, avatar, rss?]
|
||||
|
||||
let allPosts = [];
|
||||
|
||||
// Process friends in batches to avoid overwhelming network
|
||||
const BATCH_SIZE = 5;
|
||||
const friends = data.friends;
|
||||
|
||||
for (let i = 0; i < friends.length; i += BATCH_SIZE) {
|
||||
const batch = friends.slice(i, i + BATCH_SIZE);
|
||||
const promises = batch.map(async (friend) => {
|
||||
const name = friend[0];
|
||||
const blogUrl = friend[1];
|
||||
const avatar = friend[2];
|
||||
let rssUrl = friend.length >= 4 ? friend[3] : null;
|
||||
|
||||
// Heuristics for RSS URL
|
||||
const candidates = [];
|
||||
if (rssUrl) {
|
||||
candidates.push(rssUrl);
|
||||
} else {
|
||||
const cleanUrl = blogUrl.replace(/\/$/, '');
|
||||
candidates.push(`${cleanUrl}/atom.xml`);
|
||||
candidates.push(`${cleanUrl}/rss.xml`);
|
||||
candidates.push(`${cleanUrl}/feed`);
|
||||
candidates.push(`${cleanUrl}/feed/`);
|
||||
candidates.push(`${cleanUrl}/feed.xml`);
|
||||
candidates.push(`${cleanUrl}/index.xml`);
|
||||
candidates.push(`${cleanUrl}/rss`);
|
||||
candidates.push(`${cleanUrl}/atom`);
|
||||
}
|
||||
|
||||
let feed = null;
|
||||
for (const url of candidates) {
|
||||
feed = await fetchFeed(url);
|
||||
if (feed) {
|
||||
// console.log(`[${name}] Fetched RSS: ${url}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!feed) {
|
||||
console.log(`[${name}] No valid RSS found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract posts
|
||||
const posts = feed.items.slice(0, MAX_POSTS_PER_FRIEND).map(item => {
|
||||
let img = null;
|
||||
// Try to find an image in content
|
||||
// Priority: content:encoded > content > description
|
||||
const content = item['content:encoded'] || item.content || item.description || '';
|
||||
|
||||
// Better regex to capture src with single or double quotes
|
||||
const imgMatch = content.match(/<img[^>]+src=['"]([^'"]+)['"]/i);
|
||||
|
||||
if (imgMatch) {
|
||||
img = imgMatch[1];
|
||||
} else if (item.enclosure && item.enclosure.url && item.enclosure.type && item.enclosure.type.startsWith('image')) {
|
||||
img = item.enclosure.url;
|
||||
}
|
||||
|
||||
// Get a short snippet
|
||||
let snippet = '';
|
||||
if (content) {
|
||||
// Remove HTML tags
|
||||
snippet = content.replace(/<[^>]+>/g, '');
|
||||
// Remove excessive whitespace
|
||||
snippet = snippet.replace(/\s+/g, ' ').trim();
|
||||
// Truncate
|
||||
if (snippet.length > 120) {
|
||||
snippet = snippet.substring(0, 120) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
date: item.isoDate || item.pubDate, // RSS parser standardizes this
|
||||
author: name,
|
||||
avatar: avatar,
|
||||
blogUrl: blogUrl,
|
||||
image: img,
|
||||
description: snippet
|
||||
};
|
||||
});
|
||||
|
||||
allPosts = allPosts.concat(posts);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
process.stdout.write(`Processed ${Math.min(i + BATCH_SIZE, friends.length)}/${friends.length} friends...\r`);
|
||||
}
|
||||
|
||||
console.log('\nSorting and saving...');
|
||||
|
||||
// Sort by date desc
|
||||
allPosts.sort((a, b) => {
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
});
|
||||
|
||||
// Limit total
|
||||
const finalPosts = allPosts.slice(0, MAX_TOTAL_POSTS);
|
||||
|
||||
const output = {
|
||||
updated: new Date().toISOString(),
|
||||
posts: finalPosts
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
|
||||
console.log(`Saved ${finalPosts.length} posts to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -95,14 +95,6 @@ blogs:
|
||||
url: https://sumiler.com
|
||||
description: 微风轻轻起 夏日不再来
|
||||
image: https://cravatar.cn/avatar//a35f5ef7a4bce3b7cfa6f538ffcae7aa
|
||||
- name: 林林杂语
|
||||
url: https://www.xiaozonglin.cn
|
||||
description: 一位来自福建泉州的高中生
|
||||
image: https://cravatar.cn/avatar/04bfc914bd50b42eb507bf66aad0cc31?d=mp
|
||||
- name: 西瓜撞地球
|
||||
url: https://www.bio-w.cn
|
||||
description: 我在人间贩卖彩虹
|
||||
image: https://bu.dusays.com/2022/10/17/634d313602a1e.png
|
||||
- name: Kris Yan
|
||||
url: https://blog.krisyan.dev
|
||||
description: 無限進步 @Schale夏萊
|
||||
@@ -159,3 +151,7 @@ blogs:
|
||||
url: https://aloner.ink
|
||||
description: 随便写写
|
||||
image: https://cravatar.cn/avatar/af1874b7856255302fc3df5102a6ce6d
|
||||
- name: 子舒
|
||||
url: https://www.zhishu.me
|
||||
description: 子舒的博客
|
||||
image: https://gravatar.zishu.me/avatar/bdad602f1f9d29ce83944c4f6225d1cc?s=200&d=retro
|
||||
@@ -2,23 +2,398 @@
|
||||
<div id="pjax-container">
|
||||
<div class="post-content" itemprop="articleBody">
|
||||
{{ .Content }}
|
||||
<div id="hexo-circle-of-friends-root"
|
||||
uk-scrollspy="target: > .cf-article-item; cls: uk-animation-slide-bottom-small; delay: 100"></div>
|
||||
<div id="friend-circle-container" class="friend-circle-list">
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在探索朋友们的新动态...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container Layout */
|
||||
.friend-circle-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px; /* Tighter gap to make cards wider */
|
||||
margin-top: 20px;
|
||||
padding: 0; /* Remove padding to maximize width */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile Layout */
|
||||
@media (max-width: 768px) {
|
||||
.friend-circle-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Style - Refined based on User Request */
|
||||
.fc-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
height: 160px;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid #111827;
|
||||
box-shadow: none;
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
animation: ukFadeInUp 0.8s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Light Mode Override - if theme is light, keep it readable but consistent layout */
|
||||
/* Assuming the user wants the dark look as shown in the reference, we prioritize that feel
|
||||
or make it adapt. Let's make it adapt but keep the structure. */
|
||||
:root {
|
||||
/* --fc-bg: rgba(255, 255, 255, 0.8); */
|
||||
--fc-text: #333;
|
||||
--fc-meta-bg: rgba(0, 0, 0, 0.05);
|
||||
/* --fc-border: rgba(0, 0, 0, 0.05); */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* --fc-bg: rgba(35, 35, 35, 0.8); */
|
||||
--fc-text: #fff;
|
||||
--fc-meta-bg: rgba(255, 255, 255, 0.1);
|
||||
/* --fc-border: rgba(255, 255, 255, 0.1); */
|
||||
}
|
||||
|
||||
/*
|
||||
.fc-item {
|
||||
background: var(--fc-bg);
|
||||
border-color: var(--fc-border);
|
||||
}
|
||||
*/
|
||||
|
||||
/* UK Style Animation & Hover */
|
||||
.fc-item:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-color: #000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes ukFadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(40px) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Top: Title */
|
||||
.fc-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--fc-text);
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
z-index: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-right: 60px; /* Space for the image */
|
||||
}
|
||||
|
||||
.fc-item:hover .fc-title {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
/* Background Image (Decorative) */
|
||||
.fc-bg-image {
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
opacity: 0.15;
|
||||
transition: all 0.5s ease;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: grayscale(20%);
|
||||
}
|
||||
|
||||
.fc-item:hover .fc-bg-image {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
/* Bottom Row: Author Pill & Date */
|
||||
.fc-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Author Pill */
|
||||
.fc-author-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--fc-meta-bg);
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.fc-item:hover .fc-author-pill {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.fc-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-right: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.fc-author-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fc-text);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Date */
|
||||
.fc-date {
|
||||
font-size: 12px;
|
||||
color: var(--fc-text);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fc-date i {
|
||||
margin-right: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* FontAwesome fallback if not available, simple calendar icon via CSS or Unicode */
|
||||
.fc-icon-calendar::before {
|
||||
content: '\f133';
|
||||
font-family: "Font Awesome 5 Free", "FontAwesome";
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* Loading Animation */
|
||||
.loading-container {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(127,127,127,0.2);
|
||||
border-radius: 50%;
|
||||
border-top-color: #3498db;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Dark Mode Specific Adjustments to match the reference exactly */
|
||||
[data-theme="dark"] .fc-item {
|
||||
background: rgba(35, 35, 35, 0.65); /* More transparent for glass effect */
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-author-pill {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-bg-image {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Removed unused fadeInUp to keep clean, utilizing ukFadeInUp instead */
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* Load More Button */
|
||||
.fc-load-more-btn {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px 30px;
|
||||
margin: 30px auto 10px;
|
||||
background: #000; /* Black background */
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 30px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fc-load-more-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-load-more-btn {
|
||||
background: #000;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-load-more-btn:hover {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
/* Artalk Dark Mode Adjustment */
|
||||
[data-theme="dark"] .artalk .atk-main-editor {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 定义几个全局变量
|
||||
let UserConfig = {
|
||||
// 填写你的api地址
|
||||
private_api_url: 'https://usj.cc/json/link_lite.json',
|
||||
// 点击加载更多时,一次最多加载几篇文章,默认10
|
||||
page_turning_number: 10,
|
||||
// 头像加载失败时,默认头像地址
|
||||
error_img: 'https://cravatar.cn/avatar/e1e7ba949ade0936e071132d2edd3b3c?f=mp',
|
||||
// 进入页面时第一次的排序规则
|
||||
sort_rule: 'created'
|
||||
function timeAgo(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
let allPosts = [];
|
||||
let currentIndex = 0;
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
function renderPosts(posts) {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
// Check if button exists, if so, remove it temporarily to append items before it?
|
||||
// Or just insertAdjacentHTML 'beforeend' and then append button.
|
||||
|
||||
// Remove existing button if any
|
||||
const existingBtn = document.getElementById('fc-load-more-btn');
|
||||
if (existingBtn) existingBtn.remove();
|
||||
|
||||
let html = '';
|
||||
posts.forEach((post, index) => {
|
||||
const dateStr = timeAgo(post.date);
|
||||
// Stagger delay based on index in this batch
|
||||
const delay = index * 0.05;
|
||||
|
||||
const bgImageSrc = post.image || post.avatar;
|
||||
|
||||
html += `
|
||||
<a href="${post.link}" target="_blank" class="fc-item" style="animation-delay: ${delay}s">
|
||||
<div class="fc-title" title="${post.title}">${post.title}</div>
|
||||
|
||||
<img src="${bgImageSrc}" class="fc-bg-image" loading="lazy" onerror="this.style.opacity=0">
|
||||
|
||||
<div class="fc-bottom">
|
||||
<div class="fc-author-pill">
|
||||
<img src="${post.avatar}" class="fc-avatar" loading="lazy" onerror="this.src='https://cravatar.cn/avatar/default?s=50'">
|
||||
<span class="fc-author-name">${post.author}</span>
|
||||
</div>
|
||||
<div class="fc-date">
|
||||
<i class="ri-calendar-line"></i> ${dateStr}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
// Check if we need a load more button
|
||||
if (currentIndex < allPosts.length) {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'fc-load-more-btn';
|
||||
btn.className = 'fc-load-more-btn';
|
||||
btn.innerText = '加载更多';
|
||||
btn.onclick = loadMore;
|
||||
container.appendChild(btn);
|
||||
} else {
|
||||
// Add update time footer if all loaded
|
||||
// We need to retrieve updateTime from somewhere, or just don't show it repeatedly.
|
||||
// Let's store updateTime globally or just append it at the very end.
|
||||
if (window.fcUpdateTime) {
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = "grid-column: 1 / -1; text-align: center; margin-top: 20px; font-size: 12px; color: #999; opacity: 0.6;";
|
||||
footer.innerText = `最近更新: ${window.fcUpdateTime}`;
|
||||
container.appendChild(footer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
const nextBatch = allPosts.slice(currentIndex, currentIndex + BATCH_SIZE);
|
||||
currentIndex += BATCH_SIZE;
|
||||
renderPosts(nextBatch);
|
||||
}
|
||||
|
||||
function loadFriendCircle() {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
if (!container) return; // Prevent errors if container not found
|
||||
|
||||
// Reset state if needed, though loadMore will handle rendering
|
||||
// But we need to make sure we don't re-fetch if we don't have to?
|
||||
// Actually PJAX reload means we are on a "new" page state, so re-fetching is safer to ensure data is fresh and DOM is rebuilt.
|
||||
|
||||
fetch('/json/friend_circle_data.json?t=' + new Date().getTime())
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Network response was not ok');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.posts || data.posts.length === 0) {
|
||||
container.innerHTML = '<div class="empty" style="grid-column: 1/-1; text-align:center; padding:20px;">暂无动态</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allPosts = data.posts;
|
||||
window.fcUpdateTime = new Date(data.updated).toLocaleString();
|
||||
currentIndex = 0;
|
||||
|
||||
// Clear loading spinner
|
||||
container.innerHTML = '';
|
||||
|
||||
loadMore(); // Load first batch
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Friend Circle Error:', err);
|
||||
container.innerHTML = '<div class="error" style="grid-column: 1/-1; text-align:center; padding:20px; color:#d9534f;">加载失败,请稍后刷新重试</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
document.addEventListener('DOMContentLoaded', loadFriendCircle);
|
||||
|
||||
// PJAX support - listen for pjax:complete event
|
||||
document.addEventListener('pjax:complete', loadFriendCircle);
|
||||
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
loadFriendCircle();
|
||||
}
|
||||
</script>
|
||||
<script src="/js/circles/app.min.js"></script>
|
||||
<script src="/js/circles/bundle.js"></script>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -26,10 +26,10 @@
|
||||
<ul class="flat">
|
||||
<li class="active"><a href="/">首页</a></li>
|
||||
<li class="active"><a href="/about.html">关于</a></li>
|
||||
<li class="active"><a href="/circles.html">友圈</a></li>
|
||||
<li class="active"><a href="/ly.html">留言</a></li>
|
||||
<li class="active"><a href="/archives.html">归档</a></li>
|
||||
<li class="active"><a href="/links.html">友链</a></li>
|
||||
<li class="active"><a href="/circles.html">友圈</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -120,16 +120,6 @@
|
||||
"https://sumiler.com",
|
||||
"https://cravatar.cn/avatar//a35f5ef7a4bce3b7cfa6f538ffcae7aa"
|
||||
],
|
||||
[
|
||||
"林林杂语",
|
||||
"https://www.xiaozonglin.cn",
|
||||
"https://cravatar.cn/avatar/04bfc914bd50b42eb507bf66aad0cc31?d=mp"
|
||||
],
|
||||
[
|
||||
"西瓜撞地球",
|
||||
"https://www.bio-w.cn",
|
||||
"https://bu.dusays.com/2022/10/17/634d313602a1e.png"
|
||||
],
|
||||
[
|
||||
"Kris Yan",
|
||||
"https://blog.krisyan.dev",
|
||||
@@ -199,6 +189,11 @@
|
||||
"如约而至",
|
||||
"https://aloner.ink",
|
||||
"https://cravatar.cn/avatar/af1874b7856255302fc3df5102a6ce6d"
|
||||
],
|
||||
[
|
||||
"子舒",
|
||||
"https://www.zhishu.me",
|
||||
"https://gravatar.zishu.me/avatar/bdad602f1f9d29ce83944c4f6225d1cc?s=200&d=retro"
|
||||
]
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user