hexo-bb-channel 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/README.zh-CN.md +205 -0
- package/index.js +5 -0
- package/package.json +42 -0
- package/src/config.js +25 -0
- package/src/generator.js +24 -0
- package/src/icons.js +13 -0
- package/src/render-client.js +348 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaaaaai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# hexo-bb-channel
|
|
2
|
+
|
|
3
|
+
English | [Chinese](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
> Render a public Telegram channel as a dynamic microblog timeline in Hexo.
|
|
6
|
+
|
|
7
|
+
[](https://hexo.io/)
|
|
8
|
+
[](https://telegram.org/)
|
|
9
|
+
[](https://www.kaaaaai.cn/bb/)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
`hexo-bb-channel` is a small Hexo plugin that creates a `/bb/` page shell and loads posts from a separate API at runtime. Your blog stays static, while Telegram updates can appear without rebuilding the whole site.
|
|
13
|
+
|
|
14
|
+
Live sample: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
15
|
+
|
|
16
|
+
## Preview
|
|
17
|
+
|
|
18
|
+
Live demo: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## Why This Design
|
|
23
|
+
|
|
24
|
+
- **Theme-agnostic**: works through Hexo's generator system, so it can be used with NexT, Wizarding, or most other themes.
|
|
25
|
+
- **Dynamic content on static blogs**: Telegram posts are fetched in the browser from the API backend.
|
|
26
|
+
- **No Telegram token in the blog**: the Hexo side only needs a public API URL.
|
|
27
|
+
- **Media-friendly timeline**: supports text, real Telegram hashtag entities, images, files, pagination, and image preview interactions.
|
|
28
|
+
- **Comments still work**: the generated page is a normal Hexo page and keeps `comments: true`.
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### 1. Deploy the API backend
|
|
33
|
+
|
|
34
|
+
Create a public Telegram channel first. Its public link should look like `https://t.me/my_notes`, and the API `TG_CHANNEL` value should be only `my_notes`, without `@`.
|
|
35
|
+
|
|
36
|
+
Deploy the companion API first:
|
|
37
|
+
|
|
38
|
+
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FKaaaaai%2Fkaaaaai.tools.channel-api&project-name=kaaaaai-tools-channel-api&repository-name=kaaaaai.tools.channel-api&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22protocol%22%3A%22storage%22%2C%22allowConnectExistingProduct%22%3Atrue%7D%5D&env=TG_CHANNEL,ALLOWED_ORIGINS,REFRESH_SECRET&CACHE_TTL=300&PAGE_SIZE=20&MAX_FETCH_PAGES=2&POST_LIMIT=500)
|
|
39
|
+
|
|
40
|
+
Backend repo: [Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
|
|
41
|
+
See the API README for detailed Telegram channel creation steps.
|
|
42
|
+
|
|
43
|
+
### 2. Install the Hexo plugin
|
|
44
|
+
|
|
45
|
+
Install the latest stable release from npm:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install hexo-bb-channel
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To try unreleased changes from the default GitHub branch instead:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install github:Kaaaaai/kaaaaai.tools.hexo-bb-channel
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Installing the Hexo plugin does not deploy the companion API. Complete step 1 and keep the `bb_channel` block in your site's root `_config.yml`.
|
|
58
|
+
|
|
59
|
+
### 3. Configure Hexo
|
|
60
|
+
|
|
61
|
+
Add this to the root `_config.yml` of your Hexo site:
|
|
62
|
+
|
|
63
|
+
```yml
|
|
64
|
+
bb_channel:
|
|
65
|
+
enable: true
|
|
66
|
+
mode: client
|
|
67
|
+
route: bb/
|
|
68
|
+
title: moments
|
|
69
|
+
description: Notes captured from a Telegram channel
|
|
70
|
+
api_base: https://your-channel-api.vercel.app
|
|
71
|
+
page_size: 20
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then rebuild:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
hexo clean
|
|
78
|
+
hexo generate
|
|
79
|
+
hexo server
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Open:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
http://localhost:4000/bb/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
| Option | Default | Description |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `enable` | `true` | Enable or disable the generated page. |
|
|
93
|
+
| `mode` | `client` | Client-rendered dynamic mode. |
|
|
94
|
+
| `route` | `bb/` | Output route, for example `moments/`. |
|
|
95
|
+
| `title` | `moments` | Page title inside the content area. |
|
|
96
|
+
| `description` | empty | Subtitle under the title. |
|
|
97
|
+
| `api_base` | empty | Base URL of `kaaaaai.tools.channel-api`. |
|
|
98
|
+
| `page_size` | `20` | Posts requested per page. |
|
|
99
|
+
|
|
100
|
+
## How It Works
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
Hexo build
|
|
104
|
+
└─ hexo-bb-channel generates /bb/index.html
|
|
105
|
+
|
|
106
|
+
Browser
|
|
107
|
+
└─ fetches api_base/api/posts?page=1&page_size=20
|
|
108
|
+
|
|
109
|
+
kaaaaai.tools.channel-api
|
|
110
|
+
├─ fetches public Telegram channel pages
|
|
111
|
+
├─ parses posts, tags, media, files
|
|
112
|
+
└─ caches normalized payload in Upstash Redis
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The Hexo build does **not** fetch Telegram. Only the page shell is static. Runtime content comes from the API.
|
|
116
|
+
|
|
117
|
+
## Theme Integration
|
|
118
|
+
|
|
119
|
+
This plugin uses Hexo's generator API, so it does not require theme template changes. It has been used with:
|
|
120
|
+
|
|
121
|
+
- NexT theme
|
|
122
|
+
- Wizarding theme
|
|
123
|
+
- Any theme that renders the standard Hexo `page` layout
|
|
124
|
+
|
|
125
|
+
If your theme or source folder already has a `/bb/` page, remove it or change `bb_channel.route` to avoid duplicate output.
|
|
126
|
+
|
|
127
|
+
## Runtime Features
|
|
128
|
+
|
|
129
|
+
- Client-side pagination
|
|
130
|
+
- Telegram source link per post
|
|
131
|
+
- Hashtags from Telegram entities instead of loose regex matching
|
|
132
|
+
- Image thumbnails and click-to-expand preview
|
|
133
|
+
- Multi-image horizontal carousel
|
|
134
|
+
- File attachment cards
|
|
135
|
+
- Image lazy hydration compatible with Hexo image lazyload plugins
|
|
136
|
+
|
|
137
|
+
## API Contract
|
|
138
|
+
|
|
139
|
+
The plugin expects:
|
|
140
|
+
|
|
141
|
+
```http
|
|
142
|
+
GET /api/posts?page=1&page_size=20
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"channel": {
|
|
148
|
+
"title": "Channel title",
|
|
149
|
+
"description": "Channel description"
|
|
150
|
+
},
|
|
151
|
+
"posts": [
|
|
152
|
+
{
|
|
153
|
+
"id": "101",
|
|
154
|
+
"datetime": "2026-01-01T12:00:00.000Z",
|
|
155
|
+
"html": "<p>Hello</p>",
|
|
156
|
+
"tags": ["Tools"],
|
|
157
|
+
"media": [{ "type": "image", "src": "https://..." }],
|
|
158
|
+
"attachments": [{ "title": "file.dmg", "meta": "351.5 MB", "url": "https://..." }],
|
|
159
|
+
"source": { "telegramUrl": "https://t.me/channel/101" }
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"pagination": {
|
|
163
|
+
"page": 1,
|
|
164
|
+
"pageSize": 20,
|
|
165
|
+
"total": 3,
|
|
166
|
+
"totalItems": 60,
|
|
167
|
+
"hasNext": true,
|
|
168
|
+
"hasPrev": false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## FAQ
|
|
174
|
+
|
|
175
|
+
### Do I need to rebuild my blog when Telegram updates?
|
|
176
|
+
|
|
177
|
+
No. The Hexo page is static, but posts are loaded dynamically from the API.
|
|
178
|
+
|
|
179
|
+
### Does this work with private Telegram channels?
|
|
180
|
+
|
|
181
|
+
No. This project is designed for public Telegram channels that can be accessed through `t.me/s/<channel>`.
|
|
182
|
+
|
|
183
|
+
### Why do I need a separate API?
|
|
184
|
+
|
|
185
|
+
Static sites cannot safely store refresh secrets or Redis credentials. The API isolates fetching, caching, CORS, and refresh logic from your blog.
|
|
186
|
+
|
|
187
|
+
### Can I use it with non-Hexo blogs?
|
|
188
|
+
|
|
189
|
+
The UI is currently packaged as a Hexo plugin. The backend API is framework-agnostic, and the same JSON can be consumed by other static site generators.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm install
|
|
195
|
+
npm test
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Related
|
|
199
|
+
|
|
200
|
+
- API backend: [Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
|
|
201
|
+
- Live sample: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# hexo-bb-channel
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
> 将公开 Telegram Channel 渲染成 Hexo 博客里的动态碎片流页面。
|
|
6
|
+
|
|
7
|
+
[](https://hexo.io/)
|
|
8
|
+
[](https://telegram.org/)
|
|
9
|
+
[](https://www.kaaaaai.cn/bb/)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
`hexo-bb-channel` 是一个轻量 Hexo 插件:它只生成 `/bb/` 页面壳,内容在浏览器端从独立 API 拉取。这样博客仍然是静态站,但 Telegram Channel 更新后不需要重新 `hexo generate`。
|
|
13
|
+
|
|
14
|
+
在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
15
|
+
|
|
16
|
+
## 效果预览
|
|
17
|
+
|
|
18
|
+
在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## 为什么这样设计
|
|
23
|
+
|
|
24
|
+
- **主题无关**:通过 Hexo generator 输出页面,NexT、Wizarding 以及大部分主题都可以接入。
|
|
25
|
+
- **静态博客里的动态内容**:页面访问时从 API 拉取 Telegram 内容。
|
|
26
|
+
- **博客侧不放 Telegram 凭证**:Hexo 只需要配置一个 API 地址。
|
|
27
|
+
- **适合碎片流展示**:支持正文、Telegram 原生 hashtag entity、图片、文件、分页、图片预览交互。
|
|
28
|
+
- **保留评论能力**:生成的是标准 Hexo page,默认开启 `comments: true`。
|
|
29
|
+
|
|
30
|
+
## 快速开始
|
|
31
|
+
|
|
32
|
+
### 1. 先部署 API
|
|
33
|
+
|
|
34
|
+
先创建一个公开 Telegram Channel。公开链接应该类似 `https://t.me/my_notes`,API 里的 `TG_CHANNEL` 只填写 `my_notes`,不要带 `@`。
|
|
35
|
+
|
|
36
|
+
先部署配套 API:
|
|
37
|
+
|
|
38
|
+
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FKaaaaai%2Fkaaaaai.tools.channel-api&project-name=kaaaaai-tools-channel-api&repository-name=kaaaaai.tools.channel-api&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22protocol%22%3A%22storage%22%2C%22allowConnectExistingProduct%22%3Atrue%7D%5D&env=TG_CHANNEL,ALLOWED_ORIGINS,REFRESH_SECRET&CACHE_TTL=300&PAGE_SIZE=20&MAX_FETCH_PAGES=2&POST_LIMIT=500)
|
|
39
|
+
|
|
40
|
+
后端仓库:[Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
|
|
41
|
+
详细的 Telegram 频道创建步骤见 API 仓库 README。
|
|
42
|
+
|
|
43
|
+
### 2. 安装 Hexo 插件
|
|
44
|
+
|
|
45
|
+
从 npm 安装最新稳定版:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install hexo-bb-channel
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
如需试用 GitHub 默认分支上尚未发布的改动,可改为:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install github:Kaaaaai/kaaaaai.tools.hexo-bb-channel
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
安装 Hexo 插件不会自动部署配套 API。请先完成第 1 步,并保留站点根目录 `_config.yml` 中的 `bb_channel` 配置。
|
|
58
|
+
|
|
59
|
+
### 3. 配置 Hexo
|
|
60
|
+
|
|
61
|
+
在 Hexo 主目录的 `_config.yml` 增加:
|
|
62
|
+
|
|
63
|
+
```yml
|
|
64
|
+
bb_channel:
|
|
65
|
+
enable: true
|
|
66
|
+
mode: client
|
|
67
|
+
route: bb/
|
|
68
|
+
title: 闲言碎语
|
|
69
|
+
description: 这些片段可能来自于大脑皮层短暂兴奋后的捕捉 🤏
|
|
70
|
+
api_base: https://your-channel-api.vercel.app
|
|
71
|
+
page_size: 20
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
然后重新生成:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
hexo clean
|
|
78
|
+
hexo generate
|
|
79
|
+
hexo server
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
打开:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
http://localhost:4000/bb/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 配置项
|
|
89
|
+
|
|
90
|
+
| 配置 | 默认值 | 说明 |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `enable` | `true` | 是否启用页面。 |
|
|
93
|
+
| `mode` | `client` | 当前支持客户端动态渲染。 |
|
|
94
|
+
| `route` | `bb/` | 页面路由,例如 `moments/`。 |
|
|
95
|
+
| `title` | `moments` | 页面内容区标题。 |
|
|
96
|
+
| `description` | 空 | 页面描述。 |
|
|
97
|
+
| `api_base` | 空 | `kaaaaai.tools.channel-api` 的 API 根地址。 |
|
|
98
|
+
| `page_size` | `20` | 每页拉取数量。 |
|
|
99
|
+
|
|
100
|
+
## 工作原理
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
Hexo build
|
|
104
|
+
└─ hexo-bb-channel 生成 /bb/index.html
|
|
105
|
+
|
|
106
|
+
Browser
|
|
107
|
+
└─ 请求 api_base/api/posts?page=1&page_size=20
|
|
108
|
+
|
|
109
|
+
kaaaaai.tools.channel-api
|
|
110
|
+
├─ 抓取公开 Telegram Channel 页面
|
|
111
|
+
├─ 解析消息、标签、图片、文件
|
|
112
|
+
└─ 将标准化数据缓存到 Upstash Redis
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Hexo 构建阶段**不会**抓取 Telegram,只生成页面壳。实际内容在访问页面时由 API 返回。
|
|
116
|
+
|
|
117
|
+
## 主题接入说明
|
|
118
|
+
|
|
119
|
+
插件通过 Hexo generator 输出页面,理论上不需要修改主题模板。已在以下场景使用:
|
|
120
|
+
|
|
121
|
+
- NexT theme
|
|
122
|
+
- Wizarding theme
|
|
123
|
+
- 任何能渲染标准 Hexo `page` layout 的主题
|
|
124
|
+
|
|
125
|
+
如果你的 `source/` 或主题里已经存在 `/bb/` 页面,请删除旧页面,或修改 `bb_channel.route` 避免重复输出。
|
|
126
|
+
|
|
127
|
+
## 页面能力
|
|
128
|
+
|
|
129
|
+
- 客户端分页
|
|
130
|
+
- 每条消息保留 Telegram 原文链接
|
|
131
|
+
- 基于 Telegram 原生 entity 提取 hashtag,而不是宽松正则匹配
|
|
132
|
+
- 图片缩略图和点击展开预览
|
|
133
|
+
- 多图横向轮播
|
|
134
|
+
- 文件附件卡片
|
|
135
|
+
- 兼容 Hexo 图片懒加载插件的图片延迟注入
|
|
136
|
+
|
|
137
|
+
## API 返回结构
|
|
138
|
+
|
|
139
|
+
插件期望 API 返回:
|
|
140
|
+
|
|
141
|
+
```http
|
|
142
|
+
GET /api/posts?page=1&page_size=20
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"channel": {
|
|
148
|
+
"title": "Channel title",
|
|
149
|
+
"description": "Channel description"
|
|
150
|
+
},
|
|
151
|
+
"posts": [
|
|
152
|
+
{
|
|
153
|
+
"id": "101",
|
|
154
|
+
"datetime": "2026-01-01T12:00:00.000Z",
|
|
155
|
+
"html": "<p>Hello</p>",
|
|
156
|
+
"tags": ["Tools"],
|
|
157
|
+
"media": [{ "type": "image", "src": "https://..." }],
|
|
158
|
+
"attachments": [{ "title": "file.dmg", "meta": "351.5 MB", "url": "https://..." }],
|
|
159
|
+
"source": { "telegramUrl": "https://t.me/channel/101" }
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"pagination": {
|
|
163
|
+
"page": 1,
|
|
164
|
+
"pageSize": 20,
|
|
165
|
+
"total": 3,
|
|
166
|
+
"totalItems": 60,
|
|
167
|
+
"hasNext": true,
|
|
168
|
+
"hasPrev": false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## FAQ
|
|
174
|
+
|
|
175
|
+
### Telegram 更新后需要重新构建博客吗?
|
|
176
|
+
|
|
177
|
+
不需要。页面壳是静态的,但内容由 API 动态返回。
|
|
178
|
+
|
|
179
|
+
### 支持私有 Telegram Channel 吗?
|
|
180
|
+
|
|
181
|
+
不支持。当前方案面向 `t.me/s/<channel>` 可访问的公开频道。
|
|
182
|
+
|
|
183
|
+
### 为什么需要独立 API?
|
|
184
|
+
|
|
185
|
+
静态站不适合保存刷新密钥或 Redis 凭证。独立 API 可以把抓取、缓存、CORS、刷新逻辑从博客里隔离出来。
|
|
186
|
+
|
|
187
|
+
### 可以用于非 Hexo 博客吗?
|
|
188
|
+
|
|
189
|
+
当前前端封装为 Hexo 插件;后端 API 是通用 JSON 服务,其他静态站也可以消费同一份数据。
|
|
190
|
+
|
|
191
|
+
## 开发
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm install
|
|
195
|
+
npm test
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 相关项目
|
|
199
|
+
|
|
200
|
+
- API 后端:[Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
|
|
201
|
+
- 在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hexo-bb-channel",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Render Telegram channel microblog timelines in Hexo using a dynamic API backend.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"README.zh-CN.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.js",
|
|
15
|
+
"prepublishOnly": "npm test"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"hexo",
|
|
19
|
+
"hexo-plugin",
|
|
20
|
+
"telegram",
|
|
21
|
+
"microblog"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"hexo": ">=6"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"lucide-static": "^1.24.0"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel/issues"
|
|
36
|
+
},
|
|
37
|
+
"author": "Kaaaaai",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function normalizeRoute(route) {
|
|
2
|
+
return String(route || 'bb/').replace(/^\/+/, '').replace(/\/?$/, '/');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizeApiBase(apiBase) {
|
|
6
|
+
return String(apiBase || '').replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function resolveConfig(hexo = {}) {
|
|
10
|
+
const raw = (hexo.config && hexo.config.bb_channel) || {};
|
|
11
|
+
return {
|
|
12
|
+
enable: raw.enable !== false,
|
|
13
|
+
mode: raw.mode || 'client',
|
|
14
|
+
route: normalizeRoute(raw.route),
|
|
15
|
+
title: typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : 'moments',
|
|
16
|
+
description: typeof raw.description === 'string' ? raw.description.trim() : '',
|
|
17
|
+
apiBase: normalizeApiBase(raw.api_base || raw.apiBase),
|
|
18
|
+
pageSize: Number(raw.page_size || raw.pageSize || 20),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
normalizeRoute,
|
|
24
|
+
resolveConfig,
|
|
25
|
+
};
|
package/src/generator.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const { renderClientContent } = require('./render-client');
|
|
2
|
+
|
|
3
|
+
function createBbPages(config) {
|
|
4
|
+
if (config.enable === false || (config.mode || 'client') !== 'client') return [];
|
|
5
|
+
return [{
|
|
6
|
+
path: `${config.route}index.html`,
|
|
7
|
+
layout: 'page',
|
|
8
|
+
data: {
|
|
9
|
+
title: config.title,
|
|
10
|
+
description: config.description,
|
|
11
|
+
comments: true,
|
|
12
|
+
content: renderClientContent(config),
|
|
13
|
+
},
|
|
14
|
+
}];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function registerGenerator(hexo, config) {
|
|
18
|
+
hexo.extend.generator.register('bb-channel', () => createBbPages(config));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
createBbPages,
|
|
23
|
+
registerGenerator,
|
|
24
|
+
};
|
package/src/icons.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
|
|
3
|
+
function loadLucideIcon(name, className) {
|
|
4
|
+
const svgPath = require.resolve(`lucide-static/icons/${name}.svg`);
|
|
5
|
+
return fs.readFileSync(svgPath, 'utf8')
|
|
6
|
+
.replace(/<!--[\s\S]*?-->\s*/g, '')
|
|
7
|
+
.replace(/class="[^"]*"/, `class="${className}"`)
|
|
8
|
+
.replace('<svg', '<svg aria-hidden="true" focusable="false"');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
fileIcon: loadLucideIcon('file', 'bb-channel-lucide bb-channel-lucide-file'),
|
|
13
|
+
};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const { fileIcon } = require('./icons');
|
|
2
|
+
|
|
3
|
+
function escapeHtml(value = '') {
|
|
4
|
+
return String(value)
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeHtml(html) {
|
|
12
|
+
return html.trim().replace(/^[ \t]+/gm, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderClientScript() {
|
|
16
|
+
return `
|
|
17
|
+
<script data-pjax>
|
|
18
|
+
(() => {
|
|
19
|
+
const bbChannelFileIcon = ${JSON.stringify(fileIcon)};
|
|
20
|
+
window.initBbChannel = window.initBbChannel || (() => {
|
|
21
|
+
const root = document.querySelector('[data-bb-channel-root]');
|
|
22
|
+
if (!root || root.dataset.bbChannelReady === 'true') return;
|
|
23
|
+
root.dataset.bbChannelReady = 'true';
|
|
24
|
+
const apiBase = root.dataset.apiBase;
|
|
25
|
+
const pageSize = root.dataset.pageSize || '20';
|
|
26
|
+
const feed = root.querySelector('[data-bb-channel-feed]');
|
|
27
|
+
const pager = root.querySelector('[data-bb-channel-pagination]');
|
|
28
|
+
const status = root.querySelector('[data-bb-channel-status]');
|
|
29
|
+
|
|
30
|
+
const escapeHtml = (value = '') => String(value)
|
|
31
|
+
.replace(/&/g, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"');
|
|
35
|
+
|
|
36
|
+
const formatDate = (value) => new Intl.DateTimeFormat('en-US', {
|
|
37
|
+
month: 'short',
|
|
38
|
+
day: '2-digit',
|
|
39
|
+
year: 'numeric',
|
|
40
|
+
hour: '2-digit',
|
|
41
|
+
minute: '2-digit',
|
|
42
|
+
hour12: false,
|
|
43
|
+
}).format(new Date(value));
|
|
44
|
+
|
|
45
|
+
const renderCard = (post) => {
|
|
46
|
+
const images = (post.media || []).filter((item) => item.type === 'image');
|
|
47
|
+
const media = images.map((item, index) =>
|
|
48
|
+
'<button class="bb-channel-media-thumb" type="button" data-bb-media-index="' + index + '" aria-label="View image ' + (index + 1) + '">' +
|
|
49
|
+
'<span class="bb-channel-image-skeleton" aria-hidden="true"></span>' +
|
|
50
|
+
'<img class="bb-channel-media-img" data-bb-image-url="' + escapeHtml(item.src) + '" alt="' + escapeHtml(item.alt || '') + '" loading="lazy" decoding="async">' +
|
|
51
|
+
'</button>'
|
|
52
|
+
).join('');
|
|
53
|
+
const viewerImages = images.map((item) =>
|
|
54
|
+
'<figure class="bb-channel-image-slide" data-bb-image-slide>' +
|
|
55
|
+
'<span class="bb-channel-image-skeleton" aria-hidden="true"></span>' +
|
|
56
|
+
'<img class="bb-channel-image-large" data-bb-image-url="' + escapeHtml(item.src) + '" alt="' + escapeHtml(item.alt || '') + '" loading="lazy" decoding="async">' +
|
|
57
|
+
'</figure>'
|
|
58
|
+
).join('');
|
|
59
|
+
const carousel = images.length > 1;
|
|
60
|
+
const tags = (post.tags || []).map((tag) => '<span>#' + escapeHtml(tag) + '</span>').join('');
|
|
61
|
+
const attachments = (post.attachments || []).map((item) =>
|
|
62
|
+
'<a class="bb-channel-attachment" href="' + escapeHtml(item.url || (post.source && post.source.telegramUrl) || '#') + '" target="_blank" rel="noopener noreferrer">' +
|
|
63
|
+
'<span class="bb-channel-attachment-icon">' + bbChannelFileIcon + '</span>' +
|
|
64
|
+
'<span class="bb-channel-attachment-main">' +
|
|
65
|
+
'<span class="bb-channel-attachment-title">' + escapeHtml(item.title || 'Attachment') + '</span>' +
|
|
66
|
+
(item.meta ? '<span class="bb-channel-attachment-meta">' + escapeHtml(item.meta) + '</span>' : '') +
|
|
67
|
+
'</span>' +
|
|
68
|
+
'</a>'
|
|
69
|
+
).join('');
|
|
70
|
+
return '<section class="bb-channel-card" id="bb-' + escapeHtml(post.id) + '">' +
|
|
71
|
+
'<span class="bb-channel-dot" aria-hidden="true"></span>' +
|
|
72
|
+
'<span class="bb-channel-card-placeholder" aria-hidden="true"></span>' +
|
|
73
|
+
'<div class="bb-channel-card-surface">' +
|
|
74
|
+
'<header class="bb-channel-meta">' +
|
|
75
|
+
'<a class="bb-channel-time" href="' + escapeHtml(post.source && post.source.telegramUrl || '#') + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(formatDate(post.datetime)) + '</a>' +
|
|
76
|
+
'</header>' +
|
|
77
|
+
'<div class="bb-channel-body' + (media ? ' bb-channel-body-with-media' : '') + '">' +
|
|
78
|
+
'<div class="bb-channel-main">' +
|
|
79
|
+
(post.html || '') +
|
|
80
|
+
(attachments ? '<div class="bb-channel-attachments">' + attachments + '</div>' : '') +
|
|
81
|
+
(tags ? '<footer class="bb-channel-tags">' + tags + '</footer>' : '') +
|
|
82
|
+
'</div>' +
|
|
83
|
+
(media ? '<div class="bb-channel-media-rail" data-bb-media-rail>' + media + '</div>' : '') +
|
|
84
|
+
'</div>' +
|
|
85
|
+
(media ? '<div class="bb-channel-image-viewer" data-bb-image-viewer hidden>' +
|
|
86
|
+
(carousel ? '<div class="bb-channel-image-viewer-bar"><div class="bb-channel-image-count" data-bb-image-count></div></div>' : '') +
|
|
87
|
+
(carousel ? '<button class="bb-channel-image-nav bb-channel-image-prev" type="button" data-bb-image-prev aria-label="Previous image">‹</button>' : '') +
|
|
88
|
+
'<div class="bb-channel-image-track" data-bb-image-track tabindex="0" aria-label="Image carousel">' + viewerImages + '</div>' +
|
|
89
|
+
(carousel ? '<button class="bb-channel-image-nav bb-channel-image-next" type="button" data-bb-image-next aria-label="Next image">›</button>' : '') +
|
|
90
|
+
'</div>' : '') +
|
|
91
|
+
'</div>' +
|
|
92
|
+
'</section>';
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const focusWithoutScroll = (element) => {
|
|
96
|
+
if (!element || typeof element.focus !== 'function') return;
|
|
97
|
+
try {
|
|
98
|
+
element.focus({ preventScroll: true });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
element.focus();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const preserveCardViewportPosition = (card, update) => {
|
|
105
|
+
const initialTop = card.getBoundingClientRect().top;
|
|
106
|
+
update();
|
|
107
|
+
const restore = () => {
|
|
108
|
+
const delta = card.getBoundingClientRect().top - initialTop;
|
|
109
|
+
if (Math.abs(delta) > 0.5) window.scrollBy(0, delta);
|
|
110
|
+
};
|
|
111
|
+
requestAnimationFrame(() => {
|
|
112
|
+
restore();
|
|
113
|
+
requestAnimationFrame(restore);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const openImageViewer = (card, index) => {
|
|
118
|
+
const viewer = card.querySelector('[data-bb-image-viewer]');
|
|
119
|
+
const track = card.querySelector('[data-bb-image-track]');
|
|
120
|
+
const slides = [...card.querySelectorAll('[data-bb-image-slide]')];
|
|
121
|
+
const count = card.querySelector('[data-bb-image-count]');
|
|
122
|
+
if (!viewer || !track || !slides.length) return;
|
|
123
|
+
const safeIndex = (Number(index) + slides.length) % slides.length;
|
|
124
|
+
card.dataset.bbImageIndex = String(safeIndex);
|
|
125
|
+
if (count) count.textContent = slides.length > 1 ? (safeIndex + 1) + ' / ' + slides.length : '';
|
|
126
|
+
preserveCardViewportPosition(card, () => {
|
|
127
|
+
window.clearTimeout(card.bbViewerCloseTimer);
|
|
128
|
+
delete card.dataset.bbViewerClosing;
|
|
129
|
+
viewer.hidden = false;
|
|
130
|
+
hydrateImages(slides[safeIndex]);
|
|
131
|
+
requestAnimationFrame(() => {
|
|
132
|
+
card.dataset.bbViewerOpen = 'true';
|
|
133
|
+
track.scrollTo({ left: track.clientWidth * safeIndex, behavior: 'smooth' });
|
|
134
|
+
focusWithoutScroll(track);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const closeImageViewer = (card) => {
|
|
140
|
+
const viewer = card.querySelector('[data-bb-image-viewer]');
|
|
141
|
+
if (!viewer || viewer.hidden || card.dataset.bbViewerClosing === 'true') return;
|
|
142
|
+
preserveCardViewportPosition(card, () => {
|
|
143
|
+
const thumb = card.querySelector('[data-bb-media-index="' + (card.dataset.bbImageIndex || '0') + '"]');
|
|
144
|
+
card.dataset.bbViewerClosing = 'true';
|
|
145
|
+
delete card.dataset.bbViewerOpen;
|
|
146
|
+
focusWithoutScroll(thumb);
|
|
147
|
+
window.clearTimeout(card.bbViewerCloseTimer);
|
|
148
|
+
const finishClose = () => {
|
|
149
|
+
if (card.dataset.bbViewerClosing !== 'true' || card.dataset.bbViewerOpen === 'true') return;
|
|
150
|
+
viewer.hidden = true;
|
|
151
|
+
delete card.dataset.bbViewerClosing;
|
|
152
|
+
};
|
|
153
|
+
card.bbViewerCloseTimer = window.setTimeout(finishClose, 460);
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const moveImageViewer = (card, delta) => {
|
|
158
|
+
const current = Number(card.dataset.bbImageIndex || 0);
|
|
159
|
+
openImageViewer(card, current + delta);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const syncImageViewerIndex = (track) => {
|
|
163
|
+
const card = track.closest('.bb-channel-card');
|
|
164
|
+
const slides = [...card.querySelectorAll('[data-bb-image-slide]')];
|
|
165
|
+
const count = card.querySelector('[data-bb-image-count]');
|
|
166
|
+
if (!slides.length) return;
|
|
167
|
+
const nextIndex = Math.max(0, Math.min(slides.length - 1, Math.round(track.scrollLeft / Math.max(1, track.clientWidth))));
|
|
168
|
+
card.dataset.bbImageIndex = String(nextIndex);
|
|
169
|
+
if (count) count.textContent = slides.length > 1 ? (nextIndex + 1) + ' / ' + slides.length : '';
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const hydrateImages = (scope) => {
|
|
173
|
+
scope.querySelectorAll('img[data-bb-image-url]').forEach((img) => {
|
|
174
|
+
if (img.dataset.bbHydrated === 'true') return;
|
|
175
|
+
const holder = img.closest('.bb-channel-media-thumb,.bb-channel-image-slide');
|
|
176
|
+
img.dataset.bbHydrated = 'true';
|
|
177
|
+
img.addEventListener('load', () => {
|
|
178
|
+
if (holder) holder.dataset.bbLoaded = 'true';
|
|
179
|
+
}, { once: true });
|
|
180
|
+
img.addEventListener('error', () => {
|
|
181
|
+
if (holder) holder.dataset.bbLoaded = 'error';
|
|
182
|
+
}, { once: true });
|
|
183
|
+
img.src = img.dataset.bbImageUrl;
|
|
184
|
+
if (img.complete && holder) holder.dataset.bbLoaded = 'true';
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const loadPage = async (page = 1) => {
|
|
189
|
+
if (!apiBase) {
|
|
190
|
+
status.textContent = 'Missing bb_channel.api_base';
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
status.textContent = 'Loading...';
|
|
194
|
+
const url = new URL('/api/posts', apiBase);
|
|
195
|
+
url.searchParams.set('page', page);
|
|
196
|
+
url.searchParams.set('page_size', pageSize);
|
|
197
|
+
const response = await fetch(url.toString());
|
|
198
|
+
if (!response.ok) throw new Error('API responded ' + response.status);
|
|
199
|
+
const data = await response.json();
|
|
200
|
+
feed.innerHTML = (data.posts || []).map(renderCard).join('');
|
|
201
|
+
hydrateImages(feed);
|
|
202
|
+
status.textContent = '';
|
|
203
|
+
pager.innerHTML = '';
|
|
204
|
+
if (data.pagination && data.pagination.total > 1) {
|
|
205
|
+
const prev = data.pagination.hasPrev ? '<button data-page="' + (data.pagination.page - 1) + '">上一页</button>' : '';
|
|
206
|
+
const next = data.pagination.hasNext ? '<button data-page="' + (data.pagination.page + 1) + '">下一页</button>' : '';
|
|
207
|
+
pager.innerHTML = prev + '<span>' + data.pagination.page + ' / ' + data.pagination.total + '</span>' + next;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
pager.addEventListener('click', (event) => {
|
|
212
|
+
const button = event.target.closest('button[data-page]');
|
|
213
|
+
if (button) loadPage(button.dataset.page).catch((error) => { status.textContent = error.message; });
|
|
214
|
+
});
|
|
215
|
+
feed.addEventListener('click', (event) => {
|
|
216
|
+
const thumb = event.target.closest('[data-bb-media-index]');
|
|
217
|
+
const prev = event.target.closest('[data-bb-image-prev]');
|
|
218
|
+
const next = event.target.closest('[data-bb-image-next]');
|
|
219
|
+
const large = event.target.closest('.bb-channel-image-large');
|
|
220
|
+
const card = event.target.closest('.bb-channel-card');
|
|
221
|
+
if (!card) return;
|
|
222
|
+
if (large) {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
closeImageViewer(card);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (thumb) {
|
|
228
|
+
openImageViewer(card, thumb.dataset.bbMediaIndex);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (prev) {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
moveImageViewer(card, -1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (next) {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
moveImageViewer(card, 1);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
feed.addEventListener('keydown', (event) => {
|
|
242
|
+
const card = event.target.closest('.bb-channel-card');
|
|
243
|
+
if (!card || !card.querySelector('[data-bb-image-viewer]:not([hidden])')) return;
|
|
244
|
+
if (event.key === 'ArrowLeft') {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
moveImageViewer(card, -1);
|
|
247
|
+
}
|
|
248
|
+
if (event.key === 'ArrowRight') {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
moveImageViewer(card, 1);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
feed.addEventListener('scroll', (event) => {
|
|
254
|
+
const track = event.target.closest && event.target.closest('[data-bb-image-track]');
|
|
255
|
+
if (track) syncImageViewerIndex(track);
|
|
256
|
+
}, { passive: true, capture: true });
|
|
257
|
+
loadPage().catch((error) => { status.textContent = error.message; });
|
|
258
|
+
});
|
|
259
|
+
window.initBbChannel();
|
|
260
|
+
document.addEventListener('DOMContentLoaded', window.initBbChannel, { once: true });
|
|
261
|
+
document.addEventListener('pjax:success', window.initBbChannel);
|
|
262
|
+
})();
|
|
263
|
+
</script>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderClientContent(config) {
|
|
268
|
+
return normalizeHtml(`
|
|
269
|
+
<style>
|
|
270
|
+
.post-block:has(.bb-channel-portable) .post-title,.post-block:has(.bb-channel-portable) .post-meta-container,body:has(.bb-channel-portable) .post-toc-wrap{display:none}
|
|
271
|
+
.bb-channel-portable{max-width:100%;margin:0 auto;color:#242424}
|
|
272
|
+
.bb-channel-portable *{box-sizing:border-box}
|
|
273
|
+
.bb-channel-portable .bb-channel-intro{position:relative;margin:0 0 1.55rem;padding:0;border-bottom:0}
|
|
274
|
+
.bb-channel-portable .bb-channel-title{margin:0 0 .45rem;color:#202020;font-family:inherit;font-size:1.72rem;font-weight:700;line-height:1.2;letter-spacing:-.02em}
|
|
275
|
+
.bb-channel-portable .bb-channel-intro-text{margin:0;color:#777;font-size:1rem;line-height:1.6;text-align:left}
|
|
276
|
+
.bb-channel-portable .bb-channel-status{min-height:1.2rem;margin:.4rem 0 .8rem;color:#999}
|
|
277
|
+
.bb-channel-portable .bb-channel-feed{position:relative;display:flex;flex-direction:column;gap:1.05rem;padding-left:2.1rem}
|
|
278
|
+
.bb-channel-portable .bb-channel-feed::before{content:"";position:absolute;left:.22rem;top:.25rem;bottom:.25rem;width:1px;background:linear-gradient(to bottom,rgba(226,226,226,0),#e2e2e2 1.2rem,#e2e2e2 calc(100% - 1.2rem),rgba(226,226,226,0))}
|
|
279
|
+
.bb-channel-portable .bb-channel-card{position:relative;border-radius:14px}
|
|
280
|
+
.bb-channel-portable .bb-channel-card-placeholder{pointer-events:none;position:absolute;inset:0;border:1px dashed #d9d9d9;border-radius:14px;background:rgba(255,255,255,.2);opacity:0;transition:opacity 320ms cubic-bezier(.22,1,.36,1)}
|
|
281
|
+
.bb-channel-portable .bb-channel-card-surface{position:relative;z-index:1;border:1px dashed #d9d9d9;border-radius:14px;background:rgba(255,255,255,.66);padding:1.45rem 1.65rem;box-shadow:none;transition:border-color 320ms cubic-bezier(.22,1,.36,1),background-color 320ms cubic-bezier(.22,1,.36,1),box-shadow 320ms cubic-bezier(.22,1,.36,1),transform 320ms cubic-bezier(.22,1,.36,1)}
|
|
282
|
+
.bb-channel-portable .bb-channel-card:hover .bb-channel-card-placeholder{opacity:1}
|
|
283
|
+
.bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:translate(6px,-6px);border-style:solid;border-color:#e7b99d;background:rgba(255,255,255,.86);box-shadow:0 14px 30px -28px rgba(15,23,42,.32)}
|
|
284
|
+
.bb-channel-portable .bb-channel-dot{position:absolute;left:-2.16rem;top:1.7rem;z-index:2;width:.56rem;height:.56rem;border-radius:999px;background:#ff7900;box-shadow:0 0 0 .28rem rgba(255,121,0,.12)}
|
|
285
|
+
.bb-channel-portable .bb-channel-meta{display:flex;align-items:center;gap:.7rem;margin:0 0 1.08rem;color:#737373;font-weight:500}
|
|
286
|
+
.bb-channel-portable .bb-channel-time{color:inherit;text-decoration:none;border-bottom:0;font-size:.96rem;line-height:1.3}
|
|
287
|
+
.bb-channel-portable .bb-channel-body-with-media{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,13.5rem);gap:1.25rem;align-items:start}
|
|
288
|
+
.bb-channel-portable .bb-channel-main{min-width:0}
|
|
289
|
+
.bb-channel-portable .bb-channel-content{font-size:1rem;line-height:1.78;overflow-wrap:anywhere}
|
|
290
|
+
.bb-channel-portable .bb-channel-content p{margin:0 0 .9em}
|
|
291
|
+
.bb-channel-portable .bb-channel-content a{color:#e46f0a;border-bottom:1px solid rgba(228,111,10,.35);text-decoration:none}
|
|
292
|
+
.bb-channel-portable .bb-channel-media-rail{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.32rem;justify-self:end;width:min(13.5rem,100%);max-height:24rem;opacity:1;overflow:hidden;transform:translateY(0);filter:blur(0);transition:max-height 420ms cubic-bezier(.22,1,.36,1),opacity 420ms cubic-bezier(.22,1,.36,1),transform 420ms cubic-bezier(.22,1,.36,1),filter 420ms cubic-bezier(.22,1,.36,1);will-change:max-height,opacity,transform}
|
|
293
|
+
.bb-channel-portable .bb-channel-media-thumb{position:relative;display:grid;min-height:0;aspect-ratio:1/1;place-items:center;border:1px solid #e7e7e7;border-radius:8px;background:#f8f8f8;padding:0;overflow:hidden;cursor:pointer}
|
|
294
|
+
.bb-channel-portable .bb-channel-media-thumb:focus-visible,.bb-channel-portable .bb-channel-image-nav:focus-visible,.bb-channel-portable .bb-channel-image-track:focus-visible{outline:2px solid rgba(255,121,0,.46);outline-offset:3px}
|
|
295
|
+
.bb-channel-portable .bb-channel-media-thumb:only-child{aspect-ratio:16/10;grid-column:1/-1}
|
|
296
|
+
.bb-channel-portable .bb-channel-media-img{position:relative;z-index:1;display:block!important;width:100%!important;height:100%!important;max-width:100%!important;max-height:100%!important;margin:0!important;object-fit:contain!important;opacity:0;transition:opacity 180ms ease}
|
|
297
|
+
.bb-channel-portable [data-bb-loaded="true"]>.bb-channel-media-img,.bb-channel-portable [data-bb-loaded="true"]>.bb-channel-image-large{opacity:1}
|
|
298
|
+
.bb-channel-portable .bb-channel-image-skeleton{position:absolute;inset:0;display:block;border-radius:inherit;background:linear-gradient(100deg,rgba(245,245,245,.78) 0%,rgba(233,233,233,.94) 45%,rgba(247,247,247,.8) 80%);background-size:220% 100%;animation:bb-channel-shimmer 1.15s ease-in-out infinite}
|
|
299
|
+
.bb-channel-portable [data-bb-loaded="true"]>.bb-channel-image-skeleton{display:none}
|
|
300
|
+
.bb-channel-portable [data-bb-loaded="error"]>.bb-channel-image-skeleton{background:#f4eeee}
|
|
301
|
+
.bb-channel-portable .bb-channel-card[data-bb-viewer-open="true"] .bb-channel-media-rail{max-height:0;opacity:0;transform:translateY(14px);filter:blur(2px);pointer-events:none}
|
|
302
|
+
.bb-channel-portable .bb-channel-image-viewer{position:relative;margin-top:0;max-width:100%;max-height:0;overflow:hidden;opacity:0;transform:translateY(-14px);touch-action:pan-y;transition:max-height 420ms cubic-bezier(.22,1,.36,1),margin-top 420ms cubic-bezier(.22,1,.36,1),opacity 420ms cubic-bezier(.22,1,.36,1),transform 420ms cubic-bezier(.22,1,.36,1);will-change:max-height,opacity,transform}
|
|
303
|
+
.bb-channel-portable .bb-channel-image-viewer[hidden]{display:none}
|
|
304
|
+
.bb-channel-portable .bb-channel-card[data-bb-viewer-open="true"] .bb-channel-image-viewer{margin-top:.7rem;max-height:calc(68vh + 4rem);opacity:1;transform:translateY(0)}
|
|
305
|
+
.bb-channel-portable .bb-channel-card[data-bb-viewer-closing="true"] .bb-channel-image-viewer{margin-top:0;max-height:0;opacity:0;transform:translateY(14px);pointer-events:none}
|
|
306
|
+
.bb-channel-portable .bb-channel-image-viewer-bar{display:flex;align-items:center;justify-content:flex-end;gap:.75rem;margin-bottom:.55rem}
|
|
307
|
+
.bb-channel-portable .bb-channel-image-track{display:flex;gap:.75rem;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scrollbar-width:thin}
|
|
308
|
+
.bb-channel-portable .bb-channel-image-slide{position:relative;display:flex;min-width:100%;max-width:100%;min-height:12rem;align-items:flex-start;justify-content:flex-start;margin:0;scroll-snap-align:start;scroll-snap-stop:always}
|
|
309
|
+
.bb-channel-portable .bb-channel-image-slide>.bb-channel-image-skeleton{position:relative;inset:auto;width:min(100%,34rem);aspect-ratio:4/3;border:1px solid #eee;border-radius:10px;flex:0 0 auto}
|
|
310
|
+
.bb-channel-portable .bb-channel-image-large{position:relative;z-index:1;display:block!important;width:auto!important;height:auto!important;max-width:min(100%,34rem)!important;max-height:68vh!important;margin:0!important;border:1px solid #e5e5e5;border-radius:10px;background:#fafafa;object-fit:contain!important;opacity:0;cursor:zoom-out;transition:opacity 420ms cubic-bezier(.22,1,.36,1)}
|
|
311
|
+
.bb-channel-portable .bb-channel-image-nav{position:absolute;top:50%;display:grid;width:2.75rem;height:2.75rem;place-items:center;transform:translateY(-50%);border:1px solid #e1e1e1;border-radius:999px;background:rgba(255,255,255,.82);color:#555;cursor:pointer}
|
|
312
|
+
.bb-channel-portable .bb-channel-image-prev{left:.65rem}
|
|
313
|
+
.bb-channel-portable .bb-channel-image-next{right:.65rem}
|
|
314
|
+
.bb-channel-portable .bb-channel-image-count{margin-top:.45rem;color:#888;font-size:.82rem}
|
|
315
|
+
.bb-channel-portable .bb-channel-attachments{display:flex;flex-direction:column;gap:.65rem;margin-top:.9rem}
|
|
316
|
+
.bb-channel-portable .bb-channel-attachment{display:flex;align-items:center;gap:.8rem;border:1px solid #e3ddd4;border-radius:10px;background:rgba(250,248,243,.58);padding:.82rem .95rem;color:#333;text-decoration:none;transition:border-color 220ms ease,background-color 220ms ease,box-shadow 220ms ease}
|
|
317
|
+
.bb-channel-portable .bb-channel-attachment:hover{border-color:#d6cbbd;background:rgba(255,252,246,.9);box-shadow:0 8px 22px -20px rgba(15,23,42,.28)}
|
|
318
|
+
.bb-channel-portable .bb-channel-attachment-icon{display:grid;width:2rem;height:2rem;place-items:center;flex:0 0 auto;border:1px solid rgba(214,203,189,.82);border-radius:9px;background:rgba(255,255,255,.56);color:#c96b18}
|
|
319
|
+
.bb-channel-portable .bb-channel-lucide{display:block;width:1.08rem;height:1.08rem;stroke-width:1.9}
|
|
320
|
+
.bb-channel-portable .bb-channel-attachment-main{display:flex;min-width:0;flex-direction:column;gap:.12rem}
|
|
321
|
+
.bb-channel-portable .bb-channel-attachment-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.95rem;font-weight:600}
|
|
322
|
+
.bb-channel-portable .bb-channel-attachment-meta{color:#888;font-size:.82rem}
|
|
323
|
+
.bb-channel-portable .bb-channel-tags{display:flex;flex-wrap:wrap;gap:.55rem;margin-top:.9rem;color:#676767;font-size:.86rem}
|
|
324
|
+
.bb-channel-portable .bb-channel-tags span{display:inline-flex;align-items:center;border:0;border-radius:6px;background:#f3f3f3;padding:.18rem .5rem;line-height:1.5}
|
|
325
|
+
.bb-channel-portable .bb-channel-pagination{display:flex;align-items:center;justify-content:center;gap:3rem;margin-top:1.8rem}
|
|
326
|
+
.bb-channel-portable .bb-channel-pagination button,.bb-channel-portable .bb-channel-pagination span{display:inline-flex;min-width:3.4rem;height:2.15rem;align-items:center;justify-content:center;border:1px solid #e8e8e8;border-radius:7px;color:#777;background:#fff}
|
|
327
|
+
.bb-channel-portable .bb-channel-pagination span{min-width:2.15rem;background:#333;color:#fff;border-color:#333}
|
|
328
|
+
@media(max-width:720px){.bb-channel-portable .bb-channel-body-with-media{grid-template-columns:1fr}.bb-channel-portable .bb-channel-media-rail{justify-self:start;width:min(18rem,100%)}}
|
|
329
|
+
@keyframes bb-channel-shimmer{0%{background-position:180% 0}100%{background-position:-80% 0}}
|
|
330
|
+
@media(max-width:640px){.bb-channel-portable .bb-channel-intro{margin-bottom:1.2rem}.bb-channel-portable .bb-channel-title{font-size:1.45rem}.bb-channel-portable .bb-channel-intro-text{font-size:.94rem}.bb-channel-portable .bb-channel-feed{gap:.9rem;padding-left:1.15rem}.bb-channel-portable .bb-channel-feed::before{left:.06rem}.bb-channel-portable .bb-channel-dot{left:-1.32rem;top:1.42rem}.bb-channel-portable .bb-channel-card-surface{padding:1.05rem .95rem;border-radius:12px}.bb-channel-portable .bb-channel-card-placeholder{border-radius:12px}.bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:none}.bb-channel-portable .bb-channel-meta{margin-bottom:.85rem}.bb-channel-portable .bb-channel-content{font-size:.96rem;line-height:1.72}.bb-channel-portable .bb-channel-media-rail{width:100%;max-width:20rem;grid-template-columns:repeat(3,minmax(0,1fr));justify-self:center;margin-top:.15rem}.bb-channel-portable .bb-channel-media-thumb{min-height:4.1rem}.bb-channel-portable .bb-channel-image-viewer{margin-top:.95rem;max-width:100%}.bb-channel-portable .bb-channel-image-slide{min-height:10rem}.bb-channel-portable .bb-channel-image-large{max-width:100%!important;max-height:68vh!important}.bb-channel-portable .bb-channel-image-nav{width:2.55rem;height:2.55rem}.bb-channel-portable .bb-channel-image-prev{left:.45rem}.bb-channel-portable .bb-channel-image-next{right:.45rem}.bb-channel-portable .bb-channel-attachment{align-items:flex-start;padding:.78rem .82rem}.bb-channel-portable .bb-channel-attachment-title{white-space:normal;overflow-wrap:anywhere}.bb-channel-portable .bb-channel-tags{gap:.42rem}.bb-channel-portable .bb-channel-pagination{gap:1rem}}
|
|
331
|
+
@media(prefers-reduced-motion:reduce){.bb-channel-portable .bb-channel-card-placeholder,.bb-channel-portable .bb-channel-card-surface,.bb-channel-portable .bb-channel-media-rail,.bb-channel-portable .bb-channel-image-viewer,.bb-channel-portable .bb-channel-image-large,.bb-channel-portable .bb-channel-attachment{transition:none}.bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:none}}
|
|
332
|
+
</style>
|
|
333
|
+
<div class="bb-channel-portable" data-bb-channel-root data-api-base="${escapeHtml(config.apiBase)}" data-page-size="${escapeHtml(config.pageSize)}">
|
|
334
|
+
<header class="bb-channel-intro">
|
|
335
|
+
<div class="bb-channel-title" role="heading" aria-level="1">${escapeHtml(config.title)}</div>
|
|
336
|
+
<p class="bb-channel-intro-text">${escapeHtml(config.description)}</p>
|
|
337
|
+
</header>
|
|
338
|
+
<div class="bb-channel-status" data-bb-channel-status aria-live="polite"></div>
|
|
339
|
+
<article class="bb-channel-feed" data-bb-channel-feed aria-label="Telegram microblog timeline"></article>
|
|
340
|
+
<nav class="bb-channel-pagination" data-bb-channel-pagination aria-label="BB pagination"></nav>
|
|
341
|
+
</div>
|
|
342
|
+
${renderClientScript()}
|
|
343
|
+
`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
renderClientContent,
|
|
348
|
+
};
|