feat: 插件适配页面与系统完善(v0.1.0)
- 修复 /categories、/tags 因变量为 List 误用 .items 导致的 500 - 新增 layout.html 布局契约,消除插件页面 fallback 警告 - 适配 plugin-moments / plugin-photos / plugin-links(含友链申请表单与 RSS 订阅页) - 适配 halo-weread-plugin(书架页)、留言板单页模板 - 适配 plugin-extra-api 的代码高亮与暗色样式 - 修复暗黑模式评论区不可见(补充 color-scheme 与兜底样式) - 更新元数据:作者暖心向阳335、版本 0.1.0、requires >=2.26.0
This commit is contained in:
@@ -1,76 +1,100 @@
|
||||
# Theme Vite Starter
|
||||
# Whitespace 留白
|
||||
|
||||
面向 [Halo](https://www.halo.run/) 的主题脚手架:在 `src/` 中编写模板与前端资源,经构建生成 Halo 实际读取的 `templates/` 目录。
|
||||
一个移植自 Typecho 主题 [whitespace 留白](https://kehua.me) 的 [Halo](https://www.halo.run/) 主题,主打极简留白风格与清晰的排版。
|
||||
|
||||
官方主题开发指南:<https://docs.halo.run/developer-guide/theme/prepare>
|
||||
> 主题名 `whitespace`,显示名 **Whitespace 留白**,版本 `0.1.0`,作者 **暖心向阳335**。
|
||||
> 要求 Halo ≥ 2.26.0。源码在 `src/`,构建产物在 `templates/`。
|
||||
> 源码仓库:https://git.nxxy335.top/nxxy335/halo-theme-whitespace
|
||||
|
||||
## 技术栈
|
||||
## 特性
|
||||
|
||||
| 类别 | 说明 |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时 | Halo 使用 **Thymeleaf** 渲染主题;模板变量与 Finder API 随 Halo 版本演进,请以[官方文档](https://docs.halo.run/developer-guide/theme/prepare)为准。 |
|
||||
| 构建 | Vite,同时集成 [Vite Plus](https://viteplus.dev/),集成格式化、Lint 等功能 |
|
||||
| 语言 | **TypeScript**(`tsc` 参与 `build` 脚本) |
|
||||
| 主题插件 | [`@halo-dev/vite-plugin-halo-theme`](https://www.npmjs.com/package/@halo-dev/vite-plugin-halo-theme) — 源码见 [halo-sigs/vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme) |
|
||||
| 打包发布 | [`@halo-dev/theme-package-cli`](https://github.com/halo-dev/theme-package-cli) — 将主题打成 ZIP 供控制台上传 |
|
||||
| 包管理 | **pnpm**(版本见 `package.json` 的 `packageManager`) |
|
||||
- 极简留白风格,自带浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择)
|
||||
- 首页可选展示最新一篇置顶文章(`featured` 设置)
|
||||
- 文章 / 页面 / 归档 / 分类 / 标签 / 作者 / 关于 等完整页面
|
||||
- 文章页结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在主题设置中关闭
|
||||
- 内置搜索按钮(依赖官方 `PluginSearchWidget` 插件,未安装则不显示)
|
||||
- 阅读进度条、代码块复制、相对时间、回到顶部、移动端菜单等交互
|
||||
- 多语言:`i18n/default.properties`(回退)+ `i18n/zh_CN.properties`
|
||||
- 提供 `layout.html` 布局契约,供使用布局契约的插件页面复用主题导航 / 页脚 / 暗色样式
|
||||
|
||||
## 插件适配
|
||||
|
||||
主题已为以下插件提供适配页面(在 `src/` 下以同名模板提供,构建后进入 `templates/`):
|
||||
|
||||
- **plugin-moments**:`/moments` 瞬间页
|
||||
- **plugin-photos**:`/photos` 图库页
|
||||
- **plugin-links**:`/links` 友链页;并额外提供
|
||||
- `links-apply.html`:访客友链申请表单(调用插件公共 REST API,含图形验证码)
|
||||
- `links-subscribe.html`:RSS 订阅动态聚合页(使用 `linkFeedFinder`)
|
||||
- **halo-weread-plugin**:`weread.html` 微信读书书架页(前端拉取插件 `/shelf-html` 并注入)
|
||||
- **留言板**:`page_guestbook.html` 以评论区作为留言板的单页模板
|
||||
- **plugin-extra-api**:API 扩展包(无独立页面),主题已适配其 shiki 代码高亮与暗色样式
|
||||
|
||||
> `weread` / `留言板` / `友链申请` / `链接订阅` 均为「自定义页面模板」,在控制台新建单页并选择对应模板即可启用。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- **`src/` 是源码目录**:在此维护 `.html` 页面与 `partials/` 片段、`css/`、`js/`(含 TS)等。插件在 **Vite 构建时** 处理 `<include>` / `<slot>` 等语法(与服务器上 **Thymeleaf 运行时** 互不替代,可同时使用)。
|
||||
- **`templates/` 是构建产物**:Halo 只认主题根目录下的 `templates/`(内含页面 HTML 与 `templates/assets/` 等)。**不要**把 `src/` 当作 Halo 直接读取的路径;开发或发版前需执行构建,使 `templates/` 与当前源码一致。
|
||||
|
||||
```
|
||||
.
|
||||
├── src/ # 源码:页面 HTML、partials、css、js
|
||||
├── public/ # 可选;目录内容原样复制到 templates/ 根目录
|
||||
├── templates/ # 构建生成
|
||||
├── theme.yaml # 主题元数据(必填)
|
||||
├── settings.yaml # 控制台主题设置表单(可选)
|
||||
├── vite.config.ts
|
||||
└── package.json
|
||||
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
|
||||
└── i18n/ 多语言消息
|
||||
theme.yaml 主题元数据(必填)
|
||||
settings.yaml 控制台主题设置表单
|
||||
templates/ 构建产物(Halo 实际读取,勿手动编辑)
|
||||
```
|
||||
|
||||
### `@halo-dev/vite-plugin-halo-theme`
|
||||
> `src/` 与 `templates/` 的关系:`src/` 经 Vite 构建生成 `templates/`。开发或发版前务必执行构建,使 `templates/` 与源码一致。**不要**手动修改 `templates/`。
|
||||
|
||||
1. **多页入口**:自动将 `src/` 下(除 `src/partials/` 外)的 `.html` 作为入口,输出到 `templates/` 下同名文件(例如 `src/index.html` → `templates/index.html`)。
|
||||
2. **静态资源**:`src/` 中的 CSS/JS 由 Vite 打包进 `templates/assets/`;`public/` 的目录内容会原样复制到 `templates/` 根目录(不经打包)。例如,需要生成 `templates/assets/images/logo.png` 时,应将源文件放在 `public/assets/images/logo.png`。
|
||||
3. **模板复用**:支持构建期 `<include>`、`<slot>`,减轻纯 Thymeleaf 片段的重复书写。
|
||||
4. **资源路径约定**:所有 HTML(含 `partials`)里引用静态资源时,路径按 **`src/` 根** 解析,而不是按当前文件所在子目录;说明见 [vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme)。
|
||||
|
||||
## 开发
|
||||
## 开发与构建
|
||||
|
||||
```bash
|
||||
git clone git@github.com:halo-dev/theme-vite-starter.git ~/halo2-dev/themes/theme-vite-starter
|
||||
cd ~/halo2-dev/themes/theme-vite-starter
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 监听模式构建(开发时用)
|
||||
pnpm build-only # 仅构建(tsc + vp build),生成 templates/
|
||||
pnpm build # 构建并打包为可上传的 ZIP(theme-package-cli)
|
||||
pnpm check # 格式化 + Lint 修复
|
||||
```
|
||||
|
||||
`pnpm dev` 等价于对主题执行 `vp build --watch`:修改 `src/` 后会持续重新生成 `templates/`。将主题目录链到或复制到 Halo 的 `themes/<metadata.name>/` 后,在控制台安装并启用主题即可预览。
|
||||
构建输出:
|
||||
|
||||
开发 Halo 端建议关闭 Thymeleaf 缓存(例如环境变量 `SPRING_THYMELEAF_CACHE=false` 或配置 `spring.thymeleaf.cache: false`),便于模板热更新调试。
|
||||
- 页面与片段 → `templates/*.html`、`templates/modules/*.html`、`templates/error/*.html`
|
||||
- 静态资源 → `templates/assets/*`(文件名带内容哈希,运行时以 `/themes/whitespace/assets/...` 提供)
|
||||
|
||||
## 构建与打包
|
||||
## 主题设置
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
在 Halo 控制台「主题 → 设置」中可配置:
|
||||
|
||||
该命令会执行 TypeScript 检查、`vp build` 生成 `templates/`,并调用 `theme-package` 生成可分发的 ZIP。默认会打包 `templates/`、`theme.yaml` / `settings.yaml` 等必要文件,详见 [theme-package-cli](https://github.com/halo-dev/theme-package-cli)。
|
||||
| 分组 | 字段 | 说明 |
|
||||
| ------- | ------------------ | -------------------------------------------- |
|
||||
| basic | color_scheme | 默认配色:跟随系统 / 浅色 / 深色 |
|
||||
| basic | footer_copyright | 页脚自定义版权(支持 HTML,留空不显示) |
|
||||
| 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 |
|
||||
|
||||
仅需要产物目录、不需要 ZIP 时:
|
||||
## 约定与说明
|
||||
|
||||
```bash
|
||||
pnpm build-only
|
||||
```
|
||||
- Logo / favicon / SEO description·keywords / 代码注入优先使用 Halo 系统设置;主题仅输出 canonical、OG、Twitter 与结构化数据(可在 `seo` 分组关闭,避免与插件重复)。
|
||||
- 社交分享 / 结构化数据中用 `status.permalink` 与 `site.routes.*` 拼出地址,不写死可配置路由。
|
||||
- 搜索由官方 `PluginSearchWidget` 插件提供,按钮仅在插件可用时出现。
|
||||
- 浏览量来自 Halo 原生统计(`post.stats.visit`),无需主题自行计数。
|
||||
|
||||
## 其他脚本
|
||||
## 致谢
|
||||
|
||||
| 命令 | 作用 |
|
||||
| ------------ | ---------------------------------------- |
|
||||
| `pnpm check` | `vp check --fix`(执行格式化和代码检查) |
|
||||
|
||||
## Agent Skills
|
||||
|
||||
仓库在 `.agents/skills/` 下内置了 **Halo 主题开发** 相关的 Agent Skill(例如 `halo-theme-dev`),整理了 Thymeleaf 要点、Finder API、`theme.yaml` / `settings.yaml`、vite 插件用法等参考材料。若在 Cursor 等支持 Agent Skills 的环境里开发,启用后可让 AI 更贴合 Halo 主题的约定来辅助编写与修改主题。
|
||||
- 原主题:[whitespace 留白](https://kehua.me) by kehua
|
||||
- 基于 Halo 官方 [theme-vite-starter](https://github.com/halo-dev/theme-vite-starter) 脚手架移植
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
nav.home=Home
|
||||
nav.archives=Archives
|
||||
nav.categories=Categories
|
||||
nav.tags=Tags
|
||||
search.title=Search
|
||||
theme.toggle=Toggle theme
|
||||
mobile.menu=Menu
|
||||
back.to.top=Back to top
|
||||
post.views={0} views
|
||||
post.continue=Continue reading
|
||||
pagination.previous=Previous
|
||||
pagination.next=Next
|
||||
pagination.page=Page {0} of {1}
|
||||
list.empty=Nothing found here.
|
||||
home.featured=Featured
|
||||
archive.title=Archives
|
||||
archive.stats.posts=Posts
|
||||
archive.stats.categories=Categories
|
||||
archive.stats.tags=Tags
|
||||
archive.stats.years=Years
|
||||
categories.title=Categories
|
||||
tags.title=Tags
|
||||
tag.count=posts
|
||||
category.title=Category
|
||||
tag.title=Tag
|
||||
author.title=Author
|
||||
search.keyword=Keyword
|
||||
error.404.title=Page not found
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
nav.home=首页
|
||||
nav.archives=归档
|
||||
nav.categories=分类
|
||||
nav.tags=标签
|
||||
search.title=搜索
|
||||
theme.toggle=切换主题
|
||||
mobile.menu=菜单
|
||||
back.to.top=回到顶部
|
||||
post.views={0} 次浏览
|
||||
post.continue=继续阅读
|
||||
pagination.previous=上一页
|
||||
pagination.next=下一页
|
||||
pagination.page=第 {0} 页 / 共 {1} 页
|
||||
list.empty=没有找到相关内容
|
||||
home.featured=置顶
|
||||
archive.title=归档
|
||||
archive.stats.posts=篇文章
|
||||
archive.stats.categories=个分类
|
||||
archive.stats.tags=个标签
|
||||
archive.stats.years=年
|
||||
categories.title=分类
|
||||
tags.title=标签
|
||||
tag.count=篇
|
||||
category.title=分类
|
||||
tag.title=标签
|
||||
author.title=作者
|
||||
search.keyword=关键词
|
||||
error.404.title=页面未找到
|
||||
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=邮箱
|
||||
+70
-7
@@ -1,13 +1,76 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: theme-vite-starter-setting
|
||||
name: whitespace-setting
|
||||
spec:
|
||||
forms:
|
||||
- group: styles
|
||||
label: 样式
|
||||
- group: basic
|
||||
label: 基础
|
||||
formSchema:
|
||||
- $formkit: color
|
||||
name: background_color
|
||||
label: 背景颜色
|
||||
value: "#f2f2f2"
|
||||
- $formkit: radio
|
||||
name: color_scheme
|
||||
label: 默认配色
|
||||
value: system
|
||||
help: 首次访问时使用的配色,访客手动切换后以访客选择为准
|
||||
options:
|
||||
- label: 跟随系统
|
||||
value: system
|
||||
- label: 浅色
|
||||
value: light
|
||||
- label: 深色
|
||||
value: dark
|
||||
- $formkit: textarea
|
||||
name: footer_copyright
|
||||
label: 自定义版权信息
|
||||
value: ""
|
||||
placeholder: 支持编写 HTML,留空则不显示
|
||||
help: 显示在页脚的自定义版权信息
|
||||
- group: featured
|
||||
label: 置顶文章
|
||||
formSchema:
|
||||
- $formkit: checkbox
|
||||
name: enable
|
||||
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:
|
||||
- $formkit: checkbox
|
||||
name: enable_metadata
|
||||
label: 输出 canonical 和社交分享标签
|
||||
value: true
|
||||
help: 输出 canonical、Open Graph 与 Twitter Card 标签;若已由插件提供,请关闭以避免重复
|
||||
- $formkit: checkbox
|
||||
name: enable_structured_data
|
||||
label: 输出文章结构化数据
|
||||
value: true
|
||||
help: 在文章页输出 Schema.org BlogPosting JSON-LD;若已由插件提供,请关闭
|
||||
|
||||
+71
-19
@@ -1,32 +1,84 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|Archives - ${site.title}|">Archives</title>
|
||||
<title th:text="|#{archive.title} - ${site.title}|">Archives</title>
|
||||
<th:block
|
||||
th:with="seoTitle=#{archive.title}, seoUrl=${site.url + site.routes.archivesUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<h1>Archives</h1>
|
||||
<div class="container">
|
||||
<!-- 归档标题 -->
|
||||
<div class="category-header">
|
||||
<h1 class="category-title" th:text="#{archive.title}">Archives</h1>
|
||||
</div>
|
||||
|
||||
<!-- 归档统计 -->
|
||||
<div
|
||||
class="archive-stats"
|
||||
th:with="stats = ${siteStatsFinder.getStats()}, tags = ${tagFinder.listAll()}"
|
||||
th:if="${stats != null}"
|
||||
>
|
||||
<div class="archive-stat">
|
||||
<div class="archive-stat-number" th:text="${stats.post}">0</div>
|
||||
<div class="archive-stat-label" th:text="#{archive.stats.posts}">
|
||||
Posts
|
||||
</div>
|
||||
</div>
|
||||
<div class="archive-stat">
|
||||
<div class="archive-stat-number" th:text="${stats.category}">0</div>
|
||||
<div class="archive-stat-label" th:text="#{archive.stats.categories}">
|
||||
Categories
|
||||
</div>
|
||||
</div>
|
||||
<div class="archive-stat" th:if="${tags != null}">
|
||||
<div class="archive-stat-number" th:text="${#lists.size(tags)}">0</div>
|
||||
<div class="archive-stat-label" th:text="#{archive.stats.tags}">
|
||||
Tags
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按年归档 -->
|
||||
<th:block th:each="archive : ${archives.items}">
|
||||
<div class="archive-year">
|
||||
<h2 th:text="${archive.year}"></h2>
|
||||
<h2 class="archive-year-title" th:text="${archive.year}">2024</h2>
|
||||
<ul class="archive-list">
|
||||
<th:block th:each="month : ${archive.months}">
|
||||
<h3 th:text="|${archive.year}-${month.month}|"></h3>
|
||||
<ul class="post-list">
|
||||
<li class="post-item" th:each="post : ${month.posts}">
|
||||
<h2 class="post-title">
|
||||
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
|
||||
</h2>
|
||||
<p class="post-meta">
|
||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
||||
</p>
|
||||
<li
|
||||
class="archive-item"
|
||||
th:each="post : ${month.posts}"
|
||||
th:with="postCategories = ${post.categories}"
|
||||
>
|
||||
<span
|
||||
class="archive-date"
|
||||
th:if="${post.spec.publishTime != null}"
|
||||
th:text="${#temporals.format(post.spec.publishTime, 'MM-dd')}"
|
||||
>01-01</span
|
||||
>
|
||||
<span class="archive-title">
|
||||
<a th:href="@{${post.status.permalink}}"
|
||||
th:text="${post.spec.title}">Post title</a>
|
||||
</span>
|
||||
<span
|
||||
class="archive-category"
|
||||
th:if="${not #lists.isEmpty(postCategories)}"
|
||||
>
|
||||
<a
|
||||
th:href="@{${postCategories[0].status.permalink}}"
|
||||
th:text="${postCategories[0].spec.displayName}"
|
||||
></a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</ul>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<nav class="pagination" th:if="${archives.hasPrevious() || archives.hasNext()}">
|
||||
<a th:if="${archives.hasPrevious()}" th:href="@{${archives.prevUrl}}">Previous</a>
|
||||
<span th:text="|${archives.page} / ${archives.totalPages}|"></span>
|
||||
<a th:if="${archives.hasNext()}" th:href="@{${archives.nextUrl}}">Next</a>
|
||||
</nav>
|
||||
<!-- 分页 -->
|
||||
<th:block
|
||||
th:replace="~{modules/pagination :: pagination(context = |${site.routes.archivesUri}/|, prevUrl = ${archives.prevUrl}, nextUrl = ${archives.nextUrl}, totalPages = ${archives.totalPages}, page = ${archives.page}, hasPrevious = ${archives.hasPrevious()}, hasNext = ${archives.hasNext()})}"
|
||||
></th:block>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${author.spec.displayName} - ${site.title}|">
|
||||
Author
|
||||
</title>
|
||||
<th:block
|
||||
th:with="seoTitle=${author.spec.displayName}, seoUrl=${site.url + author.status.permalink}, seoDesc=${author.spec.bio}, seoImage=${author.spec.avatar ?: site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<!-- 作者信息 -->
|
||||
<div class="category-header">
|
||||
<div class="author-header">
|
||||
<div
|
||||
class="about-avatar author-avatar"
|
||||
th:if="${not #strings.isEmpty(author.spec.avatar)}"
|
||||
>
|
||||
<img th:src="${author.spec.avatar}" th:alt="${author.spec.displayName}" />
|
||||
</div>
|
||||
<h1 class="category-title" th:text="${author.spec.displayName}">
|
||||
Author
|
||||
</h1>
|
||||
<p
|
||||
class="category-description"
|
||||
th:if="${not #strings.isEmpty(author.spec.bio)}"
|
||||
th:text="${author.spec.bio}"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
|
||||
<th:block th:each="post : ${posts.items}">
|
||||
<th:block
|
||||
th:replace="~{modules/post-card :: post-card(post=${post})}"
|
||||
></th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<th:block
|
||||
th:replace="~{modules/pagination :: pagination(context = |${author.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
|
||||
></th:block>
|
||||
</div>
|
||||
</include>
|
||||
+23
-17
@@ -1,22 +1,28 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|Categories - ${site.title}|">Categories</title>
|
||||
<title th:text="|#{categories.title} - ${site.title}|">Categories</title>
|
||||
<th:block
|
||||
th:with="seoTitle=#{categories.title}, seoUrl=${site.url + site.routes.categoriesUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<h1>Categories</h1>
|
||||
<ul class="category-list">
|
||||
<li th:each="cat : ${categories.items}">
|
||||
<a
|
||||
th:href="@{${cat.status.permalink}}"
|
||||
th:text="|${cat.spec.displayName} (${cat.postCount})|"
|
||||
class="category"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="container">
|
||||
<div class="category-header">
|
||||
<h1 class="category-title" th:text="#{categories.title}">
|
||||
Categories
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav class="pagination" th:if="${categories.hasPrevious() || categories.hasNext()}">
|
||||
<a th:if="${categories.hasPrevious()}" th:href="@{${categories.prevUrl}}">Previous</a>
|
||||
<span th:text="|${categories.page} / ${categories.totalPages}|"></span>
|
||||
<a th:if="${categories.hasNext()}" th:href="@{${categories.nextUrl}}">Next</a>
|
||||
</nav>
|
||||
<div class="category-list" th:if="${not #lists.isEmpty(categories)}">
|
||||
<th:block
|
||||
th:replace="~{modules/category-tree :: category-tree(cats=${categories})}"
|
||||
></th:block>
|
||||
</div>
|
||||
|
||||
<div class="no-results" th:if="${#lists.isEmpty(categories)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
+39
-8
@@ -1,14 +1,45 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${category.spec.displayName} - ${site.title}|">Category Archive</title>
|
||||
<title th:text="|${category.spec.displayName} - ${site.title}|">
|
||||
Category
|
||||
</title>
|
||||
<th:block
|
||||
th:with="seoTitle=${category.spec.displayName}, seoUrl=${site.url + category.status.permalink}, seoDesc=${category.spec.description}, seoImage=${category.spec.cover}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<h1 th:text="|Category: ${category.spec.displayName}|"></h1>
|
||||
<p th:if="${category.spec.description}" th:text="${category.spec.description}"></p>
|
||||
<div class="container">
|
||||
<!-- 分类标题 -->
|
||||
<div class="category-header">
|
||||
<h1 class="category-title" th:text="${category.spec.displayName}">
|
||||
Category
|
||||
</h1>
|
||||
<p
|
||||
class="category-description"
|
||||
th:if="${not #strings.isEmpty(category.spec.description)}"
|
||||
th:text="${category.spec.description}"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<ul class="post-list">
|
||||
<include src="post-card.html"></include>
|
||||
</ul>
|
||||
<!-- 文章列表 -->
|
||||
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
|
||||
<th:block th:each="post : ${posts.items}">
|
||||
<th:block
|
||||
th:replace="~{modules/post-card :: post-card(post=${post})}"
|
||||
></th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<include src="pagination.html"></include>
|
||||
<!-- 空状态 -->
|
||||
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<th:block
|
||||
th:replace="~{modules/pagination :: pagination(context = |${category.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
|
||||
></th:block>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
+1415
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|#{error.404.title} - ${site.title}|">404</title>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<!-- 404 内容 -->
|
||||
<div class="error-page">
|
||||
<div class="error-code" th:text="${error.status != null ? error.status : 404}">
|
||||
404
|
||||
</div>
|
||||
<h1 class="error-title" th:text="#{error.404.title}">Page not found</h1>
|
||||
<p class="error-message" th:text="#{error.404.message}">
|
||||
Sorry, the page you visited does not exist.
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a th:href="@{/}" class="error-btn" th:text="#{error.back.home}"
|
||||
>Back to home</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="error-btn error-btn-secondary"
|
||||
onclick="history.back()"
|
||||
th:text="#{error.back.prev}"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
@@ -0,0 +1,38 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${error.status} - ${site.title}|">Error</title>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<!-- 通用错误内容 -->
|
||||
<div class="error-page">
|
||||
<div class="error-code" th:text="${error.status != null ? error.status : 500}">
|
||||
500
|
||||
</div>
|
||||
<h1
|
||||
class="error-title"
|
||||
th:text="${not #strings.isEmpty(error.title) ? error.title : 'Error'}"
|
||||
>
|
||||
Error
|
||||
</h1>
|
||||
<p
|
||||
class="error-message"
|
||||
th:if="${not #strings.isEmpty(error.detail)}"
|
||||
th:text="${error.detail}"
|
||||
></p>
|
||||
<div class="error-actions">
|
||||
<a th:href="@{/}" class="error-btn" th:text="#{error.back.home}"
|
||||
>Back to home</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="error-btn error-btn-secondary"
|
||||
onclick="history.back()"
|
||||
th:text="#{error.back.prev}"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
+56
-7
@@ -1,12 +1,61 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="${site.title}">Home</title>
|
||||
<script type="module" src="./js/index.ts"></script>
|
||||
<title th:text="${site.title}">Site Title</title>
|
||||
<th:block
|
||||
th:with="seoTitle=${site.title}, seoUrl=${site.url}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<ul class="post-list">
|
||||
<include src="post-card.html"></include>
|
||||
</ul>
|
||||
<div class="container">
|
||||
<!-- 置顶文章:首页第一页时展示最新一篇置顶文章 -->
|
||||
<th:block
|
||||
th:if="${theme.config?.featured?.enable != null and theme.config.featured.enable and posts.first}"
|
||||
th:with="featuredList = ${postFinder.list({'pinned': true, 'size': 1})}"
|
||||
>
|
||||
<th:block
|
||||
th:if="${featuredList != null and not #lists.isEmpty(featuredList.items)}"
|
||||
th:with="featured = ${featuredList.items[0]}"
|
||||
>
|
||||
<article class="post post-featured">
|
||||
<h2 class="post-title">
|
||||
<a th:href="@{${featured.status.permalink}}"
|
||||
th:text="${featured.spec.title}">Featured post</a>
|
||||
</h2>
|
||||
<p
|
||||
class="post-excerpt"
|
||||
th:if="${not #strings.isEmpty(featured.status.excerpt)}"
|
||||
th:text="${featured.status.excerpt}"
|
||||
></p>
|
||||
<th:block
|
||||
th:replace="~{modules/post-meta :: post-meta(post=${featured}, dateClass='post-date')}"
|
||||
></th:block>
|
||||
</article>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<include src="pagination.html"></include>
|
||||
<!-- 文章列表 -->
|
||||
<div
|
||||
class="post-list"
|
||||
th:if="${not #lists.isEmpty(posts.items)}"
|
||||
th:with="postList = ${posts.items}"
|
||||
>
|
||||
<th:block th:each="post : ${postList}">
|
||||
<th:block
|
||||
th:replace="~{modules/post-card :: post-card(post=${post})}"
|
||||
></th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<th:block
|
||||
th:replace="~{modules/pagination :: pagination(context = '/', prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
|
||||
></th:block>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
console.log("Hello, Halo Theme!");
|
||||
+273
@@ -1 +1,274 @@
|
||||
import "../css/main.css";
|
||||
|
||||
/**
|
||||
* whitespace 留白主题 - 全站脚本
|
||||
* 移植自 Typecho 主题 whitespace 的 main.js
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 主题切换
|
||||
// ========================================
|
||||
|
||||
function currentTheme(): string {
|
||||
return document.documentElement.getAttribute("data-theme") || "light";
|
||||
}
|
||||
|
||||
function toggleTheme(): void {
|
||||
const newTheme = currentTheme() === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
try {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function watchSystemTheme(): void {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", (e) => {
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem("theme");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!stored) {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
e.matches ? "dark" : "light",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 搜索(plugin-search-widget)
|
||||
// ========================================
|
||||
|
||||
function initSearch(): void {
|
||||
const searchToggle = document.getElementById("search-toggle");
|
||||
if (!searchToggle) return;
|
||||
searchToggle.addEventListener("click", () => {
|
||||
const widget = (window as unknown as Record<string, unknown>).SearchWidget as
|
||||
| { open?: () => void }
|
||||
| undefined;
|
||||
if (widget && typeof widget.open === "function") {
|
||||
widget.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 移动端导航
|
||||
// ========================================
|
||||
|
||||
function initMobileNav(): void {
|
||||
const mobileMenuToggle = document.getElementById("mobile-menu-toggle");
|
||||
const mobileNav = document.getElementById("mobile-nav");
|
||||
if (!mobileMenuToggle || !mobileNav) return;
|
||||
|
||||
const menuIcon = `
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
`;
|
||||
const closeIcon = `
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
`;
|
||||
|
||||
const icon = mobileMenuToggle.querySelector(".icon");
|
||||
const closeMobileNav = () => {
|
||||
if (mobileNav.classList.contains("active")) {
|
||||
mobileNav.classList.remove("active");
|
||||
if (icon) icon.innerHTML = menuIcon;
|
||||
}
|
||||
};
|
||||
|
||||
mobileMenuToggle.addEventListener("click", () => {
|
||||
mobileNav.classList.toggle("active");
|
||||
if (icon) icon.innerHTML = mobileNav.classList.contains("active") ? closeIcon : menuIcon;
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth > 768) closeMobileNav();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as Node;
|
||||
if (!mobileNav.contains(target) && !mobileMenuToggle.contains(target)) {
|
||||
closeMobileNav();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 回到顶部
|
||||
// ========================================
|
||||
|
||||
function initBackToTop(): void {
|
||||
const backToTop = document.getElementById("back-to-top");
|
||||
if (!backToTop) return;
|
||||
|
||||
backToTop.addEventListener("click", () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY > 300) {
|
||||
backToTop.classList.add("visible");
|
||||
} else {
|
||||
backToTop.classList.remove("visible");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 相对时间
|
||||
// ========================================
|
||||
|
||||
function initRelativeTime(): void {
|
||||
const formatter = new Intl.RelativeTimeFormat(navigator.language, {
|
||||
numeric: "always",
|
||||
});
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
|
||||
document.querySelectorAll<HTMLElement>("[data-relative-time]").forEach((el) => {
|
||||
const datetime = el.getAttribute("datetime");
|
||||
if (!datetime) return;
|
||||
const time = Date.parse(datetime);
|
||||
if (Number.isNaN(time)) return;
|
||||
|
||||
const diffSeconds = Math.floor((Date.now() - time) / 1000);
|
||||
// 仅对 24 小时内的内容显示相对时间,其余保留日期显示
|
||||
if (diffSeconds < 0 || diffSeconds >= 86400) return;
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
el.textContent = formatter.format(-diffSeconds, "second");
|
||||
return;
|
||||
}
|
||||
for (const [unit, seconds] of units) {
|
||||
if (diffSeconds >= seconds) {
|
||||
el.textContent = formatter.format(-Math.floor(diffSeconds / seconds), unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 图片懒加载
|
||||
// ========================================
|
||||
|
||||
function initLazyImages(): void {
|
||||
const images = document.querySelectorAll('img[loading="lazy"]');
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
|
||||
const imageObserver = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.classList.add("loaded");
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "50px 0px" },
|
||||
);
|
||||
|
||||
images.forEach((img) => imageObserver.observe(img));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 平滑滚动
|
||||
// ========================================
|
||||
|
||||
function initSmoothScroll(): void {
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((anchor) => {
|
||||
anchor.addEventListener("click", (e) => {
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href === "#") return;
|
||||
const target = document.querySelector(href);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
const header = document.querySelector(".header");
|
||||
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
||||
const targetPosition =
|
||||
target.getBoundingClientRect().top + window.scrollY - headerHeight - 20;
|
||||
window.scrollTo({ top: targetPosition, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 外部链接处理
|
||||
// ========================================
|
||||
|
||||
function initExternalLinks(): void {
|
||||
document.querySelectorAll<HTMLAnchorElement>(".article-content a").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
if (
|
||||
href &&
|
||||
href.startsWith("http") &&
|
||||
!href.includes(window.location.hostname)
|
||||
) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 键盘快捷键
|
||||
// ========================================
|
||||
|
||||
function initKeyboardShortcuts(): void {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const searchToggle = document.getElementById("search-toggle");
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k" && searchToggle) {
|
||||
e.preventDefault();
|
||||
searchToggle.click();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "d") {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 初始化
|
||||
// ========================================
|
||||
|
||||
function init(): void {
|
||||
const themeToggle = document.getElementById("theme-toggle");
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener("click", toggleTheme);
|
||||
}
|
||||
watchSystemTheme();
|
||||
initSearch();
|
||||
initMobileNav();
|
||||
initBackToTop();
|
||||
initRelativeTime();
|
||||
initLazyImages();
|
||||
initSmoothScroll();
|
||||
initExternalLinks();
|
||||
initKeyboardShortcuts();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
+82
-1
@@ -1 +1,82 @@
|
||||
console.log("Hello, Halo Theme Post!");
|
||||
/**
|
||||
* whitespace 留白主题 - 文章页脚本
|
||||
* 代码块复制 + 阅读进度条
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 代码块复制
|
||||
// ========================================
|
||||
|
||||
function initCodeCopy(): void {
|
||||
const codeBlocks = document.querySelectorAll<HTMLElement>(
|
||||
".article-content pre",
|
||||
);
|
||||
|
||||
codeBlocks.forEach((pre) => {
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "code-copy-btn";
|
||||
copyBtn.type = "button";
|
||||
copyBtn.textContent = "复制";
|
||||
|
||||
pre.style.position = "relative";
|
||||
pre.appendChild(copyBtn);
|
||||
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const code = pre.querySelector("code");
|
||||
const text = code ? (code.textContent ?? "") : (pre.textContent ?? "");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyBtn.textContent = "已复制";
|
||||
copyBtn.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = "复制";
|
||||
copyBtn.classList.remove("copied");
|
||||
}, 2000);
|
||||
} catch {
|
||||
console.error("复制失败");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 阅读进度条
|
||||
// ========================================
|
||||
|
||||
function initReadingProgress(): void {
|
||||
const article = document.querySelector<HTMLElement>(".article-content");
|
||||
if (!article) return;
|
||||
|
||||
const progressBar = document.createElement("div");
|
||||
progressBar.className = "reading-progress";
|
||||
document.body.appendChild(progressBar);
|
||||
|
||||
const updateProgress = () => {
|
||||
const articleTop = article.offsetTop;
|
||||
const articleHeight = article.offsetHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
const start = articleTop - windowHeight;
|
||||
const end = articleTop + articleHeight;
|
||||
const current = scrollY;
|
||||
|
||||
let progress = ((current - start) / (end - start)) * 100;
|
||||
progress = Math.max(0, Math.min(100, progress));
|
||||
progressBar.style.width = `${progress}%`;
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", updateProgress, { passive: true });
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
initCodeCopy();
|
||||
initReadingProgress();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="zh-CN"
|
||||
xmlns:th="https://www.thymeleaf.org"
|
||||
th:lang="${#locale.toLanguageTag}"
|
||||
th:fragment="html (head, content)"
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title th:if="${head == null}" th:text="${site.title}">Site Title</title>
|
||||
<script th:inline="javascript">
|
||||
(function () {
|
||||
var preferred = /*[[${theme.config?.basic?.color_scheme ?: 'system'}]]*/ "system";
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem("theme");
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
var resolved = stored || preferred;
|
||||
if (resolved === "system") {
|
||||
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/css/main.css" />
|
||||
<th:block th:if="${head != null}" th:replace="${head}" />
|
||||
<script type="module" src="/js/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 头部导航 -->
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a th:href="@{/}" class="logo">
|
||||
<img
|
||||
th:if="${not #strings.isEmpty(site.logo)}"
|
||||
th:src="${site.logo}"
|
||||
th:alt="${site.title}"
|
||||
class="logo-img"
|
||||
/>
|
||||
<span
|
||||
th:if="${#strings.isEmpty(site.logo)}"
|
||||
class="logo-text"
|
||||
th:text="${site.title}"
|
||||
>Site Title</span
|
||||
>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
|
||||
<a
|
||||
th:if="${primaryMenu == null}"
|
||||
class="nav-link"
|
||||
th:href="@{/}"
|
||||
th:text="#{nav.home}"
|
||||
>Home</a
|
||||
>
|
||||
<th:block th:if="${primaryMenu != null}">
|
||||
<a
|
||||
th:each="item : ${primaryMenu.menuItems}"
|
||||
th:href="@{${item.status.href}}"
|
||||
th:text="${item.status.displayName}"
|
||||
th:target="${item.spec.target?.value}"
|
||||
class="nav-link"
|
||||
></a>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-icon"
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
th:title="#{theme.toggle}"
|
||||
>
|
||||
<svg
|
||||
class="icon icon-sun"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg
|
||||
class="icon icon-moon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
th:if="${pluginFinder.available('PluginSearchWidget')}"
|
||||
class="btn-icon"
|
||||
id="search-toggle"
|
||||
type="button"
|
||||
th:title="#{search.title}"
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon mobile-menu-toggle"
|
||||
id="mobile-menu-toggle"
|
||||
type="button"
|
||||
th:title="#{mobile.menu}"
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 移动端导航菜单 -->
|
||||
<div class="mobile-nav" id="mobile-nav">
|
||||
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
|
||||
<a
|
||||
th:if="${primaryMenu == null}"
|
||||
class="mobile-nav-link"
|
||||
th:href="@{/}"
|
||||
th:text="#{nav.home}"
|
||||
>Home</a
|
||||
>
|
||||
<th:block th:if="${primaryMenu != null}">
|
||||
<a
|
||||
th:each="item : ${primaryMenu.menuItems}"
|
||||
th:href="@{${item.status.href}}"
|
||||
th:text="${item.status.displayName}"
|
||||
th:target="${item.spec.target?.value}"
|
||||
class="mobile-nav-link"
|
||||
></a>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<th:block th:replace="${content}" />
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div
|
||||
class="footer-copyright"
|
||||
th:if="${not #strings.isEmpty(theme.config?.basic?.footer_copyright)}"
|
||||
th:utext="${theme.config.basic.footer_copyright}"
|
||||
></div>
|
||||
<p class="footer-theme">
|
||||
Theme by
|
||||
<a href="https://kehua.me" target="_blank" rel="noopener">kehua</a>
|
||||
</p>
|
||||
<halo:footer />
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 回到顶部 -->
|
||||
<button
|
||||
class="back-to-top"
|
||||
id="back-to-top"
|
||||
type="button"
|
||||
th:title="#{back.to.top}"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,201 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title
|
||||
th:text="|${singlePage.spec.title} - ${site.title}|"
|
||||
th:if="${singlePage != null}"
|
||||
>
|
||||
申请友链
|
||||
</title>
|
||||
<style>
|
||||
.apply-form {
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.apply-form .form-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.apply-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.apply-form input,
|
||||
.apply-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
.apply-form textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.apply-captcha {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.apply-captcha img {
|
||||
height: 2.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.apply-status {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
display: none;
|
||||
}
|
||||
.apply-status.ok {
|
||||
display: block;
|
||||
background: rgba(46, 160, 67, 0.12);
|
||||
color: #2ea043;
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
}
|
||||
.apply-status.err {
|
||||
display: block;
|
||||
background: rgba(229, 57, 53, 0.12);
|
||||
color: #e53935;
|
||||
border: 1px solid rgba(229, 57, 53, 0.4);
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section class="apply-form">
|
||||
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">申请友链</h1>
|
||||
<p
|
||||
class="apply-intro"
|
||||
th:if="${singlePage.spec.description != null}"
|
||||
th:utext="${singlePage.spec.description}"
|
||||
></p>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-name">网站名称 *</label>
|
||||
<input id="f-name" name="displayName" type="text" required placeholder="我的站点" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-logo">Logo 地址</label>
|
||||
<input id="f-logo" name="logo" type="url" placeholder="https://example.com/logo.png" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-desc">网站描述</label>
|
||||
<input id="f-desc" name="description" type="text" placeholder="一句话介绍你的站点" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-email">联系邮箱</label>
|
||||
<input id="f-email" name="email" type="email" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-backlink">反链页面</label>
|
||||
<input id="f-backlink" name="backlink" type="url" placeholder="含本站链接的页面地址" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-feeds">RSS / Atom 地址(每行一个)</label>
|
||||
<textarea id="f-feeds" name="feedUrls" placeholder="https://example.com/feed.xml"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-captcha">验证码 *</label>
|
||||
<div class="apply-captcha">
|
||||
<img id="captcha-img" alt="验证码" title="点击刷新" />
|
||||
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">提交申请</button>
|
||||
</form>
|
||||
|
||||
<div class="apply-status" id="apply-status" role="alert"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script th:inline="none">
|
||||
(function () {
|
||||
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
|
||||
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
|
||||
var form = document.getElementById("link-apply-form");
|
||||
var img = document.getElementById("captcha-img");
|
||||
var status = document.getElementById("apply-status");
|
||||
var challengeId = null;
|
||||
|
||||
function loadCaptcha() {
|
||||
fetch(CAPTCHA, { method: "POST" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("captcha");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
challengeId = data.challengeId;
|
||||
img.src = data.image;
|
||||
})
|
||||
.catch(function () {
|
||||
status.className = "apply-status err";
|
||||
status.textContent = "验证码加载失败,请刷新页面重试。";
|
||||
});
|
||||
}
|
||||
|
||||
img.addEventListener("click", loadCaptcha);
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
status.className = "apply-status";
|
||||
status.textContent = "";
|
||||
if (!challengeId) {
|
||||
status.className = "apply-status err";
|
||||
status.textContent = "请等待验证码加载完成。";
|
||||
return;
|
||||
}
|
||||
var fd = new FormData(form);
|
||||
var feeds = (fd.get("feedUrls") || "")
|
||||
.split(/\r?\n/)
|
||||
.map(function (s) { return s.trim(); })
|
||||
.filter(Boolean);
|
||||
var payload = {
|
||||
url: fd.get("url"),
|
||||
displayName: fd.get("displayName"),
|
||||
logo: fd.get("logo") || undefined,
|
||||
description: fd.get("description") || undefined,
|
||||
email: fd.get("email") || undefined,
|
||||
backlink: fd.get("backlink") || undefined,
|
||||
feedUrls: feeds,
|
||||
challengeId: challengeId,
|
||||
captchaCode: fd.get("captchaCode")
|
||||
};
|
||||
fetch(API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 201) {
|
||||
form.reset();
|
||||
challengeId = null;
|
||||
loadCaptcha();
|
||||
status.className = "apply-status ok";
|
||||
status.textContent = "申请已提交,等待管理员审核。";
|
||||
} else {
|
||||
return r.json().then(function (err) {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
loadCaptcha();
|
||||
status.className = "apply-status err";
|
||||
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
|
||||
});
|
||||
});
|
||||
|
||||
loadCaptcha();
|
||||
})();
|
||||
</script>
|
||||
</include>
|
||||
@@ -0,0 +1,95 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title
|
||||
th:text="|${singlePage.spec.title} - ${site.title}|"
|
||||
th:if="${singlePage != null}"
|
||||
>
|
||||
链接订阅
|
||||
</title>
|
||||
<style>
|
||||
.feed-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.feed-item {
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.feed-item-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feed-item-title a {
|
||||
color: var(--color-link);
|
||||
}
|
||||
.feed-item-meta {
|
||||
margin: 0.35rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.feed-item-summary {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section class="feed">
|
||||
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">链接订阅</h1>
|
||||
<p
|
||||
class="feed-intro"
|
||||
th:if="${singlePage.spec.description != null}"
|
||||
th:utext="${singlePage.spec.description}"
|
||||
></p>
|
||||
|
||||
<div
|
||||
th:if="${linkFeedFinder != null}"
|
||||
th:with="feedPage=${linkFeedFinder.list({limit: 30})}"
|
||||
>
|
||||
<ul th:if="${not #lists.isEmpty(feedPage.items)}" class="feed-list">
|
||||
<li class="feed-item" th:each="item : ${feedPage.items}">
|
||||
<div class="feed-item-title">
|
||||
<a th:href="@{${item.url}}" target="_blank" rel="noopener noreferrer">
|
||||
<span th:text="${item.title}">标题</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="feed-item-meta">
|
||||
<span th:text="${item.linkName}">来源</span>
|
||||
<span th:if="${item.author != null and item.author != ''}">
|
||||
· <span th:text="${item.author}">作者</span></span
|
||||
>
|
||||
<span
|
||||
th:if="${item.publishedAt != null}"
|
||||
th:with="pt=${item.publishedAt}"
|
||||
>
|
||||
· <time
|
||||
th:datetime="${pt}"
|
||||
th:text="${#temporals.format(pt, 'yyyy-MM-dd HH:mm')}"
|
||||
>时间</time
|
||||
></span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="feed-item-summary"
|
||||
th:if="${not #strings.isEmpty(item.summary)}"
|
||||
th:utext="${item.summary}"
|
||||
></p>
|
||||
</li>
|
||||
</ul>
|
||||
<p th:if="${#lists.isEmpty(feedPage.items)}" class="no-results">
|
||||
暂无订阅动态。请在 plugin-links 设置中开启「公开 RSS 订阅动态」。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="no-results"
|
||||
th:if="${linkFeedFinder == null}"
|
||||
>
|
||||
未检测到 plugin-links,无法加载订阅动态。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</include>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${linksTitle} - ${site.title}|">链接</title>
|
||||
<style>
|
||||
.links-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.links-nav a {
|
||||
color: var(--color-link);
|
||||
}
|
||||
.links-nav a:hover,
|
||||
.links-nav a.active {
|
||||
color: var(--color-link-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
.links-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.links-list li {
|
||||
margin: 0;
|
||||
}
|
||||
.links-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.links-list a:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.links-list img {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.links-list .links-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.links-list .links-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section class="links">
|
||||
<h1 th:text="${linksTitle}">链接</h1>
|
||||
|
||||
<nav class="links-nav" aria-label="链接分组">
|
||||
<a th:href="@{/links}" th:classappend="${group == null} ? 'active'">全部</a>
|
||||
<a
|
||||
th:each="item : ${simpleGroups}"
|
||||
th:href="@{/links(group=${item.metadata.name})}"
|
||||
th:classappend="${group == item.metadata.name} ? 'active'"
|
||||
th:text="${item.spec.displayName}"
|
||||
></a>
|
||||
</nav>
|
||||
|
||||
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
|
||||
<li th:each="link : ${links}">
|
||||
<a th:href="@{${link.spec.url}}" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||||
th:src="@{${link.spec.logo}}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span>
|
||||
<span class="links-name" th:text="${link.spec.displayName}"
|
||||
>链接名称</span
|
||||
>
|
||||
<p
|
||||
class="links-desc"
|
||||
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||||
th:text="${link.spec.description}"
|
||||
></p>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p th:unless="${not #lists.isEmpty(links)}">暂无链接</p>
|
||||
</section>
|
||||
|
||||
<div class="comments-section" th:if="${haloCommentEnabled}">
|
||||
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
@@ -0,0 +1,22 @@
|
||||
<th:block th:fragment="category-tree(cats)">
|
||||
<ul class="category-tree">
|
||||
<li class="category-tree-item" th:each="cat : ${cats}">
|
||||
<a th:href="@{${cat.status.permalink}}" class="category-tree-link">
|
||||
<span class="category-tree-name" th:text="${cat.spec.displayName}">Category</span>
|
||||
<span
|
||||
class="category-tree-count"
|
||||
th:text="${cat.postCount}"
|
||||
th:if="${cat.postCount != null and cat.postCount > 0}"
|
||||
>0</span
|
||||
>
|
||||
</a>
|
||||
<th:block
|
||||
th:if="${cat.children != null and !#lists.isEmpty(cat.children)}"
|
||||
>
|
||||
<th:block
|
||||
th:replace="~{modules/category-tree :: category-tree(cats=${cat.children})}"
|
||||
></th:block>
|
||||
</th:block>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
分页导航。
|
||||
context 为当前列表的路由前缀(如 '/'、'/archives/'、分类或标签 permalink + '/'),
|
||||
页码链接由 context + 'page/' + n 构造,不硬编码可配置路由。
|
||||
-->
|
||||
<nav
|
||||
class="pagination"
|
||||
th:fragment="pagination(context, prevUrl, nextUrl, totalPages, page, hasPrevious, hasNext)"
|
||||
th:if="${hasPrevious || hasNext}"
|
||||
>
|
||||
<a
|
||||
th:if="${hasPrevious}"
|
||||
th:href="@{${prevUrl}}"
|
||||
class="pagination-item pagination-prev"
|
||||
>
|
||||
<span>←</span>
|
||||
<span th:text="#{pagination.previous}">Previous</span>
|
||||
</a>
|
||||
<span class="pagination-pages">
|
||||
<a
|
||||
th:each="i : ${#numbers.sequence(1, totalPages)}"
|
||||
th:href="@{${i == 1 ? context : (context + 'page/' + i)}}"
|
||||
th:text="${i}"
|
||||
class="pagination-item"
|
||||
th:classappend="${i == page} ? 'active'"
|
||||
>1</a
|
||||
>
|
||||
</span>
|
||||
<a
|
||||
th:if="${hasNext}"
|
||||
th:href="@{${nextUrl}}"
|
||||
class="pagination-item pagination-next"
|
||||
>
|
||||
<span th:text="#{pagination.next}">Next</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- 文章列表卡片 -->
|
||||
<article
|
||||
class="post"
|
||||
th:fragment="post-card(post)"
|
||||
th:classappend="${not #strings.isEmpty(post.spec.cover)} ? 'post-with-thumb'"
|
||||
>
|
||||
<div class="post-thumb" th:if="${not #strings.isEmpty(post.spec.cover)}">
|
||||
<a th:href="@{${post.status.permalink}}">
|
||||
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" loading="lazy" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"
|
||||
>Post title</a
|
||||
>
|
||||
</h2>
|
||||
<p
|
||||
class="post-excerpt"
|
||||
th:if="${not #strings.isEmpty(post.status.excerpt)}"
|
||||
th:text="${post.status.excerpt}"
|
||||
></p>
|
||||
<th:block th:replace="~{modules/post-meta :: post-meta(post=${post}, dateClass='post-date')}"></th:block>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- 文章元信息(时间 / 分类 / 浏览量),供列表卡片与详情页复用 -->
|
||||
<div class="post-meta" th:fragment="post-meta(post, dateClass)">
|
||||
<time
|
||||
th:if="${post.spec.publishTime != null}"
|
||||
th:class="${dateClass}"
|
||||
th:datetime="${post.spec.publishTime}"
|
||||
th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"
|
||||
data-relative-time
|
||||
></time>
|
||||
<th:block th:if="${not #lists.isEmpty(post.categories)}">
|
||||
<span class="post-meta-sep">·</span>
|
||||
<span class="post-category">
|
||||
<a
|
||||
th:each="cat : ${post.categories}"
|
||||
th:href="@{${cat.status.permalink}}"
|
||||
th:text="${cat.spec.displayName}"
|
||||
></a>
|
||||
</span>
|
||||
</th:block>
|
||||
<th:block th:if="${post.stats != null and post.stats.visit != null and post.stats.visit > 0}">
|
||||
<span class="post-meta-sep">·</span>
|
||||
<span class="post-views" th:text="#{post.views(${post.stats.visit})}"
|
||||
>1 views</span
|
||||
>
|
||||
</th:block>
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${title} - ${site.title}|">瞬间</title>
|
||||
<style>
|
||||
.moments > nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.moments > nav a {
|
||||
color: var(--color-link);
|
||||
}
|
||||
.moments > nav a:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
.moments > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.moments article {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.moments article header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.75;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.moments .moment-owner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.moments .moment-owner img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.moments .moment-media img,
|
||||
.moments .moment-media video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-top: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.moments .moment-media audio {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section class="moments">
|
||||
<h1 th:text="${title}">瞬间</h1>
|
||||
|
||||
<nav th:if="${not #lists.isEmpty(tags)}" aria-label="瞬间标签">
|
||||
<a th:href="@{/moments}">全部</a>
|
||||
<a
|
||||
th:each="tag : ${tags}"
|
||||
th:href="@{${tag.permalink}}"
|
||||
th:text="|${tag.name} (${tag.momentCount})|"
|
||||
></a>
|
||||
</nav>
|
||||
|
||||
<ul th:if="${not #lists.isEmpty(moments.items)}">
|
||||
<li th:each="moment : ${moments.items}">
|
||||
<article 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}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span th:text="${moment.owner.displayName}">用户名</span>
|
||||
</span>
|
||||
<a th:href="@{/moments/{name}(name=${moment.metadata.name})}">
|
||||
<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
|
||||
>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div
|
||||
th:if="${not #strings.isEmpty(content.html)}"
|
||||
th:utext="${content.html}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="moment-media"
|
||||
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>
|
||||
</li>
|
||||
</ul>
|
||||
<p th:if="${#lists.isEmpty(moments.items)}">暂无瞬间</p>
|
||||
|
||||
<nav
|
||||
th:if="${moments.hasPrevious() or moments.hasNext()}"
|
||||
aria-label="瞬间分页"
|
||||
>
|
||||
<a th:if="${moments.hasPrevious()}" th:href="@{${moments.prevUrl}}">上一页</a>
|
||||
<span th:text="${moments.page}">1</span>
|
||||
<a th:if="${moments.hasNext()}" th:href="@{${moments.nextUrl}}">下一页</a>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
</include>
|
||||
+26
-5
@@ -1,20 +1,41 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${singlePage.spec.title} - ${site.title}|">Page Title</title>
|
||||
<title th:text="|${singlePage.spec.title} - ${site.title}|">
|
||||
Page Title
|
||||
</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>
|
||||
|
||||
<article>
|
||||
<div class="container">
|
||||
<!-- 默认页面样式 -->
|
||||
<article class="article-detail">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title" th:text="${singlePage.spec.title}"></h1>
|
||||
<h1 class="article-title" th:text="${singlePage.spec.title}">
|
||||
Page title
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="article-cover"
|
||||
th:if="${not #strings.isEmpty(singlePage.spec.cover)}"
|
||||
>
|
||||
<img th:src="${singlePage.spec.cover}" th:alt="${singlePage.spec.title}" />
|
||||
</div>
|
||||
|
||||
<div class="article-content" th:utext="${singlePage.content.content}"></div>
|
||||
</article>
|
||||
|
||||
<div th:if="${haloCommentEnabled}">
|
||||
<!-- 评论区 -->
|
||||
<div th:if="${haloCommentEnabled}" class="comments-section">
|
||||
<halo:comment
|
||||
group="content.halo.run"
|
||||
kind="SinglePage"
|
||||
th:attr="name=${singlePage.metadata.name}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title
|
||||
th:text="|${singlePage.spec.title} - ${site.title}|"
|
||||
th:if="${singlePage != null}"
|
||||
>
|
||||
留言板
|
||||
</title>
|
||||
<style>
|
||||
.guestbook-intro {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.guestbook-comments {
|
||||
margin-top: var(--spacing-2xl);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--spacing-xl);
|
||||
}
|
||||
.guestbook-comments h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<article class="page-content guestbook-page">
|
||||
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">留言板</h1>
|
||||
|
||||
<div
|
||||
class="article-content guestbook-intro"
|
||||
th:if="${singlePage.status.excerpt != null}"
|
||||
th:utext="${singlePage.status.excerpt}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="article-content"
|
||||
th:if="${singlePage.content != null}"
|
||||
th:utext="${singlePage.content.content}"
|
||||
></div>
|
||||
|
||||
<section class="guestbook-comments" th:if="${haloCommentEnabled}">
|
||||
<h2>留言</h2>
|
||||
<halo:comment
|
||||
group="content.halo.run"
|
||||
kind="SinglePage"
|
||||
th:attr="name=${singlePage.metadata.name}"
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</include>
|
||||
+172
-15
@@ -1,42 +1,199 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<html
|
||||
lang="zh-CN"
|
||||
xmlns:th="https://www.thymeleaf.org"
|
||||
th:lang="${#locale.toLanguageTag}"
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<slot name="head">
|
||||
<title th:text="${site.title}">Site Title</title>
|
||||
</slot>
|
||||
<script type="module" src="./js/main.ts"></script>
|
||||
<script th:inline="javascript">
|
||||
(function () {
|
||||
var preferred = /*[[${theme.config?.basic?.color_scheme ?: 'system'}]]*/ "system";
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem("theme");
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
var resolved = stored || preferred;
|
||||
if (resolved === "system") {
|
||||
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/js/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<a class="site-title" th:href="@{/}" th:text="${site.title}">Site Title</a>
|
||||
<nav class="site-nav" th:with="menu = ${menuFinder.getPrimary()}">
|
||||
<ul>
|
||||
<li th:each="item : ${menu.menuItems}">
|
||||
<!-- 头部导航 -->
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a th:href="@{/}" class="logo">
|
||||
<img
|
||||
th:if="${not #strings.isEmpty(site.logo)}"
|
||||
th:src="${site.logo}"
|
||||
th:alt="${site.title}"
|
||||
class="logo-img"
|
||||
/>
|
||||
<span
|
||||
th:if="${#strings.isEmpty(site.logo)}"
|
||||
class="logo-text"
|
||||
th:text="${site.title}"
|
||||
>Site Title</span
|
||||
>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
|
||||
<a
|
||||
th:if="${primaryMenu == null}"
|
||||
class="nav-link"
|
||||
th:href="@{/}"
|
||||
th:text="#{nav.home}"
|
||||
>Home</a
|
||||
>
|
||||
<th:block th:if="${primaryMenu != null}">
|
||||
<a
|
||||
th:each="item : ${primaryMenu.menuItems}"
|
||||
th:href="@{${item.status.href}}"
|
||||
th:text="${item.status.displayName}"
|
||||
th:target="${item.spec.target?.value}"
|
||||
class="nav-link"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-icon"
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
th:title="#{theme.toggle}"
|
||||
>
|
||||
<svg
|
||||
class="icon icon-sun"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg
|
||||
class="icon icon-moon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
th:if="${pluginFinder.available('PluginSearchWidget')}"
|
||||
class="btn-icon"
|
||||
id="search-toggle"
|
||||
type="button"
|
||||
th:title="#{search.title}"
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon mobile-menu-toggle"
|
||||
id="mobile-menu-toggle"
|
||||
type="button"
|
||||
th:title="#{mobile.menu}"
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="site-main">
|
||||
<div class="container">
|
||||
<slot />
|
||||
<!-- 移动端导航菜单 -->
|
||||
<div class="mobile-nav" id="mobile-nav">
|
||||
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
|
||||
<a
|
||||
th:if="${primaryMenu == null}"
|
||||
class="mobile-nav-link"
|
||||
th:href="@{/}"
|
||||
th:text="#{nav.home}"
|
||||
>Home</a
|
||||
>
|
||||
<th:block th:if="${primaryMenu != null}">
|
||||
<a
|
||||
th:each="item : ${primaryMenu.menuItems}"
|
||||
th:href="@{${item.status.href}}"
|
||||
th:text="${item.status.displayName}"
|
||||
th:target="${item.spec.target?.value}"
|
||||
class="mobile-nav-link"
|
||||
></a>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
|
||||
<div
|
||||
class="footer-copyright"
|
||||
th:if="${not #strings.isEmpty(theme.config?.basic?.footer_copyright)}"
|
||||
th:utext="${theme.config.basic.footer_copyright}"
|
||||
></div>
|
||||
<p class="footer-theme">
|
||||
Theme by
|
||||
<a href="https://kehua.me" target="_blank" rel="noopener">kehua</a>
|
||||
</p>
|
||||
<halo:footer />
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 回到顶部 -->
|
||||
<button
|
||||
class="back-to-top"
|
||||
id="back-to-top"
|
||||
type="button"
|
||||
th:title="#{back.to.top}"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!--
|
||||
公共 SEO 片段:输出 canonical、Open Graph 与 Twitter Card。
|
||||
由各页面通过 th:with 提供 seoTitle / seoUrl / seoDesc / seoImage / seoType 变量。
|
||||
description / keywords 由 Halo 自动注入,此处不重复输出。
|
||||
-->
|
||||
<th:block th:fragment="seo" th:if="${theme.config?.seo?.enable_metadata}">
|
||||
<link rel="canonical" th:href="@{${seoUrl}}" />
|
||||
<meta property="og:site_name" th:content="${site.title}" />
|
||||
<meta
|
||||
property="og:type"
|
||||
th:content="${seoType != null ? seoType : 'website'}"
|
||||
/>
|
||||
<meta property="og:title" th:content="${seoTitle}" />
|
||||
<meta property="og:url" th:content="${seoUrl}" />
|
||||
<meta
|
||||
th:if="${seoDesc != null and not #strings.isEmpty(seoDesc)}"
|
||||
property="og:description"
|
||||
th:content="${seoDesc}"
|
||||
/>
|
||||
<meta
|
||||
th:if="${seoImage != null and not #strings.isEmpty(seoImage)}"
|
||||
property="og:image"
|
||||
th:content="${seoImage}"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
th:content="${seoImage != null and not #strings.isEmpty(seoImage)} ? 'summary_large_image' : 'summary'"
|
||||
/>
|
||||
<meta name="twitter:title" th:content="${seoTitle}" />
|
||||
<meta
|
||||
th:if="${seoDesc != null and not #strings.isEmpty(seoDesc)}"
|
||||
name="twitter:description"
|
||||
th:content="${seoDesc}"
|
||||
/>
|
||||
<meta
|
||||
th:if="${seoImage != null and not #strings.isEmpty(seoImage)}"
|
||||
name="twitter:image"
|
||||
th:content="${seoImage}"
|
||||
/>
|
||||
</th:block>
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${title} - ${site.title}|">图库</title>
|
||||
<style>
|
||||
: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) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
: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(.photos-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
:where(.photos-card__image) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
:where(.photos-card__title) {
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
:where(.photos-card__description, .photos-card__tags) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
:where(.photos-card__tags) {
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
:where(.photos-pagination) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section
|
||||
class="photos-page"
|
||||
th:attr="data-template-id=${_templateId},data-group=${group},data-page=${photos.page},data-size=${photos.size},data-total=${photos.total}"
|
||||
>
|
||||
<header class="photos-header">
|
||||
<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' : ''"
|
||||
>
|
||||
<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' : ''"
|
||||
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">
|
||||
<a class="photos-card__media" th:href="${photoUrl.detail(photo)}">
|
||||
<img
|
||||
class="photos-card__image"
|
||||
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
|
||||
th:alt="${photo.spec.displayName}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div class="photos-card__content">
|
||||
<h2 class="photos-card__title">
|
||||
<a
|
||||
class="photos-card__title-link"
|
||||
th:href="${photoUrl.detail(photo)}"
|
||||
th:text="${photo.spec.displayName}"
|
||||
>照片</a
|
||||
>
|
||||
</h2>
|
||||
<p
|
||||
class="photos-card__description"
|
||||
th:if="${!#strings.isEmpty(photo.spec.description)}"
|
||||
th:text="${photo.spec.description}"
|
||||
></p>
|
||||
<ul
|
||||
class="photos-card__tags"
|
||||
th:if="${!#lists.isEmpty(photo.spec.tags)}"
|
||||
aria-label="照片标签"
|
||||
>
|
||||
<li
|
||||
class="photos-card__tag"
|
||||
th:each="tag : ${photo.spec.tags}"
|
||||
th:text="${tag}"
|
||||
></li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="photos-empty"
|
||||
th:if="${#lists.isEmpty(photos.items)}"
|
||||
role="status"
|
||||
>
|
||||
暂无照片
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</include>
|
||||
+73
-18
@@ -1,56 +1,111 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${post.spec.title} - ${site.title}|">Post Title</title>
|
||||
<script type="module" src="./js/post.ts"></script>
|
||||
<th:block
|
||||
th:with="seoTitle=${post.spec.title}, seoUrl=${site.url + post.status.permalink}, seoDesc=${post.status.excerpt}, seoImage=${post.spec.cover}, seoType='article'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
<th:block th:if="${theme.config?.seo?.enable_structured_data}">
|
||||
<script type="application/ld+json" th:inline="javascript">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": /*[[${post.spec.title}]]*/ "",
|
||||
"url": /*[[${site.url + post.status.permalink}]]*/ "",
|
||||
"description": /*[[${post.status.excerpt}]]*/ "",
|
||||
"image": /*[[${post.spec.cover}]]*/ "",
|
||||
"datePublished": /*[[${post.spec.publishTime != null ? #temporals.format(post.spec.publishTime, "yyyy-MM-dd'T'HH:mm:ssXXX") : null}]]*/ "",
|
||||
"dateModified": /*[[${post.status.lastModifyTime != null ? #temporals.format(post.status.lastModifyTime, "yyyy-MM-dd'T'HH:mm:ssXXX") : null}]]*/ "",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": /*[[${post.owner?.displayName}]]*/ ""
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</th:block>
|
||||
<script type="module" src="/js/post.ts"></script>
|
||||
</template>
|
||||
|
||||
<article>
|
||||
<div class="container">
|
||||
<!-- 文章内容 -->
|
||||
<article class="article-detail">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title" th:text="${post.spec.title}"></h1>
|
||||
<h1 class="article-title" th:text="${post.spec.title}">Post title</h1>
|
||||
<div class="article-meta">
|
||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
||||
<span th:if="${not #lists.isEmpty(post.categories)}">
|
||||
·
|
||||
<time
|
||||
th:if="${post.spec.publishTime != null}"
|
||||
class="article-date"
|
||||
th:datetime="${post.spec.publishTime}"
|
||||
th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"
|
||||
data-relative-time
|
||||
></time>
|
||||
<th:block th:if="${not #lists.isEmpty(post.categories)}">
|
||||
<span class="article-meta-sep">·</span>
|
||||
<span class="article-category">
|
||||
<a
|
||||
th:each="cat : ${post.categories}"
|
||||
th:href="@{${cat.status.permalink}}"
|
||||
th:text="${cat.spec.displayName}"
|
||||
class="category"
|
||||
></a>
|
||||
</span>
|
||||
</th:block>
|
||||
<th:block
|
||||
th:if="${post.stats != null and post.stats.visit != null and post.stats.visit > 0}"
|
||||
>
|
||||
<span class="article-meta-sep">·</span>
|
||||
<span
|
||||
class="article-views"
|
||||
th:text="#{post.views(${post.stats.visit})}"
|
||||
>1 views</span
|
||||
>
|
||||
</th:block>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-cover" th:if="${not #strings.isEmpty(post.spec.cover)}">
|
||||
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" />
|
||||
</div>
|
||||
|
||||
<div class="article-content" th:utext="${post.content.content}"></div>
|
||||
|
||||
<!-- 文章标签 -->
|
||||
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
|
||||
<span class="tag-label" th:text="#{tag.title}">Tags</span>
|
||||
<a
|
||||
th:each="tag : ${post.tags}"
|
||||
th:href="@{${tag.status.permalink}}"
|
||||
th:text="${tag.spec.displayName}"
|
||||
class="tag"
|
||||
></a>
|
||||
</div>
|
||||
|
||||
<nav class="post-nav" th:with="cursor = ${postFinder.cursor(post.metadata.name)}">
|
||||
<span>
|
||||
<!-- 文章导航 -->
|
||||
<nav
|
||||
class="article-nav"
|
||||
th:with="cursor = ${postFinder.cursor(post.metadata.name)}"
|
||||
>
|
||||
<a
|
||||
th:if="${cursor.hasPrevious()}"
|
||||
class="article-nav-item article-nav-prev"
|
||||
th:href="@{${cursor.previous.status.permalink}}"
|
||||
th:text="|← ${cursor.previous.spec.title}|"
|
||||
th:text="${cursor.previous.spec.title}"
|
||||
></a>
|
||||
</span>
|
||||
<span>
|
||||
<a
|
||||
th:if="${cursor.hasNext()}"
|
||||
class="article-nav-item article-nav-next"
|
||||
th:href="@{${cursor.next.status.permalink}}"
|
||||
th:text="|${cursor.next.spec.title} →|"
|
||||
th:text="${cursor.next.spec.title}"
|
||||
></a>
|
||||
</span>
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<div th:if="${haloCommentEnabled}">
|
||||
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
|
||||
<!-- 评论区 -->
|
||||
<div th:if="${haloCommentEnabled}" class="comments-section">
|
||||
<halo:comment
|
||||
group="content.halo.run"
|
||||
kind="Post"
|
||||
th:attr="name=${post.metadata.name}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
+30
-7
@@ -1,13 +1,36 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|${tag.spec.displayName} - ${site.title}|">Tag Archive</title>
|
||||
<title th:text="|${tag.spec.displayName} - ${site.title}|">Tag</title>
|
||||
<th:block
|
||||
th:with="seoTitle=${tag.spec.displayName}, seoUrl=${site.url + tag.status.permalink}, seoDesc=null, seoImage=${tag.spec.cover ?: site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<h1 th:text="|Tag: ${tag.spec.displayName}|"></h1>
|
||||
<div class="container">
|
||||
<!-- 标签标题 -->
|
||||
<div class="category-header">
|
||||
<h1 class="category-title" th:text="${tag.spec.displayName}">Tag</h1>
|
||||
</div>
|
||||
|
||||
<ul class="post-list">
|
||||
<include src="post-card.html"></include>
|
||||
</ul>
|
||||
<!-- 文章列表 -->
|
||||
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
|
||||
<th:block th:each="post : ${posts.items}">
|
||||
<th:block
|
||||
th:replace="~{modules/post-card :: post-card(post=${post})}"
|
||||
></th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<include src="pagination.html"></include>
|
||||
<!-- 空状态 -->
|
||||
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<th:block
|
||||
th:replace="~{modules/pagination :: pagination(context = |${tag.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
|
||||
></th:block>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
+25
-13
@@ -1,18 +1,30 @@
|
||||
<include src="layout.html">
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title th:text="|Tags - ${site.title}|">Tags</title>
|
||||
<title th:text="|#{tags.title} - ${site.title}|">Tags</title>
|
||||
<th:block
|
||||
th:with="seoTitle=#{tags.title}, seoUrl=${site.url + site.routes.tagsUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
|
||||
>
|
||||
<include src="seo.html"></include>
|
||||
</th:block>
|
||||
</template>
|
||||
|
||||
<h1>Tags</h1>
|
||||
<ul class="tag-list">
|
||||
<li th:each="tag : ${tags.items}">
|
||||
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}" class="tag"></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="container">
|
||||
<div class="category-header">
|
||||
<h1 class="category-title" th:text="#{tags.title}">Tags</h1>
|
||||
</div>
|
||||
|
||||
<nav class="pagination" th:if="${tags.hasPrevious() || tags.hasNext()}">
|
||||
<a th:if="${tags.hasPrevious()}" th:href="@{${tags.prevUrl}}">Previous</a>
|
||||
<span th:text="|${tags.page} / ${tags.totalPages}|"></span>
|
||||
<a th:if="${tags.hasNext()}" th:href="@{${tags.nextUrl}}">Next</a>
|
||||
</nav>
|
||||
<div class="sidebar-tags tag-cloud" th:if="${not #lists.isEmpty(tags)}">
|
||||
<a
|
||||
th:each="tag : ${tags}"
|
||||
th:href="@{${tag.status.permalink}}"
|
||||
class="tag"
|
||||
th:text="${tag.spec.displayName}"
|
||||
th:title="${tag.postCount != null ? tag.postCount + ' ' + #{'tag.count'} : ''}"
|
||||
></a>
|
||||
</div>
|
||||
|
||||
<div class="no-results" th:if="${#lists.isEmpty(tags)}">
|
||||
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</include>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<include src="partials/layout.html">
|
||||
<template name="head">
|
||||
<title
|
||||
th:text="|${singlePage.spec.title} - ${site.title}|"
|
||||
th:if="${singlePage != null}"
|
||||
>
|
||||
微信读书
|
||||
</title>
|
||||
<style>
|
||||
.weread-mount {
|
||||
margin: 2rem auto;
|
||||
min-height: 12rem;
|
||||
}
|
||||
.weread-loading {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 3rem 0;
|
||||
}
|
||||
.weread-error {
|
||||
text-align: center;
|
||||
color: var(--color-primary);
|
||||
padding: 3rem 0;
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<section class="weread-page">
|
||||
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">微信读书</h1>
|
||||
<p
|
||||
class="weread-intro"
|
||||
th:if="${singlePage.spec.description != null}"
|
||||
th:utext="${singlePage.spec.description}"
|
||||
></p>
|
||||
|
||||
<div class="weread-mount" id="wr-mount">
|
||||
<p class="weread-loading">正在加载书架…</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script th:inline="none">
|
||||
(function () {
|
||||
var mount = document.getElementById("wr-mount");
|
||||
var BASE = "/halo-weread-plugin/shelf-html";
|
||||
fetch(BASE, { headers: { Accept: "text/html" } })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("shelf " + r.status);
|
||||
return r.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
mount.innerHTML = html;
|
||||
// 重新执行被注入的脚本(innerHTML 不会自动执行脚本)
|
||||
mount.querySelectorAll("script").forEach(function (s) {
|
||||
var el = document.createElement("script");
|
||||
el.textContent = s.textContent;
|
||||
document.body.appendChild(el);
|
||||
el.remove();
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
mount.innerHTML =
|
||||
'<p class="weread-error">书架加载失败,请确认已安装并启用 halo-weread-plugin 插件。</p>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</include>
|
||||
+35
-13
@@ -1,21 +1,43 @@
|
||||
apiVersion: theme.halo.run/v1alpha1
|
||||
kind: Theme
|
||||
metadata:
|
||||
name: theme-vite-starter
|
||||
name: whitespace
|
||||
spec:
|
||||
displayName: Vite Starter
|
||||
displayName: Whitespace 留白
|
||||
author:
|
||||
name: Halo
|
||||
website: https://github.com/halo-dev
|
||||
description: A development starter theme with Vite Ecosystem for Halo
|
||||
logo: https://www.halo.run/logo
|
||||
homepage: https://github.com/halo-dev/theme-vite-starter#readme
|
||||
repo: https://github.com/halo-dev/theme-vite-starter
|
||||
issues: https://github.com/halo-dev/theme-vite-starter/issues
|
||||
settingName: "theme-vite-starter-setting"
|
||||
configMapName: "theme-vite-starter-configMap"
|
||||
version: 1.0.0
|
||||
requires: ">=2.0.0"
|
||||
name: 暖心向阳335
|
||||
website: https://git.nxxy335.top/nxxy335
|
||||
description: 一款极简单栏博客主题,移植自 Typecho 主题 whitespace 留白,支持亮暗双主题、搜索与丰富的归档能力,并适配 moments/photos/links/weread 等插件。
|
||||
logo: ""
|
||||
homepage: https://git.nxxy335.top/nxxy335/halo-theme-whitespace
|
||||
repo: https://git.nxxy335.top/nxxy335/halo-theme-whitespace.git
|
||||
issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
|
||||
settingName: "whitespace-setting"
|
||||
configMapName: "whitespace-configMap"
|
||||
version: 0.1.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:
|
||||
file: weread.html
|
||||
- name: 留言板页面
|
||||
description: 以评论区作为留言板的单页模板
|
||||
screenshot:
|
||||
file: page_guestbook.html
|
||||
- name: 友链申请页面
|
||||
description: 适配 plugin-links 的访客友链申请表单
|
||||
screenshot:
|
||||
file: links-apply.html
|
||||
- name: 链接订阅页面
|
||||
description: 适配 plugin-links 的 RSS 订阅动态聚合页
|
||||
screenshot:
|
||||
file: links-subscribe.html
|
||||
|
||||
Reference in New Issue
Block a user