前篇:
Hugo Stack主题装修笔记
Hugo Stack主题装修笔记Part 2
Neodb 自动化短评卡片
2025-01-20 更新:优化了一下代码,现在根据书影音类别和评分状态会显示出对应文字,如“在玩”、“不看了”。唯一的使用要求是neodb链接的条目是要被你标注过的,如果没有的话会报错。
看了好几个版本都不是我想达到的效果,研究了一下 neodb 的 API 后在 GPT 的帮助下搞出了下面这个全自动版,使用方法和最终效果如下:
{{< neodb-review "https://neodb.social/book/1qPRxweiyxXlGqN3azjEy8" >}}
创建 Neodb access token
点击 neodo.social
右上角头像 - 设置 - 更多设置 - 查看已授权的应用程序 - 点击 Create Personal Token - 记下生成的 token。
创建 Neodb 卡片
新建文件 layouts/shortcodes/neodb-review.html 如下,将 neodb_personal_token
的部分替换为上面的 token
点我展开代码
{{ $dbUrl := .Get 0 }}
{{ $apiUrl := "https://neodb.social/api/me/shelf/item/" }}
{{ $itemUuid := "" }}
{{ $authToken := "neodb_personal_token" }} <!-- Replace with your actual API token -->
<!-- Extract item_uuid from the URL -->
{{ if (findRE `.*neodb\.social\/.*\/(.*)` $dbUrl) }}
{{ $itemUuid = replaceRE `.*neodb\.social\/.*\/(.*)` "$1" $dbUrl }}
{{ else }}
<p style="text-align: center;"><small>Invalid URL format.</small></p>
{{ return }}
{{ end }}
<!-- Construct the API URL -->
{{ $dbApiUrl := print $apiUrl $itemUuid }}
<!-- Set up the Authorization header -->
{{ $headers := dict "Authorization" (print "Bearer " $authToken) }}
<!-- Fetch JSON from the API -->
{{ $dbFetch := getJSON $dbApiUrl $headers }}
<!-- Determine shelf status -->
{{ $shelfType := $dbFetch.shelf_type }}
{{ $category := $dbFetch.item.category }}
{{ $action := "" }}
{{ $prefix := "" }}
{{ $suffix := "" }}
{{ $displayText := "" }}
<!-- Determine the action based on category -->
{{ if eq $category "book" }}
{{ $action = "读" }}
{{ else if or (eq $category "tv") (eq $category "movie") }}
{{ $action = "看" }}
{{ else if or (eq $category "podcast") (eq $category "album") }}
{{ $action = "听" }}
{{ else if eq $category "game" }}
{{ $action = "玩" }}
{{ end }}
<!-- Determine the prefix and suffix based on shelf type -->
{{ if eq $shelfType "wishlist" }}
{{ $prefix = "想" }}
{{ else if eq $shelfType "complete" }}
{{ $prefix = "" }}
{{ $suffix = "过" }}
{{ else if eq $shelfType "progress" }}
{{ $prefix = "在" }}
{{ else if eq $shelfType "dropped" }}
{{ $prefix = "不" }}
{{ $suffix = "了" }}
{{ end }}
<!-- Combine prefix, action, and suffix -->
{{ $displayText = print $prefix $action $suffix }}
<!-- Prep for star rating -->
{{ $fullStars := 0 }}
{{ $starCount := 0 }}
{{ $halfStar := 0 }}
{{ $emptyStars := 5 }}
<!-- Calc star rating -->
{{ $rating := $dbFetch.rating_grade }} <!-- Get the rating -->
{{ if $rating }}
{{ $starCount = div (mul $rating 5) 10 }}
{{ $fullStars = int $starCount }} <!-- Full stars count -->
<!-- Determine if there is a half star -->
{{ if (mod $rating 2) }}
{{ $halfStar = 1 }}
{{ end }}
<!-- Calculate empty stars -->
{{ $emptyStars = sub 5 (add $fullStars $halfStar) }} <!-- Empty stars count -->
{{ end }}
<!-- Check if data is retrieved -->
{{ if $dbFetch }}
<div class="db-card">
<div class="db-card-subject">
<div class="db-card-post"><img src="{{ $dbFetch.item.cover_image_url }}" alt="Cover Image"
style="max-width: 100%; height: auto;"></div>
<div class="db-card-content">
<div class="db-card-title">
<a href="{{ $dbUrl }}" class="cute" target="_blank" rel="noreferrer">{{ $dbFetch.item.title }}</a>
</div>
<div class="db-card-rating">
{{ $dbFetch.created_time | time.Format "2006-01-02T15:04:05Z" | time.Format "2006年01月02日" }} {{ $displayText }}
<!-- Add the rating as stars -->
<!-- Full stars -->
{{ if $fullStars }}
{{ range $i := (seq 1 $fullStars) }}
<i class="fa-solid fa-star"></i>
{{ end }}
{{ end }}
<!-- Half star -->
{{ if $halfStar }}
<i class="fa-regular fa-star-half-stroke"></i>
{{ end }}
<!-- Empty stars -->
{{ if $emptyStars }}
{{ range $i := (seq 1 $emptyStars) }}
<i class="fa-regular fa-star"></i>
{{ end }}
{{ end }}
</div>
<div class="db-card-comment">{{ $dbFetch.comment_text }}</div>
</div>
<div class="db-card-cate">{{ $dbFetch.item.category }}</div>
</div>
</div>
{{ else }}
<p style="text-align: center;"><small>Failed to fetch content, please check the API validity.</small></p>
{{ end }}
这上面的代码里有一个步骤是将 neodb 评分(1-10 的数字)转换成了星星,其中使用到了 Font Awesome,如果博客没有这个的话的话需要去 Font Awesome
上注册一个账号 - Add a new kit - 进入 kit 界面就能看到如下格式的代码,粘贴在 layouts/partials/head/custom.html 内:
<!-- Font awesome -->
<script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>
自定义 css 外观样式
在 assets/scss/custom.scss 里增加如下代码,其中很多 font-size
的部分我引用了 Hugo Stack 主题里的变量,如果是别的主题则需要自行修改。
点我展开代码
// Neodb card style
.db-card {
margin: 2.5rem 2.5rem;
background: var(--color-codebg);
border-radius: 7px;
box-shadow: 0 6px 10px 0 #00000053;
}
.db-card-subject {
display: flex;
align-items: flex-start;
line-height: 1.6;
padding: 12px;
position: relative;
}
.dark .db-card {
background: var(--color-codebg);
}
.db-card-content {
flex: 1 1 auto;
overflow: auto;
margin-top: 8px;
}
.db-card-post {
width: 100px;
margin-right: 15px;
margin-top: 20px;
display: flex;
flex: 0 0 auto;
}
.db-card-title {
margin-bottom: 3px;
font-size: 1.6rem;
color: var(--card-text-color-main);
font-weight: bold;
}
.db-card-title a {
text-decoration: none!important;
}
.db-card-rating {
font-size: calc(var(--article-font-size) * 0.9);
}
.db-card-comment {
font-size: calc(var(--article-font-size) * 0.9);
margin-top: 10px;
margin-bottom: 15px;
overflow: auto;
max-height: 150px!important;
color: var(--card-text-color-main);
}
.db-card-cate {
position: absolute;
top: 0;
right: 0;
background: #8aa2d3;
padding: 1px 8px;
font-size: small;
font-style: italic;
border-radius: 0 8px 0 8px;
text-transform: capitalize;
}
.db-card-post img {
width: 100px!important;
height: 150px!important;
border-radius: 4px;
-o-object-fit: cover;
object-fit: cover;
}
@media (max-width: 600px) {
.db-card {
margin: 0.8rem 0.5rem;
}
.db-card-title {
font-size: calc(var(--article-font-size) * 0.75);
}
.db-card-rating {
font-size: calc(var(--article-font-size) * 0.7);
}
.db-card-comment {
font-size: calc(var(--article-font-size) * 0.7);
}
}
macOS 风格的代码块
效果如下:
看了博友 Yelle的装修博文
发现的,具体代码来自 L1nSn0w’s Blog
,我调了下样式,比原版教程更紧凑一些。在 assets/scss/partials/layout/article.scss,找到 .highlight
部分并修改成如下:
.highlight {
background-color: var(--pre-background-color);
padding: var(--card-padding);
position: relative;
border-radius: 10px;
max-width: 100% !important;
margin: 0 !important;
box-shadow: var(--shadow-l1) !important;
创建 static/img/code-header.svg 文件:
点我展开代码
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="450px" height="130px">
<ellipse cx="65" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)"/>
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)"/>
<ellipse cx="385" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)"/>
</svg>
最后在 assets/scss/custom.scss 添加代码块的样式:
// 为代码块顶部添加macos样式
.article-content {
.highlight:before {
content: "";
display: block;
background: url(/img/code-header.svg);
height: 25px;
width: 100%;
background-size: 52px;
background-repeat: no-repeat;
margin-top: -10px;
margin-bottom: 0;
}
}
首页标签云显示数目
效果:
在前情提要 “在归档页增加标签云tags”
里,已经在归档页增加了标签云及其数目,但首页的标签云还没有显示数量,这里也补充一下。在下述文件增加代码:
assets/scss/partials/widgets.scss
.tagCloud-count-main {
margin-left: 7px; // Use a separate setting so that it didn't affect the style in archive page
color: var(--body-text-color);
}
layouts/partials/widget/tag-cloud.html
{{ .Page.Title }}<span class="tagCloud-count-main">{{ .Count }}</span
修改博客运行时间格式成 “x 年 x 月 x 天 “
前情提要:见第一篇的 博客已运行x天x小时x分钟字样
。前篇的时间格式最大单位为天,随着博客变老(?),我决定把单位改为年月日,效果:
在 layouts/partials/footer/custom.html,修改代码如下:
<!-- layouts/partials/footer/custom.html -->
<script>
let s1 = '2023-3-18'; //website start date
s1 = new Date(s1.replace(/-/g, "/"));
let s2 = new Date();
// Calculate the difference
let diffInMilliseconds = s2.getTime() - s1.getTime();
let totalDays = Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24));
// Create a new date object starting from the initial date
let years = s2.getFullYear() - s1.getFullYear();
let months = s2.getMonth() - s1.getMonth();
let days = s2.getDate() - s1.getDate();
// Adjust months and years if necessary
if (days < 0) {
months -= 1;
let prevMonth = new Date(s2.getFullYear(), s2.getMonth(), 0); // Get the last day of the previous month
days += prevMonth.getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
// Format the result
let result = `${years}年${months}月${days}天`;
document.getElementById('runningdays').innerHTML = result;
</script>
修改文章统计总数格式为 x 万 x 千字
前情提要:见 总字数统计发表了x篇文章共计x字
。随着博客字数的增加,这里把字数格式单位增加到了万,效果同样见上图。
修改 layouts/partials/footer/footer.html 成如下
<!-- Add total page and word count time -->
<section class="totalcount">
{{$scratch := newScratch}}
{{ range (where .Site.Pages "Kind" "page" )}}
{{$scratch.Add "total" .WordCount}}
{{ end }}
{{ $totalWords := $scratch.Get "total" }}
{{ $tenThousands := div $totalWords 10000 }}
{{ $remainingThousands := mod (div $totalWords 1000) 10 }}
发表了{{ len (where .Site.RegularPages "Section" "post") }}篇文章 ·
总计{{ $tenThousands }}万{{ $remainingThousands }}千字
<br>
</section>
使图床链接的图片居中
在我的 Hugo Stack 主题版本里,默认只支持本地引用的图片居中,而在使用 url 图片链接时没有居中格式。在 assets/scss/partials/layout/article.scss 里增加以下代码 p > img
的部分,我放在了 figure
的后面:
figure {
text-align: center;
// other code
}
// Center image from url source
p > img {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
给文章增加 emoji 点赞按钮
前几天 竹子的博客发了这个open heart的教程
,看上去很好玩就立马加上了。这是我第一次用 Cloudflare kv namespace 的服务,中途走了点弯路所以这里详细讲一下,最主要的变动是解决了 api 代码占用很多 kv 资源的问题(太容易超过 Cloudflare 每日限额了)。最终效果如图:
创建 Cloudflare worker
这一步和原教程一样。
- 注册 Cloudflare
账号
- 安装 node.js和npm
- 参照 官方指南
,在 Terminal 里用以下命令行创建一个 worker project 文件夹。在这个示例里,代码在 username/path 文件夹内新建了一个名为 worker-test 的 worker 文件夹。第一次运行时可能会出现要安装 create-cloudflare 的提示,按 y 回车继续。
cd username/path
npm create cloudflare@latest -- worker-test
Need to install the following packages:
create-cloudflare@2.36.0
Ok to proceed? (y)
-
从命令行的提示里选择模板。逐步选择 Hello World example - Hello World Worker - TypeScript,之后的两个问题 git version control 和 deploy your application 分别选 Yes 和 No。
-
用命令行 cd worker-test
定位到刚才新建的文件夹,再 npx wrangler dev
或 npm run start
,运行后就能在浏览器的 http://localhost:8787
里看到 Hello world 了。
创建 KV namespace 并更新设置
具体参考 Cloudflare官方创建KV namespace的文档
。开一个新的 Terminal 并确保 cd 位置到同一个文件夹 worker-test,用以下代码新建一个 cloudflare kv namesace,以下是起名为 worker-test-kv 的示例:
npx wrangler kv namespace create worker-test-kv
这个时候会弹出 Cloudflare 的登录页,授权完成后,回到 Terminal 就会有成功的提示了,记下最后的 binding
和 id
值。
🌀 Creating namespace with title "worker-test-worker-test-kv"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "worker_test_kv"
id = "11111222223333333"
在本地的 worker 文件夹根目录找到 wrangler.toml,搜索 kv_namespaces 并 uncomment 掉以下三行,填入上一步的 binding
和 id
值,示例如下:
[[kv_namespaces]]
binding = "worker_test_kv"
id = "11111222223333333"
创建 emoji script
在 worker 文件夹内新建 src/index.ts,我这里没有完全照抄 open heart protocol提供的api代码
,因为原代码会使用很多list operations,而 Cloudflare 免费版 KV 资源有限
。我的办法简而言之就是通过直接在 script 里定义 emoji 串、来替代用 list()
方法查找,所以需要在这一行 const emojis = ["❤️", "👍", "😂", "🎉"];
自定义支持的 emoji 列表。最后,在使用时需要把代码里的 env.KV
全部按照 binding
值替换,如示例中应该替换为 env.worker_test_kv
。
点我展开代码
const instruction = `.^⋁^.
'. .'
\`
dddddddddzzzz
OpenHeart protocol API
https://api.oh.dddddddddzzzz.org
Test with example.com as <domain>.
GET /<domain>/<uid> to look up reactions for <uid> under <domain>
POST /<domain>/<uid> to send an emoji
<uid> must not contain a forward slash.
<domain> owner has the right to remove data under its domain scope.
----- Test in CLI -----
Send emoji:
curl -d '<emoji>' -X POST 'https://api.oh.dddddddddzzzz.org/example.com/uid'
Get all emoji counts for /example.com/uid:
curl 'https://api.oh.dddddddddzzzz.org/example.com/uid'
`;
export default {
async fetch(request, env) {
if (request.method == 'OPTIONS') {
return new Response(null, { headers });
}
if (request.method === 'GET') {
if (url(request).pathname === '/') {
return new Response(instruction, { headers });
} else {
return handleGet(request, env);
}
}
if (request.method === 'POST') return handlePost(request, env);
},
};
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST",
"Access-Control-Max-Age": "86400",
};
function error(text, code = 400) {
return new Response(text, { headers, status: code });
}
async function handleGet(request, env) {
const [domain, ...uidParts] = url(request).pathname.slice(1).split('/');
const uid = uidParts ? uidParts.join('/') : null;
if (!domain || !uid) {
return error('Domain or UID missing.');
}
const list = {};
const emojis = ["❤️", "👍", "😂", "🎉"]; // Add expected emojis here
// Fetch counts for each emoji directly
for (const emoji of emojis) {
const key = `${domain}:${uid}:${emoji}`;
const value = await env.KV.get(key);
if (value) {
list[emoji] = Number(value);
}
}
return new Response(
JSON.stringify(list, null, 2), // Return only the found counts
{ headers: { ...headers, "Content-Type": "application/json;charset=UTF-8" } }
);
}
function url(request) {
return new URL(request.url);
}
async function handlePost(request, env) {
const urlObject = url(request);
const path = urlObject.pathname.slice(1);
if (path === '') return error('Pathname missing');
const [domain, ...uidParts] = path.split('/');
const uid = uidParts ? uidParts.join('/') : '';
if (uid.length < 1) return error('UID required.');
const id = [encodeURI(domain), uid].join(':');
const emoji = ensureEmoji(await request.text());
if (!emoji) return error('Request body should contain an emoji');
const key = `${id}:${emoji}`;
const currentCount = Number(await env.KV.get(key) || 0);
await env.KV.put(key, (currentCount + 1).toString());
const redirection = urlObject.searchParams.get('redirect');
if (redirection !== null) {
headers['Location'] = redirection || request.headers.get('Referer');
return new Response('recorded', { headers, status: 303 });
} else {
return new Response('recorded', { headers });
}
}
function ensureEmoji(emoji) {
const segments = Array.from(
new Intl.Segmenter({ granularity: 'grapheme' }).segment(emoji.trim())
);
const parsedEmoji = segments.length > 0 ? segments[0].segment : null;
if (/\p{Emoji}/u.test(parsedEmoji)) return parsedEmoji;
}
发布 worker 到 Cloudflare
用 npm run deploy
或 npx wrangler deploy
将 worker 发布到 Cloudflare 上,命令行末尾会显示 Cloudflare 的 worker 地址,如示例中是 https://worker-test.myusername.workers.dev
。发布后,同样能在 Cloudflare 网页端看到这个新的 worker project。
(可选)使用 Custom domain 替代 Cloudflare worker 地址
默认的 worker 地址中含有 Cloudflare 用户名,如果你跟我一样希望隐藏它,可以选择用 Custom domain 替代这个地址,前提是域名已经用 Cloudflare DNS 解析,这个相关教程很多就不展开了。
-
进入 Cloudflare 的对应 worker 界面,点击 Setting - 点击 Domains & Routes 右上角的 Add - 选择 Custom domain - 输入合适的 custom domain,比如我的是 open-heart-reaction.thirdshire.com
-
回到本地的 worker project 的 wrangler.toml,添加以下代码
[[routes]]
pattern = "open-heart-reaction.thirdshire.com"
custom_domain = true
-
用 npm run deploy
将更新推送到 Cloudflare 上,这时应该会显示上面的 custom domain 地址而不是原先的默认 workers.dev
在博客页面添加 emoji 按钮
到这里就属于前端和 UI 的部分了,作用是把 emoji 按钮显示在合适的地方。
第一步是载入 emoji 按钮。以我的 Hugo Stack 主题为例,新建 layouts/partials/article/components/reaction.html,其中第一行的链接里是之前显示的默认 worker 地址或 custom domain 地址:
<!-- emoji 可为多个,但必须要在前面的可识别列表里出现 -->
<open-heart href="https://worker-test.myusername.workers.dev/{{ .Permalink }}" emoji="❤️">❤️</open-heart>
<!-- load web component -->
<script src="https://unpkg.com/open-heart-element" type="module"></script>
<!-- when the webcomponent loads, fetch the current counts for that page -->
<script>
window.customElements.whenDefined('open-heart').then(() => {
for (const oh of document.querySelectorAll('open-heart')) {
oh.getCount()
}
})
// refresh component after click
window.addEventListener('open-heart', e => {
e && e.target && e.target.getCount && e.target.getCount()
})
</script>
第二步是在博客合适的位置插入。找到 layouts/partials/article/article.html,将刚才的 reaction.html 放在 content 和 footer 位置之间:
{{ partial "article/components/content" . }}
<!-- Add reaction -->
{{ partial "article/components/reaction.html" . }}
{{ partial "article/components/footer" . }}
最后在 assets/scss/custom.scss 增加 css 外观样式:
// Open heart reaction style
open-heart {
margin: var(--card-padding);
margin-top: 0;
display: block; // Center alignment
margin-left: auto;
margin-right: auto;
width: fit-content;
border: 1px solid #FFA7B6;
border-radius: .4em;
padding: .4em;
}
open-heart:not([disabled]):hover,
open-heart:not([disabled]):focus {
border-color: var(--accent-color);
cursor: pointer;
}
open-heart[disabled] {
background: #FFA7B6;
border: 1px solid #FFA7B6;
cursor: not-allowed;
color: #fff;
}
open-heart[count]:not([count="0"])::after {
content: attr(count);
padding: .2em;
}
可以点击下方尝试喔 ⬇️ ⬇️