Compare commits

...
31 Commits
Author SHA1 Message Date
github-actions[bot] 49c1a87a67 chore: release v2.8.2 (#769)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-02-05 19:09:15 +08:00
qwqcode ec7f81cb83 chore(docs): import artalk css via node 2024-02-05 19:01:39 +08:00
qwqcode 4b115d7b6a perf(ui/editor): optimize editor styles for narrow-width screens 2024-02-05 17:19:53 +08:00
qwqcode 1bae6d8d2a perf(ui/editor): modify editor plug-in text buttons to icons for brevity 2024-02-05 17:19:53 +08:00
qwqcode bdafa19470 perf(ui/editor): place Cancel Reply at the bottom of editor area (#644) 2024-02-05 17:19:40 +08:00
qwqcode 342236b6a7 perf(ui/lite): disable ArtalkLite preview config option by default 2024-02-05 17:05:00 +08:00
qwqcode ffce9f9e85 fix(style): replace style break-all with break-word in content el (#644) 2024-02-05 15:05:58 +08:00
qwqcode 0097e041b1 fix(api/comments): ua field is optional and not required (#767) 2024-02-05 14:30:17 +08:00
github-actions[bot] 42da621dfc chore: release v2.8.1 (#766)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-02-04 22:21:43 +08:00
qwqcode 2f5dbaa022 chore(i18n): update zh-CN 2024-02-04 22:02:13 +08:00
qwqcode e181a6d5dd perf(style/sidebar): improve some detail in sidebar 2024-02-04 21:55:59 +08:00
qwqcode 7873b21fb7 fix(ui/scroll): scroll into view issue when relativeTo parameter is provided 2024-02-04 21:53:02 +08:00
qwqcode 2a219518a4 feat(ui/goto): improved solution of navigating to comment via link hash (#693) (#765) 2024-02-04 21:47:06 +08:00
qwqcode afa7888db0 chore(typo): fix comment typo 2024-02-04 20:26:34 +08:00
qwqcode f6a589d5b2 refactor(ui/list): refactor the list layout manager 2024-02-04 20:25:21 +08:00
qwqcode 41a09d5996 refactor(ui/comment): loose coupling between Comment and Context 2024-02-04 20:15:28 +08:00
qwqcode 191977c0df feat(api): add GET /comments/:id endpoint (#764) 2024-02-04 20:08:38 +08:00
qwqcode 5a4c06daee chore(ip_region): skip empty string 2024-02-04 19:48:26 +08:00
qwqcode c4942ab807 chore(Makefile): add http-client build cmd to update-swagger 2024-02-04 03:24:58 +08:00
qwqcode 6b83a9f632 chore(transfer): remove the url_resolver disabled warning msg 2024-02-04 02:25:33 +08:00
qwqcode 3c88c0c29c docs: supplement and refine fe-api.md 2024-02-04 02:22:49 +08:00
qwqcode 35aab6bedc fix(ui/list): unable to reply or delete comments after switching pagination 2024-02-04 01:40:07 +08:00
qwqcode 17033dc111 fix(ui/no_comment): always appears when creating comment 2024-02-03 23:45:48 +08:00
qwqcode 559df60f0d fix(ui/pv): missing pageTitle field in the PV request 2024-02-03 23:12:12 +08:00
qwqcode 22d4ec1c31 chore(docs): rename IP region in zh-CN 2024-02-03 21:44:37 +08:00
qwqcode d97ad5c843 fix(ip_region): unable to output the IP region correctly (#760) 2024-02-03 21:43:42 +08:00
qwqcode 34a67f647b chore(docs): update cdn source links 2024-02-03 19:58:38 +08:00
qwqcode 9c3228b8fc feat(config): add db.prepare_stmt config option (#760) (#762)
The Prepared Statement database feature is enabled by default. You can disable it in config file or a env variable `ATK_DB_PREPARE__STMT=0`.
2024-02-03 19:19:00 +08:00
qwqcode 4012d3e090 fix(cmd/admin): update user role to admin if already exists 2024-02-03 16:54:13 +08:00
qwqcode bc1c4d315c fix(api/comments): counting comments issue in PostgreSQL db (#760) (#761) 2024-02-03 15:34:25 +08:00
qwqcode 2a6f0c56a4 chore(docs): fix script cdn links 2024-02-01 21:43:52 +08:00
85 changed files with 1142 additions and 573 deletions

No files matched your search

+47
View File
@@ -1,4 +1,51 @@
## [v2.8.2](https://github.com/ArtalkJS/Artalk/compare/v2.8.1...v2.8.2) (2024-02-05)
### Bug Fixes
* **api/comments:** `ua` field is optional and not required ([#767](https://github.com/ArtalkJS/Artalk/issues/767))
* **style:** replace style `break-all` with `break-word` in content el ([#644](https://github.com/ArtalkJS/Artalk/issues/644))
### Performance Improvements
* **ui/editor:** optimize editor styles for narrow-width screens
* **ui/editor:** modify editor plug-in text buttons to icons for brevity
* **ui/editor:** place `Cancel Reply` at the bottom of editor area ([#644](https://github.com/ArtalkJS/Artalk/issues/644))
* **ui/lite:** disable ArtalkLite `preview` config option by default
## [v2.8.1](https://github.com/ArtalkJS/Artalk/compare/v2.8.0...v2.8.1) (2024-02-04)
### Features
* **api:** add GET `/comments/:id` endpoint ([#764](https://github.com/ArtalkJS/Artalk/issues/764))
* **config:** add `db.prepare_stmt` config option ([#760](https://github.com/ArtalkJS/Artalk/issues/760)) ([#762](https://github.com/ArtalkJS/Artalk/issues/762))
* **ui/goto:** improved solution of navigating to comment via link hash ([#693](https://github.com/ArtalkJS/Artalk/issues/693)) ([#765](https://github.com/ArtalkJS/Artalk/issues/765))
### Bug Fixes
* **api/comments:** counting comments issue in PostgreSQL db ([#760](https://github.com/ArtalkJS/Artalk/issues/760)) ([#761](https://github.com/ArtalkJS/Artalk/issues/761))
* **cmd/admin:** update user role to admin if already exists
* **ip_region:** unable to output the IP region correctly ([#760](https://github.com/ArtalkJS/Artalk/issues/760))
* **ui/list:** unable to reply or delete comments after switching pagination
* **ui/no_comment:** always appears when creating comment
* **ui/pv:** missing `pageTitle` field in the PV request
* **ui/scroll:** scroll into view issue when `relativeTo` parameter is provided
### Performance Improvements
* **style/sidebar:** improve some detail in sidebar
### Code Refactoring
* **ui/comment:** loose coupling between `Comment` and `Context`
* **ui/list:** refactor the list layout manager
### Documentation
* supplement and refine `fe-api.md`
## [v2.8.0](https://github.com/ArtalkJS/Artalk/compare/v2.7.3...v2.8.0) (2024-01-31)
### Features
+1
View File
@@ -53,6 +53,7 @@ update-i18n:
update-swagger:
go install github.com/swaggo/swag/cmd/swag@latest
swag init -g server/server.go --output ./docs/swagger --requiredByDefault
pnpm -r swagger:build-http-client
docker-build:
./scripts/docker-build.sh
+1
View File
@@ -41,6 +41,7 @@ func NewAdminCommand(app *ArtalkCmd) *cobra.Command {
findUser := app.Dao().FindUser(username, email)
if !findUser.IsEmpty() {
findUser.SetPasswordEncrypt(password)
findUser.IsAdmin = true
if err := app.Dao().UpdateUser(&findUser); err != nil {
log.Fatal(err)
}
-2
View File
@@ -30,8 +30,6 @@ func NewImportCommand(app *ArtalkCmd) *cobra.Command {
params := artransfer.ArrToImportParams(payload)
params.Assumeyes, _ = cmd.Flags().GetBool("assumeyes")
artransfer.RunImportArtrans(app.Dao(), params)
log.Info(i18n.T("Import complete"))
},
}
+1
View File
@@ -17,6 +17,7 @@ db:
password: ""
charset: "utf8mb4"
ssl: false
prepare_stmt: true
log:
enabled: true
filename: "./data/artalk.log"
+2
View File
@@ -47,6 +47,8 @@ db:
charset: "utf8mb4"
# Enable SSL mode
ssl: false
# Prepared Statement
prepare_stmt: true
# Logging
log:
+5 -3
View File
@@ -44,6 +44,8 @@ db:
table_prefix: ""
# 启用 SSL
ssl: false
# 预编译语句
prepare_stmt: true
# 日志
log:
@@ -162,9 +164,9 @@ captcha:
captcha_id: ""
captcha_key: ""
# IP 归属地
# IP 属地
ip_region:
# 启用 IP 归属地展示
# 启用 IP 属地展示
enabled: false
# 数据文件路径 (.xdb 格式)
db_path: "./data/ip2region.xdb"
@@ -276,7 +278,7 @@ admin_notify:
- "USER_ID_1"
- "GROUP_ID_1"
# 前端配置
# 界面配置
frontend:
# 评论框占位文字
placeholder: ""
+14 -14
View File
@@ -19,29 +19,29 @@ export default defineConfig({
],
// artalk
// ['link', { href: `https://npm.elemecdn.com/artalk@${Version.latest}/dist/Artalk.css`, rel: 'stylesheet' }],
[
"link",
{ href: `https://artalk.qwqaq.com/dist/Artalk.css`, rel: "stylesheet" },
],
// [
// "link",
// { href: `https://artalk.qwqaq.com/dist/Artalk.css`, rel: "stylesheet" },
// ],
// ['script', { src: ArtalkCDN.JS }],
// light gallery
[
"link",
{
href: "https://npm.elemecdn.com/lightgallery@2.3.0/css/lightgallery.css",
href: "https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.3.0/css/lightgallery.css",
rel: "stylesheet",
},
],
[
"script",
{
src: "https://npm.elemecdn.com/lightgallery@2.3.0/lightgallery.min.js",
src: "https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.3.0/lightgallery.min.js",
},
],
// katex
// ['link', { href: "https://npm.elemecdn.com/katex@0.15.3/dist/katex.min.css", rel: 'stylesheet' }],
// ['script', { src: 'https://npm.elemecdn.com/katex@0.15.3/dist/katex.min.js' }],
// ['script', { src: 'https://npm.elemecdn.com/@artalkjs/plugin-katex/dist/artalk-plugin-katex.js' }],
['link', { href: "https://unpkg.com/katex@0.15.3/dist/katex.min.css", rel: 'stylesheet' }],
['script', { src: 'https://unpkg.com/katex@0.15.3/dist/katex.min.js' }],
// ['script', { src: 'https://unpkg.com/@artalk/plugin-katex/dist/artalk-plugin-katex.js' }],
],
lastUpdated: true,
@@ -118,7 +118,7 @@ export default defineConfig({
{ text: "浏览量统计", link: "/guide/frontend/pv.md" },
{ text: "Latex", link: "/guide/frontend/latex.md" },
{ text: "图片灯箱", link: "/guide/frontend/lightbox.md" },
{ text: "IP 归属地", link: "/guide/frontend/ip-region.md" },
{ text: "IP 属地", link: "/guide/frontend/ip-region.md" },
{ text: "精简版本", link: "/guide/frontend/artalk-lite.md" },
{ text: "置入博客", link: "/guide/frontend/import-blog.md" },
{ text: "置入框架", link: "/guide/frontend/import-framework.md" },
@@ -215,10 +215,10 @@ export default defineConfig({
text: "代码仓库",
link: "https://github.com/ArtalkJS/Artalk",
},
{
text: "文档镜像 (国内)",
link: "https://artalk-docs.qwqaq.com",
},
// {
// text: "文档镜像 (国内)",
// link: "https://artalk-docs.qwqaq.com",
// },
],
},
],
+1
View File
@@ -6,6 +6,7 @@
import { watch, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { useData, useRouter } from 'vitepress'
import Artalk from 'artalk'
import 'artalk/dist/Artalk.css'
const el = ref<HTMLElement | null>(null)
+1 -1
View File
@@ -1,3 +1,3 @@
{
"latest": "2.8.0"
"latest": "2.8.2"
}
+21 -9
View File
@@ -26,14 +26,16 @@ const artalk = Artalk.init({
})
```
调用该函数会向后端发起请求:
调用该函数会异步地向后端发起请求:
1. 获取前端配置
2. 获取评论列表
配置和评论加载完毕后会分别触发 `conf-loaded` 和 `list-loaded` 事件。
配置和插件加载完毕后会触发 `mounted` 事件。
注:前端配置可能会被覆盖,详情见:[在后端控制前端](../guide/backend/fe-control.md)。
评论列表加载完毕后会触发 `list-loaded` 事件。
注:前端界面配置可能会被覆盖,详情见:[在后端控制前端](../guide/backend/fe-control.md)。
## 释放资源 `destroy`
@@ -45,7 +47,7 @@ const artalk = Artalk.init({ ... })
artalk.destroy()
```
释放资源将执行 Artalk 挂载的 DOM 节点删除,以及解除 Artalk 事件监听等操作。
调用该函数时,将触发 `unmounted` 事件。释放资源将执行 Artalk 挂载的 DOM 节点删除,以及解除 Artalk 事件监听等操作。当该函数执行完毕后,Artalk 实例将不再可用。
在 Vue / React 等框架中,务必在组件销毁时调用该函数,否则会造成内存泄漏:
@@ -69,11 +71,11 @@ artalk.update({
})
```
调用该函数将触发 `conf-loaded` 事件。
调用该函数将触发 `updated` 事件。
更新配置不会自动刷新评论列表,可按需继续执行 `artalk.reload()` 函数。
请注意,该函数是一个实例化对象中的方法,并非全局函数。
请注意,该函数是一个实例化对象中的成员方法,并非全局函数。
前端配置参考:[前端配置](../guide/frontend/config.md)
@@ -96,8 +98,9 @@ artalk.reload()
```js
const artalk = Artalk.init({ ... })
artalk.on('list-loaded', () => {
alert('评论列表加载完毕')
artalk.on('list-loaded', (comments) => {
// comments 包含当前页面所有评论数据 (而非仅本次请求获取的部分评论)
console.log('评论列表加载完毕: ', comments)
})
```
@@ -125,7 +128,7 @@ artalk.off('list-loaded', handler)
```js
const artalk = Artalk.init({ ... })
artalk.trigger('list-loaded')
artalk.trigger('list-loaded', [...])
```
## 加载插件 `use`
@@ -154,6 +157,15 @@ const artalk = Artalk.init({ ... })
artalk.setDarkMode(true)
```
也可以通过调用 `Artalk.init` 时,通过 `darkMode` 参数设置初始夜间模式。
```js
const artalk = Artalk.init({
// ...其他配置
darkMode: 'auto',
})
```
## 浏览量组件 `loadCountWidget`
详情见:[浏览量统计](../guide/frontend/pv.md)
+38 -12
View File
@@ -6,12 +6,26 @@
import Artalk from 'artalk'
Artalk.use((ctx) => {
ctx.on('mounted', () => {
ctx.get('editor').setContent("Hello World")
})
ctx.on('list-loaded', () => {
console.log('评论列表加载完毕')
ctx.getCommentNodes().forEach((node) => {
node.getEl().style.background = 'red'
})
})
})
Artalk.use((ctx) => {
let el = null
ctx.on('mounted', () => {
el = document.createElement('div')
document.body.appendChild(el)
console.log('Artalk 实例被挂载')
})
ctx.on('unmounted', () => {
el.remove()
console.log('Artalk 实例被销毁')
})
})
@@ -20,11 +34,14 @@ const artalk = Artalk.init({ ... })
请注意:
- `Artalk.use` 必须在 `Artalk.init` 之前调用。
- `Artalk.use` 必须在 `Artalk.init` 之前调用才生效。
- 请勿依赖插件的加载顺序,请监听事件来执行插件逻辑。
- 当所有的插件加载完毕后,会触发 `inited` 事件。
- 当所有的插件加载完毕后,会触发 `created` 事件。
- 当插件和配置加载完毕后,会触发 `mounted` 事件。
- 当 Artalk 实例被销毁时,会触发 `unmounted` 事件。
- 当配置发生变化时,会触发 `updated` 事件。
在 use 函数中,你可以通过 `ctx` 来访问 Artalk 的上下文。
在 use 函数中,你可以通过 `ctx` 来访问 Artalk 的 Context 对象。
## Context
@@ -32,15 +49,24 @@ Context 对象包含了 Artalk 的上下文信息。
| 成员 | 说明 |
| --- | --- |
| `ctx.$root` | Artalk 容器元素 |
| `ctx.conf` | Artalk 配置 |
| `ctx.fetch` | 加载数据数据 |
| `ctx.getEl` | 获取容器元素 |
| `ctx.getConf` | 获取配置 |
| `ctx.updateConf` | 更新配置 |
| `ctx.watchConf` | 监听配置 |
| `ctx.setDarkMode` | 设置夜间模式 |
| `ctx.getApi` | 获取 API 客户端对象 |
|--|--|
| `ctx.fetch` | 获取评论数据 |
| `ctx.reload` | 重载评论列表 |
| `ctx.getComments` | 获取所有评论数据对象 |
| `ctx.getCommentNodes` | 获取所有评论节点对象 |
|--|--|
| `ctx.on` | 添加事件监听 |
| `ctx.off` | 解除事件监听 |
| `ctx.trigger` | 触发事件 |
| `ctx.inject` | 依赖注入 |
|--|--|
| `ctx.get` | 获取依赖 |
| `ctx.inject` | 注入依赖 |
::: warning
Context API 目前仍不稳定,开发可能会有变动,升级请关注 CHANGELOG。
@@ -48,7 +74,7 @@ Context API 目前仍不稳定,开发可能会有变动,升级请关注 CHAN
参考:[@artalk/src/types/context.ts](https://github.com/ArtalkJS/Artalk/blob/master/ui/artalk/src/types/context.ts)
Artalk 拥有完整的 TypeScript 类型定义,你可以通过编辑器的自动补全来查看 API。
Artalk 包含完整的 TypeScript 类型定义,你可以通过编辑器的自动补全来查看 API。
## 示例插件
+1
View File
@@ -96,6 +96,7 @@ db:
charset: "utf8mb4" # 编码格式
table_prefix: "" # 表前缀 (例如:"atk_")
ssl: false # 启用 SSL
prepare_stmt: true # 预编译语句
```
数据表将在 Artalk 启动时自动完成创建,无需额外操作。
+3 -3
View File
@@ -1,10 +1,10 @@
# 在后端控制前端
你可以在后端完全控制前端界面的行为(包括设定[前端的配置](/guide/frontend/config)),我们推荐使用这种方式部署 Artalk,并且这个特性「默认开启」。
你可以在后端完全控制前端界面的行为(包括设定几乎所有[前端配置项](/guide/frontend/config)),我们推荐使用这种方式配置 Artalk 界面,并且这个特性「默认开启」。
## 如何修改前端配置
## 如何修改前端界面配置
在侧边栏的[控制中心](/guide/frontend/sidebar.md#控制中心)提供了图形设置界面,你能快速轻松地对前端配置进行修改。
在侧边栏的[控制中心](/guide/frontend/sidebar.md#控制中心)提供了图形设置界面,你能快速轻松地对前端界面配置进行修改。
<img src="../../images/sidebar/fe-setting.png" width="400px">
+11 -5
View File
@@ -127,21 +127,27 @@ Artalk 静态资源通过上游 CDNJS 分发,以下镜像资源可供选择:
>
> https://cdnjs.cloudflare.com/ajax/libs/artalk/:ArtalkVersion:/Artalk.css
**Staticfile CDN (国内)**
**SUSTech Mirrors (境内)**
> https://mirrors.sustech.edu.cn/cdnjs/ajax/libs/artalk/:ArtalkVersion:/Artalk.js
>
> https://mirrors.sustech.edu.cn/cdnjs/ajax/libs/artalk/:ArtalkVersion:/Artalk.css
::: details 查看更多
**Staticfile CDN (境内)**
> https://cdn.staticfile.org/artalk/:ArtalkVersion:/Artalk.js
>
> https://cdn.staticfile.org/artalk/:ArtalkVersion:/Artalk.css
::: details 查看更多
**BootCDN (国内)**
**BootCDN (境内)**
> https://cdn.bootcdn.net/ajax/libs/artalk/:ArtalkVersion:/Artalk.js
>
> https://cdn.bootcdn.net/ajax/libs/artalk/:ArtalkVersion:/Artalk.css
**75CDN (国内)**
**75CDN (境内)**
> https://lib.baomitu.com/artalk/:ArtalkVersion:/Artalk.js
>
+1 -1
View File
@@ -57,7 +57,7 @@ Artalk 除了支持 OwO 格式外,还内置支持一种标准的表情包列
::: warning
前端的配置将默认被后端覆盖,详情参考:[在后端控制前端](/guide/backend/fe-control.html)
前端的配置可能会被后端所覆盖,详情参考:[在后端控制前端](/guide/backend/fe-control.html)
:::
+8 -8
View File
@@ -1,14 +1,14 @@
# IP 归属地
# IP 属地
Artalk 内置用户 IP 归属地展示功能,并且你可以设置显示的精度:精确到市、省。
Artalk 内置用户 IP 属地展示功能,并且你可以设置显示的精度:精确到市、省。
该功能默认关闭,你可在 Artalk 控制中心设置中启用 IP 归属地展示功能。
该功能默认关闭,你可在 Artalk 控制中心设置中启用 IP 属地展示功能。
## IP 归属地数据库
## IP 属地数据库
在开启 IP 归属地展示功能之前,你需要下载一个数据库文件:
在开启 IP 属地展示功能之前,你需要下载一个数据库文件:
- [GitHub 下载](https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region.xdb) / [镜像站下载](https://ghproxy.com/?q=https%3A%2F%2Fgithub.com%2Flionsoul2014%2Fip2region%2Fblob%2Fmaster%2Fdata%2Fip2region.xdb) (国内推荐)
- [GitHub 下载](https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region.xdb) / [镜像站下载](https://gh-proxy.com/?q=https%3A%2F%2Fgithub.com%2Flionsoul2014%2Fip2region%2Fblob%2Fmaster%2Fdata%2Fip2region.xdb) (境内推荐)
下载后请手动放置到 `./data/` 目录下,文件命名为:`ip2region.xdb`
@@ -25,9 +25,9 @@ Artalk 内置用户 IP 归属地展示功能,并且你可以设置显示的精
配置文件:
```yaml
# IP 归属地
# IP 属地
ip_region:
# 启用 IP 归属地展示
# 启用 IP 属地展示
enabled: false
# 数据文件路径 (.xdb 格式)
db_path: "./data/ip2region.xdb"
+92
View File
@@ -494,6 +494,73 @@ const docTemplate = `{
}
},
"/comments/{id}": {
"get": {
"description": "Get the detail of a comment by comment id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Comment"
],
"summary": "Get a comment",
"operationId": "GetComment",
"parameters": [
{
"type": "integer",
"description": "The comment ID you want to get",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.ResponseCommentGet"
}
},
"404": {
"description": "Not Found",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.Map"
},
{
"type": "object",
"properties": {
"msg": {
"type": "string"
}
}
}
]
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.Map"
},
{
"type": "object",
"properties": {
"msg": {
"type": "string"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
@@ -3967,6 +4034,31 @@ const docTemplate = `{
}
}
},
"handler.ResponseCommentGet": {
"type": "object",
"required": [
"comment",
"reply_comment"
],
"properties": {
"comment": {
"description": "The comment detail",
"allOf": [
{
"$ref": "#/definitions/entity.CookedComment"
}
]
},
"reply_comment": {
"description": "The reply comment if exists (like reply)",
"allOf": [
{
"$ref": "#/definitions/entity.CookedComment"
}
]
}
}
},
"handler.ResponseCommentList": {
"type": "object",
"required": [
+92
View File
@@ -487,6 +487,73 @@
}
},
"/comments/{id}": {
"get": {
"description": "Get the detail of a comment by comment id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Comment"
],
"summary": "Get a comment",
"operationId": "GetComment",
"parameters": [
{
"type": "integer",
"description": "The comment ID you want to get",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.ResponseCommentGet"
}
},
"404": {
"description": "Not Found",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.Map"
},
{
"type": "object",
"properties": {
"msg": {
"type": "string"
}
}
}
]
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.Map"
},
{
"type": "object",
"properties": {
"msg": {
"type": "string"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
@@ -3960,6 +4027,31 @@
}
}
},
"handler.ResponseCommentGet": {
"type": "object",
"required": [
"comment",
"reply_comment"
],
"properties": {
"comment": {
"description": "The comment detail",
"allOf": [
{
"$ref": "#/definitions/entity.CookedComment"
}
]
},
"reply_comment": {
"description": "The reply comment if exists (like reply)",
"allOf": [
{
"$ref": "#/definitions/entity.CookedComment"
}
]
}
}
},
"handler.ResponseCommentList": {
"type": "object",
"required": [
+53
View File
@@ -659,6 +659,20 @@ definitions:
- vote_down
- vote_up
type: object
handler.ResponseCommentGet:
properties:
comment:
allOf:
- $ref: '#/definitions/entity.CookedComment'
description: The comment detail
reply_comment:
allOf:
- $ref: '#/definitions/entity.CookedComment'
description: The reply comment if exists (like reply)
required:
- comment
- reply_comment
type: object
handler.ResponseCommentList:
properties:
comments:
@@ -1428,6 +1442,45 @@ paths:
summary: Delete Comment
tags:
- Comment
get:
consumes:
- application/json
description: Get the detail of a comment by comment id
operationId: GetComment
parameters:
- description: The comment ID you want to get
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.ResponseCommentGet'
"404":
description: Not Found
schema:
allOf:
- $ref: '#/definitions/handler.Map'
- properties:
msg:
type: string
type: object
"500":
description: Internal Server Error
schema:
allOf:
- $ref: '#/definitions/handler.Map'
- properties:
msg:
type: string
type: object
summary: Get a comment
tags:
- Comment
put:
consumes:
- application/json
+7 -7
View File
@@ -67,10 +67,10 @@
"Wrong captcha": 验证码错误
"{{count}} items imported": 已导入 {{count}} 个项目
"{{done}} of {{total}} done": 已完成 {{done}} 共 {{total}} 个
"{{name}} already exists": '{{name}} 已存在'
"{{name}} cannot be empty": '{{name}} 不能为空'
"{{name}} creation failed": '{{name}} 创建失败'
"{{name}} deletion failed": '{{name}} 删除失败'
"{{name}} is required": '{{name}} 必须填写'
"{{name}} not found": '{{name}} 未找到'
"{{name}} save failed": '{{name}} 保存失败'
"{{name}} already exists": '{{name}}已存在'
"{{name}} cannot be empty": '{{name}}不能为空'
"{{name}} creation failed": '{{name}}创建失败'
"{{name}} deletion failed": '{{name}}删除失败'
"{{name}} is required": '{{name}}必须填写'
"{{name}} not found": '{{name}}未找到'
"{{name}} save failed": '{{name}}保存失败'
-4
View File
@@ -11,10 +11,6 @@ func RunExportArtrans(dao *dao.Dao, params *ExportParams) (string, error) {
}
func RunImportArtrans(dao *dao.Dao, params *ImportParams) {
if !params.UrlResolver {
logWarn("Target site URL resolver disabled")
}
// 读取 JSON
if params.JsonData == "" {
if params.JsonFile == "" {
+1 -1
View File
@@ -176,7 +176,7 @@ func (conf *Config) ipRegionPatch() {
// 检测配置文件是否存在
if !utils.CheckFileExist(conf.IPRegion.DBPath) {
log.Warn("未找到 IP 数据库文件:" + strconv.Quote(conf.IPRegion.DBPath) + ",IP 归属地功能已禁用," +
log.Warn("未找到 IP 数据库文件:" + strconv.Quote(conf.IPRegion.DBPath) + ",IP 属地功能已禁用," +
"参考链接:https://artalk.js.org/guide/frontend/ip-region.html")
conf.IPRegion.Enabled = false
}
+3 -2
View File
@@ -24,7 +24,7 @@ type Config struct {
Moderator ModeratorConf `koanf:"moderator" json:"moderator"` // 评论审查
Captcha CaptchaConf `koanf:"captcha" json:"captcha"` // 验证码
Email EmailConf `koanf:"email" json:"email"` // 邮箱提醒
IPRegion IPRegionConf `koanf:"ip_region" json:"ip_region"` // IP 归属地展示
IPRegion IPRegionConf `koanf:"ip_region" json:"ip_region"` // IP 属地展示
ImgUpload ImgUploadConf `koanf:"img_upload" json:"img_upload"` // 图片上传
AdminNotify AdminNotifyConf `koanf:"admin_notify" json:"admin_notify"` // 其他通知方式
Frontend map[string]interface{} `koanf:"frontend" json:"frontend"`
@@ -57,6 +57,7 @@ type DBConf struct {
TablePrefix string `koanf:"table_prefix" json:"table_prefix"`
Charset string `koanf:"charset" json:"charset"`
SSL bool `koanf:"ssl" json:"ssl"`
PrepareStmt *bool `koanf:"prepare_stmt" json:"prepare_stmt"`
}
type CacheConf struct {
@@ -251,7 +252,7 @@ type RedisConf struct {
}
type IPRegionConf struct {
Enabled bool `koanf:"enabled" json:"enabled"` // 启用 IP 归属地展示
Enabled bool `koanf:"enabled" json:"enabled"` // 启用 IP 属地展示
DBPath string `koanf:"db_path" json:"db_path"` // 数据文件路径
Precision string `koanf:"precision" json:"precision"` // 显示精度
}
+7
View File
@@ -17,6 +17,13 @@ func NewDB(conf config.DBConf) (*gorm.DB, error) {
},
}
// Enable Prepared Statement by default
if prepareStmt := conf.PrepareStmt; prepareStmt != nil {
gormConfig.PrepareStmt = *prepareStmt
} else {
gormConfig.PrepareStmt = true
}
var dsn string
if conf.Dsn != "" {
dsn = conf.Dsn
+8 -1
View File
@@ -27,7 +27,14 @@ func OpenMySql(dsn string, gormConfig *gorm.Config) (*gorm.DB, error) {
}
func OpenPostgreSQL(dsn string, gormConfig *gorm.Config) (*gorm.DB, error) {
return gorm.Open(postgres.Open(dsn), gormConfig)
return gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
// gorm v2 use `pgx` as postgres’s database/sql driver,
// it enables prepared statement cache by default,
// disable it when `PrepareStmt` is false by following code:
PreferSimpleProtocol: !gormConfig.PrepareStmt,
}), gormConfig)
}
func OpenSqlServer(dsn string, gormConfig *gorm.Config) (*gorm.DB, error) {
+7 -2
View File
@@ -1,6 +1,8 @@
package ip_region
import (
"strings"
"github.com/ArtalkJS/Artalk/internal/config"
"github.com/ArtalkJS/Artalk/internal/log"
)
@@ -21,11 +23,14 @@ func NewIPRegion(conf IPRegionConf) *IPRegion {
}
func (ipRegion *IPRegion) IP2Region(ip string) string {
ip = ipScraper(ip)
if strings.TrimSpace(ip) == "" {
return ""
}
ip = ipScraper(ip)
region, err := search(ip, ipRegion.conf.DBPath, ipRegion.conf.CacheEnabled)
if err != nil {
log.Error(err)
log.Warn("[IP2Region] ", err)
return ""
}
+13 -9
View File
@@ -179,15 +179,7 @@ func CommentCreate(app *core.App, router fiber.Router) {
}()
cookedComment := app.Dao().CookComment(&comment)
// IP 归属地
if app.Conf().IPRegion.Enabled {
if ipRegionService, err := core.AppService[*core.IPRegionService](app); err == nil {
cookedComment.IPRegion = ipRegionService.Query(comment.IP)
} else {
log.Error("[IPRegionService] err: ", err)
}
}
cookedComment = fetchIPRegionForComment(app, cookedComment)
return common.RespData(c, ResponseCommentCreate{
CookedComment: cookedComment,
@@ -195,6 +187,18 @@ func CommentCreate(app *core.App, router fiber.Router) {
}))
}
// Fetch IP Region for Comment
func fetchIPRegionForComment(app *core.App, comment entity.CookedComment) entity.CookedComment {
if app.Conf().IPRegion.Enabled {
if ipRegionService, err := core.AppService[*core.IPRegionService](app); err == nil {
comment.IPRegion = ipRegionService.Query(comment.IP)
} else {
log.Error("[IPRegionService] err: ", err)
}
}
return comment
}
func isAllowComment(app *core.App, c *fiber.Ctx, name string, email string, page entity.Page) (bool, error) {
isAdminUser := app.Dao().IsAdminUserByNameEmail(name, email)
+56
View File
@@ -0,0 +1,56 @@
package handler
import (
"github.com/ArtalkJS/Artalk/internal/core"
"github.com/ArtalkJS/Artalk/internal/entity"
"github.com/ArtalkJS/Artalk/internal/i18n"
"github.com/ArtalkJS/Artalk/server/common"
"github.com/gofiber/fiber/v2"
)
type ResponseCommentGet struct {
Comment entity.CookedComment `json:"comment"` // The comment detail
ReplyComment *entity.CookedComment `json:"reply_comment"` // The reply comment if exists (like reply)
}
// @Id GetComment
// @Summary Get a comment
// @Description Get the detail of a comment by comment id
// @Tags Comment
// @Param id path int true "The comment ID you want to get"
// @Accept json
// @Produce json
// @Success 200 {object} ResponseCommentGet
// @Failure 404 {object} Map{msg=string}
// @Failure 500 {object} Map{msg=string}
// @Router /comments/{id} [get]
func CommentGet(app *core.App, router fiber.Router) {
router.Get("/comments/:id", func(c *fiber.Ctx) error {
id, _ := c.ParamsInt("id")
// Find comment by id
comment := app.Dao().FindComment(uint(id))
if comment.IsEmpty() {
return common.RespError(c, 404, i18n.T("{{name}} not found", Map{"name": i18n.T("Comment")}))
}
// Find linked comment by id
var replyComment *entity.CookedComment
if comment.Rid != 0 {
rComment := app.Dao().FindComment(uint(comment.Rid))
if !comment.IsEmpty() {
rComment := app.Dao().CookComment(&rComment)
rComment.Visible = false
replyComment = &rComment
}
}
cookedComment := app.Dao().CookComment(&comment)
cookedComment = fetchIPRegionForComment(app, cookedComment)
return common.RespData(c, ResponseCommentGet{
Comment: cookedComment,
ReplyComment: replyComment,
})
})
}
+5 -2
View File
@@ -21,7 +21,7 @@ type ParamsCommentUpdate struct {
Email string `json:"email" validate:"optional"` // The comment email
Link string `json:"link" validate:"optional"` // The comment link
Rid uint `json:"rid" validate:"required"` // The comment rid
UA string `json:"ua" validate:"required"` // The comment ua
UA string `json:"ua" validate:"optional"` // The comment ua
IP string `json:"ip" validate:"optional"` // The comment ip
IsCollapsed bool `json:"is_collapsed" validate:"required"` // The comment is_collapsed
IsPending bool `json:"is_pending" validate:"required"` // The comment is_pending
@@ -134,8 +134,11 @@ func CommentUpdate(app *core.App, router fiber.Router) {
return common.RespError(c, 500, i18n.T("{{name}} save failed", Map{"name": i18n.T("Comment")}))
}
cookedComment := app.Dao().CookComment(&comment)
cookedComment = fetchIPRegionForComment(app, cookedComment)
return common.RespData(c, ResponseCommentUpdate{
CookedComment: app.Dao().CookComment(&comment),
CookedComment: cookedComment,
})
}))
}
+8 -3
View File
@@ -29,6 +29,11 @@ type QueryOptions struct {
}
// Get query scope by params
//
// Please be aware that only `WHERE` conditions are permissible in this function.
// For `ORDER BY`, `LIMIT`, and `OFFSET`, please utilize separate functions, as this
// function is invoked in both `Find` and `Count`. `ORDER BY`, `LIMIT`, and `OFFSET` cannot
// be employed within `Count`.
func GetQueryScopes(dao *dao.Dao, opts QueryOptions) func(*gorm.DB) *gorm.DB {
return func(q *gorm.DB) *gorm.DB {
// Basic scope
@@ -39,9 +44,6 @@ func GetQueryScopes(dao *dao.Dao, opts QueryOptions) func(*gorm.DB) *gorm.DB {
q.Scopes(SearchScope(dao, opts.Search))
}
// Sort by
q.Order(GetSortSQL(opts.Scope, opts.SortBy))
// Scopes
scopes := map[Scope]func(*gorm.DB) *gorm.DB{
ScopePage: PageScopeQuery(opts.PagePayload, PageScopeOpts{
@@ -79,6 +81,9 @@ func FindComments(dao *dao.Dao, opts QueryOptions, pg FindOptions) []entity.Comm
q := dao.DB().Model(&entity.Comment{}).
Scopes(GetQueryScopes(dao, opts))
// Sort Rule
q.Order(GetSortSQL(opts.Scope, opts.SortBy))
q.Offset(pg.Offset).
Limit(pg.Limit)
+2 -2
View File
@@ -56,8 +56,8 @@ func FindIPRegionForComments(app *core.App, comments []entity.CookedComment) []e
ipRegionService, err := core.AppService[*core.IPRegionService](app)
if err == nil {
for _, c := range comments {
c.IPRegion = ipRegionService.Query(c.IP)
for i, c := range comments {
comments[i].IPRegion = ipRegionService.Query(c.IP)
}
} else {
log.Error("[IPRegionService] err: ", err)
+1
View File
@@ -79,6 +79,7 @@ func Serve(app *core.App) (*fiber.App, error) {
{
h.CommentCreate(app, api)
h.CommentList(app, api)
h.CommentGet(app, api)
h.Vote(app, api)
h.PagePV(app, api)
h.Stat(app, api)
+9
View File
@@ -116,6 +116,15 @@ const darkMode = ref(bootParams.darkMode)
left: 0;
background: var(--at-color-bg);
border-top: 1px solid var(--at-color-border);
// A patch for loading
// Because the parent position set to fixed,
// the loading layer will be fixed to the area if position is absolute (which is default for atk-loading)
.atk-loading {
position: fixed;
top: 102px;
height: calc(100% - 102px);
}
}
}
</style>
-3
View File
@@ -45,9 +45,6 @@ onMounted(() => {
artalk!.ctx.on('comment-rendered', (comment) => {
const pageURL = comment.getData().page_url
comment.getRender().setOpenURL(`${pageURL}#atk-comment-${comment.getID()}`)
comment.getConf().onReplyBtnClick = () => {
artalk!.ctx.replyComment(comment.getData(), comment.getEl())
}
})
artalk!.ctx.updateConf({
+7
View File
@@ -14,6 +14,13 @@ body {
.atk-sidebar-inner {
}
.atk-list {
.atk-main-editor {
margin-top: -4px;
margin-bottom: -2px;
}
}
.atk-sidebar-no-content {
position: absolute;
left: 0;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artalk",
"version": "2.8.0",
"version": "2.8.2",
"description": "A self-hosted comment system",
"keywords": [
"comment-system",
+38
View File
@@ -333,6 +333,13 @@ export interface HandlerResponseCommentCreate {
vote_up: number
}
export interface HandlerResponseCommentGet {
/** The comment detail */
comment: EntityCookedComment
/** The reply comment if exists (like reply) */
reply_comment: EntityCookedComment
}
export interface HandlerResponseCommentList {
comments: EntityCookedComment[]
count: number
@@ -972,6 +979,37 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
...params,
}),
/**
* @description Get the detail of a comment by comment id
*
* @tags Comment
* @name GetComment
* @summary Get a comment
* @request GET:/comments/{id}
* @response `200` `HandlerResponseCommentGet` OK
* @response `404` `(HandlerMap & {
msg?: string,
})` Not Found
* @response `500` `(HandlerMap & {
msg?: string,
})` Internal Server Error
*/
getComment: (id: number, params: RequestParams = {}) =>
this.request<
HandlerResponseCommentGet,
HandlerMap & {
msg?: string
}
>({
path: `/comments/${id}`,
method: 'GET',
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Update a specific comment
*
-1
View File
@@ -115,7 +115,6 @@ export default class Artalk {
Stat.initCountWidget({
getApi: () => new Api(convertApiOptions(conf)),
siteName: conf.site,
pageKey: conf.pageKey,
countEl: conf.countEl,
pvEl: conf.pvEl,
pvAdd: false
+13 -12
View File
@@ -1,12 +1,13 @@
import ActionBtn from '@/components/action-btn'
import $t from '@/i18n'
import type { CommentNode } from '.'
import ActionBtn from '../components/action-btn'
export default class CommentActions {
private comment: CommentNode
private get ctx() { return this.comment.ctx }
private get data() { return this.comment.getData() }
private get cConf() { return this.comment.getConf() }
private get opts() { return this.comment.getOpts() }
private getApi() { return this.comment.getOpts().getApi() }
public constructor(comment: CommentNode) {
this.comment = comment
@@ -16,7 +17,7 @@ export default class CommentActions {
public vote(type: 'up'|'down') {
const actionBtn = (type === 'up') ? this.comment.getRender().voteBtnUp : this.comment.getRender().voteBtnDown
this.ctx.getApi().votes.vote(`comment_${type}`, this.data.id, { ...this.ctx.getApi().getUserFields() })
this.getApi().votes.vote(`comment_${type}`, this.data.id, { ...this.getApi().getUserFields() })
.then((res) => {
this.data.vote_up = res.data.up
this.data.vote_down = res.data.down
@@ -24,7 +25,7 @@ export default class CommentActions {
this.comment.getRender().voteBtnDown?.updateText()
})
.catch((err) => {
actionBtn?.setError(this.ctx.$t('voteFail'))
actionBtn?.setError($t('voteFail'))
console.log(err)
})
}
@@ -33,7 +34,7 @@ export default class CommentActions {
public adminEdit(type: 'collapsed'|'pending'|'pinned', btnElem: ActionBtn) {
if (btnElem.isLoading) return // 若正在修改中
btnElem.setLoading(true, `${this.ctx.$t('editing')}...`)
btnElem.setLoading(true, `${$t('editing')}...`)
// 克隆并修改当前数据
const modify = { ...this.data }
@@ -45,7 +46,7 @@ export default class CommentActions {
modify.is_pinned = !modify.is_pinned
}
this.ctx.getApi().comments.updateComment(this.data.id, {
this.getApi().comments.updateComment(this.data.id, {
...modify,
}).then((res) => {
btnElem.setLoading(false)
@@ -54,7 +55,7 @@ export default class CommentActions {
this.comment.setData(res.data)
}).catch((err) => {
console.error(err)
btnElem.setError(this.ctx.$t('editFail'))
btnElem.setError($t('editFail'))
})
}
@@ -62,15 +63,15 @@ export default class CommentActions {
public adminDelete(btnElem: ActionBtn) {
if (btnElem.isLoading) return // 若正在删除中
btnElem.setLoading(true, `${this.ctx.$t('deleting')}...`)
this.ctx.getApi().comments.deleteComment(this.data.id)
btnElem.setLoading(true, `${$t('deleting')}...`)
this.getApi().comments.deleteComment(this.data.id)
.then(() => {
btnElem.setLoading(false)
if (this.cConf.onDelete) this.cConf.onDelete(this.comment)
if (this.opts.onDelete) this.opts.onDelete(this.comment)
})
.catch((e) => {
console.error(e)
btnElem.setError(this.ctx.$t('deleteFail'))
btnElem.setError($t('deleteFail'))
})
}
+101 -60
View File
@@ -1,42 +1,54 @@
import type { CommentData, ContextApi } from '@/types'
import Component from '../lib/component'
import type { CommentData, ArtalkConfig, ContextApi } from '@/types'
import $t from '@/i18n'
import type { Api } from '../api'
import * as Ui from '../lib/ui'
import * as Utils from '../lib/utils'
import marked from '../lib/marked'
import UADetect from '../lib/detect'
import CommentUI from './render'
import CommentActions from './actions'
export interface CommentConf {
isUnread?: boolean
openURL?: string
isFlatMode: boolean
replyTo?: CommentData
afterRender?: () => void
openEvt?: () => void
onReplyBtnClick?: Function
export interface CommentOptions {
// Hooks
onAfterRender?: () => void
onDelete?: Function
/** The comment being replied to (linked comment) */
replyTo?: CommentData
// Referenced from ArtalkConfig
flatMode: boolean
vote: boolean
voteDown: boolean
uaBadge: boolean
nestMax: number
gravatar: ArtalkConfig['gravatar']
heightLimit: ArtalkConfig['heightLimit']
avatarURLBuilder: ArtalkConfig['avatarURLBuilder']
scrollRelativeTo: ArtalkConfig['scrollRelativeTo']
// TODO: Move to plugin folder and remove from core
getApi: () => Api
replyComment: ContextApi['replyComment']
editComment: ContextApi['editComment']
}
export default class CommentNode extends Component {
export default class CommentNode {
$el?: HTMLElement
private renderInstance: CommentUI
private actionInstance: CommentActions
private data: CommentData
private cConf: CommentConf
private opts: CommentOptions
private parent: CommentNode|null
private children: CommentNode[] = []
private nestCurt: number // 当前嵌套层数
private nestMax: number // 最大嵌套层数
constructor(ctx: ContextApi, data: CommentData, conf: CommentConf) {
super(ctx)
// 最大嵌套数
this.nestMax = ctx.conf.nestMax || 3
this.cConf = conf
constructor(data: CommentData, opts: CommentOptions) {
this.opts = opts
this.data = { ...data }
this.data.date = this.data.date.replace(/-/g, '/') // 解决 Safari 日期解析 NaN 问题
@@ -53,8 +65,12 @@ export default class CommentNode extends Component {
if (this.$el) this.$el.replaceWith(newEl)
this.$el = newEl
// Please be aware of the memory leak, the $el may be replaced multiple times.
// If somewhere else has a reference to the old $el, it will cause a memory leak.
// So it's limited to use the $el reference by `getEl()`.
// The `getEl()` will always return the latest $el.
if (this.cConf.afterRender) this.cConf.afterRender()
if (this.opts.onAfterRender) this.opts.onAfterRender()
}
/** 获取评论操作实例对象 */
@@ -106,62 +122,87 @@ export default class CommentNode extends Component {
}
/** 置入子评论 */
public putChild(childC: CommentNode, insertMode: 'append'|'prepend' = 'append') {
childC.parent = this
childC.nestCurt = this.nestCurt + 1 // 嵌套层数 +1
public putChild(childNode: CommentNode, insertMode: 'append'|'prepend' = 'append') {
childNode.parent = this
childNode.nestCurt = this.nestCurt + 1 // 嵌套层数 +1
this.children.push(childNode)
this.children.push(childC)
const $childrenWrap = this.getChildrenWrapEl()
const $childComment = childNode.getEl()
if (insertMode === 'append') $childrenWrap.append($childComment)
else if (insertMode === 'prepend') $childrenWrap.prepend($childComment)
const $children = this.getChildrenEl()
if (insertMode === 'append') $children.append(childC.getEl())
else if (insertMode === 'prepend') $children.prepend(childC.getEl())
childC.getRender().playFadeAnim()
// 内容限高
childC.getRender().checkHeightLimit()
childNode.getRender().playFadeAnim()
childNode.getRender().checkHeightLimit() // 内容限高
}
/** 获取存放子评论的元素对象 */
public getChildrenEl(): HTMLElement {
let $children = this.getRender().getChildrenWrap()
if (!$children) {
// console.log(this.nestCurt)
if (this.nestCurt < this.nestMax) {
$children = this.getRender().renderChildrenWrap()
} else {
$children = this.parent!.getChildrenEl()
}
public getChildrenWrapEl(): HTMLElement {
// console.log(this.nestCurt)
if (this.nestCurt >= this.opts.nestMax) {
return this.parent!.getChildrenWrapEl()
}
return $children
return this.getRender().getChildrenWrap()
}
/** 获取所有父评论 */
public getParents() {
const parents: CommentNode[] = []
const once = (c: CommentNode) => {
if (c.parent) {
parents.push(c.parent)
once(c.parent)
}
const flattenParents: CommentNode[] = []
let parent = this.parent
while (parent) {
flattenParents.push(parent)
parent = parent.getParent()
}
once(this)
return parents
return flattenParents
}
/** 获取评论元素对象 */
/**
* Get the element of the comment
*
* The `getEl()` will always return the latest $el after calling `render()`.
* Please be aware of the memory leak if you use the $el reference directly.
*/
public getEl() {
if (!this.$el) throw new Error('comment element not initialized before `getEl()`')
return this.$el
}
/**
* Focus on the comment
*
* Scroll to the comment and perform flash animation
*/
focus() {
if (!this.$el) throw new Error('comment element not initialized before `focus()`')
// 若父评论存在 “子评论部分” 限高,取消限高
this.getParents().forEach((p) => {
p.getRender().heightLimitRemoveForChildren()
})
// Scroll to comment
this.scrollIntoView()
// Perform flash animation
this.getRender().playFlashAnim()
}
scrollIntoView() {
this.$el && Ui.scrollIntoView(this.$el, false, this.opts.scrollRelativeTo && this.opts.scrollRelativeTo())
}
/**
* Remove the comment node
*/
remove() {
this.$el?.remove()
}
/** 获取 Gravatar 头像 URL */
public getGravatarURL() {
return Utils.getGravatarURL({
mirror: this.ctx.conf.gravatar.mirror,
params: this.ctx.conf.gravatar.params,
mirror: this.opts.gravatar.mirror,
params: this.opts.gravatar.params,
emailMD5: this.data.email_encrypted,
})
}
@@ -173,7 +214,7 @@ export default class CommentNode extends Component {
/** 获取格式化后的日期 */
public getDateFormatted() {
return Utils.timeAgo(new Date(this.data.date), this.ctx.$t)
return Utils.timeAgo(new Date(this.data.date), $t)
}
/** 获取用户 UserAgent 信息 */
@@ -186,7 +227,7 @@ export default class CommentNode extends Component {
}
/** 获取配置 */
public getConf() {
return this.cConf
public getOpts() {
return this.opts
}
}
+28 -22
View File
@@ -10,10 +10,8 @@ import loadRenders from './renders'
export default class Render {
public comment: CommentNode
public get ctx() { return this.comment.ctx }
public get data() { return this.comment.getData() }
public get conf() { return this.comment.conf }
public get cConf() { return this.comment.getConf() }
public get opts() { return this.comment.getOpts() }
public $el!: HTMLElement
public $main!: HTMLElement
@@ -34,6 +32,15 @@ export default class Render {
this.comment = comment
}
/**
* Render the comment ui
*
* If comment data is updated, call this method to re-render the comment ui.
* The method will be called multiple times, so it should be idempotent.
*
* Renders may add event listeners to the comment ui, so it should be called only once or override the original.
* Please be aware of the memory leak caused by the event listener.
*/
public render() {
// init ui elements
this.$el = Utils.createElement(CommentHTML)
@@ -49,14 +56,17 @@ export default class Render {
// call all renders
loadRenders(this)
this.recoveryChildrenWrap()
// append children wrap if exists
if (this.$childrenWrap) {
this.$main.append(this.$childrenWrap)
}
return this.$el
}
/** 内容限高检测 */
public checkHeightLimit() {
const conf = this.ctx.conf.heightLimit
const conf = this.opts.heightLimit
if (!conf || !conf.content || !conf.children) return // 关闭限高
const contentMaxH = conf.content
@@ -94,28 +104,26 @@ export default class Render {
Ui.playFadeInAnim(this.comment.getRender().$body)
}
/** 获取子评论 Wrap */
public getChildrenWrap() {
return this.$childrenWrap
/** Perform the flash animation */
playFlashAnim() {
// Make sure the class is removed
this.$el.classList.remove('atk-flash-once')
window.setTimeout(() => {
// Add the class to perform the animation
this.$el.classList.add('atk-flash-once')
}, 150)
}
/** 初始化子评论区域 Wrap */
public renderChildrenWrap() {
/** 获取子评论 Wrap */
public getChildrenWrap() {
if (!this.$childrenWrap) {
// if not exists, create a new one
this.$childrenWrap = Utils.createElement('<div class="atk-comment-children"></div>')
this.$main.append(this.$childrenWrap)
}
return this.$childrenWrap
}
/** 恢复原有的子评论区域 Wrap */
public recoveryChildrenWrap() {
if (this.$childrenWrap) {
this.$main.append(this.$childrenWrap)
}
}
/** 设置已读 */
public setUnread(val: boolean) {
if (val) this.$el.classList.add('atk-unread')
@@ -132,10 +140,8 @@ export default class Render {
public setOpenURL(url: string) {
this.setOpenable(true)
this.$el.onclick = (evt) => {
evt.preventDefault()
evt.stopPropagation()
window.open(url)
if (this.cConf.openEvt) this.cConf.openEvt()
}
}
@@ -143,7 +149,7 @@ export default class Render {
public setOpenAction(action: () => void) {
this.setOpenable(true)
this.$el.onclick = (evt) => {
evt.preventDefault()
evt.stopPropagation()
action()
}
}
+14 -17
View File
@@ -1,3 +1,4 @@
import $t from '@/i18n'
import * as Utils from '../../lib/utils'
import ActionBtn from '../../components/action-btn'
import type Render from '../render'
@@ -18,17 +19,17 @@ export default function renderActions(r: Render) {
// 操作按钮 - 投票
function renderVote(r: Render) {
if (!r.ctx.conf.vote) return // 关闭投票功能
if (!r.opts.vote) return // 关闭投票功能
// 赞同按钮
r.voteBtnUp = new ActionBtn(() => `${r.ctx.$t('voteUp')} (${r.data.vote_up || 0})`).appendTo(r.$actions)
r.voteBtnUp = new ActionBtn(() => `${$t('voteUp')} (${r.data.vote_up || 0})`).appendTo(r.$actions)
r.voteBtnUp.setClick(() => {
r.comment.getActions().vote('up')
})
// 反对按钮
if (r.ctx.conf.voteDown) {
r.voteBtnDown = new ActionBtn(() => `${r.ctx.$t('voteDown')} (${r.data.vote_down || 0})`).appendTo(r.$actions)
if (r.opts.voteDown) {
r.voteBtnDown = new ActionBtn(() => `${$t('voteDown')} (${r.data.vote_down || 0})`).appendTo(r.$actions)
r.voteBtnDown.setClick(() => {
r.comment.getActions().vote('down')
})
@@ -39,22 +40,18 @@ function renderVote(r: Render) {
function renderReply(r: Render) {
if (!r.data.is_allow_reply) return // 不允许回复
const replyBtn = Utils.createElement(`<span>${r.ctx.$t('reply')}</span>`)
const replyBtn = Utils.createElement(`<span>${$t('reply')}</span>`)
r.$actions.append(replyBtn)
replyBtn.addEventListener('click', (e) => {
e.stopPropagation() // 防止穿透
if (!r.cConf.onReplyBtnClick) {
r.ctx.replyComment(r.data, r.$el)
} else {
r.cConf.onReplyBtnClick()
}
r.opts.replyComment(r.data, r.$el)
})
}
// 操作按钮 - 折叠
function renderCollapse(r: Render) {
const collapseBtn = new ActionBtn({
text: () => (r.data.is_collapsed ? r.ctx.$t('expand') : r.ctx.$t('collapse')),
text: () => (r.data.is_collapsed ? $t('expand') : $t('collapse')),
adminOnly: true
})
collapseBtn.appendTo(r.$actions)
@@ -66,7 +63,7 @@ function renderCollapse(r: Render) {
// 操作按钮 - 审核
function renderModerator(r: Render) {
const pendingBtn = new ActionBtn({
text: () => (r.data.is_pending ? r.ctx.$t('pending') : r.ctx.$t('approved')),
text: () => (r.data.is_pending ? $t('pending') : $t('approved')),
adminOnly: true
})
pendingBtn.appendTo(r.$actions)
@@ -78,7 +75,7 @@ function renderModerator(r: Render) {
// 操作按钮 - 置顶
function renderPin(r: Render) {
const pinnedBtn = new ActionBtn({
text: () => (r.data.is_pinned ? r.ctx.$t('unpin') : r.ctx.$t('pin')),
text: () => (r.data.is_pinned ? $t('unpin') : $t('pin')),
adminOnly: true
})
pinnedBtn.appendTo(r.$actions)
@@ -90,21 +87,21 @@ function renderPin(r: Render) {
// 操作按钮 - 编辑
function renderEdit(r: Render) {
const editBtn = new ActionBtn({
text: r.ctx.$t('edit'),
text: $t('edit'),
adminOnly: true
})
editBtn.appendTo(r.$actions)
editBtn.setClick(() => {
r.ctx.editComment(r.data, r.$el)
r.opts.editComment(r.data, r.$el)
})
}
// 操作按钮 - 删除
function renderDel(r: Render) {
const delBtn = new ActionBtn({
text: r.ctx.$t('delete'),
text: $t('delete'),
confirm: true,
confirmText: r.ctx.$t('deleteConfirm'),
confirmText: $t('deleteConfirm'),
adminOnly: true,
})
delBtn.appendTo(r.$actions)
+1 -1
View File
@@ -8,7 +8,7 @@ export default function renderAvatar(r: Render) {
const $avatar = r.$el.querySelector<HTMLElement>('.atk-avatar')!
const $avatarImg = Utils.createElement<HTMLImageElement>('<img />')
const avatarURLBuilder = r.conf.avatarURLBuilder
const avatarURLBuilder = r.opts.avatarURLBuilder
$avatarImg.src = avatarURLBuilder ? avatarURLBuilder(r.data) : r.comment.getGravatarURL()
if (r.data.link) {
+5 -4
View File
@@ -1,3 +1,4 @@
import $t from '@/i18n'
import * as Utils from '../../lib/utils'
import * as Ui from '../../lib/ui'
import type Render from '../render'
@@ -16,8 +17,8 @@ export default function renderContent(r: Render) {
r.$content.classList.add('atk-hide', 'atk-type-collapsed')
const collapsedInfoEl = Utils.createElement(`
<div class="atk-collapsed">
<span class="atk-text">${r.ctx.$t('collapsedMsg')}</span>
<span class="atk-show-btn">${r.ctx.$t('expand')}</span>
<span class="atk-text">${$t('collapsedMsg')}</span>
<span class="atk-show-btn">${$t('expand')}</span>
</div>`)
r.$body.insertAdjacentElement('beforeend', collapsedInfoEl)
@@ -29,11 +30,11 @@ export default function renderContent(r: Render) {
r.$content.innerHTML = r.comment.getContentMarked()
r.$content.classList.remove('atk-hide')
Ui.playFadeInAnim(r.$content)
contentShowBtn.innerText = r.ctx.$t('collapse')
contentShowBtn.innerText = $t('collapse')
} else {
r.$content.innerHTML = ''
r.$content.classList.add('atk-hide')
contentShowBtn.innerText = r.ctx.$t('expand')
contentShowBtn.innerText = $t('expand')
}
})
}
+5 -4
View File
@@ -1,3 +1,4 @@
import $t from '@/i18n'
import * as Utils from '../../lib/utils'
import type Render from '../render'
@@ -33,13 +34,13 @@ function renderVerifyBadge(ctx: Render) {
const badgeColor = ctx.data.badge_color
if (badgeText) {
const $badge = Utils.createElement(`<span class="atk-badge"></span>`)
$badge.innerText = badgeText.replace('管理员', ctx.ctx.$t('admin')) // i18n patch
$badge.innerText = badgeText.replace('管理员', $t('admin')) // i18n patch
$badge.style.backgroundColor = badgeColor || ''
ctx.$headerBadgeWrap.append($badge)
}
if (ctx.data.is_pinned) {
const $pinnedBadge = Utils.createElement(`<span class="atk-pinned-badge">${ctx.ctx.$t('pin')}</span>`) // 置顶徽章
const $pinnedBadge = Utils.createElement(`<span class="atk-pinned-badge">${$t('pin')}</span>`) // 置顶徽章
ctx.$headerBadgeWrap.append($pinnedBadge)
}
}
@@ -51,7 +52,7 @@ function renderDate(ctx: Render) {
}
function renderUABadge(ctx: Render) {
if (!ctx.ctx.conf.uaBadge && !ctx.data.ip_region) return
if (!ctx.opts.uaBadge && !ctx.data.ip_region) return
let $uaWrap = ctx.$header.querySelector('atk-ua-wrap')
if (!$uaWrap) {
@@ -67,7 +68,7 @@ function renderUABadge(ctx: Render) {
$uaWrap.append($regionBadge)
}
if (ctx.ctx.conf.uaBadge) {
if (ctx.opts.uaBadge) {
const { browser, os } = ctx.comment.getUserUA()
if (String(browser).trim()) {
const $uaBrowser = Utils.createElement(`<span class="atk-ua ua-browser"></span>`)
+2 -1
View File
@@ -1,3 +1,4 @@
import $t from '@/i18n'
import * as Utils from '../../lib/utils'
import type Render from '../render'
@@ -7,6 +8,6 @@ import type Render from '../render'
export default function renderPending(r: Render) {
if (!r.data.is_pending) return
const pendingEl = Utils.createElement(`<div class="atk-pending">${r.ctx.$t('pendingMsg')}</div>`)
const pendingEl = Utils.createElement(`<div class="atk-pending">${$t('pendingMsg')}</div>`)
r.$body.prepend(pendingEl)
}
+3 -3
View File
@@ -5,11 +5,11 @@ import type Render from '../render'
* 层级嵌套模式显示 AT 界面
*/
export default function renderReplyAt(r: Render) {
if (r.cConf.isFlatMode || r.data.rid === 0) return // not 平铺模式 或 根评论
if (!r.cConf.replyTo) return
if (r.opts.flatMode || r.data.rid === 0) return // not 平铺模式 或 根评论
if (!r.opts.replyTo) return
r.$replyAt = Utils.createElement(`<span class="atk-item atk-reply-at"><span class="atk-arrow"></span><span class="atk-nick"></span></span>`)
r.$replyAt.querySelector<HTMLElement>('.atk-nick')!.innerText = `${r.cConf.replyTo.nick}`
r.$replyAt.querySelector<HTMLElement>('.atk-nick')!.innerText = `${r.opts.replyTo.nick}`
r.$replyAt.onclick = () => { r.comment.getActions().goToReplyComment() }
r.$headerBadgeWrap.insertAdjacentElement('afterend', r.$replyAt)
+16 -9
View File
@@ -1,24 +1,31 @@
import * as Utils from '../../lib/utils'
import marked from '../../lib/marked'
import * as Utils from '@/lib/utils'
import marked from '@/lib/marked'
import $t from '@/i18n'
import type Render from '../render'
/**
* 回复的对象界面
* 关联评论显示 (被回复的评论)
*/
export default function renderReplyTo(r: Render) {
if (!r.cConf.isFlatMode) return // 仅平铺模式显示
if (!r.cConf.replyTo) return
if (!r.opts.flatMode) return // 仅平铺模式显示
if (!r.opts.replyTo) return
r.$replyTo = Utils.createElement(`
<div class="atk-reply-to">
<div class="atk-meta">${r.ctx.$t('reply')} <span class="atk-nick"></span>:</div>
<div class="atk-meta">${$t('reply')} <span class="atk-nick"></span>:</div>
<div class="atk-content"></div>
</div>`)
// Comment author name
const $nick = r.$replyTo.querySelector<HTMLElement>('.atk-nick')!
$nick.innerText = `@${r.cConf.replyTo.nick}`
$nick.innerText = `@${r.opts.replyTo.nick}`
$nick.onclick = () => { r.comment.getActions().goToReplyComment() }
let replyContent = marked(r.cConf.replyTo.content)
if (r.cConf.replyTo.is_collapsed) replyContent = `[${r.ctx.$t('collapsed')}]`
// Comment content
let replyContent = marked(r.opts.replyTo.content)
if (r.opts.replyTo.is_collapsed) replyContent = `[${$t('collapsed')}]`
r.$replyTo.querySelector<HTMLElement>('.atk-content')!.innerHTML = replyContent
// Mount the replyTo element
r.$body.prepend(r.$replyTo)
}
+1 -1
View File
@@ -73,7 +73,7 @@ export function handleConfFormServer(conf: Partial<ArtalkConfig>) {
if (conf.emoticons && typeof conf.emoticons === "string") {
conf.emoticons = conf.emoticons.trim()
if (conf.emoticons.startsWith("[") || conf.emoticons.startsWith("{")) {
conf.emoticons = JSON.parse(conf.emoticons) // pase json
conf.emoticons = JSON.parse(conf.emoticons) // parse json
} else if (conf.emoticons === "false") {
conf.emoticons = false
}
+1
View File
@@ -56,6 +56,7 @@ if (ARTALK_LITE) {
defaults.vote = false
defaults.uaBadge = false
defaults.emoticons = false
defaults.preview = false
}
export default defaults
+4 -1
View File
@@ -9,7 +9,10 @@
</div>
<div class="atk-plug-panel-wrap" style="display: none;"></div>
<div class="atk-bottom">
<div class="atk-item atk-plug-btn-wrap"></div>
<div class="atk-item atk-bottom-left">
<span class="atk-state-wrap"></span>
<span class="atk-plug-btn-wrap"></span>
</div>
<div class="atk-item">
<button type="button" class="atk-send-btn"></button>
</div>
+3 -1
View File
@@ -11,6 +11,8 @@ const Sel = {
$bottom: '.atk-bottom',
$submitBtn: '.atk-send-btn',
$notifyWrap: '.atk-notify-wrap',
$bottomLeft: '.atk-bottom-left',
$stateWrap: '.atk-state-wrap',
$plugBtnWrap: '.atk-plug-btn-wrap',
$plugPanelWrap: '.atk-plug-panel-wrap',
}
@@ -22,7 +24,7 @@ export interface EditorUI extends Record<keyof typeof Sel, HTMLElement> {
$link: HTMLInputElement
$textarea: HTMLTextAreaElement
$submitBtn: HTMLButtonElement
$sendReply?: HTMLElement
$sendReplyBtn?: HTMLElement
$editCancelBtn?: HTMLElement
}
+1 -1
View File
@@ -9,7 +9,7 @@ const en = {
link: 'Website',
emoticon: 'Emoji',
preview: 'Preview',
image: 'Image',
uploadImage: 'Upload Image',
uploadFail: 'Upload Failed',
commentFail: 'Failed to comment',
restoredMsg: 'Content has been restored',
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineLocaleExternal('jp', {
link: 'ウェブサイト',
emoticon: '絵文字',
preview: 'プレビュー',
image: '画像',
uploadImage: '画像をアップロード',
uploadFail: 'アップロードに失敗しました',
commentFail: 'コメントに失敗しました',
restoredMsg: '内容が自動的に復元されました',
+3 -3
View File
@@ -4,14 +4,14 @@ const zhCN: I18n = {
/* Editor */
placeholder: '键入内容...',
noComment: '「此时无声胜有声」',
send: '发送评论',
save: '保存评论',
send: '发送',
save: '保存',
nick: '昵称',
email: '邮箱',
link: '网址',
emoticon: '表情',
preview: '预览',
image: '图片',
uploadImage: '上传图片',
uploadFail: '上传失败',
commentFail: '评论失败',
restoredMsg: '内容已自动恢复',
+3 -3
View File
@@ -4,14 +4,14 @@ export default defineLocaleExternal('zh-TW', {
/* Editor */
placeholder: '輸入內容...',
noComment: '「此時無聲勝有聲」',
send: '發送評論',
save: '保存評論',
send: '發送',
save: '保存',
nick: '暱稱',
email: '電子郵件',
link: '網址',
emoticon: '表情',
preview: '預覽',
image: '圖片',
uploadImage: '上傳圖片',
uploadFail: '上傳失敗',
commentFail: '評論失敗',
restoredMsg: '內容已自動恢復',
+4
View File
@@ -9,4 +9,8 @@ export default abstract class Component {
public constructor(
public ctx: ContextApi
) {}
getEl() {
return this.$el
}
}
+3 -4
View File
@@ -7,7 +7,7 @@ export function showLoading(parentElem: HTMLElement, conf?: { transparentBg?: bo
let $loading = parentElem.querySelector<HTMLElement>(':scope > .atk-loading')
if (!$loading) {
$loading = Utils.createElement(
`<div class="atk-loading atk-fade-in" style="display: none;">
`<div class="atk-loading" style="display: none;">
<div class="atk-loading-spinner">
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" fill="none" stroke-width="2" stroke-miterlimit="10"></circle></svg>
</div>
@@ -22,7 +22,7 @@ export function showLoading(parentElem: HTMLElement, conf?: { transparentBg?: bo
if ($spinner) {
$spinner.style.display = 'none'
window.setTimeout(() => {
$spinner.style.display = ''
if ($spinner.isConnected) $spinner.style.display = ''
}, 500)
}
}
@@ -60,8 +60,7 @@ export function scrollIntoView(elem: HTMLElement, enableAnim: boolean = true, re
if (relativeTo) {
const containerRect = relativeTo.getBoundingClientRect()
const elementRect = elem.getBoundingClientRect()
top = elementRect.top - containerRect.top + relativeTo.scrollTop
top += Utils.getHeight(relativeTo) / 2 - Utils.getHeight(elem) / 2
top = elementRect.top - containerRect.top + relativeTo.scrollTop - relativeTo.clientHeight / 2 + elem.clientHeight / 2
} else {
const rect = elem.getBoundingClientRect()
const elemTop = rect.top + window.scrollY
+25 -5
View File
@@ -1,16 +1,36 @@
import type { ContextApi, CommentData } from '@/types'
import { CommentNode } from '@/comment'
export function createComment(ctx: ContextApi, comment: CommentData, ctxComments: CommentData[]): CommentNode {
const instance = new CommentNode(ctx, comment, {
isFlatMode: ctx.getData().getListLastFetch()?.params.flatMode!,
afterRender: () => {
interface CreateCommentNodeOptions {
forceFlatMode?: boolean
}
export function createCommentNode(ctx: ContextApi, comment: CommentData, replyComment?: CommentData, opts?: CreateCommentNodeOptions): CommentNode {
const instance = new CommentNode(comment, {
onAfterRender: () => {
ctx.trigger('comment-rendered', instance)
},
onDelete: (c: CommentNode) => {
ctx.getData().deleteComment(c.getID())
},
replyTo: (comment.rid ? ctxComments.find(c => c.id === comment.rid) : undefined)
replyTo: replyComment,
// TODO simplify reference
flatMode: typeof opts?.forceFlatMode === 'boolean' ? opts?.forceFlatMode : ctx.conf.flatMode as boolean,
gravatar: ctx.conf.gravatar,
nestMax: ctx.conf.nestMax,
heightLimit: ctx.conf.heightLimit,
avatarURLBuilder: ctx.conf.avatarURLBuilder,
scrollRelativeTo: ctx.conf.scrollRelativeTo,
vote: ctx.conf.vote,
voteDown: ctx.conf.voteDown,
uaBadge: ctx.conf.uaBadge,
// TODO: move to plugin folder and remove from core
getApi: () => ctx.getApi(),
replyComment: (c, $el) => ctx.replyComment(c, $el),
editComment: (c, $el) => ctx.editComment(c, $el)
})
// 渲染元素
-120
View File
@@ -1,120 +0,0 @@
import type { CommentData } from '@/types'
import * as Ui from '@/lib/ui'
import type { CommentNode } from '@/comment'
import * as ListNest from './nest'
export interface LayoutOptions {
$commentsWrap: HTMLElement
nestSortBy: ListNest.SortByType
nestMax: number
flatMode: boolean
createCommentNode(comment: CommentData, ctxComments: CommentData[]): CommentNode
findCommentNode(id: number): CommentNode|undefined
getCommentDataList(): CommentData[]
}
export default class ListLayout {
constructor(private options: LayoutOptions) {}
// TODO: refactor `if syntax` to strategy pattern
import(comments: CommentData[]) {
if (this.options.flatMode) {
comments.forEach((commentData: CommentData) => {
this.putCommentFlatMode(commentData, comments, 'append')
})
} else {
this.importCommentsNestMode(comments)
}
}
insert(comment: CommentData) {
if (!this.options.flatMode) {
this.insertCommentNest(comment)
} else {
this.insertCommentFlatMode(comment)
}
}
// 导入评论 · 嵌套模式
private importCommentsNestMode(srcData: CommentData[]) {
// 遍历 root 评论
const rootNodes = ListNest.makeNestCommentNodeList(srcData, this.options.nestSortBy, this.options.nestMax)
rootNodes.forEach((rootNode: ListNest.CommentNode) => {
const rootC = this.options.createCommentNode(rootNode.comment, srcData)
// 显示并播放渐入动画
this.options.$commentsWrap?.appendChild(rootC.getEl())
rootC.getRender().playFadeAnim()
// 加载子评论
const loadChildren = (parentC: CommentNode, parentNode: ListNest.CommentNode) => {
parentNode.children.forEach((node: ListNest.CommentNode) => {
const childD = node.comment
const childC = this.options.createCommentNode(childD, srcData)
// 插入到父评论中
parentC.putChild(childC)
// 递归加载子评论
loadChildren(childC, node)
})
}
loadChildren(rootC, rootNode)
// 限高检测
rootC.getRender().checkHeightLimit()
})
}
/** 导入评论 · 平铺模式 */
private putCommentFlatMode(cData: CommentData, ctxData: CommentData[], insertMode: 'append'|'prepend') {
if (cData.is_collapsed) cData.is_allow_reply = false
const comment = this.options.createCommentNode(cData, ctxData)
// 可见评论添加到界面
// 注:不可见评论用于显示 “引用内容”
if (cData.visible) {
if (insertMode === 'append') this.options.$commentsWrap?.append(comment.getEl())
if (insertMode === 'prepend') this.options.$commentsWrap?.prepend(comment.getEl())
comment.getRender().playFadeAnim()
}
// 平铺评论插入后 · 内容限高检测
comment.getRender().checkHeightLimit()
return comment
}
private insertCommentNest(commentData: CommentData) {
// 嵌套模式
const comment = this.options.createCommentNode(commentData, this.options.getCommentDataList())
if (commentData.rid === 0) {
// root评论 新增
this.options.$commentsWrap?.prepend(comment.getEl())
} else {
// 子评论 新增
const parent = this.options.findCommentNode(commentData.rid)
if (parent) {
parent.putChild(comment, (this.options.nestSortBy === 'DATE_ASC' ? 'append' : 'prepend'))
// 若父评论存在 “子评论部分” 限高,取消限高
comment.getParents().forEach((p) => {
p.getRender().heightLimitRemoveForChildren()
})
}
}
comment.getRender().checkHeightLimit()
Ui.scrollIntoView(comment.getEl()) // 滚动到可以见
comment.getRender().playFadeAnim() // 播放评论渐出动画
}
private insertCommentFlatMode(commentData: CommentData) {
// 平铺模式
const comment = this.putCommentFlatMode(commentData, this.options.getCommentDataList(), 'prepend')
Ui.scrollIntoView(comment.getEl()) // 滚动到可见
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { CommentData } from '@/types'
import * as Ui from '@/lib/ui'
import type { LayoutStrategyCreator, LayoutOptions } from '.'
export const createFlatStrategy: LayoutStrategyCreator = (opts) => ({
import: (comments) => {
comments.forEach((comment: CommentData) => {
const replyComment = (comment.rid === 0 ? undefined : comments.find(c => c.id === comment.rid))
insertComment(opts, 'append', comment, replyComment)
})
},
insert: (comment, replyComment) => {
const node = insertComment(opts, 'prepend', comment, replyComment)
node.scrollIntoView()
}
})
/** 导入评论 · 平铺模式 */
function insertComment(opts: LayoutOptions, insertMode: 'append'|'prepend', comment: CommentData, replyComment?: CommentData | undefined) {
if (comment.is_collapsed) comment.is_allow_reply = false
const node = opts.createCommentNode(comment, replyComment)
// 可见评论添加到界面
// 注:不可见评论用于显示 “引用内容”
if (comment.visible) {
const $comment = node.getEl()
const $listCommentsWrap = opts.$commentsWrap
if (insertMode === 'append') $listCommentsWrap?.append($comment)
if (insertMode === 'prepend') $listCommentsWrap?.prepend($comment)
node.getRender().playFadeAnim()
}
// 平铺评论插入后 · 内容限高检测
// 插入列表的评论 (visible=true) 和关联的评论 (visible=false) 都需要检测
node.getRender().checkHeightLimit()
return node
}
+39
View File
@@ -0,0 +1,39 @@
import type { CommentData } from '@/types'
import type { CommentNode } from '@/comment'
import * as ListNest from '../nest'
import { createNestStrategy } from './nest'
import { createFlatStrategy } from './flat'
export interface LayoutOptions {
/** The comments wrap of list */
$commentsWrap: HTMLElement
nestSortBy: ListNest.SortByType
nestMax: number
flatMode: boolean
createCommentNode(comment: CommentData, replyComment?: CommentData): CommentNode
findCommentNode(id: number): CommentNode|undefined
}
export interface LayoutStrategy {
import(comments: CommentData[]): void
insert(comment: CommentData, replyComment?: CommentData): void
}
export type LayoutStrategyCreator = (opts: LayoutOptions) => LayoutStrategy
export class ListLayout {
constructor(private options: LayoutOptions) {}
private getStrategy() {
return this.options.flatMode ? createFlatStrategy(this.options) : createNestStrategy(this.options)
}
import(comments: CommentData[]) {
this.getStrategy().import(comments)
}
insert(comment: CommentData, replyComment?: CommentData) {
this.getStrategy().insert(comment, replyComment)
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { CommentNode } from '@/comment'
import * as Ui from '@/lib/ui'
import * as ListNest from '@/list/nest'
import type { LayoutStrategyCreator } from '.'
export const createNestStrategy: LayoutStrategyCreator = (opts) => ({
import: (comments) => {
// 遍历 root 评论
const rootNodes = ListNest.makeNestCommentNodeList(comments, opts.nestSortBy, opts.nestMax)
rootNodes.forEach((rootNode: ListNest.CommentNode) => {
const rootC = opts.createCommentNode(rootNode.comment)
// 显示并播放渐入动画
opts.$commentsWrap?.appendChild(rootC.getEl())
rootC.getRender().playFadeAnim()
// 加载子评论
const loadChildren = (parentC: CommentNode, parentNode: ListNest.CommentNode) => {
parentNode.children.forEach((node: ListNest.CommentNode) => {
const childD = node.comment
const childC = opts.createCommentNode(childD, parentC.getData())
// 插入到父评论中
parentC.putChild(childC)
// 递归加载子评论
loadChildren(childC, node)
})
}
loadChildren(rootC, rootNode)
// 限高检测
rootC.getRender().checkHeightLimit()
})
},
insert: (comment, replyComment) => {
// 嵌套模式
const node = opts.createCommentNode(comment, replyComment)
if (comment.rid === 0) {
// root评论 新增
opts.$commentsWrap?.prepend(node.getEl())
} else {
// 子评论 新增
const parent = opts.findCommentNode(comment.rid)
if (parent) {
parent.putChild(node, (opts.nestSortBy === 'DATE_ASC' ? 'append' : 'prepend'))
// 若父评论存在 “子评论部分” 限高,取消限高
node.getParents().forEach((p) => {
p.getRender().heightLimitRemoveForChildren()
})
}
}
node.getRender().checkHeightLimit()
node.scrollIntoView() // 滚动到可以见
node.getRender().playFadeAnim() // 播放评论渐出动画
}
})
+12 -10
View File
@@ -3,8 +3,8 @@ import Component from '@/lib/component'
import * as Utils from '@/lib/utils'
import { CommentNode } from '@/comment'
import ListHTML from './list.html?raw'
import ListLayout from './layout'
import { createComment as createCommentNode } from './comment'
import { ListLayout } from './layout'
import { createCommentNode } from './comment'
import { initListPaginatorFunc } from './page'
export default class List extends Component {
@@ -29,20 +29,19 @@ export default class List extends Component {
this.initCrudEvents()
}
getListLayout() {
getListLayout({ forceFlatMode }: { forceFlatMode?: boolean } = {}) {
return new ListLayout({
$commentsWrap: this.$commentsWrap,
nestSortBy: this.ctx.conf.nestSort,
nestMax: this.ctx.conf.nestMax,
flatMode: this.ctx.conf.flatMode as boolean,
flatMode: typeof forceFlatMode === 'boolean' ? forceFlatMode : this.ctx.conf.flatMode as boolean,
// flatMode must be boolean because it had been handled when Artalk.init
createCommentNode: (d, c) => {
const node = createCommentNode(this.ctx, d, c)
this.commentNodes.push(node)
createCommentNode: (d, r) => {
const node = createCommentNode(this.ctx, d, r, { forceFlatMode })
this.commentNodes.push(node) // store node instance
return node
},
findCommentNode: (id) => this.commentNodes.find(c => c.getID() === id),
getCommentDataList: () => this.ctx.getData().getComments(),
})
}
@@ -53,7 +52,10 @@ export default class List extends Component {
})
this.ctx.on('list-loaded', (comments) => {
if (comments.length === 0) this.$commentsWrap.innerHTML = ''
if (comments.length === 0) {
this.commentNodes = []
this.$commentsWrap.innerHTML = ''
}
})
// When comment insert
@@ -65,7 +67,7 @@ export default class List extends Component {
this.ctx.on('comment-deleted', (comment) => {
const node = this.commentNodes.find(c => c.getID() === comment.id)
if (!node) { console.error(`comment node id=${comment.id} not found`);return }
node.getEl().remove()
node.remove()
this.commentNodes = this.commentNodes.filter(c => c.getID() !== comment.id)
// TODO: remove child nodes
})
-30
View File
@@ -67,9 +67,6 @@ export const initListPaginatorFunc = (ctx: ContextApi) => {
paginator?.showErr?.($t('loadFail'))
})
// List goto auto next page when comment not found
// autoJumpToNextPage(ctx, paginator)
// loading
ctx.on('list-fetch', (params) => {
paginator?.setLoading(true)
@@ -78,30 +75,3 @@ export const initListPaginatorFunc = (ctx: ContextApi) => {
paginator?.setLoading(false)
})
}
function autoJumpToNextPage(ctx: ContextApi, paginator: Paginator|null) {
// TODO: Disable this feature temporarily. Because it may cause the page memory leak.
// if the comments is too much and the comment still not found.
// Consider to refactor to a better solution.
// Such as: calculate in backend and jump to the specific page directly.
return
const autoSwitchPageForFindComment = (commentID: number) => {
const comment = ctx.getData().findComment(commentID)
if (!!comment || !paginator?.getHasMore()) return
// wait for list loaded
ctx.on('list-loaded', () => {
autoSwitchPageForFindComment(commentID) // recursive, until comment found or no more page
}, { once: true })
// TODO: 自动范围改为直接跳转到计算后的页面
setTimeout(() => {
paginator?.next()
}, 80)
}
ctx.on('list-goto', (commentID) => {
autoSwitchPageForFindComment(commentID)
})
}
+1 -1
View File
@@ -18,7 +18,7 @@ export interface Paginator {
showErr?(msg: string): void
getHasMore(): boolean
/** Clear comments when fetch new page data */
getIsClearComments(params: Partial<ListFetchParams>): boolean
getIsClearComments(params: { offset?: number }): boolean
dispose(): void
}
+1 -1
View File
@@ -56,7 +56,7 @@ export default class ReadMorePaginator implements Paginator {
return this.instance.hasMore
}
getIsClearComments(params: Partial<ListFetchParams>): boolean {
getIsClearComments(params: { offset?: number }): boolean {
return params.offset === 0
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default class Emoticons extends EditorPlug {
this.kit.useMounted(() => {
this.usePanel(`<div class="atk-editor-plug-emoticons"></div>`)
this.useBtn($t('emoticon'))
this.useBtn(`<i aria-label="${$t('emoticon')}"><svg fill="currentColor" aria-hidden="true" height="14" viewBox="0 0 14 14" width="14"><path d="m4.26829 5.29294c0-.94317.45893-1.7074 1.02439-1.7074.56547 0 1.02439.76423 1.02439 1.7074s-.45892 1.7074-1.02439 1.7074c-.56546 0-1.02439-.76423-1.02439-1.7074zm4.43903 1.7074c.56546 0 1.02439-.76423 1.02439-1.7074s-.45893-1.7074-1.02439-1.7074c-.56547 0-1.02439.76423-1.02439 1.7074s.45892 1.7074 1.02439 1.7074zm-1.70732 2.73184c-1.51883 0-2.06312-1.52095-2.08361-1.58173l-1.29551.43231c.03414.10244.868 2.51604 3.3798 2.51604 2.51181 0 3.34502-2.41291 3.37982-2.51604l-1.29484-.43573c-.02254.06488-.56683 1.58583-2.08498 1.58583zm7-2.73252c0 3.86004-3.1401 7.00034-7 7.00034s-7-3.1396-7-6.99966c0-3.86009 3.1401-7.00034 7-7.00034s7 3.14025 7 7.00034zm-1.3659 0c0-3.10679-2.5275-5.63442-5.6341-5.63442-3.10663 0-5.63415 2.52832-5.63415 5.6351 0 3.10676 2.52752 5.63446 5.63415 5.63446 3.1066 0 5.6341-2.5277 5.6341-5.63446z"/></svg></i>`)
})
this.kit.useUnmounted(() => {})
+1 -3
View File
@@ -15,9 +15,7 @@ export default class Preview extends EditorPlug {
this.usePanel(`<div class="atk-editor-plug-preview"></div>`)
// initialize plug button
let btnText = $t('preview')
if (marked.getInstance()) btnText += ` <i title="Markdown is supported"><svg class="markdown" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"></path></svg></i>`
this.useBtn(btnText)
this.useBtn(`<i aria-label="${$t('preview')}"><svg fill="currentColor" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"></path></svg></i>`)
})
this.kit.useUnmounted(() => {})
+6 -4
View File
@@ -51,15 +51,17 @@ export default class StateEdit extends EditorPlug {
const ui = this.kit.useUI()
if (!ui.$editCancelBtn) {
const $btn = Utils.createElement(
`<div class="atk-send-reply">` +
`${$t('editCancel')} ` +
`<span class="atk-state-btn">` +
`<span class="atk-text-wrap">` +
`${$t('editCancel')}` +
`</span>` +
`<span class="atk-cancel">×</span>` +
`</div>`
`</span>`
)
$btn.onclick = () => {
this.kit.useEditor().resetState()
}
ui.$textareaWrap.append($btn)
ui.$stateWrap.append($btn)
ui.$editCancelBtn = $btn
}
this.comment = comment
+15 -12
View File
@@ -59,18 +59,21 @@ export default class StateReply extends EditorPlug {
private setReply(commentData: CommentData) {
const ui = this.kit.useUI()
if (!ui.$sendReply) {
ui.$sendReply = Utils.createElement(
`<div class="atk-send-reply">` +
`${$t('reply')} ` +
`<span class="atk-text"></span><span class="atk-cancel">×</span>` +
`</div>`
if (!ui.$sendReplyBtn) {
const $btn = Utils.createElement(
`<span class="atk-state-btn">` +
`<span class="atk-text-wrap">` +
`${$t('reply')} <span class="atk-text"></span>` +
`</span>` +
`<span class="atk-cancel">×</span>` +
`</span>`
)
ui.$sendReply.querySelector<HTMLElement>('.atk-text')!.innerText = `@${commentData.nick}`
ui.$sendReply.addEventListener('click', () => {
$btn.querySelector<HTMLElement>('.atk-text')!.innerText = `@${commentData.nick}`
$btn.addEventListener('click', () => {
this.kit.useEditor().resetState()
})
ui.$textareaWrap.append(ui.$sendReply)
ui.$stateWrap.append($btn)
ui.$sendReplyBtn = $btn
}
this.comment = commentData
@@ -82,9 +85,9 @@ export default class StateReply extends EditorPlug {
if (!this.comment) return
const ui = this.kit.useUI()
if (ui.$sendReply) {
ui.$sendReply.remove()
ui.$sendReply = undefined
if (ui.$sendReplyBtn) {
ui.$sendReplyBtn.remove()
ui.$sendReplyBtn = undefined
}
this.comment = undefined
}
+1
View File
@@ -8,6 +8,7 @@ interface CustomSubmit {
activeCond: () => void
pre?: () => void
req?: () => Promise<CommentData>
/** The `post` method is called after the comment is successfully submitted */
post?: (nComment: CommentData) => void
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default class Upload extends EditorPlug {
this.$imgUploadInput.accept = AllowImgExts.map(o => `.${o}`).join(',')
// TODO: Use btn cannot refresh when mounted event is triggered
const $btn = this.useBtn(`${$t('image')}`)
const $btn = this.useBtn(`<i aria-label="${$t('uploadImage')}"><svg fill="currentColor" aria-hidden="true" height="14" viewBox="0 0 14 14" width="14"><path d="m0 1.94444c0-1.074107.870333-1.94444 1.94444-1.94444h10.11116c1.0741 0 1.9444.870333 1.9444 1.94444v10.11116c0 1.0741-.8703 1.9444-1.9444 1.9444h-10.11116c-1.074107 0-1.94444-.8703-1.94444-1.9444zm1.94444-.38888c-.21466 0-.38888.17422-.38888.38888v7.06689l2.33333-2.33333 2.33333 2.33333 3.88888-3.88889 2.3333 2.33334v-5.51134c0-.21466-.1742-.38888-.3888-.38888zm10.49996 8.09977-2.3333-2.33333-3.88888 3.8889-2.33333-2.33334-2.33333 2.33334v.8447c0 .2146.17422.3888.38888.3888h10.11116c.2146 0 .3888-.1742.3888-.3888zm-7.1944-6.54422c-.75133 0-1.36111.60978-1.36111 1.36111 0 .75134.60978 1.36111 1.36111 1.36111s1.36111-.60977 1.36111-1.36111c0-.75133-.60978-1.36111-1.36111-1.36111z"/></svg></i>`)
$btn.after(this.$imgUploadInput)
$btn.onclick = () => {
// 选择图片
@@ -0,0 +1,41 @@
import type { ArtalkPlugin } from '@/types'
import * as Utils from '@/lib/utils'
export const GotoDispatcher: ArtalkPlugin = (ctx) => {
let lastID = 0
const check = ({ locker }: { locker: boolean }) => {
const commentID = extractCommentID()
if (!commentID) return
if (locker && lastID === commentID) return // if the commentID is the same as the last one, do nothing
lastID = commentID // record the last commentID
ctx.trigger('list-goto', commentID) // trigger event
}
const hashChangeHandler = () => check({ locker: false })
const listLoadedHandler = () => check({ locker: true })
ctx.on('mounted', () => {
window.addEventListener('hashchange', hashChangeHandler)
ctx.on('list-loaded', listLoadedHandler)
})
ctx.on('unmounted', () => {
window.removeEventListener('hashchange', hashChangeHandler)
ctx.off('list-loaded', listLoadedHandler)
})
}
function extractCommentID(): number|null {
// Try get from hash
// Hash retrieval priority is higher than query,
// Because click goto will change hash.
const match = window.location.hash.match(/#atk-comment-([0-9]+)/)
let commentId = match && match[1] && !Number.isNaN(parseFloat(match[1])) ? parseFloat(match[1]) : null
// Fail over to get from query
if (!commentId) {
commentId = Number(Utils.getQueryParam('atk_comment')) // same as backend GetReplyLink()
}
return commentId || null
}
+17
View File
@@ -0,0 +1,17 @@
import type { ArtalkPlugin } from '@/types'
export const GotoFocus: ArtalkPlugin = (ctx) => {
ctx.on('list-goto', async (commentID) => {
// find the comment node
let comment = ctx.getCommentNodes().find(c => c.getID() === commentID)
if (!comment) {
// fetch and insert the comment from the server
const data = (await ctx.getApi().comments.getComment(commentID)).data
ctx.get('list').getListLayout({ forceFlatMode: true }).insert(data.comment, data.reply_comment)
comment = ctx.getCommentNodes().find(c => c.getID() === commentID)
}
if (!comment) return
comment.focus()
})
}
-72
View File
@@ -1,72 +0,0 @@
import type { ArtalkPlugin } from '@/types'
import * as Utils from '@/lib/utils'
import * as Ui from '@/lib/ui'
export const Goto: ArtalkPlugin = (ctx) => {
let delayGoto = true
const check = () => {
const commentID = extractCommentID()
if (!commentID) return
// trigger event
ctx.trigger('list-goto', commentID)
// reset delayGoto
delayGoto = true
}
const hashChangeHandler = () => {
delayGoto = false
check()
}
ctx.on('mounted', () => {
window.addEventListener('hashchange', hashChangeHandler)
ctx.on('list-loaded', check)
})
ctx.on('unmounted', () => {
window.removeEventListener('hashchange', hashChangeHandler)
ctx.off('list-loaded', check)
})
let foundID = 0
ctx.on('list-goto', (commentID) => {
if (foundID === commentID) return
const comment = ctx.get('list').getCommentNodes().find(c => c.getID() === commentID)
if (!comment) return
foundID = commentID
// 若父评论存在 “子评论部分” 限高,取消限高
comment.getParents().forEach((p) => {
p.getRender().heightLimitRemoveForChildren()
})
const goTo = () => {
Ui.scrollIntoView(comment.getEl(), false)
comment.getEl().classList.remove('atk-flash-once')
window.setTimeout(() => {
comment.getEl().classList.add('atk-flash-once')
}, 150)
}
if (!delayGoto) goTo()
else window.setTimeout(() => goTo(), 350)
})
}
function extractCommentID(): number|null {
// try get from query
let commentId = Number(Utils.getQueryParam('atk_comment')) // same as backend GetReplyLink()
// fail over to get from hash
if (!commentId) {
const match = window.location.hash.match(/#atk-comment-([0-9]+)/)
if (!match || !match[1] || Number.isNaN(Number(match[1]))) return null
commentId = Number(match[1])
}
return commentId || null
}
+3 -2
View File
@@ -4,7 +4,8 @@ import { Unread } from './unread'
import { Count } from './count'
import { SidebarBtn } from './sidebar-btn'
import { UnreadBadge } from './unread-badge'
import { Goto } from './goto'
import { GotoDispatcher } from './goto-dispatcher'
import { GotoFocus } from './goto-focus'
import { Copyright } from './copyright'
import { NoComment } from './no-comment'
import { Dropdown } from './dropdown'
@@ -18,7 +19,7 @@ import { GotoFirst } from './goto-first'
const ListPlugins: ArtalkPlugin[] = [
Fetch, Loading, Unread,
WithEditor, Count, SidebarBtn, UnreadBadge,
Dropdown, Goto, NoComment, Copyright,
Dropdown, GotoDispatcher, GotoFocus, NoComment, Copyright,
TimeTicking, ErrorDialog, ReachBottom, GotoFirst
]
-7
View File
@@ -3,14 +3,7 @@ import * as Utils from '@/lib/utils'
import { sanitize } from '@/lib/sanitizer'
export const NoComment: ArtalkPlugin = (ctx) => {
let isFetching = false
ctx.on('list-fetch', (params) => {
isFetching = true
})
ctx.on('list-loaded', (comments) => {
if (!isFetching) return // Skip before fetch (e.g. clear all comments before fetch)
isFetching = false
const list = ctx.get('list')!
// 无评论
+9 -6
View File
@@ -5,7 +5,8 @@ export interface CountOptions {
getApi(): Api
siteName: string
pageKey: string
pageKey?: string
pageTitle?: string
countEl: string
pvEl: string
@@ -15,12 +16,13 @@ export interface CountOptions {
export const PvCountWidget: ArtalkPlugin = (ctx: ContextApi) => {
ctx.watchConf([
'site', 'pageKey', 'countEl', 'pvEl',
'site', 'pageKey', 'pageTitle', 'countEl', 'pvEl',
], (conf) => {
initCountWidget({
getApi: () => ctx.getApi(),
siteName: conf.site,
pageKey: conf.pageKey,
pageTitle: conf.pageTitle,
countEl: conf.countEl,
pvEl: conf.pvEl,
pvAdd: (typeof ctx.conf.pvAdd === 'boolean' ? ctx.conf.pvAdd : true),
@@ -36,10 +38,10 @@ export async function initCountWidget(opt: CountOptions) {
}
// PV
const initialData = opt.pvAdd ? {
const initialData = (opt.pvAdd && opt.pageKey) ? {
[opt.pageKey]: (await opt.getApi().pages.logPv({
page_key: opt.pageKey,
page_title: opt.pageKey,
page_title: opt.pageTitle,
site_name: opt.siteName,
})).data.pv // pv+1 and get pv count
} : undefined
@@ -68,7 +70,7 @@ async function refreshStatCount(
// Get page keys which will be queried
let queryPageKeys = Array.from(document.querySelectorAll(args.numEl))
.map((e) => e.getAttribute('data-page-key') || opt.pageKey)
.filter((k) => typeof data[k] !== 'number') // filter out keys that already have data
.filter((k) => k && typeof data[k] !== 'number') // filter out keys that already have data
queryPageKeys = [...new Set(queryPageKeys)] // deduplicate
@@ -81,7 +83,8 @@ async function refreshStatCount(
data = { ...data, ...res }
}
applyCountData(args.numEl, data, data[opt.pageKey])
const defaultCount = opt.pageKey ? data[opt.pageKey] : 0
applyCountData(args.numEl, data, defaultCount)
}
function applyCountData(selector: string, data: CountData, defaultCount: number) {
+1 -1
View File
@@ -161,7 +161,7 @@
}
& > .atk-content {
word-break: break-all;
word-break: break-word;
&.atk-type-collapsed {
border: 3px solid var(--at-color-bg-grey);
+83 -45
View File
@@ -68,42 +68,6 @@
margin-top: 10px;
// background: repeating-linear-gradient(145deg, #f2f2f2, #f2f2f2 15px, #fff 0, #fff 30px);
}
& > .atk-send-reply {
z-index: 2;
position: absolute;
right: 7px;
bottom: 2px;
max-width: calc(100% - 20px);
height: 30px;
line-height: 30px;
font-size: 14px;
background: var(--at-color-bg-grey-transl);
padding: 0 30px 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-radius: calc($block-radius / 2);
.atk-text {
}
.atk-cancel {
position: absolute;
right: 0;
bottom: 0;
height: 30px;
text-align: center;
width: 30px;
line-height: 30px;
font-weight: 700;
&:hover {
color: var(--at-color-deep);
}
}
}
}
& > .atk-plug-panel-wrap {
@@ -119,8 +83,10 @@
& > .atk-bottom {
display: flex;
flex-direction: row;
row-gap: 5px;
justify-content: space-between;
padding: 5px 5px 5px 10px;
padding: 5px;
flex-wrap: wrap;
& > .atk-item {
display: flex;
@@ -128,14 +94,33 @@
align-items: center;
}
& > .atk-bottom-left {
& > .atk-state-wrap {
margin-right: 4px;
}
& > .atk-plug-btn-wrap {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
row-gap: 5px;
}
}
.atk-plug-btn {
padding: 0 8px;
line-height: 24px;
display: flex;
justify-content: center;
place-items: center;
padding: 0 10px;
line-height: 30px;
height: 30px;
cursor: pointer;
color: var(--at-color-grey);
font-size: $font-size;
user-select: none;
border-radius: calc($block-radius / 2);
word-break: keep-all;
&:not(:last-child) {
margin-right: 2px;
@@ -154,16 +139,65 @@
}
i {
display: flex;
justify-content: center;
place-items: center;
color: var(--at-color-grey);
display: inline-flex;
vertical-align: sub;
margin-left: 4px;
svg.markdown path {
fill: var(--at-color-grey);
&:not(:first-child) {
margin-left: 4px;
}
}
}
.atk-state-btn {
z-index: 2;
height: 30px;
padding: 0 0 0 10px;
font-size: 14px;
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
place-items: center;
background: var(--at-color-bg-grey-transl);
cursor: pointer;
overflow: hidden;
border-radius: calc($block-radius / 2);
&:hover {
.atk-cancel {
background: rgba(0, 0, 0, 4%);
}
}
@media only screen and (max-width: 768px) {
padding: 0;
.atk-text-wrap {
display: none;
}
}
.atk-text-wrap {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px 0 2px;
max-width: 8em;
}
.atk-cancel {
display: flex;
justify-content: center;
place-items: center;
padding: 0 12px;
font-weight: 700;
height: 100%;
background: rgba(0, 0, 0, 2%);
}
}
.atk-send-btn {
background: var(--at-color-main);
color: #fff;
@@ -171,12 +205,16 @@
border: none;
margin: 0;
height: 30px;
width: 8em;
min-width: 7em;
cursor: pointer;
transition: opacity 0.3s ease-in-out;
outline: none;
border-radius: calc($block-radius / 2);
@media only screen and (max-width: 768px) {
width: 6em;
}
&:active {
opacity: 0.9;
}
+1 -1
View File
@@ -41,7 +41,7 @@ export interface CommentData {
/** 徽章颜色 */
badge_color?: string
/** IP 归属地 */
/** IP 属地 */
ip_region?: string
/** 是否允许回复 */
+1 -1
View File
@@ -82,7 +82,7 @@ export interface EditorApi extends Component {
hideLoading(): void
/**
* Start replaying a comment
* Start replying a comment
*/
setReply(commentData: CommentData, $comment: HTMLElement, scroll?: boolean): void
+6 -6
View File
@@ -2,8 +2,8 @@ import type { CommentNode } from '@/comment'
import { CommentData, ListData, ListFetchParams, NotifyData, PageData } from './data'
import { ArtalkConfig, LocalUser } from './config'
interface ErrorData { msg: string, data?: any }
interface ListFetchedArgs { params: Partial<ListFetchParams>, data?: ListData, error?: ErrorData }
export interface ListErrorData { msg: string, data?: any }
export interface ListFetchedArgs { params: Partial<ListFetchParams>, data?: ListData, error?: ListErrorData }
/** EventName to EventPayload Type */
export interface EventPayloadMap {
@@ -16,10 +16,10 @@ export interface EventPayloadMap {
'conf-fetch': undefined // 配置请求时
'list-fetch': Partial<ListFetchParams> // 评论列表请求时
'list-fetched': ListFetchedArgs // 评论列表请求后
'list-load': CommentData[] // 评论装载前
'list-load': CommentData[] // 评论装载前 (list-load payload is partial comments)
'list-loaded': CommentData[] // 评论装载后
'list-failed': ErrorData // 评论加载错误时
'list-loaded': CommentData[] // 评论装载后 (list-loaded payload is full comments)
'list-failed': ListErrorData // 评论加载错误时
'list-goto-first': undefined // 评论列表归位时
'list-reach-bottom': undefined // 评论列表滚动到底部时
@@ -27,7 +27,7 @@ export interface EventPayloadMap {
'comment-inserted': CommentData // 评论插入后
'comment-updated': CommentData // 评论更新后
'comment-deleted': CommentData // 评论删除后
'comment-rendered': CommentNode // 评论节点渲染后
'comment-rendered': CommentNode // 评论节点渲染后
'notifies-updated': NotifyData[] // 消息列表变更时
'list-goto': number // 评论跳转时
'page-loaded': PageData // 页面数据更新后