Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c1a87a67 | ||
|
|
ec7f81cb83 | ||
|
|
4b115d7b6a | ||
|
|
1bae6d8d2a | ||
|
|
bdafa19470 | ||
|
|
342236b6a7 | ||
|
|
ffce9f9e85 | ||
|
|
0097e041b1 | ||
|
|
42da621dfc | ||
|
|
2f5dbaa022 | ||
|
|
e181a6d5dd | ||
|
|
7873b21fb7 | ||
|
|
2a219518a4 | ||
|
|
afa7888db0 | ||
|
|
f6a589d5b2 | ||
|
|
41a09d5996 | ||
|
|
191977c0df | ||
|
|
5a4c06daee | ||
|
|
c4942ab807 | ||
|
|
6b83a9f632 | ||
|
|
3c88c0c29c | ||
|
|
35aab6bedc | ||
|
|
17033dc111 | ||
|
|
559df60f0d | ||
|
|
22d4ec1c31 | ||
|
|
d97ad5c843 | ||
|
|
34a67f647b | ||
|
|
9c3228b8fc | ||
|
|
4012d3e090 | ||
|
|
bc1c4d315c | ||
|
|
2a6f0c56a4 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ db:
|
||||
password: ""
|
||||
charset: "utf8mb4"
|
||||
ssl: false
|
||||
prepare_stmt: true
|
||||
log:
|
||||
enabled: true
|
||||
filename: "./data/artalk.log"
|
||||
|
||||
@@ -47,6 +47,8 @@ db:
|
||||
charset: "utf8mb4"
|
||||
# Enable SSL mode
|
||||
ssl: false
|
||||
# Prepared Statement
|
||||
prepare_stmt: true
|
||||
|
||||
# Logging
|
||||
log:
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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",
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"latest": "2.8.0"
|
||||
"latest": "2.8.2"
|
||||
}
|
||||
@@ -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
@@ -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。
|
||||
|
||||
## 示例插件
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ db:
|
||||
charset: "utf8mb4" # 编码格式
|
||||
table_prefix: "" # 表前缀 (例如:"atk_")
|
||||
ssl: false # 启用 SSL
|
||||
prepare_stmt: true # 预编译语句
|
||||
```
|
||||
|
||||
数据表将在 Artalk 启动时自动完成创建,无需额外操作。
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -57,7 +57,7 @@ Artalk 除了支持 OwO 格式外,还内置支持一种标准的表情包列
|
||||
|
||||
::: warning
|
||||
|
||||
前端的配置将默认被后端覆盖,详情参考:[在后端控制前端](/guide/backend/fe-control.html)
|
||||
前端的配置可能会被后端所覆盖,详情参考:[在后端控制前端](/guide/backend/fe-control.html)
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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}}保存失败'
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"` // 显示精度
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "artalk",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.2",
|
||||
"description": "A self-hosted comment system",
|
||||
"keywords": [
|
||||
"comment-system",
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ if (ARTALK_LITE) {
|
||||
defaults.vote = false
|
||||
defaults.uaBadge = false
|
||||
defaults.emoticons = false
|
||||
defaults.preview = false
|
||||
}
|
||||
|
||||
export default defaults
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineLocaleExternal('jp', {
|
||||
link: 'ウェブサイト',
|
||||
emoticon: '絵文字',
|
||||
preview: 'プレビュー',
|
||||
image: '画像',
|
||||
uploadImage: '画像をアップロード',
|
||||
uploadFail: 'アップロードに失敗しました',
|
||||
commentFail: 'コメントに失敗しました',
|
||||
restoredMsg: '内容が自動的に復元されました',
|
||||
|
||||
@@ -4,14 +4,14 @@ const zhCN: I18n = {
|
||||
/* Editor */
|
||||
placeholder: '键入内容...',
|
||||
noComment: '「此时无声胜有声」',
|
||||
send: '发送评论',
|
||||
save: '保存评论',
|
||||
send: '发送',
|
||||
save: '保存',
|
||||
nick: '昵称',
|
||||
email: '邮箱',
|
||||
link: '网址',
|
||||
emoticon: '表情',
|
||||
preview: '预览',
|
||||
image: '图片',
|
||||
uploadImage: '上传图片',
|
||||
uploadFail: '上传失败',
|
||||
commentFail: '评论失败',
|
||||
restoredMsg: '内容已自动恢复',
|
||||
|
||||
@@ -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: '內容已自動恢復',
|
||||
|
||||
@@ -9,4 +9,8 @@ export default abstract class Component {
|
||||
public constructor(
|
||||
public ctx: ContextApi
|
||||
) {}
|
||||
|
||||
getEl() {
|
||||
return this.$el
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
// 渲染元素
|
||||
|
||||
@@ -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()) // 滚动到可见
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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')!
|
||||
|
||||
// 无评论
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface CommentData {
|
||||
/** 徽章颜色 */
|
||||
badge_color?: string
|
||||
|
||||
/** IP 归属地 */
|
||||
/** IP 属地 */
|
||||
ip_region?: string
|
||||
|
||||
/** 是否允许回复 */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 // 页面数据更新后
|
||||
|
||||
Reference in new issue
Block a user