@esershnr/artalk-sidebar 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +16 -0
  2. package/auto-imports.d.ts +76 -0
  3. package/components.d.ts +31 -0
  4. package/env.d.ts +2 -0
  5. package/index.html +15 -0
  6. package/package.json +32 -0
  7. package/public/favicon.png +0 -0
  8. package/public/robots.txt +2 -0
  9. package/src/App.vue +89 -0
  10. package/src/artalk.ts +82 -0
  11. package/src/assets/favicon.png +0 -0
  12. package/src/assets/icon-darkmode-off.svg +1 -0
  13. package/src/assets/icon-darkmode-on.svg +1 -0
  14. package/src/assets/icon-eye-off.svg +1 -0
  15. package/src/assets/icon-eye-on.svg +1 -0
  16. package/src/assets/nav-icon-comments.svg +1 -0
  17. package/src/assets/nav-icon-pages.svg +1 -0
  18. package/src/assets/nav-icon-search.svg +1 -0
  19. package/src/assets/nav-icon-settings.svg +1 -0
  20. package/src/assets/nav-icon-sites.svg +1 -0
  21. package/src/assets/nav-icon-transfer.svg +1 -0
  22. package/src/assets/nav-icon-users.svg +1 -0
  23. package/src/components/AppHeader.vue +235 -0
  24. package/src/components/AppNavigation.vue +11 -0
  25. package/src/components/AppNavigationDesktop.vue +176 -0
  26. package/src/components/AppNavigationMenu.ts +152 -0
  27. package/src/components/AppNavigationMobile.vue +187 -0
  28. package/src/components/AppNavigationSearch.vue +137 -0
  29. package/src/components/FileUploader.vue +149 -0
  30. package/src/components/ItemTextEditor.vue +130 -0
  31. package/src/components/LoadingLayer.vue +37 -0
  32. package/src/components/LogTerminal.vue +89 -0
  33. package/src/components/PageEditor.vue +171 -0
  34. package/src/components/Pagination.vue +253 -0
  35. package/src/components/PreferenceArr.vue +105 -0
  36. package/src/components/PreferenceGrp.vue +153 -0
  37. package/src/components/PreferenceItem.vue +159 -0
  38. package/src/components/SiteCreate.vue +96 -0
  39. package/src/components/SiteEditor.vue +138 -0
  40. package/src/components/SiteSwitcher.vue +184 -0
  41. package/src/components/UserEditor.vue +229 -0
  42. package/src/global.ts +62 -0
  43. package/src/hooks/MobileWidth.ts +27 -0
  44. package/src/i18n/fr.ts +103 -0
  45. package/src/i18n/ja.ts +100 -0
  46. package/src/i18n/ko.ts +99 -0
  47. package/src/i18n/ru.ts +102 -0
  48. package/src/i18n/tr.ts +102 -0
  49. package/src/i18n/zh-CN.ts +97 -0
  50. package/src/i18n/zh-TW.ts +97 -0
  51. package/src/i18n-en.ts +99 -0
  52. package/src/i18n.ts +37 -0
  53. package/src/lib/promise-polyfill.ts +9 -0
  54. package/src/lib/settings-option.ts +186 -0
  55. package/src/lib/settings-sensitive.ts +44 -0
  56. package/src/lib/settings.ts +94 -0
  57. package/src/main.ts +65 -0
  58. package/src/pages/comments.vue +110 -0
  59. package/src/pages/index.vue +33 -0
  60. package/src/pages/login.vue +245 -0
  61. package/src/pages/pages.vue +309 -0
  62. package/src/pages/settings.vue +181 -0
  63. package/src/pages/sites.vue +353 -0
  64. package/src/pages/transfer.vue +204 -0
  65. package/src/pages/users.vue +271 -0
  66. package/src/stores/nav.ts +114 -0
  67. package/src/stores/user.ts +48 -0
  68. package/src/style/_extends.scss +100 -0
  69. package/src/style/_variables.scss +18 -0
  70. package/src/style.scss +245 -0
  71. package/src/vue-i18n.d.ts +7 -0
  72. package/tsconfig.json +40 -0
  73. package/tsconfig.node.json +11 -0
  74. package/typed-router.d.ts +30 -0
  75. package/vite.config.ts +71 -0
@@ -0,0 +1,204 @@
1
+ <script setup lang="ts">
2
+ import { storeToRefs } from 'pinia'
3
+ import { useNavStore } from '../stores/nav'
4
+ import { useUserStore } from '../stores/user'
5
+ import { artalk } from '../global'
6
+
7
+ const nav = useNavStore()
8
+ const user = useUserStore()
9
+ const { curtTab } = storeToRefs(nav)
10
+ const { t } = useI18n()
11
+
12
+ const importParams = ref({
13
+ siteName: '',
14
+ siteURL: '',
15
+ payload: '',
16
+ })
17
+
18
+ const isLoading = ref(false)
19
+
20
+ const uploadApiURL = ref('')
21
+ const importTaskApiURL = ref('')
22
+
23
+ const uploadedFilename = ref('')
24
+
25
+ const importTaskStarted = ref(false)
26
+ const importTaskParams = ref<Record<string, string>>({})
27
+
28
+ const exportTaskStarted = ref(false)
29
+
30
+ onMounted(() => {
31
+ nav.updateTabs(
32
+ {
33
+ import: 'import',
34
+ export: 'export',
35
+ },
36
+ 'import',
37
+ )
38
+ watch(curtTab, (tab) => {
39
+ if (tab === 'export') {
40
+ startExportTask()
41
+ curtTab.value = 'import'
42
+ }
43
+ })
44
+
45
+ uploadApiURL.value = `${artalk?.ctx.conf.server}/api/v2/transfer/upload`
46
+ importTaskApiURL.value = `${artalk?.ctx.conf.server}/api/v2/transfer/import`
47
+ })
48
+
49
+ function setError(msg: string) {
50
+ window.alert(msg)
51
+ }
52
+
53
+ function fileUploaded(filename: string) {
54
+ uploadedFilename.value = filename
55
+ }
56
+
57
+ function startImportTask() {
58
+ if (!uploadedFilename.value) {
59
+ setError(`Please upload a data file first`)
60
+ return
61
+ }
62
+
63
+ const p = importParams.value
64
+ const siteName = p.siteName.trim()
65
+ const siteURL = p.siteURL.trim()
66
+ const payload = p.payload.trim()
67
+
68
+ // Prepare request params
69
+ let rData: any = {}
70
+ if (payload) {
71
+ // Validate payload JSON
72
+ try {
73
+ rData = JSON.parse(payload)
74
+ } catch (err) {
75
+ setError(`Payload JSON invalid: ${err}`)
76
+ return
77
+ }
78
+
79
+ if (typeof rData !== 'object' || Array.isArray(rData)) {
80
+ setError(`Payload should be an object`)
81
+ return
82
+ }
83
+ }
84
+ if (siteName) rData.target_site_name = siteName
85
+ if (siteURL) rData.target_site_url = siteURL
86
+ rData.json_file = uploadedFilename.value
87
+
88
+ // Create an import task
89
+ importTaskParams.value = {
90
+ ...rData,
91
+ token: user.token,
92
+ }
93
+ importTaskStarted.value = true
94
+ }
95
+
96
+ function importTaskDone() {
97
+ importTaskStarted.value = false
98
+ }
99
+
100
+ async function startExportTask() {
101
+ if (exportTaskStarted.value) return
102
+ exportTaskStarted.value = true
103
+ isLoading.value = true
104
+ try {
105
+ const res = await artalk!.ctx.getApi().transfer.exportArtrans()
106
+ downloadFile(`backup-${getYmdHisFilename()}.artrans`, res.data.artrans)
107
+ } catch (err: any) {
108
+ console.error(err)
109
+ window.alert(`${String(err)}`)
110
+ return
111
+ } finally {
112
+ exportTaskStarted.value = false
113
+ isLoading.value = false
114
+ }
115
+ }
116
+
117
+ function downloadFile(filename: string, text: string) {
118
+ const el = document.createElement('a')
119
+ el.setAttribute('href', `data:text/json;charset=utf-8,${encodeURIComponent(text)}`)
120
+ el.setAttribute('download', filename)
121
+ el.style.display = 'none'
122
+ document.body.appendChild(el)
123
+ el.click()
124
+ document.body.removeChild(el)
125
+ }
126
+
127
+ function getYmdHisFilename() {
128
+ const date = new Date()
129
+
130
+ const year = date.getFullYear()
131
+ const month = date.getMonth() + 1
132
+ const day = date.getDate()
133
+ const hours = date.getHours()
134
+ const minutes = date.getMinutes()
135
+ const seconds = date.getSeconds()
136
+
137
+ return `${year}${month}${day}-${hours}${padWithZeros(minutes, 2)}${padWithZeros(seconds, 2)}`
138
+ }
139
+
140
+ function padWithZeros(vNumber: number, width: number) {
141
+ let numAsString = vNumber.toString()
142
+ while (numAsString.length < width) {
143
+ numAsString = `0${numAsString}`
144
+ }
145
+ return numAsString
146
+ }
147
+
148
+ const artransferToolHint = computed(() =>
149
+ t('artransferToolHint', { link: '__LINK__' }).replace(
150
+ '__LINK__',
151
+ `<a href="https://artalk.js.org/guide/transfer.html" target="_blank">${t('artransfer')}</a>`,
152
+ ),
153
+ )
154
+ </script>
155
+
156
+ <template>
157
+ <LoadingLayer v-if="isLoading" />
158
+ <LogTerminal
159
+ v-if="importTaskStarted"
160
+ :api-url="importTaskApiURL"
161
+ :req-params="importTaskParams"
162
+ @back="importTaskDone()"
163
+ />
164
+ <div v-show="!importTaskStarted" class="atk-form">
165
+ <div class="atk-label atk-data-file-label">Artrans {{ t('dataFile') }}</div>
166
+ <FileUploader :api-url="uploadApiURL" @done="fileUploaded">
167
+ <template #tip>
168
+ <!-- eslint-disable-next-line vue/no-v-html -->
169
+ <span v-html="artransferToolHint" />
170
+ </template>
171
+ <template #done-msg>
172
+ {{ t('uploadReadyToImport') }}
173
+ </template>
174
+ </FileUploader>
175
+ <div class="atk-label">{{ t('targetSiteName') }}</div>
176
+ <input
177
+ v-model="importParams.siteName"
178
+ type="text"
179
+ name="AtkSiteName"
180
+ :placeholder="t('inputHint')"
181
+ autocomplete="off"
182
+ />
183
+ <div class="atk-label">{{ t('targetSiteURL') }}</div>
184
+ <input
185
+ v-model="importParams.siteURL"
186
+ type="text"
187
+ name="AtkSiteURL"
188
+ :placeholder="t('inputHint')"
189
+ autocomplete="off"
190
+ />
191
+ <div class="atk-label">{{ t('payload') }} ({{ t('optional') }})</div>
192
+ <textarea v-model="importParams.payload" name="AtkPayload"></textarea>
193
+ <span class="atk-desc">
194
+ <a href="https://artalk.js.org/guide/transfer.html" target="_blank">
195
+ {{ t('moreDetails') }}
196
+ </a>
197
+ </span>
198
+ <button class="atk-btn" name="AtkSubmit" @click="startImportTask()">
199
+ {{ t('import') }}
200
+ </button>
201
+ </div>
202
+ </template>
203
+
204
+ <style scoped lang="scss"></style>
@@ -0,0 +1,271 @@
1
+ <script setup lang="ts">
2
+ import type { ArtalkType } from '@esershnr/artalk'
3
+ import { storeToRefs } from 'pinia'
4
+ import { useNavStore } from '../stores/nav'
5
+ import { artalk } from '../global'
6
+ import Pagination from '../components/Pagination.vue'
7
+
8
+ const nav = useNavStore()
9
+ const { curtTab } = storeToRefs(nav)
10
+ const users = ref<ArtalkType.UserDataForAdmin[]>([])
11
+ const { t } = useI18n()
12
+
13
+ const pageSize = ref(30)
14
+ const pageTotal = ref(0)
15
+ const pagination = ref<InstanceType<typeof Pagination>>()
16
+ const curtType = ref<'all' | 'admin' | 'in_conf' | undefined>('all')
17
+
18
+ const userEditorState = reactive({
19
+ show: false,
20
+ user: undefined as ArtalkType.UserDataForAdmin | undefined,
21
+ })
22
+ const search = ref('')
23
+
24
+ watch(curtTab, (tab) => {
25
+ if (tab === 'create') {
26
+ createUser()
27
+ } else {
28
+ curtType.value = tab as any
29
+ fetchUsers(0)
30
+ closeUserEditor()
31
+ }
32
+ })
33
+
34
+ onMounted(() => {
35
+ fetchUsers(0)
36
+
37
+ nav.updateTabs(
38
+ {
39
+ all: 'all',
40
+ admin: 'admin',
41
+ create: 'create',
42
+ },
43
+ 'all',
44
+ )
45
+
46
+ // Users search
47
+ nav.enableSearch(
48
+ (value: string) => {
49
+ search.value = value
50
+ fetchUsers(0)
51
+ },
52
+ () => {
53
+ if (search.value === '') return
54
+ search.value = ''
55
+ fetchUsers(0)
56
+ },
57
+ )
58
+ })
59
+
60
+ watch(
61
+ () => userEditorState.show,
62
+ () => nav.scrollPageToTop(),
63
+ )
64
+
65
+ function fetchUsers(offset: number) {
66
+ if (offset === 0) pagination.value?.reset()
67
+ nav.setPageLoading(true)
68
+ artalk?.ctx
69
+ .getApi()
70
+ .users.getUsers(curtType.value, {
71
+ offset,
72
+ limit: pageSize.value,
73
+ search: search.value,
74
+ })
75
+ .then((res) => {
76
+ pageTotal.value = res.data.count
77
+ users.value = res.data.users
78
+ nav.scrollPageToTop()
79
+ })
80
+ .finally(() => {
81
+ nav.setPageLoading(false)
82
+ })
83
+ }
84
+
85
+ function onChangePage(offset: number) {
86
+ fetchUsers(offset)
87
+ }
88
+
89
+ function editUser(user: ArtalkType.UserDataForAdmin) {
90
+ if (user.is_in_conf) {
91
+ alert(t('userInConfCannotEditHint'))
92
+ return
93
+ }
94
+
95
+ userEditorState.show = true
96
+ userEditorState.user = user
97
+ }
98
+
99
+ function createUser() {
100
+ userEditorState.show = true
101
+ userEditorState.user = undefined
102
+ }
103
+
104
+ function updateUser(user: ArtalkType.UserDataForAdmin) {
105
+ const index = users.value.findIndex((u) => u.id === user.id)
106
+ if (index != -1) {
107
+ // Edit user
108
+ const orgUser = users.value[index]
109
+ Object.keys(user).forEach((key) => {
110
+ ;(orgUser as any)[key] = (user as any)[key]
111
+ })
112
+ } else {
113
+ // Create user
114
+ fetchUsers(0)
115
+ }
116
+
117
+ closeUserEditor()
118
+ }
119
+
120
+ function closeUserEditor() {
121
+ userEditorState.show = false
122
+ userEditorState.user = undefined
123
+ }
124
+
125
+ function delUser(user: ArtalkType.UserDataForAdmin) {
126
+ if (
127
+ window.confirm(
128
+ t('userDeleteConfirm', {
129
+ name: user.name,
130
+ email: user.email,
131
+ }),
132
+ )
133
+ ) {
134
+ artalk!.ctx
135
+ .getApi()
136
+ .users.deleteUser(user.id)
137
+ .then(() => {
138
+ const index = users.value.findIndex((u) => u.id === user.id)
139
+ users.value.splice(index, 1)
140
+
141
+ if (user.is_in_conf) {
142
+ alert(t('userDeleteManuallyHint'))
143
+ }
144
+ })
145
+ .catch((e: ArtalkType.FetchError) => {
146
+ alert(e.message)
147
+ })
148
+ }
149
+ }
150
+ </script>
151
+
152
+ <template>
153
+ <div class="user-list-wrap">
154
+ <div class="user-list">
155
+ <div v-for="user in users" :key="user.id" class="user-item">
156
+ <div class="user-main">
157
+ <div class="title">
158
+ {{ user.name }}
159
+ <span class="badge-grp">
160
+ <span
161
+ v-if="user.badge_name"
162
+ class="badge"
163
+ :style="{ backgroundColor: user.badge_color }"
164
+ >
165
+ {{ user.badge_name }}
166
+ </span>
167
+ <span v-else-if="user.is_admin" class="badge admin" :title="t('userAdminHint')">
168
+ {{ t('Admin') }}
169
+ </span>
170
+ <span v-if="user.is_in_conf" class="badge in-conf" :title="t('userInConfHint')">
171
+ {{ t('Config') }}
172
+ </span>
173
+ </span>
174
+ </div>
175
+ <div class="sub">{{ user.email }}</div>
176
+ </div>
177
+ <div class="user-actions">
178
+ <span @click="editUser(user)">{{ t('edit') }}</span>
179
+ <span>{{ t('comment') }} ({{ user.comment_count }})</span>
180
+ <span @click="delUser(user)">{{ t('delete') }}</span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ <Pagination
185
+ ref="pagination"
186
+ :page-size="pageSize"
187
+ :total="pageTotal"
188
+ :disabled="nav.isPageLoading"
189
+ @change="onChangePage"
190
+ />
191
+
192
+ <UserEditor
193
+ v-if="userEditorState.show"
194
+ :user="userEditorState.user"
195
+ @update="updateUser"
196
+ @close="closeUserEditor"
197
+ />
198
+ </div>
199
+ </template>
200
+
201
+ <style scoped lang="scss">
202
+ .user-list-wrap {
203
+ .user-list {
204
+ .user-item {
205
+ padding: 15px 30px;
206
+
207
+ &:not(:last-child) {
208
+ border-bottom: 1px solid var(--at-color-border);
209
+ }
210
+
211
+ .user-main {
212
+ .title {
213
+ display: flex;
214
+ flex-direction: row;
215
+ align-items: center;
216
+ color: var(--at-color-deep);
217
+ font-size: 20px;
218
+
219
+ .badge-grp {
220
+ display: flex;
221
+ margin-left: 5px;
222
+ }
223
+
224
+ .badge {
225
+ margin-left: 6px;
226
+ font-size: 13px;
227
+ color: var(--at-color-meta);
228
+ background: var(--at-color-bg-grey);
229
+ padding: 0 6px;
230
+ line-height: 17px;
231
+ border-radius: 2px;
232
+ color: #fff;
233
+
234
+ &.admin {
235
+ background: #0083ff;
236
+ }
237
+
238
+ &.in-conf {
239
+ background: #89b1a5;
240
+ }
241
+ }
242
+ }
243
+
244
+ .sub {
245
+ color: var(--at-color-sub);
246
+ font-size: 15px;
247
+ margin-top: 5px;
248
+ }
249
+ }
250
+
251
+ .user-actions {
252
+ margin-top: 10px;
253
+
254
+ & > span {
255
+ cursor: pointer;
256
+ color: var(--at-color-meta);
257
+ font-size: 13px;
258
+
259
+ &:not(:last-child) {
260
+ margin-right: 16px;
261
+ }
262
+
263
+ &:hover {
264
+ color: var(--at-color-deep);
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ </style>
@@ -0,0 +1,114 @@
1
+ import { defineStore } from 'pinia'
2
+ import type { ArtalkType } from '@esershnr/artalk'
3
+ import { artalk, bootParams, getArtalk } from '@/global'
4
+ import { useMobileWidth } from '@/hooks/MobileWidth'
5
+
6
+ type TabsObj = { [name: string]: string }
7
+
8
+ export const useNavStore = defineStore('nav', () => {
9
+ const curtTab = ref('')
10
+ const tabs = ref<TabsObj>({})
11
+
12
+ const curtPage = ref('comments')
13
+ const sites = ref<ArtalkType.SiteData[]>([])
14
+ const siteSwitcherShow = ref(false)
15
+
16
+ const isSearchEnabled = ref(false)
17
+ const searchEvent = ref<((val: string) => void) | null>(null)
18
+ const searchResetEvent = ref<(() => void) | null>(null)
19
+
20
+ const isPageLoading = ref(false)
21
+ const scrollableArea = ref<HTMLElement | null>(null)
22
+
23
+ const darkMode = ref(bootParams.darkMode)
24
+ watch(darkMode, (val) => {
25
+ getArtalk()?.setDarkMode(val)
26
+ if (val != window.matchMedia('(prefers-color-scheme: dark)').matches)
27
+ localStorage.setItem('ATK_SIDEBAR_DARK_MODE', val ? '1' : '0')
28
+ else localStorage.removeItem('ATK_SIDEBAR_DARK_MODE') // enable auto switch
29
+ })
30
+
31
+ const updateTabs = (aTabs: TabsObj, activeTab?: string) => {
32
+ tabs.value = aTabs
33
+ if (activeTab) curtTab.value = activeTab
34
+ }
35
+
36
+ const setTabActive = (tabName: string) => {
37
+ curtTab.value = tabName
38
+ }
39
+
40
+ const showSiteSwitcher = () => {
41
+ siteSwitcherShow.value = true
42
+ }
43
+
44
+ const hideSiteSwitcher = () => {
45
+ siteSwitcherShow.value = false
46
+ }
47
+
48
+ const toggleSiteSwitcher = () => {
49
+ siteSwitcherShow.value = !siteSwitcherShow.value
50
+ }
51
+
52
+ const scrollPageToTop = () => {
53
+ scrollableArea.value?.scrollTo(0, 0)
54
+ }
55
+
56
+ const scrollPageToEl = (el: HTMLElement) => {
57
+ scrollableArea.value?.scrollTo(0, el.offsetTop)
58
+ }
59
+
60
+ const setPageLoading = (pageLoading: boolean) => {
61
+ isPageLoading.value = pageLoading
62
+ }
63
+
64
+ const refreshSites = () => {
65
+ artalk?.ctx
66
+ .getApi()
67
+ .sites.getSites()
68
+ .then((res) => {
69
+ sites.value = res.data.sites
70
+ })
71
+ }
72
+
73
+ const enableSearch = (searchEvt: (val: string) => void, searchResetEvt: () => void) => {
74
+ isSearchEnabled.value = true
75
+ searchEvent.value = searchEvt
76
+ searchResetEvent.value = searchResetEvt
77
+ }
78
+
79
+ const toggleDarkMode = () => {
80
+ darkMode.value = !darkMode.value
81
+ }
82
+
83
+ useRouter().beforeEach((to, from) => {
84
+ isSearchEnabled.value = false
85
+ })
86
+
87
+ const isMobile = useMobileWidth()
88
+
89
+ return {
90
+ sites,
91
+ curtPage,
92
+ curtTab,
93
+ tabs,
94
+ siteSwitcherShow,
95
+ scrollableArea,
96
+ isPageLoading,
97
+ updateTabs,
98
+ setTabActive,
99
+ showSiteSwitcher,
100
+ hideSiteSwitcher,
101
+ toggleSiteSwitcher,
102
+ scrollPageToTop,
103
+ scrollPageToEl,
104
+ setPageLoading,
105
+ refreshSites,
106
+ isSearchEnabled,
107
+ searchEvent,
108
+ searchResetEvent,
109
+ enableSearch,
110
+ isMobile,
111
+ darkMode,
112
+ toggleDarkMode,
113
+ }
114
+ })
@@ -0,0 +1,48 @@
1
+ import type { LocalUser } from '@esershnr/artalk'
2
+ import { defineStore } from 'pinia'
3
+ import sha256 from 'crypto-js/sha256'
4
+ import md5 from 'crypto-js/md5'
5
+ import { bootParams, getArtalk } from '../global'
6
+
7
+ interface UserState extends LocalUser {
8
+ /**
9
+ * Current site name
10
+ */
11
+ site: string
12
+ }
13
+
14
+ export const useUserStore = defineStore('user', {
15
+ state: () =>
16
+ <UserState>{
17
+ site: bootParams.site || '',
18
+ ...bootParams.user,
19
+ },
20
+ actions: {
21
+ logout() {
22
+ this.$reset()
23
+ getArtalk()?.ctx.getUser().logout()
24
+ },
25
+ sync() {
26
+ const user = getArtalk()?.ctx.getUser()
27
+ if (!user) throw new Error('Artalk is not initialized')
28
+ if (!user.checkHasBasicUserInfo()) throw new Error('User is not logged in')
29
+ this.$patch({ ...user.getData(), site: '' })
30
+ },
31
+ },
32
+ getters: {
33
+ avatar: (state) => getGravatar(state.email),
34
+ },
35
+ })
36
+
37
+ function getGravatar(email: string) {
38
+ // TODO get avatar url from backend api
39
+ const conf = getArtalk()?.ctx.conf?.gravatar
40
+ if (!conf) return ''
41
+
42
+ const emailHash =
43
+ typeof conf.params == 'string' && conf.params.includes('sha256=1')
44
+ ? sha256(email.toLowerCase()).toString()
45
+ : md5(email.toLowerCase()).toString()
46
+
47
+ return `${conf.mirror.replace(/\/$/, '')}/${emailHash}?${conf.params.replace(/^\?/, '')}`
48
+ }