feat: 图库重做/瞬间详情/重要日子适配页/全站灯箱,修复豆瓣页白屏(v0.2.0)

- 图库页重做:首屏按相册分组展示,封面自动取相册第一张图,可在设置中按相册手动配置封面;点击相册进入 ?group= 瀑布流视图(一行三张、页面加宽),支持预览模式(lightgallery 放大,默认值可配置、前台可切换)
- 瞬间页:新增 /moments/{name} 详情页(完整内容、媒体、标签、作者信息、评论区);列表页发布人与发布时间同行对齐;补充单条标签
- 新增 plugin-important-dates 适配页 important-dates.html(即将到来提醒、全部日期倒计时、重要标记、人员生日)
- 全站集成 lightgallery.js 灯箱(动态模式、按需加载、挂载 body 避免层叠上下文遮挡),可在设置中关闭
- 友链页:「全部」视图按分组展示并显示分组标题(空分组自动隐藏);卡片简介限制两行,统一卡片高度与尺寸
- 修复 /douban 白屏:SpEL 不支持 th:with 中的嵌套 map 字面量(EL1041E),改为四段显式区块
- 版本号升至 0.2.0;README 同步更新

Co-Authored-By: WorkBuddy <noreply@workbuddy.ai>
This commit is contained in:
2026-09-30 09:10:32 +08:00
co-authored by WorkBuddy
parent c0ea6ec0df
commit 4e3001674a
17 changed files with 1125 additions and 160 deletions
+10 -4
View File
@@ -18,6 +18,7 @@
- **页脚可关闭的主题署名**:默认「Theme by WhiteSpace」并链接到主题仓库,可在设置中修改名称 / 链接或整体关闭
- **页脚全站数据统计**:文章 / 分类 / 标签 / 评论 / 访问量 / 字数,可按项开关,整体可关闭
- **文章卡片字数 + 阅读时长**:可在设置中关闭(需 `halo-plugin-extra-api` 提供数据)
- **全站 lightgallery.js 灯箱**:文章、页面、瞬间、图库等页面的图片点击放大预览(按需懒加载,可在设置中整体关闭)
- 多语言:`i18n/default.properties`(回退)+ `i18n/zh_CN.properties`
- 提供 `layout.html` 布局契约,供使用布局契约的插件页面复用主题导航 / 页脚 / 暗色样式
@@ -25,9 +26,10 @@
主题已为以下插件提供适配页面(在 `src/` 下以同名模板提供,构建后进入 `templates/`):
- **plugin-moments**:`/moments` 瞬间页
- **plugin-photos**:`/photos` 图库页
- **plugin-links**:`/links` 友链页;友链申请表单已**直接内嵌**在友链页底部(点击「申请友链」展开,调用插件公共 REST API,含图形验证码);并额外提供
- **plugin-moments**:`/moments` 瞬间列表页 + `/moments/{name}` 瞬间详情页(完整内容、媒体、标签、作者信息与评论区);列表页发布人与发布时间同行对齐
- **plugin-photos**:`/photos` 图库页 —— 首屏按相册分组展示(封面默认自动取相册内第一张图,可在主题设置「图库」中为每个相册手动配置封面);点击相册进入 `?group=photo-group-*` 瀑布流视图(一行三张、页面加宽),支持**预览模式**(lightgallery 点击放大,默认值可在设置中配置,访客可在前台自行切换)
- **plugin-important-dates**:`/important-dates` 重要日子页(即将到来提醒、全部日期倒计时、重要标记、人员生日)
- **plugin-links**:`/links` 友链页 —— 「全部」视图按分组展示并显示分组标题,卡片简介最多两行、卡片高度统一;友链申请表单已**直接内嵌**在友链页底部(点击「申请友链」展开,调用插件公共 REST API,含图形验证码);并额外提供
- `links-subscribe.html`:RSS 订阅动态聚合页(使用 `linkFeedFinder`);每条仅显示作者与发布时间,作者名链接到来源博客,摘要默认折叠为 5 行
- **halo-weread-plugin**:`weread.html` 微信读书书架页(前端拉取插件 `/shelf-html` 并注入)
- **plugin-douban**:`douban.html` 豆瓣收藏页(按「读过 / 在读 / 看过 / 在追」分栏展示书影音,使用 `doubanFinder.getMovies(...)`;未安装插件时显示提示)。在控制台新建单页并选择「豆瓣页面」模板即可启用
@@ -88,8 +90,10 @@ pnpm check # 格式化 + Lint 修复
| basic | color_scheme | 默认配色:跟随系统 / 浅色 / 深色 |
| basic | footer_copyright | 页脚自定义版权(支持 HTML,留空不显示) |
| basic | show_theme_credit | 页脚是否显示「Theme by」署名(默认开) |
| basic | theme_credit_text | 署名名称(默认「暖心向阳335」) |
| basic | theme_credit_text | 署名名称(默认「WhiteSpace」,可改) |
| basic | theme_credit_url | 署名跳转链接(默认主题仓库地址) |
| basic | show_word_count | 文章卡片是否显示字数与阅读时长(需 extra-api)|
| basic | enable_lightbox | 全站 lightgallery 图片灯箱(默认开) |
| featured| enable | 首页是否展示置顶文章 |
| about | avatar / bio / github_url / twitter_url / contact_email | 关于页(自定义模板 `page_about.html`)内容 |
| seo | enable_metadata | 输出 canonical / Open Graph / Twitter Card |
@@ -99,6 +103,8 @@ pnpm check # 格式化 + Lint 修复
| stats | enable | 页脚是否显示全站数据统计(默认关) |
| stats | show_posts / show_categories / show_tags / show_comments / show_visits | 各项统计的显隐开关 |
| stats | show_words | 全站总字数(需 extra-api 插件,默认开) |
| photos | preview_default | 相册瀑布流是否默认开启预览模式(默认开) |
| photos | album_covers | 各相册手动封面(「相册名称 + 封面图」列表,留空自动取第一张) |
## 约定与说明
+3
View File
@@ -47,3 +47,6 @@ stats.comments={0} comments
stats.visits={0} visits
stats.words={0} words
douban.plugin_required=Douban plugin (plugin-douban) not detected. Please install and enable it, then set your Douban ID in the plugin settings.
photos.photo_count={0} photos
moment.back_to_list=Back to moments
moment.tags=Tags
+3
View File
@@ -47,3 +47,6 @@ stats.comments={0} 条评论
stats.visits={0} 次访问
stats.words={0} 字
douban.plugin_required=未检测到豆瓣插件(plugin-douban),请安装并启用后在插件设置中填写豆瓣 ID。
photos.photo_count={0} 张照片
moment.back_to_list=返回瞬间列表
moment.tags=标签
+3
View File
@@ -25,5 +25,8 @@
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
}
},
"dependencies": {
"lightgallery": "^2.9.0"
}
}
+10
View File
@@ -11,6 +11,10 @@ overrides:
importers:
.:
dependencies:
lightgallery:
specifier: ^2.9.0
version: 2.9.0
devDependencies:
'@halo-dev/theme-package-cli':
specifier: ^1.0.1
@@ -761,6 +765,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
lightgallery@2.9.0:
resolution: {integrity: sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==}
engines: {node: '>=6.0.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1629,6 +1637,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8
lightgallery@2.9.0: {}
lightningcss-android-arm64@1.32.0:
optional: true
+27
View File
@@ -47,6 +47,11 @@ spec:
label: 文章卡片显示字数
value: true
help: 在文章卡片与置顶文章上展示字数及阅读时长(需 extra-api 插件提供数据)
- $formkit: checkbox
name: enable_lightbox
label: 全站图片灯箱
value: true
help: 使用 lightgallery 为文章、瞬间、图库等页面的图片提供点击放大预览
- group: featured
label: 置顶文章
formSchema:
@@ -153,3 +158,25 @@ spec:
name: show_words
label: 全站总字数(需 extra-api 插件)
value: true
- group: photos
label: 图库
formSchema:
- $formkit: checkbox
name: preview_default
label: 相册默认开启预览模式
value: true
help: 进入相册瀑布流时默认开启点击放大预览,访客可在前台自行切换
- $formkit: repeater
name: album_covers
label: 相册封面
value: []
help: 为相册手动指定封面;不配置时自动使用相册内第一张图片。「相册名称」填写图库页面显示的相册名
children:
- $formkit: text
name: group
label: 相册名称
validation: required
- $formkit: attachment
name: cover
label: 封面图
validation: required
+155 -43
View File
@@ -80,52 +80,164 @@
<th:block th:if="${pluginFinder.available('plugin-douban')}">
<th:block
th:with="collections=${{ {'type':'book','status':'done','label':'读过'}, {'type':'book','status':'doing','label':'在读'}, {'type':'movie','status':'done','label':'看过'}, {'type':'drama','status':'doing','label':'在追'} }}"
th:with="movies=${doubanFinder.getMovies(24, 0, 'book', 'done')}"
>
<th:block th:each="col : ${collections}">
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, col.type, col.status)}"
>
<div
class="douban-section"
th:if="${not #lists.isEmpty(movies)}"
<div
class="douban-section"
th:if="${not #lists.isEmpty(movies)}"
>
<h2 class="douban-section-title">读过</h2>
<div class="douban-grid">
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
target="_blank"
rel="noopener noreferrer"
>
<h2 class="douban-section-title" th:text="${col.label}">
读过
</h2>
<div class="douban-grid">
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
target="_blank"
rel="noopener noreferrer"
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
loading="lazy"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
>年份</span
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
loading="lazy"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
</th:block>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'book', 'doing')}"
>
<div
class="douban-section"
th:if="${not #lists.isEmpty(movies)}"
>
<h2 class="douban-section-title">在读</h2>
<div class="douban-grid">
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
loading="lazy"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'movie', 'done')}"
>
<div
class="douban-section"
th:if="${not #lists.isEmpty(movies)}"
>
<h2 class="douban-section-title">看过</h2>
<div class="douban-grid">
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
loading="lazy"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'drama', 'doing')}"
>
<div
class="douban-section"
th:if="${not #lists.isEmpty(movies)}"
>
<h2 class="douban-section-title">在追</h2>
<div class="douban-grid">
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
loading="lazy"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
</th:block>
+237
View File
@@ -0,0 +1,237 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${title} - ${site.title}|">记得</title>
<style>
.dates-intro {
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.dates-section {
margin: 2rem 0;
}
.dates-section-title {
font-size: 1.15rem;
margin: 0 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.dates-reminders {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
gap: 1rem;
}
.dates-reminder {
padding: 1rem 1.25rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.dates-reminder-name {
margin: 0;
font-weight: 600;
}
.dates-reminder-text {
margin: 0.35rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.dates-list {
list-style: none;
margin: 0;
padding: 0;
}
.dates-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.9rem 0;
border-bottom: 1px solid var(--color-border);
}
.dates-item-name {
font-weight: 600;
}
.dates-item-date {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.dates-badge {
margin-left: auto;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.78rem;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
white-space: nowrap;
}
.dates-badge.soon {
border-color: var(--color-primary);
color: var(--color-primary);
}
.dates-badge.today {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-bg);
}
.dates-badge.important {
border-color: #e5a50a;
color: #e5a50a;
}
.dates-persons {
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.people-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
gap: 1rem;
}
.people-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.people-item img {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.people-name {
font-weight: 600;
line-height: 1.3;
}
.people-meta {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.dates-empty {
color: var(--color-text-muted);
padding: 1rem 0;
}
</style>
</template>
<div class="container">
<section class="important-dates">
<h1 th:text="${title}">记得</h1>
<p class="dates-intro">重要的日子,值得被记住。</p>
<!-- 即将到来的提醒 -->
<th:block th:if="${not #lists.isEmpty(reminders)}">
<div class="dates-section">
<h2 class="dates-section-title">即将到来</h2>
<div class="dates-reminders">
<div class="dates-reminder" th:each="r : ${reminders}">
<p class="dates-reminder-name" th:text="${r.title}">名称</p>
<p class="dates-reminder-text">
<span
th:if="${not #strings.isEmpty(r.dateText)}"
th:text="${r.dateText}"
></span>
<span
th:if="${not #strings.isEmpty(r.stageText)}"
th:text="${r.stageText}"
></span>
</p>
</div>
</div>
</div>
</th:block>
<!-- 全部日期 -->
<div class="dates-section">
<h2 class="dates-section-title">全部日期</h2>
<ul class="dates-list" th:if="${not #lists.isEmpty(dates)}">
<li class="dates-item" th:each="d : ${dates}">
<span class="dates-item-name" th:text="${d.title}">名称</span>
<span
class="dates-badge important"
th:if="${showImportantTag and d.important}"
>重要</span
>
<span
class="dates-item-date"
th:if="${not #strings.isEmpty(d.dateText)}"
th:text="${d.dateText}"
></span>
<span
class="dates-item-date"
th:if="${d.daysValid and not #strings.isEmpty(d.nextSolarDate)}"
th:text="${d.nextSolarDate}"
></span>
<span
class="dates-persons"
th:if="${not #lists.isEmpty(d.personNames)}"
>
<span th:each="pn : ${d.personNames}" th:text="'@' + ${pn}"></span>
</span>
<span
class="dates-badge today"
th:if="${d.daysValid and d.daysUntil == 0}"
>今天</span
>
<span
class="dates-badge soon"
th:if="${d.daysValid and d.daysUntil > 0}"
th:text="|还有 ${d.daysUntil} 天|"
>还有 N 天</span
>
</li>
</ul>
<p class="dates-empty" th:if="${#lists.isEmpty(dates)}">暂无重要日期</p>
</div>
<!-- 人员与生日 -->
<th:block th:if="${not #lists.isEmpty(people)}">
<div class="dates-section">
<h2 class="dates-section-title">人员</h2>
<ul class="people-list">
<li class="people-item" th:each="p : ${people}">
<img
th:if="${showAvatar and not #strings.isEmpty(p.avatar)}"
th:src="${p.avatar}"
th:alt="${p.displayName}"
loading="lazy"
/>
<span>
<span class="people-name" th:text="${p.displayName}">姓名</span>
<p class="people-meta">
<span
th:if="${not #strings.isEmpty(p.relation)}"
th:text="${p.relation}"
></span>
<span
th:if="${not #strings.isEmpty(p.nextSolarDate) and p.daysUntil == 0}"
th:text="'生日 · 今天'"
></span>
<span
th:if="${not #strings.isEmpty(p.nextSolarDate) and p.daysUntil > 0}"
th:text="|生日还有 ${p.daysUntil} 天|"
></span>
<span
th:if="${not #strings.isEmpty(p.nextSolarDate)}"
th:text="${p.nextSolarDate}"
></span>
</p>
</span>
</li>
</ul>
</div>
</th:block>
</section>
</div>
</include>
+46
View File
@@ -0,0 +1,46 @@
/**
* whitespace 留白主题 - lightgallery 灯箱
* 动态模式:扫描带 data-lightbox 属性的容器内的图片,点击放大预览。
*/
import lightGallery from "lightgallery";
import lgZoom from "lightgallery/plugins/zoom";
import "lightgallery/css/lightgallery.css";
import "lightgallery/css/lg-zoom.css";
export function initLightbox(): void {
const containers = document.querySelectorAll<HTMLElement>("[data-lightbox]");
if (!containers.length) return;
containers.forEach((container) => {
const images = Array.from(container.querySelectorAll("img"));
if (!images.length) return;
const items = images
.map((img) => {
const src = img.getAttribute("data-full") || img.currentSrc || img.src;
if (!src) return null;
return { src, thumb: src, alt: img.alt || "" };
})
.filter((item): item is { src: string; thumb: string; alt: string } =>
Boolean(item),
);
if (!items.length) return;
// 挂载到 body,避免父级 stacking context 遮挡灯箱
const gallery = lightGallery(document.body, {
dynamic: true,
dynamicEl: items,
plugins: [lgZoom],
speed: 400,
});
images.forEach((img, index) => {
img.style.cursor = "zoom-in";
img.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
gallery.openGallery(index);
});
});
});
}
+15
View File
@@ -252,6 +252,20 @@ function initKeyboardShortcuts(): void {
});
}
// ========================================
// lightgallery 灯箱(按需加载)
// ========================================
async function initLightbox(): Promise<void> {
if (!document.querySelector("[data-lightbox]")) return;
try {
const { initLightbox } = await import("./lightbox");
initLightbox();
} catch {
/* 加载失败时静默降级为普通图片展示 */
}
}
// ========================================
// 初始化
// ========================================
@@ -270,6 +284,7 @@ function init(): void {
initSmoothScroll();
initExternalLinks();
initKeyboardShortcuts();
void initLightbox();
}
if (document.readyState === "loading") {
+125 -23
View File
@@ -26,12 +26,15 @@
}
.links-list li {
margin: 0;
display: flex;
}
.links-list a {
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
@@ -48,6 +51,11 @@
object-fit: cover;
flex-shrink: 0;
}
.links-list .links-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.links-list .links-name {
font-weight: 600;
}
@@ -55,6 +63,19 @@
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.links-group-title {
font-size: 1.1rem;
margin: 2rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.links-section:first-of-type .links-group-title {
margin-top: 0;
}
.links-apply {
margin-top: 2rem;
@@ -138,29 +159,103 @@
></a>
</nav>
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
<li th:each="link : ${links}">
<a th:href="@{${link.spec.url}}" target="_blank" rel="noopener noreferrer">
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
<span>
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
<th:block th:if="${group == null}">
<section class="links-section" th:each="grp : ${simpleGroups}">
<h2 class="links-group-title" th:text="${grp.spec.displayName}">
分组
</h2>
<ul class="links-list">
<li
th:each="link : ${links}"
th:if="${link.spec.groupName == grp.metadata.name}"
>
<a
th:href="@{${link.spec.url}}"
target="_blank"
rel="noopener noreferrer"
>
<p
class="links-desc"
th:if="${not #strings.isEmpty(link.spec.description)}"
th:text="${link.spec.description}"
></p>
</span>
</a>
</li>
</ul>
<p th:unless="${not #lists.isEmpty(links)}">暂无链接</p>
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
>
<p
class="links-desc"
th:if="${not #strings.isEmpty(link.spec.description)}"
th:text="${link.spec.description}"
></p>
</span>
</a>
</li>
</ul>
</section>
<section class="links-section">
<h2 class="links-group-title">未分组</h2>
<ul class="links-list">
<li
th:each="link : ${links}"
th:if="${#strings.isEmpty(link.spec.groupName)}"
>
<a
th:href="@{${link.spec.url}}"
target="_blank"
rel="noopener noreferrer"
>
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
>
<p
class="links-desc"
th:if="${not #strings.isEmpty(link.spec.description)}"
th:text="${link.spec.description}"
></p>
</span>
</a>
</li>
</ul>
</section>
</th:block>
<th:block th:unless="${group == null}">
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
<li th:each="link : ${links}">
<a
th:href="@{${link.spec.url}}"
target="_blank"
rel="noopener noreferrer"
>
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
>
<p
class="links-desc"
th:if="${not #strings.isEmpty(link.spec.description)}"
th:text="${link.spec.description}"
></p>
</span>
</a>
</li>
</ul>
<p th:if="${#lists.isEmpty(links)}">暂无链接</p>
</th:block>
<div class="links-apply">
<button
@@ -221,6 +316,13 @@
<script th:inline="none">
(function () {
// 收起没有链接的分组区块
document.querySelectorAll(".links-section").forEach(function (sec) {
if (!sec.querySelector(".links-list li")) {
sec.remove();
}
});
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
var toggle = document.getElementById("link-apply-toggle");
+185
View File
@@ -0,0 +1,185 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${title} - ${site.title}|">瞬间</title>
<style>
.moment-detail {
max-width: 46rem;
margin: 0 auto;
}
.moment-back {
display: inline-block;
margin-bottom: 1.5rem;
color: var(--color-link);
font-size: 0.9rem;
}
.moment-back:hover {
color: var(--color-link-hover);
}
.moment-card {
padding: 1.5rem 0;
}
.moment-card header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
color: var(--color-text-secondary);
}
.moment-owner {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.moment-owner img {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
object-fit: cover;
}
.moment-owner-info {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.moment-owner-name {
color: var(--color-text);
font-weight: 600;
}
.moment-owner-bio {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.moment-meta {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 0.85rem;
}
.moment-meta a {
color: var(--color-link);
}
.moment-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.moment-tags a {
color: var(--color-link);
}
.moment-media img,
.moment-media video {
display: block;
max-width: 100%;
height: auto;
margin-top: 1rem;
border-radius: var(--radius-md);
}
.moment-media audio {
width: 100%;
margin-top: 1rem;
}
.comments-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
</style>
</template>
<div class="container">
<section class="moment-detail" th:if="${moment != null}">
<a class="moment-back" th:href="@{/moments}" th:text="#{moment.back_to_list}"
>返回瞬间列表</a
>
<article class="moment-card" th:with="content=${moment.spec.content}">
<header>
<span class="moment-owner" th:if="${moment.owner != null}">
<img
th:if="${not #strings.isEmpty(moment.owner.avatar)}"
th:src="${moment.owner.avatar}"
th:alt="${moment.owner.displayName}"
loading="lazy"
/>
<span class="moment-owner-info">
<span
class="moment-owner-name"
th:text="${moment.owner.displayName}"
>用户名</span
>
<span
class="moment-owner-bio"
th:if="${not #strings.isEmpty(moment.owner.bio)}"
th:text="${moment.owner.bio}"
></span>
</span>
</span>
<span class="moment-meta">
<time
th:if="${moment.spec.releaseTime != null}"
th:datetime="${moment.spec.releaseTime}"
th:text="${#temporals.format(moment.spec.releaseTime, 'yyyy-MM-dd HH:mm')}"
>发布时间</time
>
</span>
</header>
<div
class="moment-tags"
th:if="${not #lists.isEmpty(moment.spec.tags)}"
>
<a
th:each="tag : ${moment.spec.tags}"
th:href="@{/moments(tag=${tag})}"
th:text="'#' + ${tag}"
></a>
</div>
<div
class="article-content"
th:if="${not #strings.isEmpty(content.html)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:utext="${content.html}"
></div>
<div
class="moment-media"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:if="${not #lists.isEmpty(content.medium)}"
>
<th:block th:each="medium : ${content.medium}">
<img
th:if="${medium.type.name == 'PHOTO'}"
th:src="${medium.url}"
alt="瞬间图片"
loading="lazy"
/>
<video
th:if="${medium.type.name == 'VIDEO'}"
th:src="${medium.url}"
controls
></video>
<audio
th:if="${medium.type.name == 'AUDIO'}"
th:src="${medium.url}"
controls
></audio>
</th:block>
</div>
</article>
<div class="comments-section" th:if="${haloCommentEnabled}">
<halo:comment
group="moment.halo.run"
kind="Moment"
th:attr="name=${moment.metadata.name}"
/>
</div>
</section>
<p th:if="${moment == null}">瞬间不存在或不可见。</p>
</div>
</include>
+29
View File
@@ -26,11 +26,17 @@
.moments article header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
opacity: 0.75;
color: var(--color-text-secondary);
}
.moments article header > a {
display: inline-flex;
align-items: center;
line-height: 2rem;
}
.moments .moment-owner {
display: inline-flex;
align-items: center;
@@ -42,6 +48,16 @@
border-radius: 50%;
object-fit: cover;
}
.moments .moment-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.moments .moment-tags a {
color: var(--color-link);
}
.moments .moment-media img,
.moments .moment-media video {
display: block;
@@ -102,13 +118,26 @@
</th:block>
</header>
<div
class="moment-tags"
th:if="${not #lists.isEmpty(moment.spec.tags)}"
>
<a
th:each="tag : ${moment.spec.tags}"
th:href="@{/moments(tag=${tag})}"
th:text="'#' + ${tag}"
></a>
</div>
<div
th:if="${not #strings.isEmpty(content.html)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:utext="${content.html}"
></div>
<div
class="moment-media"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:if="${not #lists.isEmpty(content.medium)}"
>
<th:block th:each="medium : ${content.medium}">
+5 -1
View File
@@ -26,7 +26,11 @@
<img th:src="${singlePage.spec.cover}" th:alt="${singlePage.spec.title}" />
</div>
<div class="article-content" th:utext="${singlePage.content.content}"></div>
<div
class="article-content"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:utext="${singlePage.content.content}"
></div>
</article>
<!-- 评论区 -->
+266 -87
View File
@@ -2,6 +2,10 @@
<template name="head">
<title th:text="|${title} - ${site.title}|">图库</title>
<style>
/* 图库页面适当加宽 */
.container {
max-width: 75rem;
}
:where(.photos-groups) {
margin: 1.5rem 0;
}
@@ -27,30 +31,121 @@
:where(.photos-group.is-active .photos-group__link) {
font-weight: 600;
}
:where(.photos-grid) {
/* 相册网格 */
:where(.albums-grid) {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
gap: 1.25rem;
grid-template-columns: repeat(
auto-fill,
minmax(min(16rem, 100%), 1fr)
);
gap: 1.5rem;
}
:where(.photos-card__image) {
:where(.albums-card) {
display: block;
color: var(--color-text);
}
:where(.albums-card:hover .albums-card__name) {
color: var(--color-primary);
}
:where(.albums-card__cover) {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
:where(.albums-card__cover img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
:where(.albums-card__cover img.album-cover-custom) {
z-index: 1;
}
:where(.albums-card__name) {
margin: 0.75rem 0 0;
font-size: 1rem;
font-weight: 600;
}
:where(.albums-card__count) {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
/* 瀑布流(一行三张) */
:where(.photos-masonry) {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
columns: 3;
column-gap: 1rem;
}
:where(.photos-masonry > li) {
break-inside: avoid;
margin: 0 0 1rem;
}
:where(.photos-masonry .photos-card__media) {
display: block;
}
:where(.photos-masonry img) {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
:where(.photos-card__title) {
margin: 0.75rem 0 0.25rem;
font-size: 1rem;
}
:where(.photos-card__description, .photos-card__tags) {
margin-top: 0.5rem;
}
:where(.photos-card__tags) {
gap: 0.5rem;
font-size: 0.875rem;
:where(.photos-masonry .photos-card__caption) {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
:where(.photos-masonry) {
columns: 2;
}
}
@media (max-width: 560px) {
:where(.photos-masonry) {
columns: 1;
}
}
/* 预览模式切换 */
:where(.photos-toolbar) {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
:where(.photos-preview-toggle) {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
:where(.photos-preview-toggle)[aria-pressed="true"] {
border-color: var(--color-primary);
color: var(--color-primary);
}
:where(.photos-empty) {
color: var(--color-text-muted);
padding: 2rem 0;
}
:where(.photos-pagination) {
display: flex;
@@ -63,10 +158,7 @@
</template>
<div class="container">
<section
class="photos-page"
th:attr="data-template-id=${_templateId},data-group=${group},data-page=${photos.page},data-size=${photos.size},data-total=${photos.total}"
>
<section class="photos-page">
<header class="photos-header">
<h1 class="photos-header__title" th:text="${title}">图库</h1>
</header>
@@ -81,14 +173,13 @@
class="photos-group__link"
th:href="${photoUrl.list()}"
th:attr="aria-current=${#strings.isEmpty(group) ? 'page' : null}"
>全部</a
>全部相册</a
>
</li>
<li
class="photos-group"
th:each="photoGroup : ${groups}"
th:classappend="${group == photoGroup.metadata.name} ? 'is-active' : ''"
th:attr="data-group=${photoGroup.metadata.name}"
>
<a
class="photos-group__link"
@@ -98,11 +189,11 @@
<span
class="photos-group__name"
th:text="${photoGroup.spec.displayName}"
>分组</span
>相册</span
>
<span
class="photos-group__count"
th:if="${photoGroup.status != null && photoGroup.status.photoCount != null}"
th:if="${photoGroup.status != null and photoGroup.status.photoCount != null}"
th:text="|(${photoGroup.status.photoCount})|"
></span>
</a>
@@ -110,80 +201,168 @@
</ul>
</nav>
<ul class="photos-grid" th:if="${!#lists.isEmpty(photos.items)}">
<li
class="photos-grid__item"
th:each="photo : ${photos.items}"
th:attr="data-photo-name=${photo.metadata.name},data-group=${photo.spec.groupName},data-priority=${photo.spec.priority}"
<!-- 相册视图:按分组展示各相册 -->
<th:block th:if="${#strings.isEmpty(group)}">
<th:block th:with="albums=${photoFinder.groupBy()}">
<ul class="albums-grid" th:if="${not #lists.isEmpty(albums)}">
<li th:each="album : ${albums}">
<a
class="albums-card"
th:href="${photoUrl.list(album.metadata.name)}"
>
<span class="albums-card__cover">
<img
th:if="${not #lists.isEmpty(album.photos)}"
th:src="${album.photos[0].spec.cover != null and !#strings.isEmpty(album.photos[0].spec.cover) ? album.photos[0].spec.cover : album.photos[0].spec.url}"
th:alt="${album.spec.displayName}"
loading="lazy"
/>
<th:block
th:if="${not #lists.isEmpty(theme.config?.photos?.album_covers)}"
>
<img
class="album-cover-custom"
th:each="cv : ${theme.config.photos.album_covers}"
th:if="${cv != null and cv.group == album.spec.displayName and not #strings.isEmpty(cv.cover)}"
th:src="${cv.cover}"
th:alt="${album.spec.displayName}"
loading="lazy"
/>
</th:block>
</span>
<p
class="albums-card__name"
th:text="${album.spec.displayName}"
>
相册
</p>
<p
class="albums-card__count"
th:if="${album.status != null and album.status.photoCount != null}"
th:text="#{photos.photo_count(${album.status.photoCount})}"
>
12 张照片
</p>
</a>
</li>
</ul>
<p class="photos-empty" th:if="${#lists.isEmpty(albums)}" role="status">
暂无相册
</p>
</th:block>
</th:block>
<!-- 相册内瀑布流视图 -->
<th:block th:unless="${#strings.isEmpty(group)}">
<div class="photos-toolbar" th:if="${not #lists.isEmpty(photos.items)}">
<button
class="photos-preview-toggle"
id="photos-preview-toggle"
type="button"
th:attr="data-default=${theme.config?.photos?.preview_default != false ? 'on' : 'off'}"
>
预览模式
</button>
</div>
<ul
class="photos-masonry"
th:if="${not #lists.isEmpty(photos.items)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
>
<article class="photos-card">
<a class="photos-card__media" th:href="${photoUrl.detail(photo)}">
<li th:each="photo : ${photos.items}">
<a
class="photos-card__media"
th:href="${photoUrl.detail(photo)}"
th:title="${photo.spec.displayName}"
>
<img
class="photos-card__image"
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
th:alt="${photo.spec.displayName}"
loading="lazy"
/>
</a>
<div class="photos-card__content">
<h2 class="photos-card__title">
<a
class="photos-card__title-link"
th:href="${photoUrl.detail(photo)}"
th:text="${photo.spec.displayName}"
>照片</a
>
</h2>
<p
class="photos-card__description"
th:if="${!#strings.isEmpty(photo.spec.description)}"
th:text="${photo.spec.description}"
></p>
<ul
class="photos-card__tags"
th:if="${!#lists.isEmpty(photo.spec.tags)}"
aria-label="照片标签"
>
<li
class="photos-card__tag"
th:each="tag : ${photo.spec.tags}"
th:text="${tag}"
></li>
</ul>
</div>
</article>
</li>
</ul>
<p
class="photos-empty"
th:if="${#lists.isEmpty(photos.items)}"
role="status"
>
暂无照片
</p>
<p
class="photos-card__caption"
th:if="${not #strings.isEmpty(photo.spec.displayName)}"
th:text="${photo.spec.displayName}"
>
照片
</p>
</li>
</ul>
<p
class="photos-empty"
th:if="${#lists.isEmpty(photos.items)}"
role="status"
>
暂无照片
</p>
<nav
class="photos-pagination"
th:if="${photos.hasPrevious() || photos.hasNext()}"
aria-label="分页"
>
<a
class="photos-pagination__previous"
th:if="${photos.hasPrevious()}"
th:href="${photos.prevUrl}"
>上一页</a
<nav
class="photos-pagination"
th:if="${photos.hasPrevious() || photos.hasNext()}"
aria-label="分页"
>
<span class="photos-pagination__status">
第 <strong th:text="${photos.page}">1</strong> 页,共
<strong th:text="${photos.totalPages}">1</strong> 页
</span>
<a
class="photos-pagination__next"
th:if="${photos.hasNext()}"
th:href="${photos.nextUrl}"
>下一页</a
>
</nav>
<a
class="photos-pagination__previous"
th:if="${photos.hasPrevious()}"
th:href="${photos.prevUrl}"
>上一页</a
>
<span class="photos-pagination__status">
第 <strong th:text="${photos.page}">1</strong> 页,共
<strong th:text="${photos.totalPages}">1</strong> 页
</span>
<a
class="photos-pagination__next"
th:if="${photos.hasNext()}"
th:href="${photos.nextUrl}"
>下一页</a
>
</nav>
</th:block>
</section>
</div>
<script th:inline="none">
(function () {
var btn = document.getElementById("photos-preview-toggle");
var masonry = document.querySelector(".photos-masonry");
if (!btn || !masonry) return;
var KEY = "whitespace-photo-preview";
var defaultOn = btn.getAttribute("data-default") === "on";
var stored = null;
try {
stored = localStorage.getItem(KEY);
} catch (e) {
/* ignore */
}
var mode = stored === null ? defaultOn : stored === "on";
function apply() {
if (mode) {
masonry.setAttribute("data-lightbox", "true");
} else {
masonry.removeAttribute("data-lightbox");
}
btn.textContent = mode ? "预览模式:开" : "预览模式:关";
btn.setAttribute("aria-pressed", mode ? "true" : "false");
}
btn.addEventListener("click", function () {
mode = !mode;
try {
localStorage.setItem(KEY, mode ? "on" : "off");
} catch (e) {
/* ignore */
}
// 重新加载以按新模式初始化灯箱
window.location.reload();
});
apply();
})();
</script>
</include>
+5 -1
View File
@@ -67,7 +67,11 @@
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" />
</div>
<div class="article-content" th:utext="${post.content.content}"></div>
<div
class="article-content"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:utext="${post.content.content}"
></div>
<!-- 文章标签 -->
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
+1 -1
View File
@@ -14,7 +14,7 @@ spec:
issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
settingName: "whitespace-setting"
configMapName: "whitespace-configMap"
version: 0.1.0
version: 0.2.0
requires: ">=2.26.0"
license:
- name: "GPL-3.0"