Compare commits
16
Commits
7876f9b224
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad34cf233 | ||
|
|
5e50c01c18 | ||
|
|
0e87b3011e | ||
|
|
0eb5d8e06b | ||
|
|
4e3001674a | ||
|
|
c0ea6ec0df | ||
|
|
db368f885e | ||
|
|
6b8d9ee338 | ||
|
|
8e6dccbd09 | ||
|
|
2ed0c768a4 | ||
|
|
8ab98b9e35 | ||
|
|
bd372d22b3 | ||
|
|
f1997b6734 | ||
|
|
938a59b89b | ||
|
|
ff92133a71 | ||
|
|
7ee2cca430 |
@@ -1,91 +1,85 @@
|
|||||||
---
|
---
|
||||||
name: halo-theme-dev
|
name: halo-theme-dev
|
||||||
description: >
|
description: >
|
||||||
Use when creating or modifying a Halo CMS theme: writing Thymeleaf templates,
|
Create or modify Halo CMS themes, including Thymeleaf templates, theme.yaml and
|
||||||
configuring theme.yaml or settings.yaml, calling Finder APIs, using
|
settings.yaml, Finder APIs, static assets, Vite integration, theme UI providers,
|
||||||
vite-plugin-halo-theme, defining theme settings forms, referencing static assets,
|
page-layout integration, annotations, i18n, and error pages. Use for Halo site
|
||||||
implementing halo:comment or halo:footer extension points, or defining model
|
theme implementation, migration, debugging, or packaging; do not use for
|
||||||
annotation fields (AnnotationSetting).
|
unrelated Thymeleaf applications or plugin-only changes.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Halo Theme Development
|
# Halo Theme Development
|
||||||
|
|
||||||
Halo is built on **Spring Boot + Spring WebFlux + Thymeleaf**. Themes use Thymeleaf templates for frontend page rendering.
|
Work from the target theme and fetch current Halo documentation only when the
|
||||||
|
task needs a Halo-specific contract. Do not rely on Finder signatures, template
|
||||||
|
variables, or configuration fields recalled from training data.
|
||||||
|
|
||||||
> **Important**: Halo's APIs, VO field names, and template variables evolve across versions. **Do not rely on training data for specific field names, method signatures, or type structures.** When writing code that accesses template variables or calls Finder API methods, always fetch the relevant online doc from the References section below first.
|
## Establish the Target
|
||||||
|
|
||||||
## Thymeleaf Quick Reference
|
- Inspect `theme.yaml`, `settings.yaml`, the package manifest and build config,
|
||||||
|
and representative templates before editing.
|
||||||
|
- Determine `theme.yaml` `spec.requires` and the actual Halo version. Do not
|
||||||
|
silently raise the requirement or replace the theme's build system.
|
||||||
|
- Identify whether runtime templates are authored directly in `templates/` or
|
||||||
|
generated from a source directory such as `src/`.
|
||||||
|
|
||||||
Full docs: https://raw.githubusercontent.com/thymeleaf/thymeleaf-docs/refs/heads/master/docs/tutorials/3.1/usingthymeleaf.md
|
## Fetch Documentation on Demand
|
||||||
|
|
||||||
Core syntax cheatsheet:
|
1. Start with the [theme documentation index](https://docs.halo.run/developer-guide/theme/index.md)
|
||||||
|
when the task involves Halo theme configuration, templates, Finder APIs,
|
||||||
|
global variables, assets, annotations, i18n, packaging, or plugin integration.
|
||||||
|
2. Use the complete [Halo documentation index](https://docs.halo.run/llms.txt)
|
||||||
|
only when the route is unclear or the task crosses documentation sections.
|
||||||
|
3. Fetch only the Markdown pages relevant to the current task. Do not load
|
||||||
|
`llms-full.txt` by default.
|
||||||
|
4. For version-sensitive work, also read the [theme API changelog](https://docs.halo.run/developer-guide/theme/api-changelog.md),
|
||||||
|
then verify exact fields and signatures against the target version or existing
|
||||||
|
templates. The target version wins over current-site docs.
|
||||||
|
|
||||||
```html
|
If online docs are unavailable, continue from the target checkout and installed
|
||||||
<!-- Output text -->
|
dependencies when possible, and disclose what could not be verified instead of
|
||||||
<h1 th:text="${site.title}"></h1>
|
guessing.
|
||||||
|
|
||||||
<!-- Output unescaped HTML -->
|
## Halo-Specific Boundaries
|
||||||
<div th:utext="${post.content.content}"></div>
|
|
||||||
|
|
||||||
<!-- Links -->
|
- Keep the theme directory name aligned with `theme.yaml` `metadata.name`.
|
||||||
<a th:href="@{${post.status.permalink}}">Post link</a>
|
- In a build-tool theme, edit source templates and assets, then run the existing
|
||||||
<link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
|
build; do not hand-edit generated `templates/` output or hashed assets.
|
||||||
|
- Read the current [Thymeleaf guide](https://docs.halo.run/developer-guide/theme/thymeleaf.md)
|
||||||
<!-- Loop -->
|
before writing expressions. Halo 2.26 uses Thymeleaf 3.1, where `#request`,
|
||||||
<li th:each="post : ${posts.items}" th:text="${post.spec.title}"></li>
|
`#response`, `#session`, and `#servletContext` are unavailable. Do not guess
|
||||||
|
expression methods or derive page URLs from request objects; use documented
|
||||||
<!-- Conditionals -->
|
template variables and permalinks.
|
||||||
<div th:if="${posts.hasNext()}">Next page</div>
|
- Treat site identity and global injection as Halo-owned by default. Use
|
||||||
<div th:unless="${posts.hasNext()}">Last page</div>
|
`site.logo`, system SEO settings, Halo's head processing, and `<halo:footer />`;
|
||||||
|
add a theme setting only for a theme-specific override with a system fallback.
|
||||||
<!-- Local variable -->
|
Check the current [settings guide](https://docs.halo.run/developer-guide/theme/settings.md)
|
||||||
<div th:with="menu = ${menuFinder.getPrimary()}">...</div>
|
before adding fields to `settings.yaml`.
|
||||||
|
- Do not duplicate Halo-injected description or keywords. Canonical, Open Graph,
|
||||||
<!-- Fragment include -->
|
Twitter Card, and structured data are optional theme/plugin capabilities and
|
||||||
<div th:replace="~{fragments/header :: header}"></div>
|
must follow the current [SEO guide](https://docs.halo.run/developer-guide/theme/seo.md)
|
||||||
|
with conflict-avoidance switches.
|
||||||
<!-- Layout reuse: pages pass fragments into a parameterized layout -->
|
- Preserve the project's established Thymeleaf layout and asset conventions.
|
||||||
<html th:replace="~{layout :: html(head = null, content = ~{::content})}">
|
Check the relevant docs before using a Finder method, template variable, Halo
|
||||||
<th:block th:fragment="content"><!-- page body --></th:block>
|
utility, custom tag, or page-layout contract. When integrating official frontend
|
||||||
</html>
|
widgets, follow the shared color-scheme contract in the current
|
||||||
|
[plugin integration guide](https://docs.halo.run/developer-guide/theme/plugin-integration.md).
|
||||||
<!-- Inline JavaScript -->
|
- Use the starter assets below only when creating a new theme. Never replace an
|
||||||
<script th:inline="javascript">
|
existing theme with a starter.
|
||||||
var url = '[(${#theme.assets("/dist/main.iife.js")})]';
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
1. Create a theme folder under `themes/` in the Halo working directory (must match `metadata.name` in `theme.yaml`)
|
|
||||||
2. Write `theme.yaml` (required) and `settings.yaml` (optional)
|
|
||||||
3. Create template files under `templates/`
|
|
||||||
4. Install and activate the theme in Console → Theme Management
|
|
||||||
5. Visit the frontend to verify
|
|
||||||
|
|
||||||
Disable Thymeleaf caching during development: set env var `SPRING_THYMELEAF_CACHE=false` (Docker), or `spring.thymeleaf.cache: false` in config (source mode).
|
|
||||||
|
|
||||||
## Starter Templates
|
## Starter Templates
|
||||||
|
|
||||||
The `assets/` directory provides two ready-to-use theme templates:
|
The `assets/` directory provides two ready-to-use theme templates:
|
||||||
|
|
||||||
- **`assets/theme-minimal/`** — Zero-build-tool minimal theme with all 8 template files; ideal for quick prototyping or simple themes
|
- **`assets/theme-minimal/`** — Zero-build-tool minimal theme with the standard route templates; ideal for quick prototyping or simple themes
|
||||||
- **`assets/theme-vite/`** — Vite project template with `vite-plugin-halo-theme` (**recommended for new themes**); includes partial layout reuse and CSS toolchain
|
- **`assets/theme-vite/`** — Vite project template with `vite-plugin-halo-theme` (**recommended for new themes**); includes partial layout reuse and CSS toolchain
|
||||||
|
|
||||||
Usage: copy the directory into `themes/` in your Halo working directory, ensure the folder name matches `metadata.name` in `theme.yaml`, then install and activate in Console.
|
Usage: copy the directory into `themes/` in your Halo working directory, ensure the folder name matches `metadata.name` in `theme.yaml`, then install and activate in Console.
|
||||||
|
|
||||||
## References Index
|
## Verify
|
||||||
|
|
||||||
| File | Content | When to read |
|
Run the theme's existing build and package checks when applicable. Test every
|
||||||
| ---- | ------- | ------------ |
|
changed template type or integration path on a compatible Halo instance; at
|
||||||
| [references/structure-and-config.md](references/structure-and-config.md) | Directory structure, theme.yaml fields, settings.yaml form definition | Creating a theme, configuring theme.yaml/settings.yaml |
|
minimum cover the affected route plus one shared-layout consumer. Check rendered
|
||||||
| [references/vite-plugin.md](references/vite-plugin.md) | vite-plugin-halo-theme integration guide, include/slot template syntax, TailwindCSS integration | Setting up a Vite-based theme (recommended) |
|
HTML, asset loading, pagination or empty states when relevant, and browser console
|
||||||
| [references/templates.md](references/templates.md) | Template route mapping, available variables per template | Writing template files |
|
errors.
|
||||||
| [references/global-variables.md](references/global-variables.md) | Global variables (site, theme, theme.config) and type definitions | Accessing site info or theme setting values |
|
|
||||||
| [references/finder-apis.md](references/finder-apis.md) | All Finder APIs (postFinder, categoryFinder, tagFinder, menuFinder, singlePageFinder, etc.) | Querying data from any template |
|
|
||||||
| [references/static-resources.md](references/static-resources.md) | Static asset reference methods (`@{}`, `#theme.assets()`) | Referencing CSS/JS/images in plain HTML themes |
|
|
||||||
| [references/template-tags.md](references/template-tags.md) | Custom tags (halo:comment extension point, halo:footer injection) | Integrating comment plugins, injecting footer code |
|
|
||||||
| [references/official-plugins.md](references/official-plugins.md) | Official plugin integration: pluginFinder.available(), search widget, dark mode color scheme adaptation | Adding search, adapting dark mode for plugin UI |
|
|
||||||
| [references/annotations.md](references/annotations.md) | AnnotationSetting for model custom fields, `#annotations` utility for reading metadata in templates | Adding custom fields to menu items/posts/categories and using them in templates |
|
|
||||||
| [references/packaging.md](references/packaging.md) | Packaging a theme as a ZIP using `@halo-dev/theme-package-cli` | Preparing a theme for release or upload |
|
|
||||||
| [references/thymeleaf-tips.md](references/thymeleaf-tips.md) | Halo-specific Thymeleaf best practices: literal substitutions, safe navigation, meta tag rules, permalink syntax | Writing any template file |
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Halo Theme Development"
|
|
||||||
short_description: "Build Halo CMS themes with Thymeleaf"
|
|
||||||
default_prompt: "Help me create a Halo theme"
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
templates
|
||||||
|
dist
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@halo-dev/theme-package-cli": "^1.0.1",
|
"@halo-dev/theme-package-cli": "^1.0.1",
|
||||||
"@halo-dev/vite-plugin-halo-theme": "latest",
|
"@halo-dev/vite-plugin-halo-theme": "1.0.3",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.0"
|
"packageManager": "pnpm@10.33.0"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<include src="layout.html">
|
||||||
|
<template name="head">
|
||||||
|
<title th:text="|${author.spec.displayName} - ${site.title}|">Author</title>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1 th:text="${author.spec.displayName}"></h1>
|
||||||
|
<p th:if="${not #strings.isEmpty(author.spec.bio)}" th:text="${author.spec.bio}"></p>
|
||||||
|
|
||||||
|
<ul class="post-list">
|
||||||
|
<include src="post-card.html"></include>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<include src="pagination.html"></include>
|
||||||
|
</section>
|
||||||
|
</include>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
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>
|
||||||
|
<link rel="stylesheet" href="./css/main.css" />
|
||||||
|
<th:block th:if="${head != null}">
|
||||||
|
<th:block th:replace="${head}" />
|
||||||
|
</th:block>
|
||||||
|
<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}">
|
||||||
|
<a
|
||||||
|
th:href="@{${item.status.href}}"
|
||||||
|
th:text="${item.status.displayName}"
|
||||||
|
th:target="${item.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="site-main">
|
||||||
|
<div class="container">
|
||||||
|
<th:block th:replace="${content}" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
|
||||||
|
<halo:footer />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:lang="${#locale.toLanguageTag}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
<h1>Tags</h1>
|
<h1>Tags</h1>
|
||||||
<ul class="tag-list">
|
<ul class="tag-list">
|
||||||
<li th:each="tag : ${tags.items}">
|
<li th:each="tag : ${tags.items}">
|
||||||
<a
|
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}" class="tag"></a>
|
||||||
th:href="@{${tag.status.permalink}}"
|
|
||||||
th:text="${tag.spec.displayName}"
|
|
||||||
class="tag"
|
|
||||||
></a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "halo-theme-dev",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "Create a corporate website theme for Halo 2.26. The theme needs settings for its color scheme, homepage sections, and footer styling. The requester also wants site logo, favicon, global head code, and footer code fields in the theme settings. Build the smallest runnable theme and explain which settings belong to Halo instead of the theme.",
|
||||||
|
"expected_output": "A runnable theme that reuses Halo's system-managed site identity and code injection while defining settings only for theme-specific appearance and layout.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"settings.yaml does not duplicate the site logo, favicon, or general head and footer code injection",
|
||||||
|
"Templates use system variables such as site.logo and site.favicon and preserve halo:footer",
|
||||||
|
"The theme directory name, theme.yaml metadata.name, and minimum Halo version are consistent",
|
||||||
|
"Theme settings contain only theme-specific color, layout, and component options"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Add canonical, Open Graph, and Article JSON-LD metadata to post.html in a Halo 2.26 theme. The page needs the current post's absolute URL. The current draft uses #request.getRequestURI() and builds JSON with string concatenation. Correct it and provide the template.",
|
||||||
|
"expected_output": "SEO metadata generated from Halo page variables and safe Thymeleaf JavaScript inlining without Servlet request objects or manual JSON escaping.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"The template does not use #request, #response, #session, #servletContext, or #ctx.exchange",
|
||||||
|
"The post URL is built from documented variables such as site.url and post.status.permalink",
|
||||||
|
"JSON-LD uses th:inline=javascript serialization instead of manually concatenating quoted strings",
|
||||||
|
"The template does not duplicate Halo-injected description or keywords and allows optional SEO output to be disabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Modify an existing vite-plugin-halo-theme project whose source files live in src/ and whose templates/ directory and hashed assets are generated by the build. Add the layout.html html(head, content) contract for plugin-rendered frontend pages and ensure every page has the correct lang and title.",
|
||||||
|
"expected_output": "Only source templates are edited, existing build tooling generates the runtime output, the layout contract produces one title, and the language follows the current locale.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Only source templates under src are edited; generated templates, hashed assets, and lockfiles are not hand-edited",
|
||||||
|
"layout.html declares html(head, content) and preserves halo:footer",
|
||||||
|
"A non-null head does not produce both a default title and a caller-provided title",
|
||||||
|
"The html lang value follows the current locale instead of being fixed to en",
|
||||||
|
"The project's existing build or verification command is run after the change"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Model Metadata (Annotations)
|
|
||||||
|
|
||||||
Themes can extend built-in Halo models with custom fields via `AnnotationSetting` resources (e.g. adding an icon to menu items, or a download URL to posts), then read those values in templates via the `#annotations` utility.
|
|
||||||
|
|
||||||
## Defining a Metadata Form (AnnotationSetting)
|
|
||||||
|
|
||||||
Create a file (any name) in the theme root, e.g. `annotation-setting.yaml`, and declare an `AnnotationSetting` resource:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1alpha1
|
|
||||||
kind: AnnotationSetting
|
|
||||||
metadata:
|
|
||||||
name: theme-foo-menuitem-abc123 # recommended: add theme prefix + random suffix to avoid conflicts
|
|
||||||
spec:
|
|
||||||
targetRef:
|
|
||||||
group: ""
|
|
||||||
kind: MenuItem
|
|
||||||
formSchema:
|
|
||||||
- $formkit: text
|
|
||||||
name: icon
|
|
||||||
label: Menu icon class
|
|
||||||
value: ""
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple models can be declared in the same file separated by `---`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1alpha1
|
|
||||||
kind: AnnotationSetting
|
|
||||||
metadata:
|
|
||||||
name: theme-foo-post-abc123
|
|
||||||
spec:
|
|
||||||
targetRef:
|
|
||||||
group: content.halo.run
|
|
||||||
kind: Post
|
|
||||||
formSchema:
|
|
||||||
- $formkit: text
|
|
||||||
name: download_url
|
|
||||||
label: Download URL
|
|
||||||
value: ""
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1alpha1
|
|
||||||
kind: AnnotationSetting
|
|
||||||
metadata:
|
|
||||||
name: theme-foo-menuitem-abc123
|
|
||||||
spec:
|
|
||||||
targetRef:
|
|
||||||
group: ""
|
|
||||||
kind: MenuItem
|
|
||||||
formSchema:
|
|
||||||
- $formkit: text
|
|
||||||
name: icon
|
|
||||||
label: Icon
|
|
||||||
value: ""
|
|
||||||
```
|
|
||||||
|
|
||||||
### Supported Models
|
|
||||||
|
|
||||||
| Model | `group` | `kind` |
|
|
||||||
| ----- | ------- | ------ |
|
|
||||||
| Post | `content.halo.run` | `Post` |
|
|
||||||
| Single page | `content.halo.run` | `SinglePage` |
|
|
||||||
| Post category | `content.halo.run` | `Category` |
|
|
||||||
| Post tag | `content.halo.run` | `Tag` |
|
|
||||||
| Menu item | `""` | `MenuItem` |
|
|
||||||
| User | `""` | `User` |
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- All values in `metadata.annotations` are **strings**, so form values must also be strings.
|
|
||||||
- Do not use components with non-string output such as `number`, `group`, or `repeater`.
|
|
||||||
- For `checkbox`, explicitly set `on-value` / `off-value` to string values (e.g. `"true"` / `"false"`).
|
|
||||||
- Use a theme-name prefix plus a random suffix for `metadata.name` to avoid conflicts with other themes/plugins, e.g. `theme-earth-post-wanfs5`.
|
|
||||||
|
|
||||||
## Reading Metadata in Templates
|
|
||||||
|
|
||||||
Halo provides a `#annotations` utility object in Thymeleaf with three methods:
|
|
||||||
|
|
||||||
### `#annotations.get(object, key)` — Get a value
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div th:with="menu = ${menuFinder.getPrimary()}">
|
|
||||||
<li th:each="item : ${menu.menuItems}">
|
|
||||||
<i th:class="${#annotations.get(item, 'icon')}"></i>
|
|
||||||
<a th:href="${item.status.href}" th:text="${item.status.displayName}"></a>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `#annotations.getOrDefault(object, key, defaultValue)` — Get a value with fallback
|
|
||||||
|
|
||||||
```html
|
|
||||||
<i th:class="${#annotations.getOrDefault(menuItem, 'icon', 'fa fa-link')}"></i>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `#annotations.contains(object, key)` — Check if a key exists
|
|
||||||
|
|
||||||
```html
|
|
||||||
<i
|
|
||||||
th:if="${#annotations.contains(menuItem, 'icon')}"
|
|
||||||
th:class="${#annotations.get(menuItem, 'icon')}"
|
|
||||||
></i>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Online Docs
|
|
||||||
|
|
||||||
> **`AnnotationSetting` spec and supported models may change across Halo versions. Fetch the relevant doc if unsure about supported `group`/`kind` combinations or form schema constraints.**
|
|
||||||
|
|
||||||
- Using metadata in templates: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/annotations.md
|
|
||||||
- Defining annotation forms: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/annotations-form.md
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Finder API
|
|
||||||
|
|
||||||
Finder APIs query data from **any template location** regardless of the current route — ideal for sidebars, footers, and other global data needs.
|
|
||||||
|
|
||||||
## Available Finders
|
|
||||||
|
|
||||||
| Finder | Purpose |
|
|
||||||
| ------ | ------- |
|
|
||||||
| `postFinder` | Post list / detail / prev-next / archives |
|
|
||||||
| `categoryFinder` | Category list / tree structure / breadcrumbs |
|
|
||||||
| `tagFinder` | Tag list / detail |
|
|
||||||
| `menuFinder` | Menus and menu items |
|
|
||||||
| `singlePageFinder` | Single page list / detail |
|
|
||||||
| `commentFinder` | Comments and replies |
|
|
||||||
| `contributorFinder` | Contributors |
|
|
||||||
| `siteStatsFinder` | Site statistics |
|
|
||||||
| `themeFinder` | Theme information |
|
|
||||||
| `pluginFinder` | Plugin information |
|
|
||||||
|
|
||||||
## Key Usage Pattern
|
|
||||||
|
|
||||||
Use `th:with` to bind the result in the current scope:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div th:with="menu = ${menuFinder.getPrimary()}">
|
|
||||||
<a
|
|
||||||
th:each="item : ${menu.menuItems}"
|
|
||||||
th:href="${item.status.href}"
|
|
||||||
th:text="${item.status.displayName}"
|
|
||||||
></a>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Notes
|
|
||||||
|
|
||||||
- `postFinder.list({...})` is the recommended unified query method (all parameters are optional); it supersedes the deprecated `list(page, size)`, `listByCategory(...)`, etc.
|
|
||||||
- `metadata.name` is the unique resource identifier — it is not the display name (`displayName`/`title`).
|
|
||||||
- Pair `settings.yaml` `categorySelect`/`tagSelect` inputs with Finder queries so users can configure query parameters in Console instead of hard-coding them in templates.
|
|
||||||
|
|
||||||
## Online Docs
|
|
||||||
|
|
||||||
> **Do not rely on training data for Finder API method signatures — Halo evolves across versions and your training data may be outdated or incomplete. Always fetch the relevant doc before writing code that calls a specific Finder method.**
|
|
||||||
|
|
||||||
- postFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/post.md
|
|
||||||
- categoryFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/category.md
|
|
||||||
- tagFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/tag.md
|
|
||||||
- menuFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/menu.md
|
|
||||||
- singlePageFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/single-page.md
|
|
||||||
- commentFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/comment.md
|
|
||||||
- contributorFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/contributor.md
|
|
||||||
- siteStatsFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/site-stats.md
|
|
||||||
- themeFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/theme.md
|
|
||||||
- pluginFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/finder-for-theme.md
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# Global Variables
|
|
||||||
|
|
||||||
The following variables are available in all templates without any additional declaration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `site` — Site information
|
|
||||||
|
|
||||||
Source: Console → System Settings.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Site Title",
|
|
||||||
"subtitle": "Site Subtitle",
|
|
||||||
"url": "https://example.com",
|
|
||||||
"logo": "https://example.com/logo.png",
|
|
||||||
"favicon": "https://example.com/favicon.ico",
|
|
||||||
"allowRegistration": false,
|
|
||||||
"post": {
|
|
||||||
"postPageSize": 10,
|
|
||||||
"archivePageSize": 10,
|
|
||||||
"categoryPageSize": 10,
|
|
||||||
"tagPageSize": 10
|
|
||||||
},
|
|
||||||
"seo": {
|
|
||||||
"blockSpiders": false,
|
|
||||||
"keywords": "keywords",
|
|
||||||
"description": "Site description"
|
|
||||||
},
|
|
||||||
"comment": {
|
|
||||||
"enable": true,
|
|
||||||
"systemUserOnly": false,
|
|
||||||
"requireReviewForNew": false
|
|
||||||
},
|
|
||||||
"routes": {
|
|
||||||
"categoriesUri": "/categories",
|
|
||||||
"tagsUri": "/tags",
|
|
||||||
"archivesUri": "/archives"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common examples**:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<title th:text="${site.title}"></title>
|
|
||||||
<img th:src="${site.logo}" alt="Logo" />
|
|
||||||
<meta name="description" th:content="${site.seo.description}" />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `theme` — Current theme info
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"metadata": {
|
|
||||||
"name": "theme-foo",
|
|
||||||
"creationTimestamp": "..."
|
|
||||||
},
|
|
||||||
"spec": {
|
|
||||||
"displayName": "My Theme",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": { "name": "Author", "website": "https://example.com" },
|
|
||||||
"description": "Theme description",
|
|
||||||
"logo": "https://example.com/logo.png",
|
|
||||||
"homepage": "https://github.com/example/theme-foo",
|
|
||||||
"settingName": "theme-foo-setting",
|
|
||||||
"configMapName": "theme-foo-configMap"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"style": { "color_scheme": "system" },
|
|
||||||
"layout": { "nav": "single" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common examples**:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Display theme version -->
|
|
||||||
<span th:text="${theme.spec.version}"></span>
|
|
||||||
|
|
||||||
<!-- Static asset reference with version (recommended — prevents caching) -->
|
|
||||||
<link rel="stylesheet" th:href="@{/assets/dist/style.css?v={v}(v=${theme.spec.version})}" />
|
|
||||||
<script th:src="@{/assets/dist/main.iife.js?v={v}(v=${theme.spec.version})}"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `theme.config` — Theme settings values
|
|
||||||
|
|
||||||
Access pattern: `theme.config.[group].[name]`
|
|
||||||
|
|
||||||
- `group`: value of `spec.forms[].group` in `settings.yaml`
|
|
||||||
- `name`: value of `spec.forms[].formSchema[].name`
|
|
||||||
|
|
||||||
**Example** (based on the settings.yaml in [structure-and-config.md](structure-and-config.md)):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<body th:class="${theme.config.style.color_scheme}">
|
|
||||||
<nav th:if="${theme.config.layout.nav == 'single'}">...</nav>
|
|
||||||
<nav th:if="${theme.config.layout.nav == 'double'}">...</nav>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `#theme.assets()` — Static asset path utility
|
|
||||||
|
|
||||||
Returns the full path to a static asset for use in non-attribute contexts (e.g. inside JavaScript).
|
|
||||||
|
|
||||||
> Note: the path passed to this function does **not** need an `/assets/` prefix.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script th:inline="javascript">
|
|
||||||
var mainJs = '[(${#theme.assets("/dist/main.iife.js")})]';
|
|
||||||
// Renders as: /themes/theme-foo/assets/dist/main.iife.js
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Online Docs
|
|
||||||
|
|
||||||
> **If you need the exact structure of `site`, `theme`, or `theme.config`, fetch the doc below — do not guess field names from training data.**
|
|
||||||
|
|
||||||
https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/global-variables.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `haloCommentEnabled` — Comment component status
|
|
||||||
|
|
||||||
Boolean. Evaluates both "is a comment plugin installed" and "are comments enabled for this page". Use together with the `halo:comment` custom tag:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div th:if="${haloCommentEnabled}">
|
|
||||||
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Official Plugin Integration
|
|
||||||
|
|
||||||
Halo's official plugins can extend the frontend UI. Themes should adapt to these plugins rather than re-implementing the same functionality.
|
|
||||||
|
|
||||||
## Checking Plugin Availability
|
|
||||||
|
|
||||||
Use `pluginFinder.available(pluginName)` to conditionally render plugin-dependent UI. This prevents broken UI when the plugin is not installed.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button th:if="${pluginFinder.available('PluginSearchWidget')}"
|
|
||||||
onclick="javascript:SearchWidget.open()">
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Always guard plugin-dependent elements with `th:if="${pluginFinder.available('...')}"`.
|
|
||||||
|
|
||||||
## Search Widget (PluginSearchWidget)
|
|
||||||
|
|
||||||
The official search plugin provides a ready-made search UI. Themes do not need to build their own search — just add a trigger button:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button th:if="${pluginFinder.available('PluginSearchWidget')}"
|
|
||||||
onclick="javascript:SearchWidget.open()">
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dark Mode Adaptation
|
|
||||||
|
|
||||||
Official plugins that provide UI components (search widget, comment component, etc.) support a shared color scheme system. Themes that implement dark mode should apply the appropriate class or `data-color-scheme` attribute to `<html>` or `<body>` so plugin UI matches the theme's color scheme automatically.
|
|
||||||
|
|
||||||
### Method 1: CSS class on `<html>` or `<body>`
|
|
||||||
|
|
||||||
| Class | Effect |
|
|
||||||
| ----- | ------ |
|
|
||||||
| `color-scheme-auto` | Follows system dark/light preference |
|
|
||||||
| `color-scheme-dark` or `dark` | Force dark mode |
|
|
||||||
| `color-scheme-light` or `light` | Force light mode |
|
|
||||||
|
|
||||||
```html
|
|
||||||
<html class="color-scheme-dark">
|
|
||||||
|
|
||||||
<!-- or -->
|
|
||||||
|
|
||||||
<html class="dark">
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: `data-color-scheme` attribute on `<html>` or `<body>`
|
|
||||||
|
|
||||||
| Value | Effect |
|
|
||||||
| ----- | ------ |
|
|
||||||
| `auto` | Follows system preference |
|
|
||||||
| `dark` | Force dark mode |
|
|
||||||
| `light` | Force light mode |
|
|
||||||
|
|
||||||
```html
|
|
||||||
<html data-color-scheme="auto">
|
|
||||||
```
|
|
||||||
|
|
||||||
> Both methods achieve the same result. Dark mode switching is typically handled by frontend JavaScript (toggling the class/attribute at runtime). This applies to all official plugins that render UI (search widget, comment component, etc.).
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Theme Packaging
|
|
||||||
|
|
||||||
Use [`@halo-dev/theme-package-cli`](https://github.com/halo-dev/theme-package-cli) to package a theme into a ZIP file for uploading to Halo Console or distributing to others.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @halo-dev/theme-package-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
Or run without installing via `npx`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @halo-dev/theme-package-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Run in the theme root directory (the one containing `theme.yaml`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Package only essential files (recommended)
|
|
||||||
theme-package
|
|
||||||
|
|
||||||
# Package all files (excluding node_modules, dist, .git, etc.)
|
|
||||||
theme-package --all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Default Package Contents (without `--all`)
|
|
||||||
|
|
||||||
| Included | Description |
|
|
||||||
| -------- | ----------- |
|
|
||||||
| `templates/` | Templates and static assets |
|
|
||||||
| `*.yaml` / `*.yml` | Config files: `theme.yaml`, `settings.yaml`, etc. |
|
|
||||||
| `i18n/` | Internationalization files (if present) |
|
|
||||||
| `README.md` | Documentation file (if present) |
|
|
||||||
| `LICENSE` | License file (if present) |
|
|
||||||
|
|
||||||
> For Vite-based themes, run `npm run build` first to generate the `templates/` output, then run the packaging command.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Static Assets & Build Tooling
|
|
||||||
|
|
||||||
## Static Asset Directory
|
|
||||||
|
|
||||||
All theme static assets **must** be placed under `templates/assets/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
templates/
|
|
||||||
└── assets/
|
|
||||||
├── css/
|
|
||||||
│ └── style.css
|
|
||||||
├── js/
|
|
||||||
│ └── main.js
|
|
||||||
└── images/
|
|
||||||
└── logo.png
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Referencing Static Assets in Templates
|
|
||||||
|
|
||||||
### Method 1: HTML tag attributes (recommended)
|
|
||||||
|
|
||||||
Use Thymeleaf's `@{}` expression:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- CSS -->
|
|
||||||
<link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
|
|
||||||
|
|
||||||
<!-- JavaScript -->
|
|
||||||
<script th:src="@{/assets/dist/main.iife.js}"></script>
|
|
||||||
|
|
||||||
<!-- Image -->
|
|
||||||
<img th:src="@{/assets/images/logo.png}" alt="Logo" />
|
|
||||||
```
|
|
||||||
|
|
||||||
The path `@{/assets/dist/style.css}` maps to `templates/assets/dist/style.css` and renders as `/themes/theme-foo/assets/dist/style.css`.
|
|
||||||
|
|
||||||
**With version query string (strongly recommended — prevents caching)**:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<link rel="stylesheet" th:href="@{/assets/dist/style.css?v={v}(v=${theme.spec.version})}" />
|
|
||||||
<script th:src="@{/assets/dist/main.iife.js?v={v}(v=${theme.spec.version})}"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: `#theme.assets()` utility
|
|
||||||
|
|
||||||
Use when you need the asset path in a non-attribute context (e.g. a JavaScript variable or inline CSS).
|
|
||||||
|
|
||||||
> Note: do **not** include `/assets/` prefix in the path argument.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script th:inline="javascript">
|
|
||||||
var logoUrl = '[(${#theme.assets("/images/logo.png")})]';
|
|
||||||
// Value: /themes/theme-foo/assets/images/logo.png
|
|
||||||
|
|
||||||
loadScript('[(${#theme.assets("/dist/main.iife.js")})]');
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Vite Integration
|
|
||||||
|
|
||||||
For themes using a modern frontend stack (TypeScript, Sass, PostCSS, TailwindCSS, etc.) or needing HTML template reuse (`include`/`slot`), the official Vite plugin is strongly recommended:
|
|
||||||
|
|
||||||
**`vite-plugin-halo-theme`**: https://github.com/halo-sigs/vite-plugin-halo-theme
|
|
||||||
|
|
||||||
See [vite-plugin.md](vite-plugin.md) for the full integration guide, template syntax, and configuration examples.
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
# Directory Structure & Configuration
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
my-theme/
|
|
||||||
├── templates/
|
|
||||||
│ ├── assets/ # Static assets (CSS/JS/images etc.) — must be placed here
|
|
||||||
│ │ ├── css/
|
|
||||||
│ │ │ └── style.css
|
|
||||||
│ │ └── js/
|
|
||||||
│ │ └── main.js
|
|
||||||
│ ├── index.html # Home page
|
|
||||||
│ ├── post.html # Post detail
|
|
||||||
│ ├── page.html # Single page detail
|
|
||||||
│ ├── archives.html # Post archives
|
|
||||||
│ ├── tags.html # Tag listing
|
|
||||||
│ ├── tag.html # Tag archive
|
|
||||||
│ ├── categories.html # Category listing
|
|
||||||
│ └── category.html # Category archive
|
|
||||||
├── theme.yaml # Theme configuration (required)
|
|
||||||
└── settings.yaml # Theme settings form definition (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Important**: The theme folder name must match the `metadata.name` field in `theme.yaml`; otherwise some assets may fail to load.
|
|
||||||
|
|
||||||
## theme.yaml
|
|
||||||
|
|
||||||
The theme root directory must contain `theme.yaml`. Minimal runnable config:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: theme.halo.run/v1alpha1
|
|
||||||
kind: Theme
|
|
||||||
metadata:
|
|
||||||
name: theme-foo # must match the theme folder name
|
|
||||||
spec:
|
|
||||||
displayName: My Theme
|
|
||||||
version: 1.0.0
|
|
||||||
requires: ">=2.0.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
Full field example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: theme.halo.run/v1alpha1
|
|
||||||
kind: Theme
|
|
||||||
metadata:
|
|
||||||
name: theme-foo
|
|
||||||
spec:
|
|
||||||
displayName: My Theme
|
|
||||||
author:
|
|
||||||
name: Author Name
|
|
||||||
website: https://example.com
|
|
||||||
description: An example theme
|
|
||||||
logo: https://example.com/logo.png
|
|
||||||
homepage: https://github.com/example/theme-foo
|
|
||||||
repo: https://github.com/example/theme-foo.git
|
|
||||||
issues: https://github.com/example/theme-foo/issues
|
|
||||||
settingName: "theme-foo-setting" # must match metadata.name in settings.yaml
|
|
||||||
configMapName: "theme-foo-configMap"
|
|
||||||
customTemplates: # optional
|
|
||||||
post:
|
|
||||||
- name: Documentation
|
|
||||||
description: Article in documentation format
|
|
||||||
screenshot:
|
|
||||||
file: post_documentation.html
|
|
||||||
category:
|
|
||||||
- name: Knowledge Base
|
|
||||||
description: Knowledge base category
|
|
||||||
screenshot:
|
|
||||||
file: category_knowledge.html
|
|
||||||
page:
|
|
||||||
- name: About
|
|
||||||
description: About page
|
|
||||||
screenshot:
|
|
||||||
file: page_about.html
|
|
||||||
version: 1.0.0
|
|
||||||
requires: ">=2.0.0"
|
|
||||||
license:
|
|
||||||
- name: "GPL-3.0"
|
|
||||||
url: "https://github.com/example/theme-foo/blob/main/LICENSE"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Fields
|
|
||||||
|
|
||||||
| Field | Description | Required |
|
|
||||||
| ----- | ----------- | -------- |
|
|
||||||
| `metadata.name` | Unique theme identifier — **must match the folder name** | Yes |
|
|
||||||
| `spec.displayName` | Display name | Yes |
|
|
||||||
| `spec.version` | Theme version | Yes |
|
|
||||||
| `spec.requires` | Minimum required Halo version | Yes |
|
|
||||||
| `spec.settingName` | Setting resource name — must match `metadata.name` in settings.yaml | No |
|
|
||||||
| `spec.configMapName` | ConfigMap name for persisting settings | No (configure together with settingName) |
|
|
||||||
| `spec.customTemplates` | Custom template configuration | No |
|
|
||||||
|
|
||||||
> After modifying `theme.yaml`, click "Reload Theme Configuration" on the theme page in Console for changes to take effect.
|
|
||||||
|
|
||||||
## settings.yaml
|
|
||||||
|
|
||||||
Defines a form that is auto-rendered on the theme settings page in Console, using [FormKit](https://github.com/formkit/formkit).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1alpha1
|
|
||||||
kind: Setting
|
|
||||||
metadata:
|
|
||||||
name: theme-foo-setting # must match spec.settingName in theme.yaml
|
|
||||||
spec:
|
|
||||||
forms:
|
|
||||||
- group: style # group name (accessed in templates as theme.config.style.xxx)
|
|
||||||
label: Style
|
|
||||||
formSchema:
|
|
||||||
- $formkit: radio
|
|
||||||
name: color_scheme # field name (accessed as theme.config.style.color_scheme)
|
|
||||||
label: Default color scheme
|
|
||||||
value: system
|
|
||||||
options:
|
|
||||||
- label: Follow system
|
|
||||||
value: system
|
|
||||||
- label: Dark
|
|
||||||
value: dark
|
|
||||||
- label: Light
|
|
||||||
value: light
|
|
||||||
- $formkit: color
|
|
||||||
name: background_color
|
|
||||||
label: Background color
|
|
||||||
value: "#f2f2f2"
|
|
||||||
- group: layout
|
|
||||||
label: Layout
|
|
||||||
formSchema:
|
|
||||||
- $formkit: radio
|
|
||||||
name: nav
|
|
||||||
label: Navigation layout
|
|
||||||
value: "single"
|
|
||||||
options:
|
|
||||||
- label: Single column
|
|
||||||
value: "single"
|
|
||||||
- label: Double column
|
|
||||||
value: "double"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Settings Values in Templates
|
|
||||||
|
|
||||||
Pattern: `theme.config.[group].[name]`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Use color_scheme from the style group -->
|
|
||||||
<body th:class="${theme.config.style.color_scheme}">
|
|
||||||
<ul th:if="${theme.config.layout.nav == 'single'}">
|
|
||||||
Single-column nav
|
|
||||||
</ul>
|
|
||||||
<div th:if="${theme.config.layout.nav == 'double'}">Double-column nav</div>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Native FormKit Input Types
|
|
||||||
|
|
||||||
Commonly used native input components in theme settings (see links for full docs):
|
|
||||||
|
|
||||||
| `$formkit` value | Purpose | Docs |
|
|
||||||
| ---------------- | ------- | ---- |
|
|
||||||
| `text` | Single-line text | https://formkit.com/inputs/text.md |
|
|
||||||
| `textarea` | Multi-line text | https://formkit.com/inputs/textarea.md |
|
|
||||||
| `number` | Number input | https://formkit.com/inputs/number.md |
|
|
||||||
| `password` | Password input | https://formkit.com/inputs/password.md |
|
|
||||||
| `radio` | Radio buttons (options list) | https://formkit.com/inputs/radio.md |
|
|
||||||
| `checkbox` | Checkbox (single or multi-select) | https://formkit.com/inputs/checkbox.md |
|
|
||||||
| `color` | Color picker (returns hex value) | https://formkit.com/inputs/color.md |
|
|
||||||
| `range` | Slider range | https://formkit.com/inputs/range.md |
|
|
||||||
| `date` | Date picker | https://formkit.com/inputs/date.md |
|
|
||||||
| `datetime-local` | Date-time picker | https://formkit.com/inputs/datetime-local.md |
|
|
||||||
| `button` | Button | https://formkit.com/inputs/button.md |
|
|
||||||
| `email` | Email input | https://formkit.com/inputs/email.md |
|
|
||||||
| `group` | Group (for grouping fields) | https://formkit.com/inputs/group.md |
|
|
||||||
| `url` | URL input | https://formkit.com/inputs/url.md |
|
|
||||||
|
|
||||||
FormKit Inputs overview: https://formkit.com/inputs
|
|
||||||
|
|
||||||
FormKit Schema (conditional rendering, loops, expressions, advanced usage): https://formkit.com/essentials/schema.md
|
|
||||||
|
|
||||||
> Note: FormKit Pro input components are not supported.
|
|
||||||
|
|
||||||
### Halo Extended Input Types
|
|
||||||
|
|
||||||
Additional input components provided by Halo on top of FormKit:
|
|
||||||
|
|
||||||
| `$formkit` value | Purpose |
|
|
||||||
| ---------------- | ------- |
|
|
||||||
| `select` | Enhanced dropdown with multi-select, search, and remote data source |
|
|
||||||
| `switch` | Toggle switch (boolean or custom on/off values) |
|
|
||||||
| `toggle` | Visual toggle supporting image/color/text options |
|
|
||||||
| `attachment` | Attachment picker (preview, direct upload, select from library) |
|
|
||||||
| `attachmentInput` | Attachment picker (library popup only) |
|
|
||||||
| `code` | Code editor (supports yaml/html/js/css/json) |
|
|
||||||
| `array` | Object array (add/remove/reorder — recommended over repeater) |
|
|
||||||
| `list` | Primitive array (strings, numbers, etc.) |
|
|
||||||
| `categorySelect` | Post category selector (returns `metadata.name`) |
|
|
||||||
| `categoryCheckbox` | Post category checkbox (returns array of `metadata.name`) |
|
|
||||||
| `tagSelect` | Post tag selector (returns `metadata.name`) |
|
|
||||||
| `tagCheckbox` | Post tag checkbox (returns array of `metadata.name`) |
|
|
||||||
| `postSelect` | Post selector |
|
|
||||||
| `singlePageSelect` | Single page selector |
|
|
||||||
| `menuSelect` | Menu selector (supports multi-select) |
|
|
||||||
| `menuCheckbox` | Menu checkbox |
|
|
||||||
| `menuRadio` | Menu radio |
|
|
||||||
| `iconify` | Icon picker (Iconify-based, supports svg/dataurl/url/name formats) |
|
|
||||||
| `secret` | Secret resource selector |
|
|
||||||
| `verificationForm` | Remote verification form group |
|
|
||||||
|
|
||||||
Full parameter reference: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/form-schema.md
|
|
||||||
|
|
||||||
### FormKit Schema Gotchas
|
|
||||||
|
|
||||||
**1. Inside `array` / `list` children, use `$value` — not `$get()`**
|
|
||||||
|
|
||||||
Within the `children` of an `array`, `list`, or `repeater`, access the current item's data via `$value`, not `$get(id).value`. `$get()` can only reference standalone named input nodes by `id`; it cannot reach the current item in a nested context.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ✅ Correct: use $value.[name] to reference a sibling field
|
|
||||||
- $formkit: array
|
|
||||||
name: socials
|
|
||||||
children:
|
|
||||||
- $formkit: text
|
|
||||||
name: platform
|
|
||||||
label: Platform
|
|
||||||
- $formkit: text
|
|
||||||
name: url
|
|
||||||
label: URL
|
|
||||||
if: "$value.platform !== ''" # references sibling field "platform"
|
|
||||||
|
|
||||||
# ❌ Wrong: $get(platform).value does not work in a nested context
|
|
||||||
- $formkit: text
|
|
||||||
name: url
|
|
||||||
if: "$get(platform).value !== ''"
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Nodes using `if` must also declare a `key`**
|
|
||||||
|
|
||||||
Any `$formkit`/`$el`/`$cmp` node with an `if` attribute must declare a unique `key`. Without it, Vue reuses DOM nodes when the condition toggles, causing stale form values or rendering glitches.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ✅ Correct: add key whenever if is present
|
|
||||||
- $formkit: text
|
|
||||||
key: url-field
|
|
||||||
name: url
|
|
||||||
label: URL
|
|
||||||
if: "$value.show_link === true"
|
|
||||||
|
|
||||||
# ❌ Wrong: missing key may cause value leakage on toggle
|
|
||||||
- $formkit: text
|
|
||||||
name: url
|
|
||||||
label: URL
|
|
||||||
if: "$value.show_link === true"
|
|
||||||
```
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Custom Template Tags
|
|
||||||
|
|
||||||
Halo provides proprietary custom tags for code injection and extension points.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `<halo:comment>` — Comment extension point
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
Extension point tag for the comment component. When a plugin (such as the official comment plugin) implements this extension point, it renders the comment component at this tag's location.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```html title="templates/post.html"
|
|
||||||
<!-- haloCommentEnabled checks whether a comment plugin is installed and comments are enabled for this page -->
|
|
||||||
<div th:if="${haloCommentEnabled}">
|
|
||||||
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
```html title="templates/page.html"
|
|
||||||
<div th:if="${haloCommentEnabled}">
|
|
||||||
<halo:comment
|
|
||||||
group="content.halo.run"
|
|
||||||
kind="SinglePage"
|
|
||||||
th:attr="name=${singlePage.metadata.name}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attributes
|
|
||||||
|
|
||||||
| Attribute | Description |
|
|
||||||
| --------- | ----------- |
|
|
||||||
| `group` | Resource group |
|
|
||||||
| `kind` | Resource type |
|
|
||||||
| `name` | Unique resource identifier (`metadata.name`) |
|
|
||||||
|
|
||||||
### Supported Resource Types
|
|
||||||
|
|
||||||
| Resource | `group` | `kind` |
|
|
||||||
| -------- | ------- | ------ |
|
|
||||||
| Post | `content.halo.run` | `Post` |
|
|
||||||
| Single page | `content.halo.run` | `SinglePage` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `<halo:footer>` — Footer code injection
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
Renders the "Footer Code" configured in Console → System Settings → Code Injection at this tag's location. All themes should include this tag in the footer to ensure full Halo functionality (e.g. analytics scripts injected by plugins).
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```html title="templates/index.html (bottom of any template)"
|
|
||||||
<body>
|
|
||||||
<!-- Page content -->
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<!-- Theme's own footer content -->
|
|
||||||
<p>© 2024 My Site</p>
|
|
||||||
|
|
||||||
<!-- Halo footer injection point (place before </body>) -->
|
|
||||||
<halo:footer />
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
> For complete Halo functionality, include this tag in every template that contains a `<body>` element.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Template Route Mapping & Template Variables
|
|
||||||
|
|
||||||
## Template Route Mapping
|
|
||||||
|
|
||||||
| Template file | URL path | Main variables | `_templateId` |
|
|
||||||
| ------------- | -------- | -------------- | ------------- |
|
|
||||||
| `templates/index.html` | `/` | `posts` | `index` |
|
|
||||||
| `templates/post.html` | `/archives/:slug` | `post` | `post` |
|
|
||||||
| `templates/page.html` | `/:slug` | `singlePage` | `page` |
|
|
||||||
| `templates/archives.html` | `/archives[/:year[/:month]]` | `archives` | `archives` |
|
|
||||||
| `templates/tags.html` | `/tags` | `tags` | `tags` |
|
|
||||||
| `templates/tag.html` | `/tags/:slug` | `tag`, `posts` | `tag` |
|
|
||||||
| `templates/categories.html` | `/categories` | `categories` | `categories` |
|
|
||||||
| `templates/category.html` | `/categories/:slug` | `category`, `posts` | `category` |
|
|
||||||
| `templates/author.html` | `/authors/:slug` | `author`, `posts` | `author` |
|
|
||||||
|
|
||||||
> Route prefixes (`/archives`, `/tags`, `/categories`) can be customized by users in Console system settings.
|
|
||||||
|
|
||||||
## Custom Templates
|
|
||||||
|
|
||||||
Register additional rendering templates for posts, single pages, or category archives via `spec.customTemplates` in `theme.yaml`. Supported types: `post`, `page`, `category`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
customTemplates:
|
|
||||||
post:
|
|
||||||
- name: Documentation
|
|
||||||
file: post_documentation.html # create under templates/
|
|
||||||
```
|
|
||||||
|
|
||||||
> After modifying theme.yaml, click "Reload Theme Configuration" on the theme page in Console.
|
|
||||||
|
|
||||||
## Key Notes
|
|
||||||
|
|
||||||
- Use `th:utext` (unescaped) to render post/page body content — never `th:text`.
|
|
||||||
- List variables (`posts`, `archives`, etc.) are `UrlContextListResult`; use `.hasPrevious()`/`.hasNext()`/`.prevUrl`/`.nextUrl` for pagination.
|
|
||||||
- `post.content.content` is only available automatically in `post.html`; in other templates fetch it separately via `postFinder.content(postName)`.
|
|
||||||
|
|
||||||
## Online Docs
|
|
||||||
|
|
||||||
> **Halo's VO types (field names, nested structures) change across versions. Do not guess field names from training data. Always fetch the doc for the relevant template before accessing specific fields on template variables.**
|
|
||||||
|
|
||||||
- index: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/index_.md
|
|
||||||
- post: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/post.md
|
|
||||||
- page: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/page.md
|
|
||||||
- archives: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/archives.md
|
|
||||||
- tag: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/tag.md
|
|
||||||
- tags: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/tags.md
|
|
||||||
- category: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/category.md
|
|
||||||
- categories: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/categories.md
|
|
||||||
- author: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/author.md
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Thymeleaf Best Practices for Halo Themes
|
|
||||||
|
|
||||||
**1. Prefer literal substitutions over string concatenation**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ✅ readable, no quoting issues -->
|
|
||||||
<title th:text="|${post.spec.title} - ${site.title}|"></title>
|
|
||||||
|
|
||||||
<!-- ❌ verbose and error-prone -->
|
|
||||||
<title th:text="${post.spec.title} + ' - ' + ${site.title}"></title>
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Use safe navigation `?.` to avoid NullPointerException**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ✅ returns null instead of throwing if target is null -->
|
|
||||||
<a th:target="${item.spec.target?.value}"></a>
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Use Elvis operator `?:` for default values**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Use `th:block` to group without adding extra DOM elements**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<th:block th:each="archive : ${archives.items}">
|
|
||||||
<h2 th:text="${archive.year}"></h2>
|
|
||||||
<ul>...</ul>
|
|
||||||
</th:block>
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. Use `th:classappend` for conditional classes**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ✅ appends "active" without overwriting existing classes -->
|
|
||||||
<a th:classappend="${item.active} ? 'active'">...</a>
|
|
||||||
|
|
||||||
<!-- ❌ replaces all classes -->
|
|
||||||
<a th:class="${item.active} ? 'nav-link active' : 'nav-link'">...</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
**6. Use `#lists.isEmpty()` and `#strings.isEmpty()` for null-safe checks**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div th:if="${not #lists.isEmpty(post.tags)}">
|
|
||||||
<a th:each="tag : ${post.tags}" th:text="${tag.spec.displayName}"></a>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**7. Do not manually add meta tags — Halo injects them automatically**
|
|
||||||
|
|
||||||
Only `<title>` needs to be in the theme. Halo automatically injects at runtime:
|
|
||||||
- `<meta name="description">` and `<meta name="keywords">`
|
|
||||||
- Open Graph tags (`og:title`, `og:description`, `og:image`, etc.)
|
|
||||||
- Twitter Card tags and canonical URL
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ✅ correct -->
|
|
||||||
<head>
|
|
||||||
<title th:text="${site.title}">Site Title</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<!-- ❌ redundant — conflicts with Halo's auto-injected tags -->
|
|
||||||
<head>
|
|
||||||
<title th:text="${site.title}">Site Title</title>
|
|
||||||
<meta name="description" th:content="${site.seo.description}" />
|
|
||||||
<meta property="og:title" th:content="${site.title}" />
|
|
||||||
</head>
|
|
||||||
```
|
|
||||||
|
|
||||||
**8. Use `@{${url}}` for dynamic permalink URLs**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ✅ correct: wraps runtime URL in Thymeleaf's URL context -->
|
|
||||||
<a th:href="@{${post.status.permalink}}">...</a>
|
|
||||||
|
|
||||||
<!-- ❌ wrong: bypasses URL processing -->
|
|
||||||
<a th:href="${post.status.permalink}">...</a>
|
|
||||||
```
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# Vite Integration (Recommended)
|
|
||||||
|
|
||||||
> **Strongly recommended** for building theme projects. Compared to plain Thymeleaf, this approach provides a modern frontend development experience (TypeScript, Sass, PostCSS, TailwindCSS) plus lightweight HTML component reuse (`include`/`slot`) — significantly reducing the verbosity of Thymeleaf's native fragment system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## vite-plugin-halo-theme
|
|
||||||
|
|
||||||
- GitHub: https://github.com/halo-sigs/vite-plugin-halo-theme
|
|
||||||
- NPM: `@halo-dev/vite-plugin-halo-theme`
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
1. **HTML template reuse**: supports `<include>`, `<slot>` (default and named), and `<props>` syntax, preprocessed at Vite build time — fully independent of Thymeleaf.
|
|
||||||
2. **Automatic multi-page entry scanning**: automatically discovers all `.html` files under `src/` (except `src/partials/`) as Vite build entries, with output going to `templates/`.
|
|
||||||
3. **Static asset handling**: CSS/JS in `src/` is bundled by Vite into `templates/assets/`; files in `public/` are copied directly to `templates/assets/` without processing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory Convention
|
|
||||||
|
|
||||||
```
|
|
||||||
my-theme/
|
|
||||||
├── src/
|
|
||||||
│ ├── css/
|
|
||||||
│ │ └── main.css # CSS source
|
|
||||||
│ ├── js/
|
|
||||||
│ │ └── main.ts # TypeScript source
|
|
||||||
│ ├── partials/ # Not treated as page entries; referenced via include
|
|
||||||
│ │ ├── layout.html # Shared layout
|
|
||||||
│ │ └── post-card.html # Reusable post card component
|
|
||||||
│ ├── index.html # Home page entry → templates/index.html
|
|
||||||
│ ├── post.html # → templates/post.html
|
|
||||||
│ ├── page.html
|
|
||||||
│ ├── archives.html
|
|
||||||
│ ├── tags.html
|
|
||||||
│ ├── tag.html
|
|
||||||
│ ├── categories.html
|
|
||||||
│ └── category.html
|
|
||||||
├── public/ # Copied directly to templates/assets/ (no Vite processing)
|
|
||||||
├── templates/ # Build output directory (add to .gitignore)
|
|
||||||
├── vite.config.ts
|
|
||||||
├── package.json
|
|
||||||
├── .gitignore
|
|
||||||
├── theme.yaml
|
|
||||||
└── settings.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Setup
|
|
||||||
|
|
||||||
### package.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite build --watch",
|
|
||||||
"build": "vite build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@halo-dev/vite-plugin-halo-theme": "latest",
|
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### vite.config.ts
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { haloThemePlugin } from "@halo-dev/vite-plugin-halo-theme";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [haloThemePlugin()],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### .gitignore
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
node_modules/
|
|
||||||
templates/
|
|
||||||
!templates/.gitkeep
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Template Syntax (build-time only — independent of Thymeleaf)
|
|
||||||
|
|
||||||
> **Important**: `include`/`slot` syntax is processed at **Vite build time**; Thymeleaf syntax is processed at **server runtime**. The two systems are fully isolated and can be mixed freely.
|
|
||||||
|
|
||||||
### `<include>` — Import a partial
|
|
||||||
|
|
||||||
```html
|
|
||||||
<include src="layout.html">
|
|
||||||
<main>Page content</main>
|
|
||||||
</include>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Named Slots
|
|
||||||
|
|
||||||
Declare slots in a partial (`src/partials/layout.html`):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<head>
|
|
||||||
<slot name="head">
|
|
||||||
<title th:text="${site.title}">Default title</title>
|
|
||||||
</slot>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<slot />
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
Use in a page:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<include src="layout.html">
|
|
||||||
<template name="head">
|
|
||||||
<title th:text="${post.spec.title}">Post title</title>
|
|
||||||
</template>
|
|
||||||
<article th:utext="${post.content.content}"></article>
|
|
||||||
</include>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Include Path Resolution
|
|
||||||
|
|
||||||
| Syntax | Resolves to |
|
|
||||||
| ------ | ----------- |
|
|
||||||
| `foo.html` | `src/partials/foo.html` (preferred) |
|
|
||||||
| `partials/foo.html` | `src/partials/foo.html` |
|
|
||||||
| `./foo.html` | Relative to current file |
|
|
||||||
| `/foo.html` | Relative to `src/` root |
|
|
||||||
|
|
||||||
### Static Asset Paths
|
|
||||||
|
|
||||||
**All HTML files — both `src/*.html` and `src/partials/*.html` — resolve static asset references relative to `src/`**, regardless of the file's actual location.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- src/partials/layout.html: referencing src/css/main.css -->
|
|
||||||
<link rel="stylesheet" href="./css/main.css" /> <!-- ✅ correct (relative to src/) -->
|
|
||||||
<link rel="stylesheet" href="../css/main.css" /> <!-- ❌ wrong (relative to file location) -->
|
|
||||||
|
|
||||||
<!-- Same rule in src/index.html -->
|
|
||||||
<script type="module" src="./js/main.ts"></script> <!-- ✅ correct -->
|
|
||||||
```
|
|
||||||
|
|
||||||
Always write asset paths as if the file is in `src/`, even when it is inside `src/partials/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Example: Shared Layout
|
|
||||||
|
|
||||||
### `src/partials/layout.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="./css/main.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<a th:href="@{/}" th:text="${site.title}"></a>
|
|
||||||
<nav th:with="menu = ${menuFinder.getPrimary()}">
|
|
||||||
<ul>
|
|
||||||
<li th:each="item : ${menu.menuItems}">
|
|
||||||
<a
|
|
||||||
th:href="${item.status.href}"
|
|
||||||
th:text="${item.status.displayName}"
|
|
||||||
th:target="${item.spec.target?.value}"
|
|
||||||
></a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<halo:footer />
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `src/index.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<include src="layout.html">
|
|
||||||
<template name="head">
|
|
||||||
<title th:text="${site.title}">Home</title>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<ul>
|
|
||||||
<li th:each="post : ${posts.items}">
|
|
||||||
<a th:href="${post.status.permalink}" th:text="${post.spec.title}"></a>
|
|
||||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
|
||||||
<p th:text="${post.status.excerpt}"></p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
|
|
||||||
<a th:if="${posts.hasPrevious()}" th:href="${posts.prevUrl}">Previous</a>
|
|
||||||
<span th:text="${posts.page} + '/' + ${posts.totalPages}"></span>
|
|
||||||
<a th:if="${posts.hasNext()}" th:href="${posts.nextUrl}">Next</a>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
</include>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `src/post.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<include src="layout.html">
|
|
||||||
<template name="head">
|
|
||||||
<title th:text="${post.spec.title}">Post title</title>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h1 th:text="${post.spec.title}"></h1>
|
|
||||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
|
||||||
<div th:utext="${post.content.content}"></div>
|
|
||||||
|
|
||||||
<!-- Prev / next navigation -->
|
|
||||||
<div th:with="cursor = ${postFinder.cursor(post.metadata.name)}">
|
|
||||||
<a
|
|
||||||
th:if="${cursor.hasPrevious()}"
|
|
||||||
th:href="${cursor.previous.status.permalink}"
|
|
||||||
th:text="${cursor.previous.spec.title}"
|
|
||||||
>Previous</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
th:if="${cursor.hasNext()}"
|
|
||||||
th:href="${cursor.next.status.permalink}"
|
|
||||||
th:text="${cursor.next.spec.title}"
|
|
||||||
>Next</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div th:if="${haloCommentEnabled}">
|
|
||||||
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</include>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TailwindCSS Integration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add -D tailwindcss @tailwindcss/vite
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// vite.config.ts
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import { haloThemePlugin } from "@halo-dev/vite-plugin-halo-theme";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), haloThemePlugin()],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* src/css/main.css */
|
|
||||||
@import "tailwindcss";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ready-to-use Templates
|
|
||||||
|
|
||||||
The skill's `assets/` directory provides two ready-to-use templates:
|
|
||||||
|
|
||||||
- `assets/theme-minimal/` — Zero-build-tool minimal theme; good for quick prototyping or plain HTML/CSS themes
|
|
||||||
- `assets/theme-vite/` — Full Vite project with vite-plugin-halo-theme; recommended as the starting point for new themes
|
|
||||||
|
|
||||||
Usage: copy the directory into `themes/` in your Halo working directory, ensure the folder name matches `metadata.name` in `theme.yaml`, then install and activate in Console.
|
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/halo-theme-dev
|
||||||
+2
-1
@@ -26,4 +26,5 @@ dist-ssr
|
|||||||
|
|
||||||
.gradle
|
.gradle
|
||||||
build
|
build
|
||||||
templates
|
templates
|
||||||
|
.workbuddy/
|
||||||
|
|||||||
@@ -1,23 +1,167 @@
|
|||||||
# Theme Vite Starter
|
# Whitespace 留白
|
||||||
|
|
||||||
一个集成了 Vite 的主题快速开始模板。
|
一个移植自 Typecho 主题 [whitespace 留白](https://kehua.me) 的 [Halo](https://www.halo.run/) 主题,主打极简留白风格与清晰的排版,并为多款常用插件提供开箱即用的适配页面。
|
||||||
|
|
||||||
主题开发文档可查阅:<https://docs.halo.run/developer-guide/theme/prepare>
|
> 本项目由 AI 开发完成,人工仅进行部分功能测试及问题找寻。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Halo **2.26.0 及以上**
|
||||||
|
- 构建依赖 Node.js + pnpm(`src/` 经 Vite 构建生成 `templates/`,Halo 实际只读 `templates/`)
|
||||||
|
|
||||||
|
## 特点
|
||||||
|
|
||||||
|
1. **极简留白风格** — 浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择),专注内容本身的清晰排版
|
||||||
|
2. **二级导航** — 菜单子项自动渲染为下拉菜单(桌面端 hover/focus 展开,移动端嵌套缩进)
|
||||||
|
3. **插件兼容性** — 适配 8 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
|
||||||
|
4. **全站灯箱** — 主题内置 lightgallery.js 灯箱,文章、页面、瞬间、图库等图片点击放大预览(按需懒加载,可在设置中整体关闭)
|
||||||
|
5. **可配置性** — 署名、全站统计、字数显示、相册封面、预览模式等均可在后台开关
|
||||||
|
6. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载、CSS/HTML 压缩、静态资源带内容哈希
|
||||||
|
|
||||||
|
## 已适配插件
|
||||||
|
|
||||||
|
| 插件名称 | 插件标识 | 适配页面路由 | 说明 |
|
||||||
|
| ----------------------------------------------------------- | ------------------------ | ------------------ | ---------------------------------------------------- |
|
||||||
|
| [瞬间](https://github.com/halo-sigs/plugin-moments) | `plugin-moments` | `/moments` | 瞬间动态列表页,含 `/moments/{name}` 详情页(完整内容、媒体、标签、作者信息、评论区) |
|
||||||
|
| [图库](https://github.com/halo-sigs/plugin-photos) | `plugin-photos` | `/photos` | 图片展示页面,按相册分组、封面自动选取、瀑布流 + 预览模式 |
|
||||||
|
| [重要日子](https://github.com/yyliucha/plugin-important-dates) | `plugin-important-dates` | `/important-dates` | 即将到来提醒、全部日期倒计时、重要标记、人员生日 |
|
||||||
|
| [友情链接](https://github.com/halo-sigs/plugin-links) | `plugin-links` | `/links` | 友链展示页,按分组显示标题、卡片统一高度;内嵌申请表单 |
|
||||||
|
| [链接订阅](https://github.com/halo-sigs/plugin-links) | `plugin-links` | `/subscriptions` | RSS 订阅动态聚合,作者链接来源博客、摘要折叠 5 行 |
|
||||||
|
| [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页,按「读过 / 在读 / 看过 / 在追」分栏展示书影音 |
|
||||||
|
| [微信读书](https://github.com/yeora26/halo-weread-plugin) | `halo-weread-plugin` | 自定义页面 | 微信读书书架展示,访客免登录查看 |
|
||||||
|
| [API 扩展包](https://github.com/HowieHz/halo-plugin-extra-api) | `extra-api` | 全站 | 服务端字数统计、代码高亮、全站/单条字数展示 |
|
||||||
|
|
||||||
|
> **注意**:插件需单独安装,安装后主题自动适配对应页面。若未安装某插件,对应页面或功能自动隐藏 / 降级,不会报错。
|
||||||
|
> 豆瓣插件建议 **v1.2.6+**(SQLite 数据结构)。
|
||||||
|
|
||||||
|
## 自定义页面模板
|
||||||
|
|
||||||
|
在 Halo 后台「页面 → 新建 → 自定义模板」中选择使用:
|
||||||
|
|
||||||
|
| 模板名 | 模板文件 | 说明 |
|
||||||
|
| ---- | --------------------- | --------------------- |
|
||||||
|
| 留言板 | `page_guestbook.html` | 以评论区作为留言板的单页 |
|
||||||
|
| 微信读书 | `weread.html` | 微信读书书架展示,依赖微信读书插件 |
|
||||||
|
| 豆瓣页面 | `douban.html` | 豆瓣记录页,依赖豆瓣插件 |
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### 首页
|
||||||
|
|
||||||
|
- 可选展示最新一篇置顶文章(`featured` 设置)
|
||||||
|
- 文章卡片:可配置显示字数 + 阅读时长(依赖 API 扩展包)、分类、标签、摘要
|
||||||
|
- 文章卡片分类之间带间距,避免拥挤
|
||||||
|
|
||||||
|
### 文章
|
||||||
|
|
||||||
|
- 结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在设置中关闭
|
||||||
|
- 阅读进度条、代码块复制、相对时间、回到顶部
|
||||||
|
- 正文排版:列表(ul/ol/li)、嵌套列表、表格、分隔线、列表标记配色
|
||||||
|
- 图片点击放大(内置 lightgallery 灯箱)
|
||||||
|
|
||||||
|
### 全局
|
||||||
|
|
||||||
|
- 二级导航:菜单子项渲染为下拉菜单,hover 桥接避免鼠标下移时收起
|
||||||
|
- 页脚可关闭署名:默认「Theme by WhiteSpace」并链接到主题仓库,名称 / 链接可改
|
||||||
|
- 页脚全站统计:文章、分类、标签、评论、访问量、字数,支持开关与统计项选择
|
||||||
|
- 备案信息:ICP 与公网安备号,公网安备自动带徽标
|
||||||
|
- 暗色模式评论区可见性已适配
|
||||||
|
|
||||||
|
### 图库
|
||||||
|
|
||||||
|
- 首屏按相册网格展示,封面 4:3 自适应填满,默认自动取相册内第一张图(分组无图时回退取首图)
|
||||||
|
- 后台可为每个相册手动指定封面:「相册」直接从图库插件**已存在的分组中下拉选择**,无需手输名称
|
||||||
|
- 点击相册进入 `?group=photo-group-*` 瀑布流视图(一行三张、页面加宽)
|
||||||
|
- 相册切换由前端按 URL 上的 `group` 参数完成,不依赖服务端变量,因此**不受页面静态缓存影响**
|
||||||
|
- 预览模式:默认值可在设置中配置(默认开),访客可在前台自行切换(开 → 点击图片灯箱放大;关 → 点击进入图片详情)
|
||||||
|
|
||||||
|
### 瞬间
|
||||||
|
|
||||||
|
- 列表页发布人与发布时间同行对齐,每条展示标签
|
||||||
|
- 详情页:完整内容、图片 / 视频 / 音频、标签、作者信息、发布时间与评论区
|
||||||
|
|
||||||
|
### 灯箱集成
|
||||||
|
|
||||||
|
主题内置 lightgallery.js 灯箱,为图片提供放大预览。覆盖文章、自定义页面、瞬间(正文 + 媒体)、图库瀑布流;设置「基础 → 全站图片灯箱」可整体关闭。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. 下载主题最新 Release 的 ZIP 包
|
||||||
|
2. 进入 Halo 后台 → 主题管理 → 安装主题 → 上传 ZIP 包
|
||||||
|
3. 安装完成后点击「启用」
|
||||||
|
|
||||||
|
> **升级后如果整站没了样式或部分页面不生效**:Halo 页面缓存或前置反向代理可能还留着上一版本的 HTML,它引用的 CSS 文件名带内容哈希,升级后已被删除,请求会直接 500 或样式错乱。先在 Halo 后台执行一次「**清理缓存**」(并刷新 CDN / Nginx 缓存);若安装了「页面静态缓存」插件,还需手动点一次「刷新页面缓存」。
|
||||||
|
|
||||||
|
或从源码构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.nxxy335.top/nxxy335/halo-theme-whitespace.git
|
||||||
|
cd halo-theme-whitespace
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物在 `templates/` 目录下,同时会生成 `dist/` 内的安装包 `whitespace-<version>.zip`。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:halo-dev/theme-vite-starter.git ~/halo2-dev/themes/theme-vite-starter
|
# 安装依赖
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/halo2-dev/themes/theme-vite-starter
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
|
# 开发模式(监听文件变化,自动构建到 templates/)
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 构建(输出到 templates/,并打包 dist/whitespace-<version>.zip)
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 格式化 + Lint 修复
|
||||||
|
pnpm check
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
## 目录结构
|
||||||
pnpm dev
|
|
||||||
|
```text
|
||||||
|
├── src/
|
||||||
|
│ ├── partials/ # 公共布局(layout.html 布局契约、seo.html 片段)
|
||||||
|
│ ├── modules/ # 文章卡片 / 元信息 / 分页等可复用片段
|
||||||
|
│ ├── error/ # 404 与通用错误页
|
||||||
|
│ ├── css/main.css # 全部样式(双主题变量、响应式、正文排版)
|
||||||
|
│ ├── js/ # main.ts 全站脚本 / post.ts 文章脚本 / lightbox.ts 灯箱
|
||||||
|
│ ├── index.html post.html page.html
|
||||||
|
│ ├── archives.html category.html tag.html categories.html tags.html author.html
|
||||||
|
│ ├── moments.html moment.html photos.html important-dates.html
|
||||||
|
│ ├── weread.html douban.html links.html links-subscribe.html page_guestbook.html
|
||||||
|
│ └── i18n/ # 多语言消息
|
||||||
|
├── templates/ # 构建产物,Halo 实际加载的模板(勿手动编辑)
|
||||||
|
├── dist/ # 打包安装包 whitespace-<version>.zip
|
||||||
|
├── settings.yaml # 后台设置项定义
|
||||||
|
└── theme.yaml # 主题清单与自定义页面模板注册
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 主题开发注意事项
|
||||||
|
|
||||||
|
- **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号 / 空格 / emoji 会导致页面 500
|
||||||
|
- **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝
|
||||||
|
- **凡是后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效
|
||||||
|
- **内联脚本统一加 `th:inline="none"`**:避免 Thymeleaf 解析脚本中的 `[[` / `[(` / `${}`
|
||||||
|
- **BEM 双下划线 `__` 不要跨引号边界**:出现在 `th:*` 表达式里会被当作预处理分隔符导致 500
|
||||||
|
- **禁止静态类调用**:`java.math.BigInteger.valueOf(...)` 等被 Halo 受限 SpEL 禁止,整数运算改用 `longValue()` / 整数除法
|
||||||
|
- **`SinglePage` 描述在 `status.excerpt`**,不是 `spec.description`
|
||||||
|
- **升级主题务必清理 Halo 页面缓存**,否则旧 HTML 引用已删除的哈希资源会 500
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- [Halo](https://halo.run) 2.26+
|
||||||
|
- [Vite](https://vitejs.dev/) + [@halo-dev/vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme)
|
||||||
|
- [Thymeleaf](https://www.thymeleaf.org) 模板引擎
|
||||||
|
- [lightgallery.js](https://www.lightgalleryjs.com/) 灯箱
|
||||||
|
- TypeScript
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
[GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- 原主题:[whitespace 留白](https://kehua.me) by kehua
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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}
|
||||||
|
post.words={0} words
|
||||||
|
post.reading_time=About {0} min
|
||||||
|
archive.stats.words=Total words
|
||||||
|
links.apply=Apply for link
|
||||||
|
links.apply_sent=Application submitted, awaiting review.
|
||||||
|
links.apply_failed=Submission failed, please check and retry.
|
||||||
|
links.apply_captcha_failed=Captcha failed to load, please refresh.
|
||||||
|
stats.posts={0} posts
|
||||||
|
stats.categories={0} categories
|
||||||
|
stats.tags={0} tags
|
||||||
|
stats.comments={0} comments
|
||||||
|
stats.visits={0} visits
|
||||||
|
stats.words={0} words
|
||||||
|
douban.plugin_required=Douban plugin (plugin-douban) not detected. Please install and enable it, then set your Douban ID in the plugin settings.
|
||||||
|
moment.back_to_list=Back to moments
|
||||||
|
moment.tags=Tags
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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}
|
||||||
|
post.words={0} 字
|
||||||
|
post.reading_time=约 {0} 分钟
|
||||||
|
archive.stats.words=总字数
|
||||||
|
links.apply=申请友链
|
||||||
|
links.apply_sent=申请已提交,等待管理员审核。
|
||||||
|
links.apply_failed=提交失败,请检查信息后重试。
|
||||||
|
links.apply_captcha_failed=验证码加载失败,请刷新页面重试。
|
||||||
|
stats.posts={0} 篇文章
|
||||||
|
stats.categories={0} 个分类
|
||||||
|
stats.tags={0} 个标签
|
||||||
|
stats.comments={0} 条评论
|
||||||
|
stats.visits={0} 次访问
|
||||||
|
stats.words={0} 字
|
||||||
|
douban.plugin_required=未检测到豆瓣插件(plugin-douban),请安装并启用后在插件设置中填写豆瓣 ID。
|
||||||
|
moment.back_to_list=返回瞬间列表
|
||||||
|
moment.tags=标签
|
||||||
@@ -25,5 +25,8 @@
|
|||||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lightgallery": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+296
-293
File diff suppressed because it is too large
Load Diff
+126
-7
@@ -1,13 +1,132 @@
|
|||||||
apiVersion: v1alpha1
|
apiVersion: v1alpha1
|
||||||
kind: Setting
|
kind: Setting
|
||||||
metadata:
|
metadata:
|
||||||
name: theme-vite-starter-setting
|
name: whitespace-setting
|
||||||
spec:
|
spec:
|
||||||
forms:
|
forms:
|
||||||
- group: styles
|
- group: basic
|
||||||
label: 样式
|
label: 基础
|
||||||
formSchema:
|
formSchema:
|
||||||
- $formkit: color
|
- $formkit: radio
|
||||||
name: background_color
|
name: color_scheme
|
||||||
label: 背景颜色
|
label: 默认配色
|
||||||
value: "#f2f2f2"
|
value: system
|
||||||
|
help: 首次访问时使用的配色,访客手动切换后以访客选择为准
|
||||||
|
options:
|
||||||
|
- label: 跟随系统
|
||||||
|
value: system
|
||||||
|
- label: 浅色
|
||||||
|
value: light
|
||||||
|
- label: 深色
|
||||||
|
value: dark
|
||||||
|
- $formkit: textarea
|
||||||
|
name: footer_copyright
|
||||||
|
label: 自定义版权信息
|
||||||
|
value: ""
|
||||||
|
placeholder: 支持编写 HTML,留空则不显示
|
||||||
|
help: 显示在页脚的自定义版权信息
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_theme_credit
|
||||||
|
label: 显示主题署名
|
||||||
|
value: true
|
||||||
|
help: 在页脚显示「Theme by」署名;关闭则不显示
|
||||||
|
- $formkit: text
|
||||||
|
name: theme_credit_text
|
||||||
|
label: 署名名称
|
||||||
|
value: "WhiteSpace"
|
||||||
|
placeholder: 显示在「Theme by」之后的名称
|
||||||
|
help: 页脚主题署名的展示名称
|
||||||
|
- $formkit: text
|
||||||
|
name: theme_credit_url
|
||||||
|
label: 署名链接
|
||||||
|
value: "https://git.nxxy335.top/nxxy335/halo-theme-whitespace"
|
||||||
|
placeholder: 点击署名跳转的地址
|
||||||
|
help: 页脚「Theme by」署名的跳转链接,默认主题仓库地址
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_word_count
|
||||||
|
label: 文章卡片显示字数
|
||||||
|
value: true
|
||||||
|
help: 在文章卡片与置顶文章上展示字数及阅读时长(需 extra-api 插件提供数据)
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: enable_lightbox
|
||||||
|
label: 全站图片灯箱
|
||||||
|
value: true
|
||||||
|
help: 使用 lightgallery 为文章、瞬间、图库等页面的图片提供点击放大预览
|
||||||
|
- group: featured
|
||||||
|
label: 置顶文章
|
||||||
|
formSchema:
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: enable
|
||||||
|
label: 首页展示置顶文章
|
||||||
|
value: true
|
||||||
|
help: 在首页顶部以大标题样式展示最新的一篇置顶文章(需在文章设置中开启置顶)
|
||||||
|
- 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;若已由插件提供,请关闭
|
||||||
|
- group: footer
|
||||||
|
label: 页脚备案
|
||||||
|
formSchema:
|
||||||
|
- $formkit: text
|
||||||
|
name: icp_number
|
||||||
|
label: ICP 备案号
|
||||||
|
value: ""
|
||||||
|
placeholder: 京ICP备12345678号
|
||||||
|
help: 工信部备案号,留空则不显示
|
||||||
|
- $formkit: text
|
||||||
|
name: icp_url
|
||||||
|
label: ICP 备案查询链接
|
||||||
|
value: "https://beian.miit.gov.cn"
|
||||||
|
help: 点击备案号跳转的查询地址,默认工信部备案管理系统
|
||||||
|
- $formkit: text
|
||||||
|
name: police_number
|
||||||
|
label: 公网安备号
|
||||||
|
value: ""
|
||||||
|
placeholder: 京公网安备11000002000001号
|
||||||
|
help: 公安机关备案号,留空则不显示
|
||||||
|
- $formkit: text
|
||||||
|
name: police_url
|
||||||
|
label: 公网安备查询链接
|
||||||
|
value: "https://beian.mps.gov.cn"
|
||||||
|
help: 点击公网安备号跳转的地址,默认全国互联网安全管理服务平台
|
||||||
|
- group: stats
|
||||||
|
label: 全站数据
|
||||||
|
formSchema:
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: enable
|
||||||
|
label: 显示全站数据统计
|
||||||
|
value: false
|
||||||
|
help: 在页脚展示站点文章、分类、评论、访问量与字数等统计
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_posts
|
||||||
|
label: 文章数
|
||||||
|
value: true
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_categories
|
||||||
|
label: 分类数
|
||||||
|
value: true
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_tags
|
||||||
|
label: 标签数
|
||||||
|
value: false
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_comments
|
||||||
|
label: 评论数
|
||||||
|
value: true
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_visits
|
||||||
|
label: 访问量
|
||||||
|
value: true
|
||||||
|
- $formkit: checkbox
|
||||||
|
name: show_words
|
||||||
|
label: 全站总字数(需 extra-api 插件)
|
||||||
|
value: true
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"halo-theme-dev": {
|
"halo-theme-dev": {
|
||||||
"source": "halo-dev/dev-skills",
|
"source": "halo-dev/dev-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"computedHash": "f6b0a799298f6f729366fdad175662fa7386e615839ad4af807285954969a9f4"
|
"skillPath": "skills/halo-theme-dev/SKILL.md",
|
||||||
|
"computedHash": "3605e8cd3c7fa9390c5ef1037bf48a6392da5dc95b8ea2a08cbd4de92bf9f122"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-24
@@ -1,32 +1,95 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<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>
|
</template>
|
||||||
|
|
||||||
<h1>Archives</h1>
|
<div class="container">
|
||||||
|
<!-- 归档标题 -->
|
||||||
|
<div class="category-header">
|
||||||
|
<h1 class="category-title" th:text="#{archive.title}">Archives</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<th:block th:each="archive : ${archives.items}">
|
<!-- 归档统计 -->
|
||||||
<div class="archive-year">
|
<div
|
||||||
<h2 th:text="${archive.year}"></h2>
|
class="archive-stats"
|
||||||
<th:block th:each="month : ${archive.months}">
|
th:with="stats = ${siteStatsFinder.getStats()}, tags = ${tagFinder.listAll()}"
|
||||||
<h3 th:text="|${archive.year}-${month.month}|"></h3>
|
th:if="${stats != null}"
|
||||||
<ul class="post-list">
|
>
|
||||||
<li class="post-item" th:each="post : ${month.posts}">
|
<div class="archive-stat">
|
||||||
<h2 class="post-title">
|
<div class="archive-stat-number" th:text="${stats.post}">0</div>
|
||||||
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
|
<div class="archive-stat-label" th:text="#{archive.stats.posts}">
|
||||||
</h2>
|
Posts
|
||||||
<p class="post-meta">
|
</div>
|
||||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
</div>
|
||||||
</p>
|
<div class="archive-stat">
|
||||||
</li>
|
<div class="archive-stat-number" th:text="${stats.category}">0</div>
|
||||||
</ul>
|
<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>
|
||||||
|
<th:block th:if="${pluginFinder.available('extra-api')}">
|
||||||
|
<div class="archive-stat">
|
||||||
|
<div
|
||||||
|
class="archive-stat-number"
|
||||||
|
th:text="${extraApiStatsFinder.getPostWordCount()}"
|
||||||
|
>0</div>
|
||||||
|
<div class="archive-stat-label" th:text="#{archive.stats.words}">
|
||||||
|
Words
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<nav class="pagination" th:if="${archives.hasPrevious() || archives.hasNext()}">
|
<!-- 按年归档 -->
|
||||||
<a th:if="${archives.hasPrevious()}" th:href="@{${archives.prevUrl}}">Previous</a>
|
<th:block th:each="archive : ${archives.items}">
|
||||||
<span th:text="|${archives.page} / ${archives.totalPages}|"></span>
|
<div class="archive-year">
|
||||||
<a th:if="${archives.hasNext()}" th:href="@{${archives.nextUrl}}">Next</a>
|
<h2 class="archive-year-title" th:text="${archive.year}">2024</h2>
|
||||||
</nav>
|
<ul class="archive-list">
|
||||||
|
<th:block th:each="month : ${archive.months}">
|
||||||
|
<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>
|
||||||
|
</th:block>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<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>
|
</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">
|
<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>
|
</template>
|
||||||
|
|
||||||
<h1>Categories</h1>
|
<div class="container">
|
||||||
<ul class="category-list">
|
<div class="category-header">
|
||||||
<li th:each="cat : ${categories.items}">
|
<h1 class="category-title" th:text="#{categories.title}">
|
||||||
<a
|
Categories
|
||||||
th:href="@{${cat.status.permalink}}"
|
</h1>
|
||||||
th:text="|${cat.spec.displayName} (${cat.postCount})|"
|
</div>
|
||||||
class="category"
|
|
||||||
></a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<nav class="pagination" th:if="${categories.hasPrevious() || categories.hasNext()}">
|
<div class="category-list" th:if="${not #lists.isEmpty(categories)}">
|
||||||
<a th:if="${categories.hasPrevious()}" th:href="@{${categories.prevUrl}}">Previous</a>
|
<th:block
|
||||||
<span th:text="|${categories.page} / ${categories.totalPages}|"></span>
|
th:replace="~{modules/category-tree :: category-tree(cats=${categories})}"
|
||||||
<a th:if="${categories.hasNext()}" th:href="@{${categories.nextUrl}}">Next</a>
|
></th:block>
|
||||||
</nav>
|
</div>
|
||||||
|
|
||||||
|
<div class="no-results" th:if="${#lists.isEmpty(categories)}">
|
||||||
|
<p th:text="#{list.empty}">Nothing found here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</include>
|
</include>
|
||||||
|
|||||||
+39
-8
@@ -1,14 +1,45 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<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>
|
</template>
|
||||||
|
|
||||||
<h1 th:text="|Category: ${category.spec.displayName}|"></h1>
|
<div class="container">
|
||||||
<p th:if="${category.spec.description}" th:text="${category.spec.description}"></p>
|
<!-- 分类标题 -->
|
||||||
|
<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>
|
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
|
||||||
</ul>
|
<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>
|
</include>
|
||||||
|
|||||||
+1591
-81
File diff suppressed because it is too large
Load Diff
+264
@@ -0,0 +1,264 @@
|
|||||||
|
<include src="partials/layout.html">
|
||||||
|
<template name="head">
|
||||||
|
<title
|
||||||
|
th:text="|${singlePage.spec.title} - ${site.title}|"
|
||||||
|
th:if="${singlePage != null}"
|
||||||
|
>
|
||||||
|
豆瓣
|
||||||
|
</title>
|
||||||
|
<style>
|
||||||
|
.douban-intro {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.douban-section {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
.douban-section-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.douban-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.douban-card {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.douban-card:hover .douban-name {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.douban-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.douban-name {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.douban-meta {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.douban-score {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
.douban-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<section class="douban">
|
||||||
|
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">
|
||||||
|
豆瓣
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="douban-intro"
|
||||||
|
th:if="${singlePage.status?.excerpt != null}"
|
||||||
|
th:utext="${singlePage.status.excerpt}"
|
||||||
|
></p>
|
||||||
|
|
||||||
|
<th:block th:if="${pluginFinder.available('plugin-douban')}">
|
||||||
|
<!-- 读过 -->
|
||||||
|
<th:block
|
||||||
|
th:with="res=${doubanFinder.list(1, 24, 'book', 'done')}, movies=${res != null ? res.items : null}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="douban-section"
|
||||||
|
th:if="${not #lists.isEmpty(movies)}"
|
||||||
|
>
|
||||||
|
<h2 class="douban-section-title">读过</h2>
|
||||||
|
<div class="douban-grid">
|
||||||
|
<a
|
||||||
|
th:each="m : ${movies}"
|
||||||
|
class="douban-card"
|
||||||
|
th:href="${m.link}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="douban-cover"
|
||||||
|
th:src="${m.poster}"
|
||||||
|
th:alt="${m.name}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<p class="douban-name" th:text="${m.name}">名称</p>
|
||||||
|
<p class="douban-meta">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.year)}"
|
||||||
|
th:text="${m.year}"
|
||||||
|
>年份</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.score)}"
|
||||||
|
class="douban-score"
|
||||||
|
th:text="'★ ' + ${m.score}"
|
||||||
|
>评分</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 在读 -->
|
||||||
|
<th:block
|
||||||
|
th:with="res=${doubanFinder.list(1, 24, 'book', 'doing')}, movies=${res != null ? res.items : null}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="douban-section"
|
||||||
|
th:if="${not #lists.isEmpty(movies)}"
|
||||||
|
>
|
||||||
|
<h2 class="douban-section-title">在读</h2>
|
||||||
|
<div class="douban-grid">
|
||||||
|
<a
|
||||||
|
th:each="m : ${movies}"
|
||||||
|
class="douban-card"
|
||||||
|
th:href="${m.link}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="douban-cover"
|
||||||
|
th:src="${m.poster}"
|
||||||
|
th:alt="${m.name}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<p class="douban-name" th:text="${m.name}">名称</p>
|
||||||
|
<p class="douban-meta">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.year)}"
|
||||||
|
th:text="${m.year}"
|
||||||
|
>年份</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.score)}"
|
||||||
|
class="douban-score"
|
||||||
|
th:text="'★ ' + ${m.score}"
|
||||||
|
>评分</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 看过 -->
|
||||||
|
<th:block
|
||||||
|
th:with="res=${doubanFinder.list(1, 24, 'movie', 'done')}, movies=${res != null ? res.items : null}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="douban-section"
|
||||||
|
th:if="${not #lists.isEmpty(movies)}"
|
||||||
|
>
|
||||||
|
<h2 class="douban-section-title">看过</h2>
|
||||||
|
<div class="douban-grid">
|
||||||
|
<a
|
||||||
|
th:each="m : ${movies}"
|
||||||
|
class="douban-card"
|
||||||
|
th:href="${m.link}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="douban-cover"
|
||||||
|
th:src="${m.poster}"
|
||||||
|
th:alt="${m.name}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<p class="douban-name" th:text="${m.name}">名称</p>
|
||||||
|
<p class="douban-meta">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.year)}"
|
||||||
|
th:text="${m.year}"
|
||||||
|
>年份</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.score)}"
|
||||||
|
class="douban-score"
|
||||||
|
th:text="'★ ' + ${m.score}"
|
||||||
|
>评分</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 在追 -->
|
||||||
|
<th:block
|
||||||
|
th:with="res=${doubanFinder.list(1, 24, 'drama', 'doing')}, movies=${res != null ? res.items : null}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="douban-section"
|
||||||
|
th:if="${not #lists.isEmpty(movies)}"
|
||||||
|
>
|
||||||
|
<h2 class="douban-section-title">在追</h2>
|
||||||
|
<div class="douban-grid">
|
||||||
|
<a
|
||||||
|
th:each="m : ${movies}"
|
||||||
|
class="douban-card"
|
||||||
|
th:href="${m.link}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="douban-cover"
|
||||||
|
th:src="${m.poster}"
|
||||||
|
th:alt="${m.name}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<p class="douban-name" th:text="${m.name}">名称</p>
|
||||||
|
<p class="douban-meta">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.year)}"
|
||||||
|
th:text="${m.year}"
|
||||||
|
>年份</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(m.score)}"
|
||||||
|
class="douban-score"
|
||||||
|
th:text="'★ ' + ${m.score}"
|
||||||
|
>评分</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="douban-empty"
|
||||||
|
th:if="${pluginFinder.available('plugin-douban') == false}"
|
||||||
|
th:text="#{douban.plugin_required}"
|
||||||
|
>
|
||||||
|
未检测到豆瓣插件
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</include>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<include src="partials/layout.html">
|
||||||
|
<template name="head">
|
||||||
|
<title th:text="|${title} - ${site.title}|">记得</title>
|
||||||
|
<style>
|
||||||
|
.dates-intro {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.dates-section {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
.dates-section-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dates-reminders {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.dates-reminder {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.dates-reminder-name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dates-reminder-text {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.dates-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.dates-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dates-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dates-item-date {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.dates-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dates-badge.soon {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.dates-badge.today {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
.dates-badge.important {
|
||||||
|
border-color: #e5a50a;
|
||||||
|
color: #e5a50a;
|
||||||
|
}
|
||||||
|
.dates-persons {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.people-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.people-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.people-item img {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.people-name {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.people-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.dates-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<section class="important-dates">
|
||||||
|
<h1 th:text="${title}">记得</h1>
|
||||||
|
<p class="dates-intro">重要的日子,值得被记住。</p>
|
||||||
|
|
||||||
|
<!-- 即将到来的提醒 -->
|
||||||
|
<th:block th:if="${not #lists.isEmpty(reminders)}">
|
||||||
|
<div class="dates-section">
|
||||||
|
<h2 class="dates-section-title">即将到来</h2>
|
||||||
|
<div class="dates-reminders">
|
||||||
|
<div class="dates-reminder" th:each="r : ${reminders}">
|
||||||
|
<p class="dates-reminder-name" th:text="${r.title}">名称</p>
|
||||||
|
<p class="dates-reminder-text">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(r.dateText)}"
|
||||||
|
th:text="${r.dateText}"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(r.stageText)}"
|
||||||
|
th:text="${r.stageText}"
|
||||||
|
></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 全部日期 -->
|
||||||
|
<div class="dates-section">
|
||||||
|
<h2 class="dates-section-title">全部日期</h2>
|
||||||
|
<ul class="dates-list" th:if="${not #lists.isEmpty(dates)}">
|
||||||
|
<li class="dates-item" th:each="d : ${dates}">
|
||||||
|
<span class="dates-item-name" th:text="${d.title}">名称</span>
|
||||||
|
<span
|
||||||
|
class="dates-badge important"
|
||||||
|
th:if="${showImportantTag and d.important}"
|
||||||
|
>重要</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="dates-item-date"
|
||||||
|
th:if="${not #strings.isEmpty(d.dateText)}"
|
||||||
|
th:text="${d.dateText}"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="dates-item-date"
|
||||||
|
th:if="${d.daysValid and not #strings.isEmpty(d.nextSolarDate)}"
|
||||||
|
th:text="${d.nextSolarDate}"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="dates-persons"
|
||||||
|
th:if="${not #lists.isEmpty(d.personNames)}"
|
||||||
|
>
|
||||||
|
<span th:each="pn : ${d.personNames}" th:text="'@' + ${pn}"></span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="dates-badge today"
|
||||||
|
th:if="${d.daysValid and d.daysUntil == 0}"
|
||||||
|
>今天</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="dates-badge soon"
|
||||||
|
th:if="${d.daysValid and d.daysUntil > 0}"
|
||||||
|
th:text="|还有 ${d.daysUntil} 天|"
|
||||||
|
>还有 N 天</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="dates-empty" th:if="${#lists.isEmpty(dates)}">暂无重要日期</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 人员与生日 -->
|
||||||
|
<th:block th:if="${not #lists.isEmpty(people)}">
|
||||||
|
<div class="dates-section">
|
||||||
|
<h2 class="dates-section-title">人员</h2>
|
||||||
|
<ul class="people-list">
|
||||||
|
<li class="people-item" th:each="p : ${people}">
|
||||||
|
<img
|
||||||
|
th:if="${showAvatar and not #strings.isEmpty(p.avatar)}"
|
||||||
|
th:src="${p.avatar}"
|
||||||
|
th:alt="${p.displayName}"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="people-name" th:text="${p.displayName}">姓名</span>
|
||||||
|
<p class="people-meta">
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(p.relation)}"
|
||||||
|
th:text="${p.relation}"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(p.nextSolarDate) and p.daysUntil == 0}"
|
||||||
|
th:text="'生日 · 今天'"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(p.nextSolarDate) and p.daysUntil > 0}"
|
||||||
|
th:text="|生日还有 ${p.daysUntil} 天|"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
th:if="${not #strings.isEmpty(p.nextSolarDate)}"
|
||||||
|
th:text="${p.nextSolarDate}"
|
||||||
|
></span>
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</include>
|
||||||
+56
-7
@@ -1,12 +1,61 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<template name="head">
|
||||||
<title th:text="${site.title}">Home</title>
|
<title th:text="${site.title}">Site Title</title>
|
||||||
<script type="module" src="./js/index.ts"></script>
|
<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>
|
</template>
|
||||||
|
|
||||||
<ul class="post-list">
|
<div class="container">
|
||||||
<include src="post-card.html"></include>
|
<!-- 置顶文章:首页第一页时展示最新一篇置顶文章 -->
|
||||||
</ul>
|
<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>
|
</include>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
console.log("Hello, Halo Theme!");
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* whitespace 留白主题 - lightgallery 灯箱
|
||||||
|
* 动态模式:扫描带 data-lightbox 属性的容器内的图片,点击放大预览。
|
||||||
|
*/
|
||||||
|
import lightGallery from "lightgallery";
|
||||||
|
import lgZoom from "lightgallery/plugins/zoom";
|
||||||
|
import "lightgallery/css/lightgallery.css";
|
||||||
|
import "lightgallery/css/lg-zoom.css";
|
||||||
|
|
||||||
|
export function initLightbox(): void {
|
||||||
|
const containers = document.querySelectorAll<HTMLElement>("[data-lightbox]");
|
||||||
|
if (!containers.length) return;
|
||||||
|
|
||||||
|
containers.forEach((container) => {
|
||||||
|
const images = Array.from(container.querySelectorAll("img"));
|
||||||
|
if (!images.length) return;
|
||||||
|
|
||||||
|
const items = images
|
||||||
|
.map((img) => {
|
||||||
|
const src = img.getAttribute("data-full") || img.currentSrc || img.src;
|
||||||
|
if (!src) return null;
|
||||||
|
return { src, thumb: src, alt: img.alt || "" };
|
||||||
|
})
|
||||||
|
.filter((item): item is { src: string; thumb: string; alt: string } =>
|
||||||
|
Boolean(item),
|
||||||
|
);
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
// 挂载到 body,避免父级 stacking context 遮挡灯箱
|
||||||
|
const gallery = lightGallery(document.body, {
|
||||||
|
dynamic: true,
|
||||||
|
dynamicEl: items,
|
||||||
|
plugins: [lgZoom],
|
||||||
|
speed: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
images.forEach((img, index) => {
|
||||||
|
img.style.cursor = "zoom-in";
|
||||||
|
img.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
gallery.openGallery(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+293
@@ -1 +1,294 @@
|
|||||||
import "../css/main.css";
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击移动端菜单内的任意链接后自动收起
|
||||||
|
mobileNav.querySelectorAll<HTMLAnchorElement>("a").forEach((link) => {
|
||||||
|
link.addEventListener("click", 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// lightgallery 灯箱(按需加载)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
async function initLightbox(): Promise<void> {
|
||||||
|
if (!document.querySelector("[data-lightbox]")) return;
|
||||||
|
try {
|
||||||
|
const { initLightbox } = await import("./lightbox");
|
||||||
|
initLightbox();
|
||||||
|
} catch {
|
||||||
|
/* 加载失败时静默降级为普通图片展示 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 初始化
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
const themeToggle = document.getElementById("theme-toggle");
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener("click", toggleTheme);
|
||||||
|
}
|
||||||
|
watchSystemTheme();
|
||||||
|
initSearch();
|
||||||
|
initMobileNav();
|
||||||
|
initBackToTop();
|
||||||
|
initRelativeTime();
|
||||||
|
initLazyImages();
|
||||||
|
initSmoothScroll();
|
||||||
|
initExternalLinks();
|
||||||
|
initKeyboardShortcuts();
|
||||||
|
void initLightbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,131 @@
|
|||||||
|
<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-author {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.feed-item-author:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.feed-item-sep {
|
||||||
|
color: var(--color-border);
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
}
|
||||||
|
.feed-item-summary {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
line-clamp: 5;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</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.status?.excerpt != null}"
|
||||||
|
th:utext="${singlePage.status.excerpt}"
|
||||||
|
></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">
|
||||||
|
<th:block
|
||||||
|
th:if="${item.author != null and item.author != ''}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
th:if="${item.authorUrl != null and item.authorUrl != ''}"
|
||||||
|
class="feed-item-author"
|
||||||
|
th:href="@{${item.authorUrl}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
th:text="${item.author}"
|
||||||
|
>作者</a
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:unless="${item.authorUrl != null and item.authorUrl != ''}"
|
||||||
|
class="feed-item-author"
|
||||||
|
th:text="${item.author}"
|
||||||
|
>作者</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
th:if="${item.publishedAt != null}"
|
||||||
|
class="feed-item-sep"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
|
</th:block>
|
||||||
|
<span
|
||||||
|
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>
|
||||||
+539
@@ -0,0 +1,539 @@
|
|||||||
|
<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;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.links-list a {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
.links-list a:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.links-avatar {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.links-avatar img {
|
||||||
|
display: block;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
/* 头像右下角状态圆点 */
|
||||||
|
.links-avatar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -0.1rem;
|
||||||
|
bottom: -0.1rem;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2ea043;
|
||||||
|
border: 2px solid var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.links-list .links-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.links-list .links-name {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.links-list .links-desc {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.links-group-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.links-section:first-of-type .links-group-title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
/* 申请友链按钮 */
|
||||||
|
.links-apply {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn-apply {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.6rem 1.75rem;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-apply:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
.btn-apply:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 申请弹窗(运行时移至 body 下,避免被父级 stacking context 遮挡) */
|
||||||
|
.link-apply-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.link-apply-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.link-apply-dialog {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(26rem, calc(100vw - 2rem));
|
||||||
|
max-height: min(85vh, 44rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 1.5rem;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
.link-apply-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.link-apply-close:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.link-apply-title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
.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="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>
|
||||||
|
|
||||||
|
<th:block th:if="${group == null}">
|
||||||
|
<section class="links-section" th:each="grp : ${simpleGroups}">
|
||||||
|
<h2 class="links-group-title" th:text="${grp.spec.displayName}">
|
||||||
|
分组
|
||||||
|
</h2>
|
||||||
|
<ul class="links-list">
|
||||||
|
<li
|
||||||
|
th:each="link : ${links}"
|
||||||
|
th:if="${link.spec.groupName == grp.metadata.name}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
th:href="@{${link.spec.url}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="links-avatar">
|
||||||
|
<img
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||||||
|
th:src="@{${link.spec.logo}}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="links-body">
|
||||||
|
<span class="links-name" th:text="${link.spec.displayName}"
|
||||||
|
>链接名称</span
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="links-desc"
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||||||
|
th:text="${link.spec.description}"
|
||||||
|
></p>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="links-section">
|
||||||
|
<h2 class="links-group-title">未分组</h2>
|
||||||
|
<ul class="links-list">
|
||||||
|
<li
|
||||||
|
th:each="link : ${links}"
|
||||||
|
th:if="${#strings.isEmpty(link.spec.groupName)}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
th:href="@{${link.spec.url}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="links-avatar">
|
||||||
|
<img
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||||||
|
th:src="@{${link.spec.logo}}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="links-body">
|
||||||
|
<span class="links-name" th:text="${link.spec.displayName}"
|
||||||
|
>链接名称</span
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="links-desc"
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||||||
|
th:text="${link.spec.description}"
|
||||||
|
></p>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
<th:block th:unless="${group == null}">
|
||||||
|
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
|
||||||
|
<li th:each="link : ${links}">
|
||||||
|
<a
|
||||||
|
th:href="@{${link.spec.url}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="links-avatar">
|
||||||
|
<img
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||||||
|
th:src="@{${link.spec.logo}}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="links-body">
|
||||||
|
<span class="links-name" th:text="${link.spec.displayName}"
|
||||||
|
>链接名称</span
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="links-desc"
|
||||||
|
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||||||
|
th:text="${link.spec.description}"
|
||||||
|
></p>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p th:if="${#lists.isEmpty(links)}">暂无链接</p>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<div class="links-apply">
|
||||||
|
<button
|
||||||
|
class="btn-apply"
|
||||||
|
id="link-apply-toggle"
|
||||||
|
type="button"
|
||||||
|
th:text="#{links.apply}"
|
||||||
|
>申请友链</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 申请友链弹窗:脚本运行时移至 body 下 -->
|
||||||
|
<div class="link-apply-modal" id="link-apply-modal" hidden>
|
||||||
|
<div class="link-apply-backdrop" data-close></div>
|
||||||
|
<div
|
||||||
|
class="link-apply-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="link-apply-title"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="link-apply-close"
|
||||||
|
type="button"
|
||||||
|
data-close
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 class="link-apply-title" id="link-apply-title" th:text="#{links.apply}">
|
||||||
|
申请友链
|
||||||
|
</h2>
|
||||||
|
<form class="apply-form" id="link-apply-form" novalidate>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="f-url">网站地址 *</label>
|
||||||
|
<input id="f-url" name="url" type="url" required placeholder="https://example.com" />
|
||||||
|
</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-apply" type="submit">提交申请</button>
|
||||||
|
</form>
|
||||||
|
<div class="apply-status" id="apply-status" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section" th:if="${haloCommentEnabled}">
|
||||||
|
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script th:inline="none">
|
||||||
|
(function () {
|
||||||
|
// 收起没有链接的分组区块
|
||||||
|
document.querySelectorAll(".links-section").forEach(function (sec) {
|
||||||
|
if (!sec.querySelector(".links-list li")) {
|
||||||
|
sec.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
|
||||||
|
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
|
||||||
|
var toggle = document.getElementById("link-apply-toggle");
|
||||||
|
var modal = document.getElementById("link-apply-modal");
|
||||||
|
var form = document.getElementById("link-apply-form");
|
||||||
|
var img = document.getElementById("captcha-img");
|
||||||
|
var status = document.getElementById("apply-status");
|
||||||
|
var challengeId = null;
|
||||||
|
|
||||||
|
// 弹窗移至 body 下,避免被页面父级 stacking context 遮挡
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
modal.hidden = false;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
if (!challengeId) loadCaptcha();
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
modal.hidden = true;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener("click", openModal);
|
||||||
|
|
||||||
|
modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target.closest("[data-close]")) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && !modal.hidden) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadCaptcha() {
|
||||||
|
fetch(CAPTCHA, { method: "POST" })
|
||||||
|
.then(function (r) {
|
||||||
|
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 = "申请已提交,等待管理员审核。";
|
||||||
|
setTimeout(closeModal, 1500);
|
||||||
|
} else {
|
||||||
|
return r.json().then(function (err) {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
loadCaptcha();
|
||||||
|
status.className = "apply-status err";
|
||||||
|
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</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,44 @@
|
|||||||
|
<!-- 文章元信息(时间 / 分类 / 浏览量),供列表卡片与详情页复用 -->
|
||||||
|
<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>
|
||||||
|
<th:block
|
||||||
|
th:if="${pluginFinder.available('extra-api') and theme.config?.basic?.show_word_count != false}"
|
||||||
|
>
|
||||||
|
<th:block th:with="wc=${extraApiStatsFinder.getPostWordCount({name: post.metadata.name})}">
|
||||||
|
<th:block th:if="${wc != null and wc > 0}">
|
||||||
|
<th:block th:with="rt=${(wc.longValue() + 399) / 400}">
|
||||||
|
<span class="post-meta-sep">·</span>
|
||||||
|
<span class="post-words" th:text="#{post.words(${wc.longValue()})}">字数</span>
|
||||||
|
<span class="post-meta-sep">·</span>
|
||||||
|
<span
|
||||||
|
class="post-reading"
|
||||||
|
th:text="#{post.reading_time(${rt})}"
|
||||||
|
>阅读时长</span
|
||||||
|
>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
<include src="partials/layout.html">
|
||||||
|
<template name="head">
|
||||||
|
<title th:text="|${title} - ${site.title}|">瞬间</title>
|
||||||
|
<style>
|
||||||
|
.moment-detail {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.moment-back {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.moment-back:hover {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
.moment-card {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.moment-card header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.moment-owner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.moment-owner img {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.moment-owner-name {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.moment-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.moment-meta a {
|
||||||
|
color: var(--color-link);
|
||||||
|
}
|
||||||
|
.moment-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.moment-tags a {
|
||||||
|
color: var(--color-link);
|
||||||
|
}
|
||||||
|
.moment-media {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.moment-media img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.moment-media img:only-child {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(100%, 22rem);
|
||||||
|
max-height: 26rem;
|
||||||
|
}
|
||||||
|
.moment-media video {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.moment-media audio {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<section class="moment-detail" th:if="${moment != null}">
|
||||||
|
<a class="moment-back" th:href="@{/moments}" th:text="#{moment.back_to_list}"
|
||||||
|
>返回瞬间列表</a
|
||||||
|
>
|
||||||
|
|
||||||
|
<article class="moment-card" th:with="content=${moment.spec.content}">
|
||||||
|
<header>
|
||||||
|
<span class="moment-owner" th:if="${moment.owner != null}">
|
||||||
|
<img
|
||||||
|
th:if="${not #strings.isEmpty(moment.owner.avatar)}"
|
||||||
|
th:src="${moment.owner.avatar}"
|
||||||
|
th:alt="${moment.owner.displayName}"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span class="moment-owner-name" th:text="${moment.owner.displayName}"
|
||||||
|
>用户名</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="moment-meta">
|
||||||
|
<time
|
||||||
|
th:if="${moment.spec.releaseTime != null}"
|
||||||
|
th:datetime="${moment.spec.releaseTime}"
|
||||||
|
th:text="${#temporals.format(moment.spec.releaseTime, 'yyyy-MM-dd HH:mm')}"
|
||||||
|
>发布时间</time
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="moment-tags"
|
||||||
|
th:if="${not #lists.isEmpty(moment.spec.tags)}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
th:each="tag : ${moment.spec.tags}"
|
||||||
|
th:href="@{/moments(tag=${tag})}"
|
||||||
|
th:text="'#' + ${tag}"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="article-content"
|
||||||
|
th:if="${not #strings.isEmpty(content.html)}"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:utext="${content.html}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="moment-media"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:if="${not #lists.isEmpty(content.medium)}"
|
||||||
|
>
|
||||||
|
<th:block th:each="medium : ${content.medium}">
|
||||||
|
<img
|
||||||
|
th:if="${medium.type.name == 'PHOTO'}"
|
||||||
|
th:src="${medium.url}"
|
||||||
|
alt="瞬间图片"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
th:if="${medium.type.name == 'VIDEO'}"
|
||||||
|
th:src="${medium.url}"
|
||||||
|
controls
|
||||||
|
></video>
|
||||||
|
<audio
|
||||||
|
th:if="${medium.type.name == 'AUDIO'}"
|
||||||
|
th:src="${medium.url}"
|
||||||
|
controls
|
||||||
|
></audio>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="comments-section" th:if="${haloCommentEnabled}">
|
||||||
|
<halo:comment
|
||||||
|
group="moment.halo.run"
|
||||||
|
kind="Moment"
|
||||||
|
th:attr="name=${moment.metadata.name}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p th:if="${moment == null}">瞬间不存在或不可见。</p>
|
||||||
|
</div>
|
||||||
|
</include>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.moments article header > a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 2rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.moments article header > a:hover {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
.moments .moment-owner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.moments .moment-owner img {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.moments .moment-owner .moment-owner-name {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.moments .moment-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.moments .moment-tags a {
|
||||||
|
color: var(--color-link);
|
||||||
|
}
|
||||||
|
/* 图片排版:仿微信朋友圈(多图九宫格方图,单图放大保持原比例) */
|
||||||
|
.moments .moment-media {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.moments .moment-media img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.moments .moment-media > :only-child {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
.moments .moment-media img:only-child {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(100%, 22rem);
|
||||||
|
max-height: 26rem;
|
||||||
|
}
|
||||||
|
.moments .moment-media video {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.moments .moment-media audio {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<th:block th:if="${pluginFinder.available('extra-api') and content != null}">
|
||||||
|
<span
|
||||||
|
class="moment-words"
|
||||||
|
th:with="wc=${extraApiStatsFinder.getContentWordCount(content.html)}"
|
||||||
|
th:if="${wc != null and wc > 0}"
|
||||||
|
th:text="#{post.words(${wc})}"
|
||||||
|
>字数</span
|
||||||
|
>
|
||||||
|
</th:block>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="moment-tags"
|
||||||
|
th:if="${not #lists.isEmpty(moment.spec.tags)}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
th:each="tag : ${moment.spec.tags}"
|
||||||
|
th:href="@{/moments(tag=${tag})}"
|
||||||
|
th:text="'#' + ${tag}"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
th:if="${not #strings.isEmpty(content.html)}"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:utext="${content.html}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="moment-media"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:if="${not #lists.isEmpty(content.medium)}"
|
||||||
|
>
|
||||||
|
<th:block th:each="medium : ${content.medium}">
|
||||||
|
<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>
|
||||||
+39
-14
@@ -1,20 +1,45 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<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>
|
</template>
|
||||||
|
|
||||||
<article>
|
<div class="container">
|
||||||
<header class="article-header">
|
<!-- 默认页面样式 -->
|
||||||
<h1 class="article-title" th:text="${singlePage.spec.title}"></h1>
|
<article class="article-detail">
|
||||||
</header>
|
<header class="article-header">
|
||||||
<div class="article-content" th:utext="${singlePage.content.content}"></div>
|
<h1 class="article-title" th:text="${singlePage.spec.title}">
|
||||||
</article>
|
Page title
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div th:if="${haloCommentEnabled}">
|
<div
|
||||||
<halo:comment
|
class="article-cover"
|
||||||
group="content.halo.run"
|
th:if="${not #strings.isEmpty(singlePage.spec.cover)}"
|
||||||
kind="SinglePage"
|
>
|
||||||
th:attr="name=${singlePage.metadata.name}"
|
<img th:src="${singlePage.spec.cover}" th:alt="${singlePage.spec.title}" />
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="article-content"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:utext="${singlePage.content.content}"
|
||||||
|
></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- 评论区 -->
|
||||||
|
<div th:if="${haloCommentEnabled}" class="comments-section">
|
||||||
|
<halo:comment
|
||||||
|
group="content.halo.run"
|
||||||
|
kind="SinglePage"
|
||||||
|
th:attr="name=${singlePage.metadata.name}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</include>
|
</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>
|
||||||
+290
-21
@@ -1,42 +1,311 @@
|
|||||||
<!doctype html>
|
<!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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<slot name="head">
|
<slot name="head">
|
||||||
<title th:text="${site.title}">Site Title</title>
|
<title th:text="${site.title}">Site Title</title>
|
||||||
</slot>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<!-- 头部导航 -->
|
||||||
<div class="container">
|
<header class="header">
|
||||||
<a class="site-title" th:href="@{/}" th:text="${site.title}">Site Title</a>
|
<div class="header-inner">
|
||||||
<nav class="site-nav" th:with="menu = ${menuFinder.getPrimary()}">
|
<a th:href="@{/}" class="logo">
|
||||||
<ul>
|
<img
|
||||||
<li th:each="item : ${menu.menuItems}">
|
th:if="${not #strings.isEmpty(site.logo)}"
|
||||||
<a
|
th:src="${site.logo}"
|
||||||
th:href="@{${item.status.href}}"
|
th:alt="${site.title}"
|
||||||
th:text="${item.status.displayName}"
|
class="logo-img"
|
||||||
th:target="${item.spec.target?.value}"
|
/>
|
||||||
></a>
|
<span
|
||||||
</li>
|
th:if="${#strings.isEmpty(site.logo)}"
|
||||||
</ul>
|
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}">
|
||||||
|
<th:block th:each="item : ${primaryMenu.menuItems}">
|
||||||
|
<th:block
|
||||||
|
th:if="${not #lists.isEmpty(item.children)}"
|
||||||
|
>
|
||||||
|
<div class="nav-item nav-has-children">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
th:href="@{${item.status.href}}"
|
||||||
|
th:text="${item.status.displayName}"
|
||||||
|
th:target="${item.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<a
|
||||||
|
th:each="child : ${item.children}"
|
||||||
|
class="nav-dropdown-link"
|
||||||
|
th:href="@{${child.status.href}}"
|
||||||
|
th:text="${child.status.displayName}"
|
||||||
|
th:target="${child.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
<th:block th:unless="${not #lists.isEmpty(item.children)}">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
th:href="@{${item.status.href}}"
|
||||||
|
th:text="${item.status.displayName}"
|
||||||
|
th:target="${item.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="site-main">
|
<!-- 移动端导航菜单 -->
|
||||||
<div class="container">
|
<div class="mobile-nav" id="mobile-nav">
|
||||||
<slot />
|
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
|
||||||
</div>
|
<a
|
||||||
|
th:if="${primaryMenu == null}"
|
||||||
|
class="mobile-nav-link"
|
||||||
|
th:href="@{/}"
|
||||||
|
th:text="#{nav.home}"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
|
<th:block th:if="${primaryMenu != null}">
|
||||||
|
<th:block th:each="item : ${primaryMenu.menuItems}">
|
||||||
|
<a
|
||||||
|
class="mobile-nav-link"
|
||||||
|
th:href="@{${item.status.href}}"
|
||||||
|
th:text="${item.status.displayName}"
|
||||||
|
th:target="${item.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
<a
|
||||||
|
th:each="child : ${item.children}"
|
||||||
|
class="mobile-nav-link mobile-nav-sub"
|
||||||
|
th:href="@{${child.status.href}}"
|
||||||
|
th:text="${child.status.displayName}"
|
||||||
|
th:target="${child.spec.target?.value}"
|
||||||
|
></a>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<!-- 页脚 -->
|
||||||
|
<footer class="footer">
|
||||||
<div class="container">
|
<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"
|
||||||
|
th:if="${theme.config?.basic?.show_theme_credit != false}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="footer-theme-link"
|
||||||
|
th:href="${theme.config?.basic?.theme_credit_url ?: 'https://git.nxxy335.top/nxxy335/halo-theme-whitespace'}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
th:text="#{footer.theme(${theme.config?.basic?.theme_credit_text ?: 'WhiteSpace'})}"
|
||||||
|
>Theme by WhiteSpace</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="footer-beian"
|
||||||
|
th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number) or not #strings.isEmpty(theme.config?.footer?.police_number)}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="footer-beian-item"
|
||||||
|
th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number)}"
|
||||||
|
th:href="${theme.config?.footer?.icp_url}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
th:text="${theme.config?.footer?.icp_number}"
|
||||||
|
>ICP</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="footer-beian-item footer-police"
|
||||||
|
th:if="${not #strings.isEmpty(theme.config?.footer?.police_number)}"
|
||||||
|
th:href="${theme.config?.footer?.police_url}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="footer-police-icon"
|
||||||
|
src="https://beian.mps.gov.cn/img/footerPoliceIcon.png"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span th:text="${theme.config?.footer?.police_number}">公网安备</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="footer-stats"
|
||||||
|
th:if="${theme.config?.stats?.enable}"
|
||||||
|
th:with="stats=${siteStatsFinder.getStats()}, tags=${tagFinder.listAll()}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_posts}"
|
||||||
|
th:text="#{stats.posts(${stats.post})}"
|
||||||
|
>文章</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_categories}"
|
||||||
|
th:text="#{stats.categories(${stats.category})}"
|
||||||
|
>分类</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_tags and tags != null}"
|
||||||
|
th:text="#{stats.tags(${#lists.size(tags)})}"
|
||||||
|
>标签</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_comments}"
|
||||||
|
th:text="#{stats.comments(${stats.comment})}"
|
||||||
|
>评论</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_visits}"
|
||||||
|
th:text="#{stats.visits(${stats.visit})}"
|
||||||
|
>访问</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="footer-stat"
|
||||||
|
th:if="${theme.config?.stats?.show_words and pluginFinder.available('extra-api')}"
|
||||||
|
th:text="#{stats.words(${extraApiStatsFinder.getPostWordCount()})}"
|
||||||
|
>字数</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<halo:footer />
|
<halo:footer />
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</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>
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<include src="partials/layout.html">
|
||||||
|
<template name="head">
|
||||||
|
<title th:text="|${title} - ${site.title}|">图库</title>
|
||||||
|
<style>
|
||||||
|
/* 图库页面适当加宽 */
|
||||||
|
.container {
|
||||||
|
max-width: 75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组导航(图库标题下方) */
|
||||||
|
:where(.photos-nav) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
}
|
||||||
|
:where(.photos-nav button) {
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
|
}
|
||||||
|
:where(.photos-nav button:hover) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
:where(.photos-nav button.is-active) {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组区块 */
|
||||||
|
:where(.photos-group)[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
:where(.photos-group-title) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
:where(.photos-group-title::before) {
|
||||||
|
content: "";
|
||||||
|
width: 0.2rem;
|
||||||
|
height: 1.1em;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
:where(.photos-group-section:first-of-type .photos-group-title) {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 瀑布流(一行三张,图片自适应原比例) */
|
||||||
|
:where(.photos-masonry) {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
columns: 3;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
:where(.photos-masonry > li) {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
:where(.photos-item) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
:where(.photos-item img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
:where(.photos-item img.is-loaded) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
:where(.photos-masonry) {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:where(.photos-masonry) {
|
||||||
|
columns: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.photos-empty) {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<section class="photos-page">
|
||||||
|
<header class="photos-header">
|
||||||
|
<h1 class="photos-header__title" th:text="${title}">图库</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<th:block
|
||||||
|
th:with="albums=${photoFinder.groupBy()}, anyCheck=${photoFinder.list(1, 1)}, anyPhotos=${anyCheck != null ? anyCheck.items : null}"
|
||||||
|
>
|
||||||
|
<!-- 分组导航 -->
|
||||||
|
<nav
|
||||||
|
class="photos-nav"
|
||||||
|
id="photos-nav"
|
||||||
|
aria-label="图库分组"
|
||||||
|
th:if="${not #lists.isEmpty(albums)}"
|
||||||
|
>
|
||||||
|
<button type="button" class="is-active" data-group="">全部</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
th:each="album : ${albums}"
|
||||||
|
th:attr="data-group=${album.metadata.name}"
|
||||||
|
th:text="${album.spec.displayName}"
|
||||||
|
>
|
||||||
|
分组
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 各分组照片,一次全部渲染;默认「全部」视图,前台脚本切换 -->
|
||||||
|
<th:block th:if="${not #lists.isEmpty(albums)}">
|
||||||
|
<section
|
||||||
|
class="photos-group-section"
|
||||||
|
th:each="album : ${albums}"
|
||||||
|
th:attr="data-group=${album.metadata.name}"
|
||||||
|
>
|
||||||
|
<h2 class="photos-group-title" th:text="${album.spec.displayName}">
|
||||||
|
分组
|
||||||
|
</h2>
|
||||||
|
<th:block th:with="rawPhotos=${album.photos}">
|
||||||
|
<th:block
|
||||||
|
th:with="photos=${(rawPhotos != null and !#lists.isEmpty(rawPhotos)) ? rawPhotos : photoFinder.listBy(album.metadata.name)}"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="photos-masonry"
|
||||||
|
th:if="${not #lists.isEmpty(photos)}"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
>
|
||||||
|
<li th:each="photo : ${photos}">
|
||||||
|
<a
|
||||||
|
class="photos-item"
|
||||||
|
th:href="${photoUrl.detail(photo)}"
|
||||||
|
th:title="${photo.spec.displayName}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
|
||||||
|
th:alt="${photo.spec.displayName}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="photos-empty"
|
||||||
|
th:if="${#lists.isEmpty(anyPhotos)}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
暂无照片
|
||||||
|
</p>
|
||||||
|
</th:block>
|
||||||
|
</th:block>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script th:inline="none">
|
||||||
|
(function () {
|
||||||
|
var nav = document.getElementById("photos-nav");
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
|
var sections = document.querySelectorAll(".photos-group-section");
|
||||||
|
var buttons = nav.querySelectorAll("button");
|
||||||
|
|
||||||
|
// 深链接:?group=xxx(仅前台读取,不依赖服务端参数)
|
||||||
|
var initial = "";
|
||||||
|
try {
|
||||||
|
initial = new URLSearchParams(window.location.search).get("group") || "";
|
||||||
|
} catch (e) {
|
||||||
|
initial = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(group) {
|
||||||
|
Array.prototype.forEach.call(buttons, function (btn) {
|
||||||
|
var active = btn.getAttribute("data-group") === group;
|
||||||
|
btn.classList.toggle("is-active", active);
|
||||||
|
});
|
||||||
|
Array.prototype.forEach.call(sections, function (sec) {
|
||||||
|
var match = group === "" || sec.getAttribute("data-group") === group;
|
||||||
|
if (match) {
|
||||||
|
sec.removeAttribute("hidden");
|
||||||
|
} else {
|
||||||
|
sec.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest("button[data-group]");
|
||||||
|
if (!btn) return;
|
||||||
|
apply(btn.getAttribute("data-group"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initial && nav.querySelector('button[data-group="' + initial + '"]')) {
|
||||||
|
apply(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载后淡入
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
document.querySelectorAll(".photos-item img"),
|
||||||
|
function (img) {
|
||||||
|
if (img.complete && img.naturalWidth > 0) {
|
||||||
|
img.classList.add("is-loaded");
|
||||||
|
} else {
|
||||||
|
img.addEventListener("load", function () {
|
||||||
|
img.classList.add("is-loaded");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</include>
|
||||||
+96
-37
@@ -1,56 +1,115 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<template name="head">
|
||||||
<title th:text="|${post.spec.title} - ${site.title}|">Post Title</title>
|
<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>
|
</template>
|
||||||
|
|
||||||
<article>
|
<div class="container">
|
||||||
<header class="article-header">
|
<!-- 文章内容 -->
|
||||||
<h1 class="article-title" th:text="${post.spec.title}"></h1>
|
<article class="article-detail">
|
||||||
<div class="article-meta">
|
<header class="article-header">
|
||||||
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
|
<h1 class="article-title" th:text="${post.spec.title}">Post title</h1>
|
||||||
<span th:if="${not #lists.isEmpty(post.categories)}">
|
<div class="article-meta">
|
||||||
·
|
<time
|
||||||
<a
|
th:if="${post.spec.publishTime != null}"
|
||||||
th:each="cat : ${post.categories}"
|
class="article-date"
|
||||||
th:href="@{${cat.status.permalink}}"
|
th:datetime="${post.spec.publishTime}"
|
||||||
th:text="${cat.spec.displayName}"
|
th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"
|
||||||
class="category"
|
data-relative-time
|
||||||
></a>
|
></time>
|
||||||
</span>
|
<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}"
|
||||||
|
></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>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="article-content" th:utext="${post.content.content}"></div>
|
<div
|
||||||
|
class="article-content"
|
||||||
|
th:attr="data-lightbox=${theme.config?.basic?.enable_lightbox != false ? 'true' : null}"
|
||||||
|
th:utext="${post.content.content}"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
|
<!-- 文章标签 -->
|
||||||
<a
|
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
|
||||||
th:each="tag : ${post.tags}"
|
<span class="tag-label" th:text="#{tag.title}">Tags</span>
|
||||||
th:href="@{${tag.status.permalink}}"
|
<a
|
||||||
th:text="${tag.spec.displayName}"
|
th:each="tag : ${post.tags}"
|
||||||
class="tag"
|
th:href="@{${tag.status.permalink}}"
|
||||||
></a>
|
th:text="${tag.spec.displayName}"
|
||||||
</div>
|
></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
|
<a
|
||||||
th:if="${cursor.hasPrevious()}"
|
th:if="${cursor.hasPrevious()}"
|
||||||
|
class="article-nav-item article-nav-prev"
|
||||||
th:href="@{${cursor.previous.status.permalink}}"
|
th:href="@{${cursor.previous.status.permalink}}"
|
||||||
th:text="|← ${cursor.previous.spec.title}|"
|
th:text="${cursor.previous.spec.title}"
|
||||||
></a>
|
></a>
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<a
|
<a
|
||||||
th:if="${cursor.hasNext()}"
|
th:if="${cursor.hasNext()}"
|
||||||
|
class="article-nav-item article-nav-next"
|
||||||
th:href="@{${cursor.next.status.permalink}}"
|
th:href="@{${cursor.next.status.permalink}}"
|
||||||
th:text="|${cursor.next.spec.title} →|"
|
th:text="${cursor.next.spec.title}"
|
||||||
></a>
|
></a>
|
||||||
</span>
|
</nav>
|
||||||
</nav>
|
</article>
|
||||||
</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>
|
</div>
|
||||||
</include>
|
</include>
|
||||||
|
|||||||
+30
-7
@@ -1,13 +1,36 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<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>
|
</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>
|
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
|
||||||
</ul>
|
<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>
|
</include>
|
||||||
|
|||||||
+53
-13
@@ -1,18 +1,58 @@
|
|||||||
<include src="layout.html">
|
<include src="partials/layout.html">
|
||||||
<template name="head">
|
<template name="head">
|
||||||
<title th:text="|Tags - ${site.title}|">Tags</title>
|
<title th:text="|#{tags.title} - ${site.title}|">Tags</title>
|
||||||
|
<style>
|
||||||
|
/* 标签云:紧凑标签片,保留间距 */
|
||||||
|
.tag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.65rem 0.75rem;
|
||||||
|
margin: 1.75rem 0 0;
|
||||||
|
}
|
||||||
|
.tag-cloud .tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.95rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tag-cloud .tag:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<th:block
|
||||||
|
th: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>
|
</template>
|
||||||
|
|
||||||
<h1>Tags</h1>
|
<div class="container">
|
||||||
<ul class="tag-list">
|
<div class="category-header">
|
||||||
<li th:each="tag : ${tags.items}">
|
<h1 class="category-title" th:text="#{tags.title}">Tags</h1>
|
||||||
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}" class="tag"></a>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<nav class="pagination" th:if="${tags.hasPrevious() || tags.hasNext()}">
|
<div class="sidebar-tags tag-cloud" th:if="${not #lists.isEmpty(tags)}">
|
||||||
<a th:if="${tags.hasPrevious()}" th:href="@{${tags.prevUrl}}">Previous</a>
|
<a
|
||||||
<span th:text="|${tags.page} / ${tags.totalPages}|"></span>
|
th:each="tag : ${tags}"
|
||||||
<a th:if="${tags.hasNext()}" th:href="@{${tags.nextUrl}}">Next</a>
|
th:href="@{${tag.status.permalink}}"
|
||||||
</nav>
|
class="tag"
|
||||||
|
th:text="${tag.spec.displayName}"
|
||||||
|
th:title="|${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>
|
</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.status?.excerpt != null}"
|
||||||
|
th:utext="${singlePage.status.excerpt}"
|
||||||
|
></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>
|
||||||
+31
-13
@@ -1,21 +1,39 @@
|
|||||||
apiVersion: theme.halo.run/v1alpha1
|
apiVersion: theme.halo.run/v1alpha1
|
||||||
kind: Theme
|
kind: Theme
|
||||||
metadata:
|
metadata:
|
||||||
name: theme-vite-starter
|
name: whitespace
|
||||||
spec:
|
spec:
|
||||||
displayName: Vite Starter
|
displayName: Whitespace 留白
|
||||||
author:
|
author:
|
||||||
name: Halo
|
name: 暖心向阳335
|
||||||
website: https://github.com/halo-dev
|
website: https://git.nxxy335.top/nxxy335
|
||||||
description: A development starter theme with Vite Ecosystem for Halo
|
description: 一款极简单栏博客主题,移植自 Typecho 主题 whitespace 留白,支持亮暗双主题、搜索与丰富的归档能力,并适配 moments/photos/links/weread 等插件。
|
||||||
logo: https://www.halo.run/logo
|
logo: ""
|
||||||
homepage: https://github.com/halo-dev/theme-vite-starter#readme
|
homepage: https://git.nxxy335.top/nxxy335/halo-theme-whitespace
|
||||||
repo: https://github.com/halo-dev/theme-vite-starter
|
repo: https://git.nxxy335.top/nxxy335/halo-theme-whitespace.git
|
||||||
issues: https://github.com/halo-dev/theme-vite-starter/issues
|
issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
|
||||||
settingName: "theme-vite-starter-setting"
|
settingName: "whitespace-setting"
|
||||||
configMapName: "theme-vite-starter-configMap"
|
configMapName: "whitespace-configMap"
|
||||||
version: 1.0.0
|
version: 0.4.0
|
||||||
requires: ">=2.0.0"
|
requires: ">=2.26.0"
|
||||||
license:
|
license:
|
||||||
- name: "GPL-3.0"
|
- name: "GPL-3.0"
|
||||||
url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE"
|
url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE"
|
||||||
|
customTemplates:
|
||||||
|
page:
|
||||||
|
- name: 微信读书页面
|
||||||
|
description: 适配 halo-weread-plugin,展示微信读书书架与读书笔记
|
||||||
|
screenshot:
|
||||||
|
file: weread.html
|
||||||
|
- name: 留言板页面
|
||||||
|
description: 以评论区作为留言板的单页模板
|
||||||
|
screenshot:
|
||||||
|
file: page_guestbook.html
|
||||||
|
- name: 链接订阅页面
|
||||||
|
description: 适配 plugin-links 的 RSS 订阅动态聚合页
|
||||||
|
screenshot:
|
||||||
|
file: links-subscribe.html
|
||||||
|
- name: 豆瓣页面
|
||||||
|
description: 适配 plugin-douban(豆瓣/追番)的主题页面,按收藏状态展示条目
|
||||||
|
screenshot:
|
||||||
|
file: douban.html
|
||||||
|
|||||||
Reference in New Issue
Block a user