Compare commits

..
4 Commits
Author SHA1 Message Date
nxxy335 5ad34cf233 v0.4.0: 重做图库为瀑布流+分组导航,修复豆瓣 500 与瞬间样式,友链卡片重排+申请弹窗,标签云间距,移除关于模板 2026-09-30 13:32:54 +08:00
nxxy335andWorkBuddy 5e50c01c18 feat: 图库相册前端切换/封面下拉选择,修复 /tags 白屏(v0.3.0)
图库页:
- 相册切换改为前端按 URL 的 group 参数完成(各相册瀑布流预渲染并标记 data-album),
  不再依赖服务端的 group/photos 变量,从而不受页面静态缓存与插件版本差异影响,
  彻底修复「点击相册后仍显示 /photos」的问题
- 移除已无用的分组 tag 切换器(全部相册 / 各分组计数)
- 封面固定 4:3 并 object-fit: cover 填满;相册内视图新增「返回全部相册」与相册标题
- 预览模式开关改为作用于全部相册瀑布流,并尊重全局灯箱开关

后台设置:
- 相册封面配置的「相册名称」文本框改为下拉选择,选项通过
  /apis/console.api.photo.halo.run/v1alpha1/photogroups 自动获取已存在分组
  (label=spec.displayName,value=metadata.name);模板匹配同步改为 metadata.name

修复:
- /tags 白屏:tags.html 在 ${...} 内嵌套 #{'tag.count'} 触发 EL1043E
  (Expected 'identifier' but was 'lcurly({)'),改用 |...| 字面量替换

其他:新增 i18n photos.back;版本升至 0.3.0;README 同步

Co-Authored-By: WorkBuddy <noreply@workbuddy.ai>
2026-09-30 11:03:33 +08:00
nxxy335andWorkBuddy 0e87b3011e fix: 修复图库相册封面不显示与无法进入相册(v0.2.0)
根因(已定位到具体规则):
- .albums-card__cover 是 <span>(inline)但缺少 display:block,inline 元素忽略 width/aspect-ratio
  → 封面盒子高度为 0,内部 absolute 的 <img> 无尺寸可撑开 → 封面完全不可见(卡片只剩名称+张数)
- 相册详情页仅依赖路由注入的 photos.items,若该结果为空则显示「暂无照片」,表现为「点不进相册」

修复:
- 封面容器补 display:block(封面正常显示)
- 封面取值兜底:album.photos 为空时用 photoFinder.listBy(相册名) 取首图(Flux 解包为 List,避免 Mono 链式风险)
- 相册瀑布流数据源兜底:photos.items 为空时回退 photoFinder.listBy(group),保证进入相册一定有图;
  分页仅在真正使用分页结果时渲染,并对 photos 判空

Co-Authored-By: WorkBuddy <noreply@workbuddy.ai>
2026-09-30 10:17:39 +08:00
nxxy335andWorkBuddy 0eb5d8e06b fix: 图库相册封面兜底+可点击,按 WarmIsland 风格重写 README
- 修复 /photos 相册封面缺失:优先用 groupBy 返回的 album.photos,分组无图时回退 photoFinder.list(1,1,name) 取首图(规避部分插件版本 groupBy 未填充 photos 的情况),并将 Mono 结果通过两级 th:with 解包避免 SpEL 链式报错
- 相册卡片明确为可点击链接(photoUrl.list(name) 跳转 ?group= 瀑布流视图),新增 hover「查看相册 →」提示与 aria-label
- 按 halo-theme-WarmIsland README 结构重写本主题 README(特点/已适配插件表/自定义模板/主要功能/安装/开发/目录/注意事项/技术栈/许可),并补充「升级后清理缓存」提示
- 全站存在 data-lightbox 的容器已验证仍按开关加载灯箱 chunk,灯箱仅绑定 img 点击不劫持普通链接

Co-Authored-By: WorkBuddy <noreply@workbuddy.ai>
2026-09-30 09:46:01 +08:00
13 changed files with 623 additions and 658 deletions
+2 -1
View File
@@ -26,4 +26,5 @@ dist-ssr
.gradle
build
templates
templates
.workbuddy/
+144 -97
View File
@@ -1,120 +1,167 @@
# Whitespace 留白
一个移植自 Typecho 主题 [whitespace 留白](https://kehua.me) 的 [Halo](https://www.halo.run/) 主题,主打极简留白风格与清晰的排版。
一个移植自 Typecho 主题 [whitespace 留白](https://kehua.me) 的 [Halo](https://www.halo.run/) 主题,主打极简留白风格与清晰的排版,并为多款常用插件提供开箱即用的适配页面。
> 主题名 `whitespace`,显示名 **Whitespace 留白**,版本 `0.1.0`,作者 **暖心向阳335**。
> 要求 Halo ≥ 2.26.0。源码在 `src/`,构建产物在 `templates/`。
> 源码仓库:https://git.nxxy335.top/nxxy335/halo-theme-whitespace
> 本项目由 AI 开发完成,人工仅进行部分功能测试及问题找寻。
## 特性
## 环境要求
- 极简留白风格,自带浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择)
- 首页可选展示最新一篇置顶文章(`featured` 设置)
- 文章 / 页面 / 归档 / 分类 / 标签 / 作者 / 关于 等完整页面
- 文章页结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在主题设置中关闭
- 内置搜索按钮(依赖官方 `PluginSearchWidget` 插件,未安装则不显示)
- 阅读进度条、代码块复制、相对时间、回到顶部、移动端菜单等交互
- **二级导航**:菜单项的子项自动渲染为下拉菜单(桌面端 hover/focus 展开,移动端嵌套缩进展开)
- **页脚可关闭的主题署名**:默认「Theme by WhiteSpace」并链接到主题仓库,可在设置中修改名称 / 链接或整体关闭
- **页脚全站数据统计**:文章 / 分类 / 标签 / 评论 / 访问量 / 字数,可按项开关,整体可关闭
- **文章卡片字数 + 阅读时长**:可在设置中关闭(需 `halo-plugin-extra-api` 提供数据)
- **全站 lightgallery.js 灯箱**:文章、页面、瞬间、图库等页面的图片点击放大预览(按需懒加载,可在设置中整体关闭)
- 多语言:`i18n/default.properties`(回退)+ `i18n/zh_CN.properties`
- 提供 `layout.html` 布局契约,供使用布局契约的插件页面复用主题导航 / 页脚 / 暗色样式
- Halo **2.26.0 及以上**
- 构建依赖 Node.js + pnpm(`src/` 经 Vite 构建生成 `templates/`,Halo 实际只读 `templates/`)
## 插件适配
## 特点
主题已为以下插件提供适配页面(在 `src/` 下以同名模板提供,构建后进入 `templates/`):
1. **极简留白风格** — 浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择),专注内容本身的清晰排版
2. **二级导航** — 菜单子项自动渲染为下拉菜单(桌面端 hover/focus 展开,移动端嵌套缩进)
3. **插件兼容性** — 适配 8 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
4. **全站灯箱** — 主题内置 lightgallery.js 灯箱,文章、页面、瞬间、图库等图片点击放大预览(按需懒加载,可在设置中整体关闭)
5. **可配置性** — 署名、全站统计、字数显示、相册封面、预览模式等均可在后台开关
6. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载、CSS/HTML 压缩、静态资源带内容哈希
- **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(...)`;未安装插件时显示提示)。在控制台新建单页并选择「豆瓣页面」模板即可启用
- **留言板**:`page_guestbook.html` 以评论区作为留言板的单页模板
- **plugin-extra-api**:API 扩展包(无独立页面),主题已适配其 shiki 代码高亮与暗色样式;并在文章卡片 / 详情页展示**字数 + 阅读时长**、归档页展示**全站总字数**、瞬间页展示**单条字数**(通过 `extraApiStatsFinder`,插件未安装时自动隐藏)
## 已适配插件
> `weread` / `留言板` / `链接订阅` 均为「自定义页面模板」,在控制台新建单页并选择对应模板即可启用。`/links` 友链页与内嵌的友链申请无需单独建单页。
| 插件名称 | 插件标识 | 适配页面路由 | 说明 |
| ----------------------------------------------------------- | ------------------------ | ------------------ | ---------------------------------------------------- |
| [瞬间](https://github.com/halo-sigs/plugin-moments) | `plugin-moments` | `/moments` | 瞬间动态列表页,含 `/moments/{name}` 详情页(完整内容、媒体、标签、作者信息、评论区) |
| [图库](https://github.com/halo-sigs/plugin-photos) | `plugin-photos` | `/photos` | 图片展示页面,按相册分组、封面自动选取、瀑布流 + 预览模式 |
| [重要日子](https://github.com/yyliucha/plugin-important-dates) | `plugin-important-dates` | `/important-dates` | 即将到来提醒、全部日期倒计时、重要标记、人员生日 |
| [友情链接](https://github.com/halo-sigs/plugin-links) | `plugin-links` | `/links` | 友链展示页,按分组显示标题、卡片统一高度;内嵌申请表单 |
| [链接订阅](https://github.com/halo-sigs/plugin-links) | `plugin-links` | `/subscriptions` | RSS 订阅动态聚合,作者链接来源博客、摘要折叠 5 行 |
| [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页,按「读过 / 在读 / 看过 / 在追」分栏展示书影音 |
| [微信读书](https://github.com/yeora26/halo-weread-plugin) | `halo-weread-plugin` | 自定义页面 | 微信读书书架展示,访客免登录查看 |
| [API 扩展包](https://github.com/HowieHz/halo-plugin-extra-api) | `extra-api` | 全站 | 服务端字数统计、代码高亮、全站/单条字数展示 |
> **注意**:插件需单独安装,安装后主题自动适配对应页面。若未安装某插件,对应页面或功能自动隐藏 / 降级,不会报错。
> 豆瓣插件建议 **v1.2.6+**(SQLite 数据结构)。
## 自定义页面模板
在 Halo 后台「页面 → 新建 → 自定义模板」中选择使用:
| 模板名 | 模板文件 | 说明 |
| ---- | --------------------- | --------------------- |
| 留言板 | `page_guestbook.html` | 以评论区作为留言板的单页 |
| 微信读书 | `weread.html` | 微信读书书架展示,依赖微信读书插件 |
| 豆瓣页面 | `douban.html` | 豆瓣记录页,依赖豆瓣插件 |
## 主要功能
### 首页
- 可选展示最新一篇置顶文章(`featured` 设置)
- 文章卡片:可配置显示字数 + 阅读时长(依赖 API 扩展包)、分类、标签、摘要
- 文章卡片分类之间带间距,避免拥挤
### 文章
- 结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在设置中关闭
- 阅读进度条、代码块复制、相对时间、回到顶部
- 正文排版:列表(ul/ol/li)、嵌套列表、表格、分隔线、列表标记配色
- 图片点击放大(内置 lightgallery 灯箱)
### 全局
- 二级导航:菜单子项渲染为下拉菜单,hover 桥接避免鼠标下移时收起
- 页脚可关闭署名:默认「Theme by WhiteSpace」并链接到主题仓库,名称 / 链接可改
- 页脚全站统计:文章、分类、标签、评论、访问量、字数,支持开关与统计项选择
- 备案信息:ICP 与公网安备号,公网安备自动带徽标
- 暗色模式评论区可见性已适配
### 图库
- 首屏按相册网格展示,封面 4:3 自适应填满,默认自动取相册内第一张图(分组无图时回退取首图)
- 后台可为每个相册手动指定封面:「相册」直接从图库插件**已存在的分组中下拉选择**,无需手输名称
- 点击相册进入 `?group=photo-group-*` 瀑布流视图(一行三张、页面加宽)
- 相册切换由前端按 URL 上的 `group` 参数完成,不依赖服务端变量,因此**不受页面静态缓存影响**
- 预览模式:默认值可在设置中配置(默认开),访客可在前台自行切换(开 → 点击图片灯箱放大;关 → 点击进入图片详情)
### 瞬间
- 列表页发布人与发布时间同行对齐,每条展示标签
- 详情页:完整内容、图片 / 视频 / 音频、标签、作者信息、发布时间与评论区
### 灯箱集成
主题内置 lightgallery.js 灯箱,为图片提供放大预览。覆盖文章、自定义页面、瞬间(正文 + 媒体)、图库瀑布流;设置「基础 → 全站图片灯箱」可整体关闭。
## 安装
1. 下载主题最新 Release 的 ZIP 包
2. 进入 Halo 后台 → 主题管理 → 安装主题 → 上传 ZIP 包
3. 安装完成后点击「启用」
> **升级后如果整站没了样式或部分页面不生效**:Halo 页面缓存或前置反向代理可能还留着上一版本的 HTML,它引用的 CSS 文件名带内容哈希,升级后已被删除,请求会直接 500 或样式错乱。先在 Halo 后台执行一次「**清理缓存**」(并刷新 CDN / Nginx 缓存);若安装了「页面静态缓存」插件,还需手动点一次「刷新页面缓存」。
或从源码构建:
```bash
git clone https://git.nxxy335.top/nxxy335/halo-theme-whitespace.git
cd halo-theme-whitespace
pnpm install
pnpm build
```
构建产物在 `templates/` 目录下,同时会生成 `dist/` 内的安装包 `whitespace-<version>.zip`。
## 开发
```bash
# 安装依赖
pnpm install
# 开发模式(监听文件变化,自动构建到 templates/)
pnpm dev
# 构建(输出到 templates/,并打包 dist/whitespace-<version>.zip)
pnpm build
# 格式化 + Lint 修复
pnpm check
```
## 目录结构
```
src/ 源码目录(在此编辑)
├── partials/
│ ├── layout.html 公共布局(导航 / 页脚 / 主题切换 / 脚本入口)
│ └── seo.html SEO 片段(canonical / OG / Twitter,受开关控制)
├── modules/
│ ├── post-card.html 文章列表卡片
│ ├── post-meta.html 文章元信息(时间 / 分类 / 浏览量)
│ └── pagination.html 分页导航
├── error/ 错误页(404 / 通用错误)
│ ├── 404.html
│ └── error.html
├── css/main.css 全部样式(含双主题变量、响应式、打印)
├── js/main.ts 全站脚本
├── js/post.ts 文章页脚本(代码复制 / 阅读进度)
├── index.html post.html page.html page_about.html
├── archives.html category.html tag.html categories.html tags.html author.html
├── weread.html douban.html links-subscribe.html page_guestbook.html
└── i18n/ 多语言消息
theme.yaml 主题元数据(必填)
settings.yaml 控制台主题设置表单
templates/ 构建产物(Halo 实际读取,勿手动编辑)
```text
├── src/
│ ├── partials/ # 公共布局(layout.html 布局契约、seo.html 片段)
│ ├── modules/ # 文章卡片 / 元信息 / 分页等可复用片段
│ ├── error/ # 404 与通用错误页
│ ├── css/main.css # 全部样式(双主题变量、响应式、正文排版)
│ ├── js/ # main.ts 全站脚本 / post.ts 文章脚本 / lightbox.ts 灯箱
│ ├── index.html post.html page.html
│ ├── archives.html category.html tag.html categories.html tags.html author.html
│ ├── moments.html moment.html photos.html important-dates.html
│ ├── weread.html douban.html links.html links-subscribe.html page_guestbook.html
│ └── i18n/ # 多语言消息
├── templates/ # 构建产物,Halo 实际加载的模板(勿手动编辑)
├── dist/ # 打包安装包 whitespace-<version>.zip
├── settings.yaml # 后台设置项定义
└── theme.yaml # 主题清单与自定义页面模板注册
```
> `src/` 与 `templates/` 的关系:`src/` 经 Vite 构建生成 `templates/`。开发或发版前务必执行构建,使 `templates/` 与源码一致。**不要**手动修改 `templates/`。
## 主题开发注意事项
## 开发与构建
- **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号 / 空格 / emoji 会导致页面 500
- **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝
- **凡是后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效
- **内联脚本统一加 `th:inline="none"`**:避免 Thymeleaf 解析脚本中的 `[[` / `[(` / `${}`
- **BEM 双下划线 `__` 不要跨引号边界**:出现在 `th:*` 表达式里会被当作预处理分隔符导致 500
- **禁止静态类调用**:`java.math.BigInteger.valueOf(...)` 等被 Halo 受限 SpEL 禁止,整数运算改用 `longValue()` / 整数除法
- **`SinglePage` 描述在 `status.excerpt`**,不是 `spec.description`
- **升级主题务必清理 Halo 页面缓存**,否则旧 HTML 引用已删除的哈希资源会 500
```bash
pnpm install # 安装依赖
pnpm dev # 监听模式构建(开发时用)
pnpm build-only # 仅构建(tsc + vp build),生成 templates/
pnpm build # 构建并打包为可上传的 ZIP(theme-package-cli)
pnpm check # 格式化 + Lint 修复
```
## 技术栈
构建输出:
- [Halo](https://halo.run) 2.26+
- [Vite](https://vitejs.dev/) + [@halo-dev/vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme)
- [Thymeleaf](https://www.thymeleaf.org) 模板引擎
- [lightgallery.js](https://www.lightgalleryjs.com/) 灯箱
- TypeScript
- 页面与片段 → `templates/*.html`、`templates/modules/*.html`、`templates/error/*.html`
- 静态资源 → `templates/assets/*`(文件名带内容哈希,运行时以 `/themes/whitespace/assets/...` 提供)
## 许可
## 主题设置
在 Halo 控制台「主题 → 设置」中可配置:
| 分组 | 字段 | 说明 |
| ------- | ------------------ | -------------------------------------------- |
| basic | color_scheme | 默认配色:跟随系统 / 浅色 / 深色 |
| basic | footer_copyright | 页脚自定义版权(支持 HTML,留空不显示) |
| basic | show_theme_credit | 页脚是否显示「Theme by」署名(默认开) |
| 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 |
| seo | enable_structured_data | 输出文章页 BlogPosting JSON-LD |
| footer | icp_number / icp_url | ICP 备案号与查询链接(默认工信部备案管理系统) |
| footer | police_number / police_url | 公网安备号与查询链接(默认全国互联网安全管理服务平台) |
| stats | enable | 页脚是否显示全站数据统计(默认关) |
| stats | show_posts / show_categories / show_tags / show_comments / show_visits | 各项统计的显隐开关 |
| stats | show_words | 全站总字数(需 extra-api 插件,默认开) |
| photos | preview_default | 相册瀑布流是否默认开启预览模式(默认开) |
| photos | album_covers | 各相册手动封面(「相册名称 + 封面图」列表,留空自动取第一张) |
## 约定与说明
- Logo / favicon / SEO description·keywords / 代码注入优先使用 Halo 系统设置;主题仅输出 canonical、OG、Twitter 与结构化数据(可在 `seo` 分组关闭,避免与插件重复)。
- 社交分享 / 结构化数据中用 `status.permalink` 与 `site.routes.*` 拼出地址,不写死可配置路由。
- 搜索由官方 `PluginSearchWidget` 插件提供,按钮仅在插件可用时出现。
- 浏览量来自 Halo 原生统计(`post.stats.visit`),无需主题自行计数。
- 字数 / 阅读时长来自 `halo-plugin-extra-api` 的 `extraApiStatsFinder`(插件未安装时相关展示自动隐藏),全站字数为缓存计算结果。
[GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html)
## 致谢
- 原主题:[whitespace 留白](https://kehua.me) by kehua
- 基于 Halo 官方 [theme-vite-starter](https://github.com/halo-dev/theme-vite-starter) 脚手架移植
-4
View File
@@ -30,9 +30,6 @@ error.404.message=Sorry, the page you visited does not exist or has been deleted
error.back.home=Back to home
error.back.prev=Go back
footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=Email
post.words={0} words
post.reading_time=About {0} min
archive.stats.words=Total words
@@ -47,6 +44,5 @@ 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
-4
View File
@@ -30,9 +30,6 @@ error.404.message=抱歉,您访问的页面不存在或已被删除。
error.back.home=返回首页
error.back.prev=返回上页
footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=邮箱
post.words={0} 字
post.reading_time=约 {0} 分钟
archive.stats.words=总字数
@@ -47,6 +44,5 @@ 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=标签
-50
View File
@@ -60,34 +60,6 @@ spec:
label: 首页展示置顶文章
value: true
help: 在首页顶部以大标题样式展示最新的一篇置顶文章(需在文章设置中开启置顶)
- group: about
label: 关于页面
formSchema:
- $formkit: attachment
name: avatar
label: 博主头像
value: ""
help: 关于页面展示的头像,留空则使用站点 Logo
- $formkit: textarea
name: bio
label: 个人简介
value: "一个热爱生活、热爱技术的普通人"
help: 关于页面头像下方显示的简介
- $formkit: text
name: github_url
label: GitHub 链接
value: ""
placeholder: https://github.com/yourname
- $formkit: text
name: twitter_url
label: Twitter 链接
value: ""
placeholder: https://x.com/yourname
- $formkit: text
name: contact_email
label: 联系邮箱
value: ""
placeholder: mail@example.com
- group: seo
label: SEO
formSchema:
@@ -158,25 +130,3 @@ 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
+47 -36
View File
@@ -79,8 +79,9 @@
></p>
<th:block th:if="${pluginFinder.available('plugin-douban')}">
<!-- 读过 -->
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'book', 'done')}"
th:with="res=${doubanFinder.list(1, 24, 'book', 'done')}, movies=${res != null ? res.items : null}"
>
<div
class="douban-section"
@@ -91,27 +92,28 @@
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
@@ -119,8 +121,10 @@
</div>
</div>
</th:block>
<!-- 在读 -->
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'book', 'doing')}"
th:with="res=${doubanFinder.list(1, 24, 'book', 'doing')}, movies=${res != null ? res.items : null}"
>
<div
class="douban-section"
@@ -131,27 +135,28 @@
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
@@ -159,8 +164,10 @@
</div>
</div>
</th:block>
<!-- 看过 -->
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'movie', 'done')}"
th:with="res=${doubanFinder.list(1, 24, 'movie', 'done')}, movies=${res != null ? res.items : null}"
>
<div
class="douban-section"
@@ -171,27 +178,28 @@
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
@@ -199,8 +207,10 @@
</div>
</div>
</th:block>
<!-- 在追 -->
<th:block
th:with="movies=${doubanFinder.getMovies(24, 0, 'drama', 'doing')}"
th:with="res=${doubanFinder.list(1, 24, 'drama', 'doing')}, movies=${res != null ? res.items : null}"
>
<div
class="douban-section"
@@ -211,27 +221,28 @@
<a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.spec.link}"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.spec.poster}"
th:alt="${m.spec.name}"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.spec.name}">名称</p>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.spec.year)}"
th:text="${m.spec.year}"
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.spec.douban_score)}"
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.spec.douban_score}"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
+176 -48
View File
@@ -31,37 +31,61 @@
.links-list a {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
gap: 0.8rem;
padding: 1.1rem 1.25rem;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border-radius: var(--radius-lg, 0.75rem);
background: var(--color-bg-secondary);
color: var(--color-text);
transition: border-color var(--transition-fast);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.links-list a:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.links-list img {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-sm);
object-fit: cover;
.links-avatar {
position: relative;
display: block;
flex-shrink: 0;
}
.links-avatar img {
display: block;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-md);
object-fit: cover;
background: var(--color-bg);
}
/* 头像右下角状态圆点 */
.links-avatar::after {
content: "";
position: absolute;
right: -0.1rem;
bottom: -0.1rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #2ea043;
border: 2px solid var(--color-bg-secondary);
}
.links-list .links-body {
display: flex;
flex-direction: column;
min-width: 0;
padding-top: 0.1rem;
}
.links-list .links-name {
font-weight: 600;
line-height: 1.4;
}
.links-list .links-desc {
margin: 0.25rem 0 0;
margin: 0.3rem 0 0;
font-size: 0.85rem;
line-height: 1.55;
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -77,18 +101,79 @@
.links-section:first-of-type .links-group-title {
margin-top: 0;
}
/* 申请友链按钮 */
.links-apply {
margin-top: 2rem;
margin-top: 2.5rem;
text-align: center;
}
.link-apply-panel {
max-width: 32rem;
margin: 1.25rem auto 0;
text-align: left;
.btn-apply {
display: inline-block;
padding: 0.6rem 1.75rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-full);
background: var(--color-primary);
color: #fff;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
transition:
opacity var(--transition-fast),
transform var(--transition-fast);
}
.btn-apply:hover {
opacity: 0.88;
}
.btn-apply:active {
transform: scale(0.97);
}
/* 申请弹窗(运行时移至 body 下,避免被父级 stacking context 遮挡) */
.link-apply-modal[hidden] {
display: none;
}
.link-apply-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
}
.link-apply-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(26rem, calc(100vw - 2rem));
max-height: min(85vh, 44rem);
overflow-y: auto;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 0.75rem);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
padding: 1.5rem;
z-index: 1001;
}
.link-apply-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 2rem;
height: 2rem;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.link-apply-close:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
.link-apply-title {
margin: 0 0 0.75rem;
margin: 0 0 1rem;
font-size: 1.125rem;
padding-right: 2rem;
}
.apply-form .form-row {
margin-bottom: 1rem;
@@ -174,12 +259,14 @@
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-avatar">
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
</span>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
@@ -206,12 +293,14 @@
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-avatar">
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
</span>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
@@ -235,12 +324,14 @@
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-avatar">
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
</span>
<span class="links-body">
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
@@ -259,14 +350,35 @@
<div class="links-apply">
<button
class="btn btn-primary"
class="btn-apply"
id="link-apply-toggle"
type="button"
th:text="#{links.apply}"
>申请友链</button>
<div class="link-apply-panel apply-form" id="link-apply-panel" hidden>
<h2 class="link-apply-title" th:text="#{links.apply}">申请友链</h2>
<form id="link-apply-form" novalidate>
</div>
</section>
<!-- 申请友链弹窗:脚本运行时移至 body 下 -->
<div class="link-apply-modal" id="link-apply-modal" hidden>
<div class="link-apply-backdrop" data-close></div>
<div
class="link-apply-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="link-apply-title"
>
<button
class="link-apply-close"
type="button"
data-close
aria-label="关闭"
>
×
</button>
<h2 class="link-apply-title" id="link-apply-title" th:text="#{links.apply}">
申请友链
</h2>
<form class="apply-form" id="link-apply-form" novalidate>
<div class="form-row">
<label for="f-url">网站地址 *</label>
<input id="f-url" name="url" type="url" required placeholder="https://example.com" />
@@ -302,12 +414,11 @@
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
</div>
</div>
<button class="btn btn-primary" type="submit">提交申请</button>
<button class="btn-apply" type="submit">提交申请</button>
</form>
<div class="apply-status" id="apply-status" role="alert"></div>
</div>
</div>
</section>
<div class="comments-section" th:if="${haloCommentEnabled}">
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
@@ -326,12 +437,35 @@
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");
var panel = document.getElementById("link-apply-panel");
var modal = document.getElementById("link-apply-modal");
var form = document.getElementById("link-apply-form");
var img = document.getElementById("captcha-img");
var status = document.getElementById("apply-status");
var challengeId = null;
// 弹窗移至 body 下,避免被页面父级 stacking context 遮挡
document.body.appendChild(modal);
function openModal() {
modal.hidden = false;
document.body.style.overflow = "hidden";
if (!challengeId) loadCaptcha();
}
function closeModal() {
modal.hidden = true;
document.body.style.overflow = "";
}
toggle.addEventListener("click", openModal);
modal.addEventListener("click", function (e) {
if (e.target.closest("[data-close]")) closeModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !modal.hidden) closeModal();
});
function loadCaptcha() {
fetch(CAPTCHA, { method: "POST" })
.then(function (r) {
@@ -348,13 +482,6 @@
});
}
toggle.addEventListener("click", function () {
panel.hidden = !panel.hidden;
if (!panel.hidden && !challengeId) {
loadCaptcha();
}
});
img.addEventListener("click", loadCaptcha);
form.addEventListener("submit", function (e) {
@@ -394,6 +521,7 @@
loadCaptcha();
status.className = "apply-status ok";
status.textContent = "申请已提交,等待管理员审核。";
setTimeout(closeModal, 1500);
} else {
return r.json().then(function (err) {
throw err;
+25 -25
View File
@@ -37,19 +37,10 @@
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;
@@ -69,17 +60,35 @@
.moment-tags a {
color: var(--color-link);
}
.moment-media img,
.moment-media {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.3rem;
margin-top: 0.75rem;
}
.moment-media img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
}
.moment-media img:only-child {
aspect-ratio: auto;
width: auto;
max-width: min(100%, 22rem);
max-height: 26rem;
}
.moment-media video {
grid-column: 1 / -1;
display: block;
max-width: 100%;
height: auto;
margin-top: 1rem;
border-radius: var(--radius-md);
}
.moment-media audio {
grid-column: 1 / -1;
width: 100%;
margin-top: 1rem;
}
.comments-section {
margin-top: 2rem;
@@ -104,18 +113,9 @@
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 class="moment-owner-name" th:text="${moment.owner.displayName}"
>用户名</span
>
</span>
<span class="moment-meta">
<time
+36 -8
View File
@@ -29,13 +29,15 @@
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;
color: var(--color-text-secondary);
}
.moments article header > a:hover {
color: var(--color-link-hover);
}
.moments .moment-owner {
display: inline-flex;
@@ -43,11 +45,15 @@
gap: 0.5rem;
}
.moments .moment-owner img {
width: 2rem;
height: 2rem;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
object-fit: cover;
}
.moments .moment-owner .moment-owner-name {
color: var(--color-text);
font-weight: 600;
}
.moments .moment-tags {
display: flex;
flex-wrap: wrap;
@@ -58,17 +64,39 @@
.moments .moment-tags a {
color: var(--color-link);
}
.moments .moment-media img,
/* 图片排版:仿微信朋友圈(多图九宫格方图,单图放大保持原比例) */
.moments .moment-media {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.3rem;
margin-top: 0.75rem;
}
.moments .moment-media img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
}
.moments .moment-media > :only-child {
grid-column: auto;
}
.moments .moment-media img:only-child {
aspect-ratio: auto;
width: auto;
max-width: min(100%, 22rem);
max-height: 26rem;
}
.moments .moment-media video {
grid-column: 1 / -1;
display: block;
max-width: 100%;
height: auto;
margin-top: 1rem;
border-radius: var(--radius-md);
}
.moments .moment-media audio {
grid-column: 1 / -1;
width: 100%;
margin-top: 1rem;
}
</style>
</template>
-92
View File
@@ -1,92 +0,0 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${singlePage.spec.title} - ${site.title}|">
About
</title>
<th:block
th:with="seoTitle=${singlePage.spec.title}, seoUrl=${site.url + singlePage.status.permalink}, seoDesc=${singlePage.status.excerpt}, seoImage=${singlePage.spec.cover}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template>
<div class="container">
<!-- 关于页面样式 -->
<div class="page-content about-page">
<div class="about-header">
<div class="about-avatar">
<img
th:src="${not #strings.isEmpty(theme.config?.about?.avatar) ? theme.config.about.avatar : ((singlePage.owner != null and not #strings.isEmpty(singlePage.owner.avatar)) ? singlePage.owner.avatar : site.logo)}"
th:alt="${singlePage.owner != null ? singlePage.owner.displayName : site.title}"
/>
</div>
<h1
class="about-name"
th:text="${singlePage.owner != null ? singlePage.owner.displayName : site.title}"
>
About
</h1>
<p
class="about-bio"
th:if="${not #strings.isEmpty(theme.config?.about?.bio)}"
th:text="${theme.config.about.bio}"
></p>
<div
class="about-social"
th:if="${not #strings.isEmpty(theme.config?.about?.github_url) or not #strings.isEmpty(theme.config?.about?.twitter_url) or not #strings.isEmpty(theme.config?.about?.contact_email)}"
>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.github_url)}"
th:href="${theme.config.about.github_url}"
class="social-link"
th:title="#{about.social.github}"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.twitter_url)}"
th:href="${theme.config.about.twitter_url}"
class="social-link"
th:title="#{about.social.twitter}"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
/>
</svg>
</a>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.contact_email)}"
th:href="|mailto:${theme.config.about.contact_email}|"
class="social-link"
th:title="#{about.social.email}"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</a>
</div>
</div>
<div class="about-content article-content">
<th:block th:utext="${singlePage.content.content}"></th:block>
</div>
</div>
</div>
</include>
+163 -287
View File
@@ -6,85 +6,66 @@
.container {
max-width: 75rem;
}
:where(.photos-groups) {
margin: 1.5rem 0;
}
:where(.photos-groups__list, .photos-grid, .photos-card__tags) {
margin: 0;
padding: 0;
list-style: none;
}
:where(.photos-groups__list, .photos-card__tags) {
/* 分组导航(图库标题下方) */
:where(.photos-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
gap: 0.6rem;
margin: 1.25rem 0 0;
}
:where(.photos-group__link) {
display: inline-flex;
gap: 0.25rem;
align-items: baseline;
color: var(--color-link);
}
:where(.photos-group__link):hover {
color: var(--color-link-hover);
}
:where(.photos-group.is-active .photos-group__link) {
font-weight: 600;
}
/* 相册网格 */
: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.5rem;
}
: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);
:where(.photos-nav button) {
padding: 0.3rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.88rem;
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
:where(.albums-card__cover img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
:where(.photos-nav button:hover) {
color: var(--color-primary);
border-color: var(--color-primary);
}
: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-nav button.is-active) {
color: #fff;
border-color: var(--color-primary);
background: var(--color-primary);
}
/* 瀑布流(一行三张) */
/* 分组区块 */
:where(.photos-group)[hidden] {
display: none;
}
:where(.photos-group-title) {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 2rem 0 0;
font-size: 1.05rem;
font-weight: 600;
}
:where(.photos-group-title::before) {
content: "";
width: 0.2rem;
height: 1.1em;
border-radius: 2px;
background: var(--color-primary);
}
:where(.photos-group-section:first-of-type .photos-group-title) {
margin-top: 1.5rem;
}
/* 瀑布流(一行三张,图片自适应原比例) */
:where(.photos-masonry) {
list-style: none;
margin: 1.5rem 0 0;
margin: 1rem 0 0;
padding: 0;
columns: 3;
column-gap: 1rem;
@@ -93,23 +74,20 @@
break-inside: avoid;
margin: 0 0 1rem;
}
:where(.photos-masonry .photos-card__media) {
:where(.photos-item) {
display: block;
}
:where(.photos-masonry img) {
:where(.photos-item img) {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
opacity: 0;
transition: opacity 0.4s ease;
}
: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;
:where(.photos-item img.is-loaded) {
opacity: 1;
}
@media (max-width: 900px) {
:where(.photos-masonry) {
@@ -122,37 +100,10 @@
}
}
/* 预览模式切换 */
: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;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
text-align: center;
}
</style>
</template>
@@ -163,206 +114,131 @@
<h1 class="photos-header__title" th:text="${title}">图库</h1>
</header>
<nav class="photos-groups" aria-label="照片分组">
<ul class="photos-groups__list">
<li
class="photos-group"
th:classappend="${#strings.isEmpty(group)} ? 'is-active' : ''"
<th:block
th:with="albums=${photoFinder.groupBy()}, anyCheck=${photoFinder.list(1, 1)}, anyPhotos=${anyCheck != null ? anyCheck.items : null}"
>
<!-- 分组导航 -->
<nav
class="photos-nav"
id="photos-nav"
aria-label="图库分组"
th:if="${not #lists.isEmpty(albums)}"
>
<button type="button" class="is-active" data-group="">全部</button>
<button
type="button"
th:each="album : ${albums}"
th:attr="data-group=${album.metadata.name}"
th:text="${album.spec.displayName}"
>
<a
class="photos-group__link"
th:href="${photoUrl.list()}"
th:attr="aria-current=${#strings.isEmpty(group) ? 'page' : null}"
>全部相册</a
>
</li>
<li
class="photos-group"
th:each="photoGroup : ${groups}"
th:classappend="${group == photoGroup.metadata.name} ? 'is-active' : ''"
>
<a
class="photos-group__link"
th:href="${photoUrl.list(photoGroup.metadata.name)}"
th:attr="aria-current=${group == photoGroup.metadata.name ? 'page' : null}"
>
<span
class="photos-group__name"
th:text="${photoGroup.spec.displayName}"
>相册</span
>
<span
class="photos-group__count"
th:if="${photoGroup.status != null and photoGroup.status.photoCount != null}"
th:text="|(${photoGroup.status.photoCount})|"
></span>
</a>
</li>
</ul>
</nav>
分组
</button>
</nav>
<!-- 相册视图:按分组展示各相册 -->
<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)}"
<!-- 各分组照片,一次全部渲染;默认「全部」视图,前台脚本切换 -->
<th:block th:if="${not #lists.isEmpty(albums)}">
<section
class="photos-group-section"
th:each="album : ${albums}"
th:attr="data-group=${album.metadata.name}"
>
<h2 class="photos-group-title" th:text="${album.spec.displayName}">
分组
</h2>
<th:block th:with="rawPhotos=${album.photos}">
<th:block
th:with="photos=${(rawPhotos != null and !#lists.isEmpty(rawPhotos)) ? rawPhotos : photoFinder.listBy(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}"
<ul
class="photos-masonry"
th:if="${not #lists.isEmpty(photos)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
>
相册
</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">
暂无相册
<li th:each="photo : ${photos}">
<a
class="photos-item"
th:href="${photoUrl.detail(photo)}"
th:title="${photo.spec.displayName}"
>
<img
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
th:alt="${photo.spec.displayName}"
loading="lazy"
decoding="async"
/>
</a>
</li>
</ul>
</th:block>
</th:block>
</section>
<p
class="photos-empty"
th:if="${#lists.isEmpty(anyPhotos)}"
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}"
>
<li th:each="photo : ${photos.items}">
<a
class="photos-card__media"
th:href="${photoUrl.detail(photo)}"
th:title="${photo.spec.displayName}"
>
<img
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
th:alt="${photo.spec.displayName}"
loading="lazy"
/>
</a>
<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
>
<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 nav = document.getElementById("photos-nav");
if (!nav) return;
var KEY = "whitespace-photo-preview";
var defaultOn = btn.getAttribute("data-default") === "on";
var stored = null;
var sections = document.querySelectorAll(".photos-group-section");
var buttons = nav.querySelectorAll("button");
// 深链接:?group=xxx(仅前台读取,不依赖服务端参数)
var initial = "";
try {
stored = localStorage.getItem(KEY);
initial = new URLSearchParams(window.location.search).get("group") || "";
} 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");
initial = "";
}
btn.addEventListener("click", function () {
mode = !mode;
try {
localStorage.setItem(KEY, mode ? "on" : "off");
} catch (e) {
/* ignore */
}
// 重新加载以按新模式初始化灯箱
window.location.reload();
function apply(group) {
Array.prototype.forEach.call(buttons, function (btn) {
var active = btn.getAttribute("data-group") === group;
btn.classList.toggle("is-active", active);
});
Array.prototype.forEach.call(sections, function (sec) {
var match = group === "" || sec.getAttribute("data-group") === group;
if (match) {
sec.removeAttribute("hidden");
} else {
sec.setAttribute("hidden", "");
}
});
}
nav.addEventListener("click", function (e) {
var btn = e.target.closest("button[data-group]");
if (!btn) return;
apply(btn.getAttribute("data-group"));
});
apply();
if (initial && nav.querySelector('button[data-group="' + initial + '"]')) {
apply(initial);
}
// 图片加载后淡入
Array.prototype.forEach.call(
document.querySelectorAll(".photos-item img"),
function (img) {
if (img.complete && img.naturalWidth > 0) {
img.classList.add("is-loaded");
} else {
img.addEventListener("load", function () {
img.classList.add("is-loaded");
});
}
},
);
})();
</script>
</include>
+29 -1
View File
@@ -1,6 +1,34 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|#{tags.title} - ${site.title}|">Tags</title>
<style>
/* 标签云:紧凑标签片,保留间距 */
.tag-cloud {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.65rem 0.75rem;
margin: 1.75rem 0 0;
}
.tag-cloud .tag {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
transition:
color var(--transition-fast),
border-color var(--transition-fast);
}
.tag-cloud .tag:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
</style>
<th:block
th:with="seoTitle=#{tags.title}, seoUrl=${site.url + site.routes.tagsUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
>
@@ -19,7 +47,7 @@
th:href="@{${tag.status.permalink}}"
class="tag"
th:text="${tag.spec.displayName}"
th:title="${tag.postCount != null ? tag.postCount + ' ' + #{'tag.count'} : ''}"
th:title="|${tag.postCount} #{tag.count}|"
></a>
</div>
+1 -5
View File
@@ -14,17 +14,13 @@ spec:
issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
settingName: "whitespace-setting"
configMapName: "whitespace-configMap"
version: 0.2.0
version: 0.4.0
requires: ">=2.26.0"
license:
- name: "GPL-3.0"
url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE"
customTemplates:
page:
- name: 关于页面
description: 关于页面布局,展示头像、个人简介与社交链接
screenshot:
file: page_about.html
- name: 微信读书页面
description: 适配 halo-weread-plugin,展示微信读书书架与读书笔记
screenshot: