Compare commits

...
6 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
nxxy335andWorkBuddy 4e3001674a 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>
2026-09-30 09:10:32 +08:00
nxxy335andWorkBuddy c0ea6ec0df fix: 署名改 WhiteSpace 并链接仓库、订阅元信息精简、二级菜单与排版优化
- 页脚署名改为「Theme by WhiteSpace」并链接到主题仓库(新增 theme_credit_url 设置,默认仓库地址)
- 订阅页移除无用的 linkName 前缀,仅保留作者与发布时间;作者名链接到来源博客(authorUrl)
- 订阅摘要默认折叠为 5 行(line-clamp)
- 修复二级导航 hover 桥接:下拉与一级菜单间加透明 ::before 桥接,鼠标下移不再收起
- 优化文章组件排版:列表(ul/ol/li)、嵌套列表、表格、分隔线、列表标记配色
- 优化移动端:子菜单左侧加分隔线;点击菜单内链接自动收起移动导航

Co-Authored-By: WorkBuddy <noreply@workbuddy.ai>
2026-09-28 22:16:09 +08:00
23 changed files with 1536 additions and 492 deletions
+1
View File
@@ -27,3 +27,4 @@ dist-ssr
.gradle .gradle
build build
templates templates
.workbuddy/
+144 -91
View File
@@ -1,114 +1,167 @@
# Whitespace 留白 # 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**。 > 本项目由 AI 开发完成,人工仅进行部分功能测试及问题找寻。
> 要求 Halo ≥ 2.26.0。源码在 `src/`,构建产物在 `templates/`。
> 源码仓库:https://git.nxxy335.top/nxxy335/halo-theme-whitespace
## 特性 ## 环境要求
- 极简留白风格,自带浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择) - Halo **2.26.0 及以上**
- 首页可选展示最新一篇置顶文章(`featured` 设置) - 构建依赖 Node.js + pnpm(`src/` 经 Vite 构建生成 `templates/`,Halo 实际只读 `templates/`)
- 文章 / 页面 / 归档 / 分类 / 标签 / 作者 / 关于 等完整页面
- 文章页结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在主题设置中关闭
- 内置搜索按钮(依赖官方 `PluginSearchWidget` 插件,未安装则不显示)
- 阅读进度条、代码块复制、相对时间、回到顶部、移动端菜单等交互
- **二级导航**:菜单项的子项自动渲染为下拉菜单(桌面端 hover/focus 展开,移动端嵌套缩进展开)
- **页脚可关闭的主题署名**:默认「Theme by 暖心向阳335」,可在设置中修改名称或整体关闭
- **页脚全站数据统计**:文章 / 分类 / 标签 / 评论 / 访问量 / 字数,可按项开关,整体可关闭
- **文章卡片字数 + 阅读时长**:可在设置中关闭(需 `halo-plugin-extra-api` 提供数据)
- 多语言:`i18n/default.properties`(回退)+ `i18n/zh_CN.properties`
- 提供 `layout.html` 布局契约,供使用布局契约的插件页面复用主题导航 / 页脚 / 暗色样式
## 插件适配 ## 特点
主题已为以下插件提供适配页面(在 `src/` 下以同名模板提供,构建后进入 `templates/`): 1. **极简留白风格** — 浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择),专注内容本身的清晰排版
2. **二级导航** — 菜单子项自动渲染为下拉菜单(桌面端 hover/focus 展开,移动端嵌套缩进)
3. **插件兼容性** — 适配 8 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
4. **全站灯箱** — 主题内置 lightgallery.js 灯箱,文章、页面、瞬间、图库等图片点击放大预览(按需懒加载,可在设置中整体关闭)
5. **可配置性** — 署名、全站统计、字数显示、相册封面、预览模式等均可在后台开关
6. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载、CSS/HTML 压缩、静态资源带内容哈希
- **plugin-moments**:`/moments` 瞬间页 ## 已适配插件
- **plugin-photos**:`/photos` 图库页
- **plugin-links**:`/links` 友链页;友链申请表单已**直接内嵌**在友链页底部(点击「申请友链」展开,调用插件公共 REST API,含图形验证码);并额外提供
- `links-subscribe.html`:RSS 订阅动态聚合页(使用 `linkFeedFinder`)
- **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
```
## 目录结构 ## 目录结构
``` ```text
src/ 源码目录(在此编辑) ├── src/
├── partials/ │ ├── partials/ # 公共布局(layout.html 布局契约、seo.html 片段)
│ ├── layout.html 公共布局(导航 / 页脚 / 主题切换 / 脚本入口) │ ├── modules/ # 文章卡片 / 元信息 / 分页等可复用片段
│ └── seo.html SEO 片段(canonical / OG / Twitter,受开关控制) │ ├── error/ # 404 与通用错误页
├── modules/ │ ├── css/main.css # 全部样式(双主题变量、响应式、正文排版)
│ ├── post-card.html 文章列表卡片 │ ├── js/ # main.ts 全站脚本 / post.ts 文章脚本 / lightbox.ts 灯箱
│ ├── post-meta.html 文章元信息(时间 / 分类 / 浏览量) │ ├── index.html post.html page.html
│ └── pagination.html 分页导航 │ ├── archives.html category.html tag.html categories.html tags.html author.html
├── error/ 错误页(404 / 通用错误) │ ├── moments.html moment.html photos.html important-dates.html
│ ├── 404.html │ ├── weread.html douban.html links.html links-subscribe.html page_guestbook.html
│ └── error.html │ └── i18n/ # 多语言消息
├── css/main.css 全部样式(含双主题变量、响应式、打印) ├── templates/ # 构建产物,Halo 实际加载的模板(勿手动编辑)
├── js/main.ts 全站脚本 ├── dist/ # 打包安装包 whitespace-<version>.zip
├── js/post.ts 文章页脚本(代码复制 / 阅读进度) ├── settings.yaml # 后台设置项定义
├── index.html post.html page.html page_about.html └── theme.yaml # 主题清单与自定义页面模板注册
├── 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 实际读取,勿手动编辑)
``` ```
> `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/...` 提供)
## 主题设置 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html)
在 Halo 控制台「主题 → 设置」中可配置:
| 分组 | 字段 | 说明 |
| ------- | ------------------ | -------------------------------------------- |
| basic | color_scheme | 默认配色:跟随系统 / 浅色 / 深色 |
| basic | footer_copyright | 页脚自定义版权(支持 HTML,留空不显示) |
| basic | show_theme_credit | 页脚是否显示「Theme by」署名(默认开) |
| basic | theme_credit_text | 署名名称(默认「暖心向阳335」) |
| basic | show_word_count | 文章卡片是否显示字数与阅读时长(需 extra-api)|
| 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 插件,默认开) |
## 约定与说明
- Logo / favicon / SEO description·keywords / 代码注入优先使用 Halo 系统设置;主题仅输出 canonical、OG、Twitter 与结构化数据(可在 `seo` 分组关闭,避免与插件重复)。
- 社交分享 / 结构化数据中用 `status.permalink` 与 `site.routes.*` 拼出地址,不写死可配置路由。
- 搜索由官方 `PluginSearchWidget` 插件提供,按钮仅在插件可用时出现。
- 浏览量来自 Halo 原生统计(`post.stats.visit`),无需主题自行计数。
- 字数 / 阅读时长来自 `halo-plugin-extra-api` 的 `extraApiStatsFinder`(插件未安装时相关展示自动隐藏),全站字数为缓存计算结果。
## 致谢 ## 致谢
- 原主题:[whitespace 留白](https://kehua.me) by kehua - 原主题:[whitespace 留白](https://kehua.me) by kehua
- 基于 Halo 官方 [theme-vite-starter](https://github.com/halo-dev/theme-vite-starter) 脚手架移植
+2 -3
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.home=Back to home
error.back.prev=Go back error.back.prev=Go back
footer.theme=Theme by {0} footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=Email
post.words={0} words post.words={0} words
post.reading_time=About {0} min post.reading_time=About {0} min
archive.stats.words=Total words archive.stats.words=Total words
@@ -47,3 +44,5 @@ stats.comments={0} comments
stats.visits={0} visits stats.visits={0} visits
stats.words={0} words 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. douban.plugin_required=Douban plugin (plugin-douban) not detected. Please install and enable it, then set your Douban ID in the plugin settings.
moment.back_to_list=Back to moments
moment.tags=Tags
+2 -3
View File
@@ -30,9 +30,6 @@ error.404.message=抱歉,您访问的页面不存在或已被删除。
error.back.home=返回首页 error.back.home=返回首页
error.back.prev=返回上页 error.back.prev=返回上页
footer.theme=Theme by {0} footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=邮箱
post.words={0} 字 post.words={0} 字
post.reading_time=约 {0} 分钟 post.reading_time=约 {0} 分钟
archive.stats.words=总字数 archive.stats.words=总字数
@@ -47,3 +44,5 @@ stats.comments={0} 条评论
stats.visits={0} 次访问 stats.visits={0} 次访问
stats.words={0} 字 stats.words={0} 字
douban.plugin_required=未检测到豆瓣插件(plugin-douban),请安装并启用后在插件设置中填写豆瓣 ID。 douban.plugin_required=未检测到豆瓣插件(plugin-douban),请安装并启用后在插件设置中填写豆瓣 ID。
moment.back_to_list=返回瞬间列表
moment.tags=标签
+3
View File
@@ -25,5 +25,8 @@
"vite": "npm:@voidzero-dev/vite-plus-core@latest", "vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest" "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
} }
},
"dependencies": {
"lightgallery": "^2.9.0"
} }
} }
+10
View File
@@ -11,6 +11,10 @@ overrides:
importers: importers:
.: .:
dependencies:
lightgallery:
specifier: ^2.9.0
version: 2.9.0
devDependencies: devDependencies:
'@halo-dev/theme-package-cli': '@halo-dev/theme-package-cli':
specifier: ^1.0.1 specifier: ^1.0.1
@@ -761,6 +765,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'} 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: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -1629,6 +1637,8 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
lightgallery@2.9.0: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
+12 -29
View File
@@ -33,14 +33,25 @@ spec:
- $formkit: text - $formkit: text
name: theme_credit_text name: theme_credit_text
label: 署名名称 label: 署名名称
value: "暖心向阳335" value: "WhiteSpace"
placeholder: 显示在「Theme by」之后的名称 placeholder: 显示在「Theme by」之后的名称
help: 页脚主题署名的展示名称 help: 页脚主题署名的展示名称
- $formkit: text
name: theme_credit_url
label: 署名链接
value: "https://git.nxxy335.top/nxxy335/halo-theme-whitespace"
placeholder: 点击署名跳转的地址
help: 页脚「Theme by」署名的跳转链接,默认主题仓库地址
- $formkit: checkbox - $formkit: checkbox
name: show_word_count name: show_word_count
label: 文章卡片显示字数 label: 文章卡片显示字数
value: true value: true
help: 在文章卡片与置顶文章上展示字数及阅读时长(需 extra-api 插件提供数据) help: 在文章卡片与置顶文章上展示字数及阅读时长(需 extra-api 插件提供数据)
- $formkit: checkbox
name: enable_lightbox
label: 全站图片灯箱
value: true
help: 使用 lightgallery 为文章、瞬间、图库等页面的图片提供点击放大预览
- group: featured - group: featured
label: 置顶文章 label: 置顶文章
formSchema: formSchema:
@@ -49,34 +60,6 @@ spec:
label: 首页展示置顶文章 label: 首页展示置顶文章
value: true value: true
help: 在首页顶部以大标题样式展示最新的一篇置顶文章(需在文章设置中开启置顶) 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 - group: seo
label: SEO label: SEO
formSchema: formSchema:
+62 -3
View File
@@ -262,9 +262,10 @@ textarea {
.nav-dropdown { .nav-dropdown {
position: absolute; position: absolute;
top: calc(100% + 0.5rem); top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-top: 0.5rem;
min-width: 11rem; min-width: 11rem;
display: none; display: none;
flex-direction: column; flex-direction: column;
@@ -275,6 +276,15 @@ textarea {
box-shadow: var(--shadow, 0 12px 32px rgba(0, 0, 0, 0.12)); box-shadow: var(--shadow, 0 12px 32px rgba(0, 0, 0, 0.12));
z-index: 50; z-index: 50;
} }
/* 透明桥接:填补父项与下拉之间的间隙,鼠标从一级移到二级时下拉不再收起 */
.nav-dropdown::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
.nav-has-children:hover > .nav-dropdown, .nav-has-children:hover > .nav-dropdown,
.nav-has-children:focus-within > .nav-dropdown { .nav-has-children:focus-within > .nav-dropdown {
@@ -384,6 +394,8 @@ textarea {
.mobile-nav-sub { .mobile-nav-sub {
padding-left: calc(var(--spacing-md) + 1rem); padding-left: calc(var(--spacing-md) + 1rem);
margin-left: var(--spacing-md);
border-left: 2px solid var(--color-border);
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.85; opacity: 0.85;
} }
@@ -711,12 +723,59 @@ textarea {
.article-content ul, .article-content ul,
.article-content ol { .article-content ol {
margin-bottom: var(--spacing-lg); margin: 0 0 var(--spacing-lg);
padding-left: var(--spacing-lg); padding-left: 1.5rem;
}
.article-content ul {
list-style: disc;
}
.article-content ol {
list-style: decimal;
} }
.article-content li { .article-content li {
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
line-height: 1.9;
padding-left: 0.25rem;
}
.article-content li::marker {
color: var(--color-primary);
}
.article-content ul ul,
.article-content ol ol,
.article-content ul ol,
.article-content ol ul {
margin: var(--spacing-sm) 0;
padding-left: 1.25rem;
}
.article-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--spacing-xl) 0;
}
.article-content table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--spacing-lg);
font-size: 0.9375rem;
}
.article-content th,
.article-content td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
}
.article-content th {
background: var(--color-bg-secondary);
font-weight: 600;
} }
.article-content blockquote { .article-content blockquote {
+166 -43
View File
@@ -79,53 +79,176 @@
></p> ></p>
<th:block th:if="${pluginFinder.available('plugin-douban')}"> <th:block th:if="${pluginFinder.available('plugin-douban')}">
<!-- 读过 -->
<th:block <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="res=${doubanFinder.list(1, 24, 'book', 'done')}, movies=${res != null ? res.items : null}"
> >
<th:block th:each="col : ${collections}"> <div
<th:block class="douban-section"
th:with="movies=${doubanFinder.getMovies(24, 0, col.type, col.status)}" th:if="${not #lists.isEmpty(movies)}"
> >
<div <h2 class="douban-section-title">读过</h2>
class="douban-section" <div class="douban-grid">
th:if="${not #lists.isEmpty(movies)}" <a
th:each="m : ${movies}"
class="douban-card"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
> >
<h2 class="douban-section-title" th:text="${col.label}"> <img
读过 class="douban-cover"
</h2> th:src="${m.poster}"
<div class="douban-grid"> th:alt="${m.name}"
<a loading="lazy"
th:each="m : ${movies}" decoding="async"
class="douban-card" />
th:href="${m.spec.link}" <p class="douban-name" th:text="${m.name}">名称</p>
target="_blank" <p class="douban-meta">
rel="noopener noreferrer" <span
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
> >
<img <span
class="douban-cover" th:if="${not #strings.isEmpty(m.score)}"
th:src="${m.spec.poster}" class="douban-score"
th:alt="${m.spec.name}" th:text="'★ ' + ${m.score}"
loading="lazy" >评分</span
/> >
<p class="douban-name" th:text="${m.spec.name}">名称</p> </p>
<p class="douban-meta"> </a>
<span </div>
th:if="${not #strings.isEmpty(m.spec.year)}" </div>
th:text="${m.spec.year}" </th:block>
>年份</span
> <!-- 在读 -->
<span <th:block
th:if="${not #strings.isEmpty(m.spec.douban_score)}" th:with="res=${doubanFinder.list(1, 24, 'book', 'doing')}, movies=${res != null ? res.items : null}"
class="douban-score" >
th:text="'★ ' + ${m.spec.douban_score}" <div
>评分</span class="douban-section"
> th:if="${not #lists.isEmpty(movies)}"
</p> >
</a> <h2 class="douban-section-title">在读</h2>
</div> <div class="douban-grid">
</div> <a
</th:block> th:each="m : ${movies}"
</th:block> class="douban-card"
th:href="${m.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
<!-- 看过 -->
<th:block
th:with="res=${doubanFinder.list(1, 24, 'movie', 'done')}, movies=${res != null ? res.items : null}"
>
<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.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block>
<!-- 在追 -->
<th:block
th:with="res=${doubanFinder.list(1, 24, 'drama', 'doing')}, movies=${res != null ? res.items : null}"
>
<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.link}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="douban-cover"
th:src="${m.poster}"
th:alt="${m.name}"
loading="lazy"
decoding="async"
/>
<p class="douban-name" th:text="${m.name}">名称</p>
<p class="douban-meta">
<span
th:if="${not #strings.isEmpty(m.year)}"
th:text="${m.year}"
>年份</span
>
<span
th:if="${not #strings.isEmpty(m.score)}"
class="douban-score"
th:text="'★ ' + ${m.score}"
>评分</span
>
</p>
</a>
</div>
</div>
</th:block> </th:block>
</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);
});
});
});
}
+20
View File
@@ -100,6 +100,11 @@ function initMobileNav(): void {
closeMobileNav(); closeMobileNav();
} }
}); });
// 点击移动端菜单内的任意链接后自动收起
mobileNav.querySelectorAll<HTMLAnchorElement>("a").forEach((link) => {
link.addEventListener("click", closeMobileNav);
});
} }
// ======================================== // ========================================
@@ -247,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 {
/* 加载失败时静默降级为普通图片展示 */
}
}
// ======================================== // ========================================
// 初始化 // 初始化
// ======================================== // ========================================
@@ -265,6 +284,7 @@ function init(): void {
initSmoothScroll(); initSmoothScroll();
initExternalLinks(); initExternalLinks();
initKeyboardShortcuts(); initKeyboardShortcuts();
void initLightbox();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+40 -4
View File
@@ -28,10 +28,26 @@
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.feed-item-author {
color: var(--color-text-secondary);
transition: color var(--transition-fast);
}
.feed-item-author:hover {
color: var(--color-primary);
}
.feed-item-sep {
color: var(--color-border);
margin: 0 0.3rem;
}
.feed-item-summary { .feed-item-summary {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: 1.7; line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
} }
</style> </style>
</template> </template>
@@ -57,15 +73,35 @@
</a> </a>
</div> </div>
<p class="feed-item-meta"> <p class="feed-item-meta">
<span th:text="${item.linkName}">来源</span> <th:block
<span th:if="${item.author != null and item.author != ''}"> th:if="${item.author != null and item.author != ''}"
· <span th:text="${item.author}">作者</span></span
> >
<a
th:if="${item.authorUrl != null and item.authorUrl != ''}"
class="feed-item-author"
th:href="@{${item.authorUrl}}"
target="_blank"
rel="noopener noreferrer"
th:text="${item.author}"
>作者</a
>
<span
th:unless="${item.authorUrl != null and item.authorUrl != ''}"
class="feed-item-author"
th:text="${item.author}"
>作者</span
>
<span
th:if="${item.publishedAt != null}"
class="feed-item-sep"
>·</span
>
</th:block>
<span <span
th:if="${item.publishedAt != null}" th:if="${item.publishedAt != null}"
th:with="pt=${item.publishedAt}" th:with="pt=${item.publishedAt}"
> >
· <time <time
th:datetime="${pt}" th:datetime="${pt}"
th:text="${#temporals.format(pt, 'yyyy-MM-dd HH:mm')}" th:text="${#temporals.format(pt, 'yyyy-MM-dd HH:mm')}"
>时间</time >时间</time
+283 -53
View File
@@ -26,48 +26,154 @@
} }
.links-list li { .links-list li {
margin: 0; margin: 0;
display: flex;
} }
.links-list a { .links-list a {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.75rem; gap: 0.8rem;
padding: 1rem; padding: 1.1rem 1.25rem;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-lg, 0.75rem);
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
color: var(--color-text); 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 { .links-list a:hover {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
} }
.links-list img { .links-avatar {
width: 2.5rem; position: relative;
height: 2.5rem; display: block;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0; 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 { .links-list .links-name {
font-weight: 600; font-weight: 600;
line-height: 1.4;
} }
.links-list .links-desc { .links-list .links-desc {
margin: 0.25rem 0 0; margin: 0.3rem 0 0;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.55;
color: var(--color-text-secondary); 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 { .links-apply {
margin-top: 2rem; margin-top: 2.5rem;
text-align: center; text-align: center;
} }
.link-apply-panel { .btn-apply {
max-width: 32rem; display: inline-block;
margin: 1.25rem auto 0; padding: 0.6rem 1.75rem;
text-align: left; 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 { .link-apply-title {
margin: 0 0 0.75rem; margin: 0 0 1rem;
font-size: 1.125rem; font-size: 1.125rem;
padding-right: 2rem;
} }
.apply-form .form-row { .apply-form .form-row {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -138,40 +244,141 @@
></a> ></a>
</nav> </nav>
<ul th:if="${not #lists.isEmpty(links)}" class="links-list"> <th:block th:if="${group == null}">
<li th:each="link : ${links}"> <section class="links-section" th:each="grp : ${simpleGroups}">
<a th:href="@{${link.spec.url}}" target="_blank" rel="noopener noreferrer"> <h2 class="links-group-title" th:text="${grp.spec.displayName}">
<img 分组
th:if="${not #strings.isEmpty(link.spec.logo)}" </h2>
th:src="@{${link.spec.logo}}" <ul class="links-list">
alt="" <li
loading="lazy" th:each="link : ${links}"
/> th:if="${link.spec.groupName == grp.metadata.name}"
<span> >
<span class="links-name" th:text="${link.spec.displayName}" <a
>链接名称</span th:href="@{${link.spec.url}}"
target="_blank"
rel="noopener noreferrer"
> >
<p <span class="links-avatar">
class="links-desc" <img
th:if="${not #strings.isEmpty(link.spec.description)}" th:if="${not #strings.isEmpty(link.spec.logo)}"
th:text="${link.spec.description}" th:src="@{${link.spec.logo}}"
></p> alt=""
</span> loading="lazy"
</a> />
</li> </span>
</ul> <span class="links-body">
<p th:unless="${not #lists.isEmpty(links)}">暂无链接</p> <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"
>
<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
>
<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"
>
<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
>
<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"> <div class="links-apply">
<button <button
class="btn btn-primary" class="btn-apply"
id="link-apply-toggle" id="link-apply-toggle"
type="button" type="button"
th:text="#{links.apply}" th:text="#{links.apply}"
>申请友链</button> >申请友链</button>
<div class="link-apply-panel apply-form" id="link-apply-panel" hidden> </div>
<h2 class="link-apply-title" th:text="#{links.apply}">申请友链</h2> </section>
<form id="link-apply-form" novalidate>
<!-- 申请友链弹窗:脚本运行时移至 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"> <div class="form-row">
<label for="f-url">网站地址 *</label> <label for="f-url">网站地址 *</label>
<input id="f-url" name="url" type="url" required placeholder="https://example.com" /> <input id="f-url" name="url" type="url" required placeholder="https://example.com" />
@@ -207,12 +414,11 @@
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" /> <input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
</div> </div>
</div> </div>
<button class="btn btn-primary" type="submit">提交申请</button> <button class="btn-apply" type="submit">提交申请</button>
</form> </form>
<div class="apply-status" id="apply-status" role="alert"></div> <div class="apply-status" id="apply-status" role="alert"></div>
</div> </div>
</div> </div>
</section>
<div class="comments-section" th:if="${haloCommentEnabled}"> <div class="comments-section" th:if="${haloCommentEnabled}">
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" /> <halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
@@ -221,15 +427,45 @@
<script th:inline="none"> <script th:inline="none">
(function () { (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 API = "/apis/api.link.halo.run/v1alpha1/link-applications";
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha"; var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
var toggle = document.getElementById("link-apply-toggle"); 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 form = document.getElementById("link-apply-form");
var img = document.getElementById("captcha-img"); var img = document.getElementById("captcha-img");
var status = document.getElementById("apply-status"); var status = document.getElementById("apply-status");
var challengeId = null; 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() { function loadCaptcha() {
fetch(CAPTCHA, { method: "POST" }) fetch(CAPTCHA, { method: "POST" })
.then(function (r) { .then(function (r) {
@@ -246,13 +482,6 @@
}); });
} }
toggle.addEventListener("click", function () {
panel.hidden = !panel.hidden;
if (!panel.hidden && !challengeId) {
loadCaptcha();
}
});
img.addEventListener("click", loadCaptcha); img.addEventListener("click", loadCaptcha);
form.addEventListener("submit", function (e) { form.addEventListener("submit", function (e) {
@@ -292,6 +521,7 @@
loadCaptcha(); loadCaptcha();
status.className = "apply-status ok"; status.className = "apply-status ok";
status.textContent = "申请已提交,等待管理员审核。"; status.textContent = "申请已提交,等待管理员审核。";
setTimeout(closeModal, 1500);
} else { } else {
return r.json().then(function (err) { return r.json().then(function (err) {
throw err; throw err;
+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-name {
color: var(--color-text);
font-weight: 600;
}
.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 {
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%;
border-radius: var(--radius-md);
}
.moment-media audio {
grid-column: 1 / -1;
width: 100%;
}
.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-name" th:text="${moment.owner.displayName}"
>用户名</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>
+64 -7
View File
@@ -26,33 +26,77 @@
.moments article header { .moments article header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
opacity: 0.75; }
.moments article header > a {
display: inline-flex;
align-items: center;
line-height: 2rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.moments article header > a:hover {
color: var(--color-link-hover);
}
.moments .moment-owner { .moments .moment-owner {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.moments .moment-owner img { .moments .moment-owner img {
width: 2rem; width: 2.25rem;
height: 2rem; height: 2.25rem;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
} }
.moments .moment-media img, .moments .moment-owner .moment-owner-name {
color: var(--color-text);
font-weight: 600;
}
.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 {
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 { .moments .moment-media video {
grid-column: 1 / -1;
display: block; display: block;
max-width: 100%; max-width: 100%;
height: auto;
margin-top: 1rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.moments .moment-media audio { .moments .moment-media audio {
grid-column: 1 / -1;
width: 100%; width: 100%;
margin-top: 1rem;
} }
</style> </style>
</template> </template>
@@ -102,13 +146,26 @@
</th:block> </th:block>
</header> </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 <div
th:if="${not #strings.isEmpty(content.html)}" th:if="${not #strings.isEmpty(content.html)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:utext="${content.html}" th:utext="${content.html}"
></div> ></div>
<div <div
class="moment-media" class="moment-media"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
th:if="${not #lists.isEmpty(content.medium)}" th:if="${not #lists.isEmpty(content.medium)}"
> >
<th:block th:each="medium : ${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}" /> <img th:src="${singlePage.spec.cover}" th:alt="${singlePage.spec.title}" />
</div> </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> </article>
<!-- 评论区 --> <!-- 评论区 -->
-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>
+10 -2
View File
@@ -211,8 +211,16 @@
<p <p
class="footer-theme" class="footer-theme"
th:if="${theme.config?.basic?.show_theme_credit != false}" th:if="${theme.config?.basic?.show_theme_credit != false}"
th:text="#{footer.theme(${theme.config?.basic?.theme_credit_text ?: '暖心向阳335'})}" >
>Theme by kehua</p> <a
class="footer-theme-link"
th:href="${theme.config?.basic?.theme_credit_url ?: 'https://git.nxxy335.top/nxxy335/halo-theme-whitespace'}"
target="_blank"
rel="noopener noreferrer"
th:text="#{footer.theme(${theme.config?.basic?.theme_credit_text ?: 'WhiteSpace'})}"
>Theme by WhiteSpace</a
>
</p>
<div <div
class="footer-beian" class="footer-beian"
th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number) or not #strings.isEmpty(theme.config?.footer?.police_number)}" th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number) or not #strings.isEmpty(theme.config?.footer?.police_number)}"
+208 -153
View File
@@ -2,188 +2,243 @@
<template name="head"> <template name="head">
<title th:text="|${title} - ${site.title}|">图库</title> <title th:text="|${title} - ${site.title}|">图库</title>
<style> <style>
:where(.photos-groups) { /* 图库页面适当加宽 */
margin: 1.5rem 0; .container {
max-width: 75rem;
} }
:where(.photos-groups__list, .photos-grid, .photos-card__tags) {
margin: 0; /* 分组导航(图库标题下方) */
padding: 0; :where(.photos-nav) {
list-style: none;
}
:where(.photos-groups__list, .photos-card__tags) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; justify-content: center;
gap: 0.6rem;
margin: 1.25rem 0 0;
} }
:where(.photos-group__link) { :where(.photos-nav button) {
display: inline-flex; padding: 0.3rem 1rem;
gap: 0.25rem; border: 1px solid var(--color-border);
align-items: baseline; border-radius: var(--radius-full);
color: var(--color-link); 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(.photos-group__link):hover { :where(.photos-nav button:hover) {
color: var(--color-link-hover); color: var(--color-primary);
border-color: var(--color-primary);
} }
:where(.photos-group.is-active .photos-group__link) { :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; font-weight: 600;
} }
:where(.photos-grid) { :where(.photos-group-title::before) {
display: grid; content: "";
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr)); width: 0.2rem;
gap: 1.25rem; height: 1.1em;
border-radius: 2px;
background: var(--color-primary);
} }
:where(.photos-card__image) { :where(.photos-group-section:first-of-type .photos-group-title) {
margin-top: 1.5rem;
}
/* 瀑布流(一行三张,图片自适应原比例) */
:where(.photos-masonry) {
list-style: none;
margin: 1rem 0 0;
padding: 0;
columns: 3;
column-gap: 1rem;
}
:where(.photos-masonry > li) {
break-inside: avoid;
margin: 0 0 1rem;
}
:where(.photos-item) {
display: block;
}
:where(.photos-item img) {
display: block; display: block;
width: 100%; width: 100%;
aspect-ratio: 4 / 3; height: auto;
object-fit: cover;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
opacity: 0;
transition: opacity 0.4s ease;
} }
:where(.photos-card__title) { :where(.photos-item img.is-loaded) {
margin: 0.75rem 0 0.25rem; opacity: 1;
font-size: 1rem;
} }
:where(.photos-card__description, .photos-card__tags) { @media (max-width: 900px) {
margin-top: 0.5rem; :where(.photos-masonry) {
columns: 2;
}
} }
:where(.photos-card__tags) { @media (max-width: 560px) {
gap: 0.5rem; :where(.photos-masonry) {
font-size: 0.875rem; columns: 1;
color: var(--color-text-secondary); }
} }
:where(.photos-pagination) {
display: flex; :where(.photos-empty) {
justify-content: center; color: var(--color-text-muted);
align-items: center; padding: 2rem 0;
gap: 1rem; text-align: center;
margin-top: 2rem;
} }
</style> </style>
</template> </template>
<div class="container"> <div class="container">
<section <section class="photos-page">
class="photos-page"
th:attr="data-template-id=${_templateId},data-group=${group},data-page=${photos.page},data-size=${photos.size},data-total=${photos.total}"
>
<header class="photos-header"> <header class="photos-header">
<h1 class="photos-header__title" th:text="${title}">图库</h1> <h1 class="photos-header__title" th:text="${title}">图库</h1>
</header> </header>
<nav class="photos-groups" aria-label="照片分组"> <th:block
<ul class="photos-groups__list"> th:with="albums=${photoFinder.groupBy()}, anyCheck=${photoFinder.list(1, 1)}, anyPhotos=${anyCheck != null ? anyCheck.items : null}"
<li >
class="photos-group" <!-- 分组导航 -->
th:classappend="${#strings.isEmpty(group)} ? 'is-active' : ''" <nav
> class="photos-nav"
<a id="photos-nav"
class="photos-group__link" aria-label="图库分组"
th:href="${photoUrl.list()}" th:if="${not #lists.isEmpty(albums)}"
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' : ''"
th:attr="data-group=${photoGroup.metadata.name}"
>
<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 && photoGroup.status.photoCount != null}"
th:text="|(${photoGroup.status.photoCount})|"
></span>
</a>
</li>
</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}"
> >
<article class="photos-card"> <button type="button" class="is-active" data-group="">全部</button>
<a class="photos-card__media" th:href="${photoUrl.detail(photo)}"> <button
<img type="button"
class="photos-card__image" th:each="album : ${albums}"
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}" th:attr="data-group=${album.metadata.name}"
th:alt="${photo.spec.displayName}" th:text="${album.spec.displayName}"
loading="lazy" >
/> 分组
</a> </button>
<div class="photos-card__content"> </nav>
<h2 class="photos-card__title">
<a <!-- 各分组照片,一次全部渲染;默认「全部」视图,前台脚本切换 -->
class="photos-card__title-link" <th:block th:if="${not #lists.isEmpty(albums)}">
th:href="${photoUrl.detail(photo)}" <section
th:text="${photo.spec.displayName}" class="photos-group-section"
>照片</a 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)}"
>
<ul
class="photos-masonry"
th:if="${not #lists.isEmpty(photos)}"
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
> >
</h2> <li th:each="photo : ${photos}">
<p <a
class="photos-card__description" class="photos-item"
th:if="${!#strings.isEmpty(photo.spec.description)}" th:href="${photoUrl.detail(photo)}"
th:text="${photo.spec.description}" th:title="${photo.spec.displayName}"
></p> >
<ul <img
class="photos-card__tags" th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
th:if="${!#lists.isEmpty(photo.spec.tags)}" th:alt="${photo.spec.displayName}"
aria-label="照片标签" loading="lazy"
> decoding="async"
<li />
class="photos-card__tag" </a>
th:each="tag : ${photo.spec.tags}" </li>
th:text="${tag}" </ul>
></li> </th:block>
</ul> </th:block>
</div> </section>
</article>
</li>
</ul>
<p
class="photos-empty"
th:if="${#lists.isEmpty(photos.items)}"
role="status"
>
暂无照片
</p>
<nav <p
class="photos-pagination" class="photos-empty"
th:if="${photos.hasPrevious() || photos.hasNext()}" th:if="${#lists.isEmpty(anyPhotos)}"
aria-label="分页" role="status"
> >
<a 暂无照片
class="photos-pagination__previous" </p>
th:if="${photos.hasPrevious()}" </th:block>
th:href="${photos.prevUrl}" </th:block>
>上一页</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>
</section> </section>
</div> </div>
<script th:inline="none">
(function () {
var nav = document.getElementById("photos-nav");
if (!nav) return;
var sections = document.querySelectorAll(".photos-group-section");
var buttons = nav.querySelectorAll("button");
// 深链接:?group=xxx(仅前台读取,不依赖服务端参数)
var initial = "";
try {
initial = new URLSearchParams(window.location.search).get("group") || "";
} catch (e) {
initial = "";
}
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"));
});
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> </include>
+5 -1
View File
@@ -67,7 +67,11 @@
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" /> <img th:src="${post.spec.cover}" th:alt="${post.spec.title}" />
</div> </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)}"> <div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
+29 -1
View File
@@ -1,6 +1,34 @@
<include src="partials/layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|#{tags.title} - ${site.title}|">Tags</title> <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:block
th:with="seoTitle=#{tags.title}, seoUrl=${site.url + site.routes.tagsUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'" 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}}" th:href="@{${tag.status.permalink}}"
class="tag" class="tag"
th:text="${tag.spec.displayName}" th:text="${tag.spec.displayName}"
th:title="${tag.postCount != null ? tag.postCount + ' ' + #{'tag.count'} : ''}" th:title="|${tag.postCount} #{tag.count}|"
></a> ></a>
</div> </div>
+1 -5
View File
@@ -14,17 +14,13 @@ spec:
issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
settingName: "whitespace-setting" settingName: "whitespace-setting"
configMapName: "whitespace-configMap" configMapName: "whitespace-configMap"
version: 0.1.0 version: 0.4.0
requires: ">=2.26.0" requires: ">=2.26.0"
license: license:
- name: "GPL-3.0" - name: "GPL-3.0"
url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE" url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE"
customTemplates: customTemplates:
page: page:
- name: 关于页面
description: 关于页面布局,展示头像、个人简介与社交链接
screenshot:
file: page_about.html
- name: 微信读书页面 - name: 微信读书页面
description: 适配 halo-weread-plugin,展示微信读书书架与读书笔记 description: 适配 halo-weread-plugin,展示微信读书书架与读书笔记
screenshot: screenshot: