diff --git a/docs/docs/.vitepress/config/en.ts b/docs/docs/.vitepress/config/en.ts index 94169387..81a54938 100644 --- a/docs/docs/.vitepress/config/en.ts +++ b/docs/docs/.vitepress/config/en.ts @@ -40,6 +40,7 @@ export const en = defineConfig({ { text: 'LaTeX', link: '/en/guide/frontend/latex.md' }, { text: 'Image Lightbox', link: '/en/guide/frontend/lightbox.md' }, { text: 'Image Lazy Load', link: '/en/guide/frontend/img-lazy-load.md' }, + { text: 'Voting', link: '/zh/guide/frontend/voting.md' }, { text: 'IP Region', link: '/en/guide/frontend/ip-region.md' }, { text: 'Localization', link: '/en/guide/frontend/i18n.md' }, { text: 'Development Documentation', link: '/en/develop/index.md' }, diff --git a/docs/docs/.vitepress/config/zh.ts b/docs/docs/.vitepress/config/zh.ts index 9fc04e1a..7dc1dafb 100644 --- a/docs/docs/.vitepress/config/zh.ts +++ b/docs/docs/.vitepress/config/zh.ts @@ -40,6 +40,7 @@ export const zh = defineConfig({ { text: 'LaTeX', link: '/zh/guide/frontend/latex.md' }, { text: '图片灯箱', link: '/zh/guide/frontend/lightbox.md' }, { text: '图片懒加载', link: '/zh/guide/frontend/img-lazy-load.md' }, + { text: '投票功能', link: '/zh/guide/frontend/voting.md' }, { text: 'IP 属地', link: '/zh/guide/frontend/ip-region.md' }, { text: '多语言', link: '/zh/guide/frontend/i18n.md' }, { text: '开发文档', link: '/zh/develop/index.md' }, diff --git a/docs/docs/en/guide/frontend/config.md b/docs/docs/en/guide/frontend/config.md index a8bb766d..6261480d 100644 --- a/docs/docs/en/guide/frontend/config.md +++ b/docs/docs/en/guide/frontend/config.md @@ -280,6 +280,17 @@ When querying comment counts and page views, Artalk's statistical component uses To facilitate theme adaptation, you can customize the attribute name as needed, for example, replacing it with `data-path`, the HTML tag would be ``. +### pageVote + +**Page Voting Buttons** + +- Type: `Boolean | { upBtnEl: string; downBtnEl: string; upCountEl: string; downCountEl: string; activeClass: string }` +- Default: `true` + +Enable the page voting feature (upvote / downvote). + +For details, see: [Page Voting](./voting.md#page-voting) + ### vote **Voting Buttons** diff --git a/docs/docs/en/guide/frontend/voting.md b/docs/docs/en/guide/frontend/voting.md new file mode 100644 index 00000000..b73c2ff4 --- /dev/null +++ b/docs/docs/en/guide/frontend/voting.md @@ -0,0 +1,123 @@ +# Voting Feature + +Artalk supports voting on comments and pages, allowing users to click the "Up" or "Down" buttons to cast their votes. The comment list can be sorted based on the number of votes, which helps users better assess the quality of the comments. + +## Comment Voting + +The comment voting feature is enabled by default, allowing users to vote on comments. + +You can find the "UI Settings" in the Dashboard to modify the "Vote Button" option to enable or disable comment voting. + +Environment variable for the vote button: + +``` +ATK_FRONTEND_VOTE=1 +``` + +Configuration file for the vote button: + +```yaml +frontend: + vote: true +``` + +### Down Button + +By default, Artalk does not display the Down button. You can find the "UI Settings" in the Dashboard to modify the "Vote Down Button" option to enable it. + +Environment variable for the Down button: + +``` +ATK_FRONTEND_VOTE_DOWN=1 +``` + +Configuration file for the Down button: + +```yaml +frontend: + voteDown: true +``` + +## Page Voting + +Artalk supports voting on pages. To enable page voting, you need to add elements in the page to display the voting buttons, which Artalk will automatically initialize on load: + +```html +
+ + +
+``` + +### Voted Status Styling + +When users click the page voting button, the element will be given an `active` class to indicate that the user has voted. For example: + +```html + +``` + +You can customize the voted button style using CSS: + +```css +.artalk-page-vote-up.active { + color: #0083ff; +} +``` + +The default added class name is `active`, but it can be changed using `pageVote.activeClass` in the client configuration: + +```js +Artalk.init({ + pageVote: { + activeClass: 'active', + }, +}) +``` + +### Custom Element Selectors + +By default, Artalk searches for `.artalk-page-vote-up` and `.artalk-page-vote-down` as the voting button elements. + +You can customize the voting button selectors by modifying the `pageVote.upBtnEl` and `pageVote.downBtnEl` configuration in the client: + +```js +Artalk.init({ + pageVote: { + upBtnEl: '.artalk-page-vote-up', + downBtnEl: '.artalk-page-vote-down', + }, +}) +``` + +### Further Customizing Page Voting Buttons + +If the voting buttons do not contain any child elements, Artalk will output the text "Up (n)" into the element. + +If you want to output the vote count into a separate element, you can add a tag inside the button, for example: + +```html +
+ + 👍 () + + + 👎 () + +
+``` + +To further customize, you can replace the text with icons or add other styles. + +The default selectors for vote counts are `.artalk-page-vote-up-count` and `.artalk-page-vote-down-count`. + +You can modify `pageVote.upCountEl` and `pageVote.downCountEl` to customize the vote count output elements: + +```js +Artalk.init({ + pageVote: { + upCountEl: '.artalk-page-vote-up-count', + downCountEl: '.artalk-page-vote-down-count', + }, +}) +``` diff --git a/docs/docs/zh/guide/frontend/config.md b/docs/docs/zh/guide/frontend/config.md index f95832dc..bb2d822a 100644 --- a/docs/docs/zh/guide/frontend/config.md +++ b/docs/docs/zh/guide/frontend/config.md @@ -278,6 +278,17 @@ Artalk 统计组件查询评论数和浏览量时,会通过该属性名来查 为了便于主题适配,可根据需要自定义属性名,例如将其替换为 `data-path`,则 HTML 标签为 ``。 +### pageVote + +**页面投票** + +- 类型:`Boolean | { upBtnEl: string; downBtnEl: string; upCountEl: string; downCountEl: string; activeClass: string }` +- 默认值: `true` + +启用页面投票功能,用户可以为页面投票。 + +详情参考:[页面投票](./voting.md#页面投票) + ### vote **投票按钮** diff --git a/docs/docs/zh/guide/frontend/voting.md b/docs/docs/zh/guide/frontend/voting.md new file mode 100644 index 00000000..5fb22215 --- /dev/null +++ b/docs/docs/zh/guide/frontend/voting.md @@ -0,0 +1,123 @@ +# 投票功能 + +Artalk 支持对评论和页面进行投票,用户可以点击“赞同”或“反对”按钮进行投票。评论列表支持通过投票数进行排序。评论投票功能可以帮助用户更好地了解评论的质量。 + +## 评论投票 + +评论投票功能默认启用,用户可以对评论进行投票。 + +你可以在控制台设置界面找到「界面配置」,修改「投票按钮」选项来启用或禁用评论投票功能。 + +投票按钮的环境变量: + +``` +ATK_FRONTEND_VOTE=1 +``` + +投票按钮的配置文件: + +```yaml +frontend: + vote: true +``` + +### 反对按钮 + +默认情况下,Artalk 不会显示反对按钮。你可以在控制台设置界面找到「界面配置」,修改「反对按钮」选项来启用反对按钮。 + +反对按钮的环境变量: + +``` +ATK_FRONTEND_VOTE_DOWN=1 +``` + +反对按钮的配置文件: + +```yaml +frontend: + voteDown: true +``` + +## 页面投票 + +Artalk 支持对页面进行投票,你需要在页面中添加元素来显示页面投票按钮,Artalk 在加载时会自动初始化页面投票按钮: + +```html +
+ + +
+``` + +### 已投票状态样式 + +当用户点击了页面投票按钮后,元素会被添加 `active` 类名,表示用户已经投票,例如: + +```html + +``` + +你可以通过 CSS 样式来自定义按钮的已投票样式: + +```css +.artalk-page-vote-up.active { + color: #0083ff; +} +``` + +默认添加的类名为 `active`,可以在客户端通过 `pageVote.activeClass` 来修改: + +```js +Artalk.init({ + pageVote: { + activeClass: 'active', + }, +}) +``` + +### 自定义元素选择器 + +Artalk 默认查找 `.artalk-page-vote-up` 和 `.artalk-page-vote-down` 作为投票按钮元素。 + +修改客户端的 `pageVote.upBtnEl` 和 `pageVote.downBtnEl` 配置可以自定义投票按钮选择器: + +```js +Artalk.init({ + pageVote: { + upBtnEl: '.artalk-page-vote-up', + downBtnEl: '.artalk-page-vote-down', + }, +}) +``` + +### 进一步自定义页面投票按钮 + +如果投票按钮内没有子元素,Artalk 会输出文字 "赞同 (n)" 到元素中。 + +如果你想输出投票数量到单独的元素,可以在按钮中添加一个标签,例如: + +```html +
+ + 👍 () + + + 👎 () + +
+``` + +更进一步,你可以将文字修改为图标,或者添加其他样式。 + +投票数选择器默认为 `.artalk-page-vote-up-count` 和 `.artalk-page-vote-down-count`, + +可以修改 `pageVote.upCountEl` 和 `pageVote.downCountEl` 自定义投票数输出元素: + +```js +Artalk.init({ + pageVote: { + upCountEl: '.artalk-page-vote-up-count', + downCountEl: '.artalk-page-vote-down-count', + }, +}) +``` diff --git a/ui/artalk/index.html b/ui/artalk/index.html index c6f89934..258aeec7 100644 --- a/ui/artalk/index.html +++ b/ui/artalk/index.html @@ -139,6 +139,14 @@ | +
+ 赞同 () + 反对 () +
{ if (ExcludedKeys.includes(k as any)) delete conf[k] diff --git a/ui/artalk/src/defaults.ts b/ui/artalk/src/defaults.ts index da613c20..ff3096c5 100644 --- a/ui/artalk/src/defaults.ts +++ b/ui/artalk/src/defaults.ts @@ -26,6 +26,8 @@ const defaults: RequiredExcept = { emoticons: 'https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json', + pageVote: true, + vote: true, voteDown: false, uaBadge: true, diff --git a/ui/artalk/src/plugins/index.ts b/ui/artalk/src/plugins/index.ts index befe5f55..b930c67c 100644 --- a/ui/artalk/src/plugins/index.ts +++ b/ui/artalk/src/plugins/index.ts @@ -6,6 +6,7 @@ import { PvCountWidget } from './stat' import { VersionCheck } from './version-check' import { AdminOnlyElem } from './admin-only-elem' import { DarkMode } from './dark-mode' +import { PageVoteWidget } from './page-vote' import type { ArtalkPlugin } from '@/types' export const DefaultPlugins: ArtalkPlugin[] = [ @@ -17,4 +18,5 @@ export const DefaultPlugins: ArtalkPlugin[] = [ PvCountWidget, VersionCheck, DarkMode, + PageVoteWidget, ] diff --git a/ui/artalk/src/plugins/page-vote.ts b/ui/artalk/src/plugins/page-vote.ts new file mode 100644 index 00000000..564df93d --- /dev/null +++ b/ui/artalk/src/plugins/page-vote.ts @@ -0,0 +1,182 @@ +import type { ArtalkPlugin, ListFetchedArgs } from '@/types' +import type { Api } from '@/api' +import $t from '@/i18n' + +interface PageVoteOptions { + /** Up Vote Button Selector */ + upBtnEl: string + + /** Down Vote Button Selector */ + downBtnEl: string + + /** Up Vote Count Selector */ + upCountEl: string + + /** Down Vote Count Selector */ + downCountEl: string + + /** Active class name if the vote is already cast */ + activeClass?: string +} + +const defaults: PageVoteOptions = { + upBtnEl: '.artalk-page-vote-up', + downBtnEl: '.artalk-page-vote-down', + upCountEl: '.artalk-page-vote-up-count', + downCountEl: '.artalk-page-vote-down-count', + activeClass: 'active', +} + +type VoteBtnHandler = (evt: MouseEvent) => void + +interface PageVoteState { + upBtnEl?: HTMLElement | null + downBtnEl?: HTMLElement | null + upCountEl?: HTMLElement | null + downCountEl?: HTMLElement | null + voteUpHandler?: VoteBtnHandler + voteDownHandler?: VoteBtnHandler + activeClass?: string +} + +export const PageVoteWidget: ArtalkPlugin = (ctx) => { + let state: PageVoteState = initState() + let cleanup: (() => void) | undefined + + ctx.watchConf(['pageVote'], (conf) => { + const options = { ...defaults, ...(typeof conf.pageVote === 'object' ? conf.pageVote : {}) } + + cleanup?.() + if (conf.pageVote) { + state = loadOptions(state, options, voteUpHandler, voteDownHandler) + cleanup = () => (state = resetState(state)) + } + }) + + ctx.on('unmounted', () => { + cleanup?.() + ctx.off('list-fetched', listFetchedHandler) + }) + + ctx.on('list-fetched', listFetchedHandler) + + // List fetched handler + let currPageId = 0 + function listFetchedHandler({ data }: ListFetchedArgs) { + if (!ctx.getConf().pageVote || !checkEls(state) || !data) return + + if (currPageId !== data.page.id) { + // Initialize vote status in a new page + currPageId = data.page.id + updateVoteStatus(state, data.page.vote_up, data.page.vote_down, false, false) // reset initial status + fetchVoteStatus(state, currPageId, ctx.getApi()) + } else { + // Update vote status in the same page + updateVoteStatus(state, data.page.vote_up, data.page.vote_down) + } + } + + // Vote button click handlers + const handlerOptions = () => ({ + state, + pageId: ctx.getData().getPage()?.id || 0, + httpApi: ctx.getApi(), + }) + const voteUpHandler = voteBtnHandler('up', handlerOptions) + const voteDownHandler = voteBtnHandler('down', handlerOptions) +} + +function loadOptions( + _state: PageVoteState, + opts: PageVoteOptions, + voteUpHandler: VoteBtnHandler, + voteDownHandler: VoteBtnHandler, +): PageVoteState { + const state: PageVoteState = { ..._state } // clone to keep immutable + state.upBtnEl = document.querySelector(opts.upBtnEl) + state.downBtnEl = document.querySelector(opts.downBtnEl) + state.upCountEl = document.querySelector(opts.upCountEl) + state.downCountEl = document.querySelector(opts.downCountEl) + state.activeClass = opts.activeClass + state.voteUpHandler = voteUpHandler + state.voteDownHandler = voteDownHandler + state.upBtnEl?.addEventListener('click', voteUpHandler) + state.downBtnEl?.addEventListener('click', voteDownHandler) + return state +} + +function checkEls(state: PageVoteState) { + return state.upBtnEl || state.downBtnEl || state.upCountEl || state.downCountEl +} + +function initState(): PageVoteState { + return {} +} + +function resetState(state: PageVoteState): PageVoteState { + state.voteUpHandler && state.upBtnEl?.removeEventListener('click', state.voteUpHandler) + state.voteDownHandler && state.downBtnEl?.removeEventListener('click', state.voteDownHandler) + return initState() +} + +function updateVoteStatus( + state: PageVoteState, + up: number | string, + down: number | string, + isUp?: boolean, + isDown?: boolean, +) { + // Up vote count + if (state.upCountEl) { + state.upCountEl.innerText = String(up) + } else if (state.upBtnEl) { + state.upBtnEl.innerText = `${$t('voteUp')} (${up})` + } + + // Down vote count + if (state.downCountEl) { + state.downCountEl.innerText = String(down) + } else if (state.downBtnEl) { + state.downBtnEl.innerText = `${$t('voteDown')} (${down})` + } + + if (typeof isUp === 'boolean') + state.upBtnEl?.classList.toggle(state.activeClass || 'active', isUp) + if (typeof isDown === 'boolean') + state.downBtnEl?.classList.toggle(state.activeClass || 'active', isDown) +} + +interface VoteBtnHandlerOptions { + state: PageVoteState + pageId: number + httpApi: Api +} + +const voteBtnHandler = + (choice: 'up' | 'down', options: () => VoteBtnHandlerOptions) => (evt: MouseEvent) => { + evt.preventDefault() + const { state, pageId, httpApi } = options() + if (!pageId) return + httpApi.votes + .createVote('page', pageId, choice, { + ...httpApi.getUserFields(), + }) + .then(({ data }) => { + updateVoteStatus(state, data.up, data.down, data.is_up, data.is_down) + }) + .catch((err) => { + window.alert($t('voteFail')) + console.error('[ArtalkPageVote]', err) + }) + } + +function fetchVoteStatus(state: PageVoteState, pageId: number, httpApi: Api) { + httpApi.votes + .getVote('page', pageId) + .then(({ data }) => { + updateVoteStatus(state, data.up, data.down, data.is_up, data.is_down) + }) + .catch((err) => { + console.error('[ArtalkPageVote]', err) + }) +} diff --git a/ui/artalk/src/types/config.ts b/ui/artalk/src/types/config.ts index db8be097..5ef158fe 100644 --- a/ui/artalk/src/types/config.ts +++ b/ui/artalk/src/types/config.ts @@ -73,6 +73,26 @@ export interface ArtalkConfig { /** Downvote button for comments */ voteDown: boolean + /** Page Vote Widget */ + pageVote: + | { + /** Up Vote Button Selector */ + upBtnEl: string + + /** Down Vote Button Selector */ + downBtnEl: string + + /** Up Vote Count Selector */ + upCountEl: string + + /** Down Vote Count Selector */ + downCountEl: string + + /** Active class name if the vote is already cast */ + activeClass: string + } + | boolean + /** Preview feature for comments */ preview: boolean