@stacksjs/defaults 0.75.1 → 0.75.2

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.
@@ -4,6 +4,7 @@ import { db } from '@stacksjs/database/runtime'
4
4
  import { transaction } from '@stacksjs/orm'
5
5
  import { response } from '@stacksjs/router'
6
6
  import { dashboardOperationalError } from '../dashboard-response'
7
+ import { COMMENT_SOURCES, parseCommentSource } from './comment-input'
7
8
  import { rowExists, rowId } from './content-input'
8
9
 
9
10
  /**
@@ -11,6 +12,9 @@ import { rowExists, rowId } from './content-input'
11
12
  *
12
13
  * A hard delete. The `trash` status exists for the reversible case, and the
13
14
  * page's delete button confirms first.
15
+ *
16
+ * `?source=commentables` deletes a comment the `commentable` trait wrote; the
17
+ * two tables number their rows independently, so the id alone is ambiguous.
14
18
  */
15
19
  export default new Action({
16
20
  name: 'CommentDestroyAction',
@@ -22,19 +26,24 @@ export default new Action({
22
26
  if (!id)
23
27
  return response.json({ message: 'A valid comment id is required.' }, 422)
24
28
 
29
+ const source = parseCommentSource(request.get('source'))
30
+
31
+ if (!source)
32
+ return response.json({ message: `Source must be one of: ${COMMENT_SOURCES.join(', ')}.` }, 422)
33
+
25
34
  try {
26
35
  const deleted = await transaction(async (rawTrx) => {
27
36
  const trx = rawTrx as unknown as typeof db
28
- if (!await rowExists('comments', id, trx))
37
+ if (!await rowExists(source, id, trx))
29
38
  return false
30
- await trx.deleteFrom('comments').where('id', '=', id).execute()
39
+ await trx.deleteFrom(source).where('id', '=', id).execute()
31
40
  return true
32
41
  })
33
42
 
34
43
  if (!deleted)
35
44
  return response.json({ message: 'Comment not found.' }, 404)
36
45
 
37
- return response.json({ message: 'Comment deleted.', id })
46
+ return response.json({ message: 'Comment deleted.', id, source })
38
47
  }
39
48
  catch (error) {
40
49
  return dashboardOperationalError(error, 'Comment could not be deleted.', 'CommentDestroyAction', 500)
@@ -1,7 +1,8 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database/runtime'
3
3
  import { dashboardOperationalError } from '../dashboard-response'
4
- import { normalizeCommentStatus } from './comment-input'
4
+ import type { CommentableRow, DashboardComment } from './comment-input'
5
+ import { commentableRecord, isMissingTableError, isSafeTableName, newestFirst, normalizeCommentStatus } from './comment-input'
5
6
 
6
7
  interface CommentRow {
7
8
  id: number
@@ -16,9 +17,82 @@ interface CommentRow {
16
17
  updated_at: string | null
17
18
  }
18
19
 
20
+ /**
21
+ * What each trait comment was left on, keyed `type:id`: the owner's title, else
22
+ * its name, else `posts #3`. `commentables_type` is the owner's table, so this
23
+ * is a join done per type, because one SQL join cannot follow a polymorphic
24
+ * key. A type that is not a safe identifier, has no such table, or has neither
25
+ * column falls back to the plain label rather than failing the whole list.
26
+ */
27
+ async function ownerLabels(rows: CommentableRow[]): Promise<Map<string, string>> {
28
+ const labels = new Map<string, string>()
29
+ const idsByType = new Map<string, number[]>()
30
+
31
+ for (const row of rows) {
32
+ const type = String(row.commentables_type || '')
33
+ labels.set(`${type}:${row.commentables_id}`, `${type} #${row.commentables_id}`)
34
+ if (isSafeTableName(type))
35
+ idsByType.set(type, [...(idsByType.get(type) ?? []), Number(row.commentables_id)])
36
+ }
37
+
38
+ for (const [type, ids] of idsByType) {
39
+ for (const column of ['title', 'name']) {
40
+ try {
41
+ const owners = await (db as any)
42
+ .selectFrom(type)
43
+ .select(['id', column])
44
+ .whereIn('id', [...new Set(ids)])
45
+ .execute() as Array<Record<string, unknown>>
46
+
47
+ for (const owner of owners) {
48
+ const label = String(owner[column] ?? '').trim()
49
+ if (label)
50
+ labels.set(`${type}:${owner.id}`, label)
51
+ }
52
+ break
53
+ }
54
+ catch {
55
+ // No such table or no such column: try the next column, then keep
56
+ // the fallback label.
57
+ }
58
+ }
59
+ }
60
+
61
+ return labels
62
+ }
63
+
64
+ /**
65
+ * Comments the `commentable` trait wrote. An app that has never migrated the
66
+ * trait tables has none, which is not an error; any other failure is.
67
+ */
68
+ async function commentableComments(): Promise<DashboardComment[]> {
69
+ let rows: CommentableRow[]
70
+ try {
71
+ rows = await db
72
+ .selectFrom('commentables')
73
+ .selectAll()
74
+ .orderBy('created_at', 'desc')
75
+ .execute() as unknown as CommentableRow[]
76
+ }
77
+ catch (error) {
78
+ if (isMissingTableError(error))
79
+ return []
80
+ throw error
81
+ }
82
+
83
+ const labels = await ownerLabels(rows)
84
+
85
+ return rows.map(row => commentableRecord(row, labels.get(`${row.commentables_type}:${row.commentables_id}`) ?? ''))
86
+ }
87
+
19
88
  /**
20
89
  * `GET /api/dashboard/comments` — backs `views/dashboard/content/comments/index.stx`.
21
90
  *
91
+ * Lists both places a comment can live, newest first: the model-backed
92
+ * `comments` table and the polymorphic `commentables` table the `commentable`
93
+ * trait writes. Their ids overlap, so every record carries its `source` and a
94
+ * `key` unique across both, and the moderation writes take `source` back.
95
+ *
22
96
  * Reads the `comments` table via `db`. The previous `Comment.orderBy(...)` call
23
97
  * threw on every request (the ORM model exposes no query methods) and the catch
24
98
  * turned that into an empty list, so a broken read looked like a CMS with no
@@ -41,7 +115,9 @@ export default new Action({
41
115
  .orderBy('created_at', 'desc')
42
116
  .execute() as unknown as CommentRow[]
43
117
 
44
- const comments = rows.map(row => ({
118
+ const legacy: DashboardComment[] = rows.map(row => ({
119
+ key: `comments:${row.id}`,
120
+ source: 'comments',
45
121
  id: Number(row.id),
46
122
  author_name: String(row.author_name || ''),
47
123
  author_email: String(row.author_email || ''),
@@ -53,6 +129,8 @@ export default new Action({
53
129
  updated_at: row.updated_at || null,
54
130
  }))
55
131
 
132
+ const comments = [...legacy, ...await commentableComments()].sort(newestFirst)
133
+
56
134
  return { comments }
57
135
  }
58
136
  catch (error) {
@@ -4,7 +4,8 @@ import { db } from '@stacksjs/database/runtime'
4
4
  import { transaction } from '@stacksjs/orm'
5
5
  import { response } from '@stacksjs/router'
6
6
  import { dashboardOperationalError } from '../dashboard-response'
7
- import { COMMENT_STATUSES, parseCommentStatus } from './comment-input'
7
+ import type { CommentableRow } from './comment-input'
8
+ import { COMMENT_SOURCES, COMMENT_STATUSES, commentableRecord, commentableStatusFor, parseCommentSource, parseCommentStatus } from './comment-input'
8
9
  import { findRow, rowExists, rowId, timestamp } from './content-input'
9
10
 
10
11
  /**
@@ -16,6 +17,11 @@ import { findRow, rowExists, rowId, timestamp } from './content-input'
16
17
  * `is_approved` is kept in step with `status` so the two never disagree — the
17
18
  * table carries both, and the page falls back to `is_approved` when a row has
18
19
  * no status.
20
+ *
21
+ * `source: 'commentables'` moderates a comment the `commentable` trait wrote
22
+ * instead. That table only knows pending, approved and rejected, so spam and
23
+ * trash both store as rejected, and the response carries the status the page
24
+ * should now show rather than echoing the one it asked for.
19
25
  */
20
26
  export default new Action({
21
27
  name: 'CommentUpdateAction',
@@ -32,6 +38,47 @@ export default new Action({
32
38
  if (!status)
33
39
  return response.json({ message: `Status must be one of: ${COMMENT_STATUSES.join(', ')}.` }, 422)
34
40
 
41
+ const source = parseCommentSource(request.get('source'))
42
+
43
+ if (!source)
44
+ return response.json({ message: `Source must be one of: ${COMMENT_SOURCES.join(', ')}.` }, 422)
45
+
46
+ if (source === 'commentables') {
47
+ try {
48
+ const comment = await transaction(async (rawTrx) => {
49
+ const trx = rawTrx as unknown as typeof db
50
+ if (!await rowExists('commentables', id, trx))
51
+ return null
52
+
53
+ const stored = commentableStatusFor(status)
54
+ const now = Date.now()
55
+ await trx
56
+ .updateTable('commentables')
57
+ .set({
58
+ status: stored,
59
+ approved_at: stored === 'approved' ? now : null,
60
+ rejected_at: stored === 'rejected' ? now : null,
61
+ updated_at: timestamp(),
62
+ } as any)
63
+ .where('id', '=', id)
64
+ .execute()
65
+
66
+ const updated = await findRow('commentables', id, trx)
67
+ if (!updated)
68
+ throw new Error('Updated comment could not be loaded.')
69
+ return updated as CommentableRow
70
+ })
71
+
72
+ if (!comment)
73
+ return response.json({ message: 'Comment not found.' }, 404)
74
+
75
+ return response.json(commentableRecord(comment))
76
+ }
77
+ catch (error) {
78
+ return dashboardOperationalError(error, 'Comment could not be updated.', 'CommentUpdateAction', 500)
79
+ }
80
+ }
81
+
35
82
  try {
36
83
  const comment = await transaction(async (rawTrx) => {
37
84
  const trx = rawTrx as unknown as typeof db
@@ -33,3 +33,137 @@ export function parseCommentStatus(value: unknown): CommentStatus | undefined {
33
33
 
34
34
  return isCommentStatus(status) ? status : undefined
35
35
  }
36
+
37
+ /**
38
+ * Where a dashboard comment lives.
39
+ *
40
+ * `comments` is the model-backed table the CMS form writes. `commentables` is
41
+ * the polymorphic table the `commentable` model trait writes
42
+ * (`post.addComment(...)`), with its own ids, so the two can both have a
43
+ * comment 1. Every write names its source; an absent one means `comments`,
44
+ * which is what every caller sent before trait comments were listed.
45
+ */
46
+ export const COMMENT_SOURCES = ['comments', 'commentables'] as const
47
+
48
+ export type CommentSource = typeof COMMENT_SOURCES[number]
49
+
50
+ export function parseCommentSource(value: unknown): CommentSource | undefined {
51
+ const source = str(value).trim().toLowerCase()
52
+ if (!source)
53
+ return 'comments'
54
+
55
+ return (COMMENT_SOURCES as readonly string[]).includes(source) ? source as CommentSource : undefined
56
+ }
57
+
58
+ /** The statuses the `commentable` trait, the CMS and `buddy comments:*` use. */
59
+ export type CommentableStatus = 'pending' | 'approved' | 'rejected'
60
+
61
+ /**
62
+ * A dashboard status as the trait stores it. The trait has one way to hide a
63
+ * comment, `rejected`, so spam and trash both land there: writing either
64
+ * spelling would hide it from `rejectedComments()` and the CLI's filter.
65
+ */
66
+ export function commentableStatusFor(status: CommentStatus): CommentableStatus {
67
+ if (status === 'approved' || status === 'pending')
68
+ return status
69
+
70
+ return 'rejected'
71
+ }
72
+
73
+ /** A stored trait status as the dashboard shows it; `rejected` reads as spam. */
74
+ export function dashboardStatusForCommentable(value: unknown): CommentStatus {
75
+ const status = str(value).toLowerCase()
76
+ if (status === 'approved')
77
+ return 'approved'
78
+ if (status === 'rejected')
79
+ return 'spam'
80
+
81
+ return 'pending'
82
+ }
83
+
84
+ export interface CommentableRow {
85
+ id: number
86
+ title?: string | null
87
+ body: string | null
88
+ status: string | null
89
+ commentables_id: number
90
+ commentables_type: string
91
+ user_id?: number | null
92
+ author_name?: string | null
93
+ author_email?: string | null
94
+ created_at: string | null
95
+ updated_at?: string | null
96
+ }
97
+
98
+ /** One row of the dashboard's comment list, from either table. */
99
+ export interface DashboardComment {
100
+ key: string
101
+ source: CommentSource
102
+ id: number
103
+ author_name: string
104
+ author_email: string
105
+ content: string
106
+ post_title: string
107
+ status: CommentStatus
108
+ is_approved: boolean
109
+ created_at: string | null
110
+ updated_at: string | null
111
+ }
112
+
113
+ /**
114
+ * A trait comment in the dashboard's shape. `ownerLabel` names what it was
115
+ * left on, e.g. the post title, since the row only carries a type and an id.
116
+ *
117
+ * `author_email` is included because this is the moderation view, the one
118
+ * place a site owner needs it. It is not for display anywhere else.
119
+ */
120
+ export function commentableRecord(row: CommentableRow, ownerLabel = ''): DashboardComment {
121
+ const status = dashboardStatusForCommentable(row.status)
122
+ const author = str(row.author_name) || (row.user_id ? `User #${row.user_id}` : '')
123
+ // The trait's title is optional and usually empty; when a caller did give
124
+ // one it is part of what the reader wrote, so keep it above the body.
125
+ const title = str(row.title).trim()
126
+ const body = str(row.body)
127
+
128
+ return {
129
+ key: `commentables:${row.id}`,
130
+ source: 'commentables',
131
+ id: Number(row.id),
132
+ author_name: author,
133
+ author_email: str(row.author_email),
134
+ content: title ? `${title}\n\n${body}` : body,
135
+ post_title: ownerLabel,
136
+ status,
137
+ is_approved: status === 'approved',
138
+ created_at: row.created_at || null,
139
+ updated_at: row.updated_at || null,
140
+ }
141
+ }
142
+
143
+ /** A table name read from `commentables_type`, safe to use as an identifier. */
144
+ export function isSafeTableName(value: string): boolean {
145
+ return /^[a-z_][a-z0-9_]*$/i.test(value) && value.length <= 64
146
+ }
147
+
148
+ /**
149
+ * Newest first across both tables. `comments` writes `YYYY-MM-DD HH:MM:SS`
150
+ * and the trait writes `YYYY-MM-DDTHH:MM:SS`, so compare the normalized form
151
+ * rather than the raw strings, which would sort every ISO timestamp after
152
+ * every space-separated one on the same day.
153
+ */
154
+ export function newestFirst(a: DashboardComment, b: DashboardComment): number {
155
+ const at = (value: string | null) => str(value).replace('T', ' ').slice(0, 19)
156
+
157
+ return at(b.created_at).localeCompare(at(a.created_at)) || b.key.localeCompare(a.key)
158
+ }
159
+
160
+ /**
161
+ * Whether a database error means the table is not there at all: SQLite's
162
+ * "no such table", MySQL's "Table '…' doesn't exist", Postgres's "relation
163
+ * … does not exist". Narrow on purpose, a missing COLUMN is a real fault.
164
+ */
165
+ export function isMissingTableError(error: unknown): boolean {
166
+ const message = error instanceof Error ? error.message : String(error)
167
+
168
+ return /no such table|table \S+ doesn't exist|relation \S+ does not exist/i.test(message)
169
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.1",
5
+ "version": "0.75.2",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.1",
5
+ "version": "0.75.2",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.27",
58
- "@stacksjs/mobile": "^0.75.1",
58
+ "@stacksjs/mobile": "^0.75.2",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -4,7 +4,14 @@ import { pushToast } from '../../../../functions/toasts'
4
4
 
5
5
  type CommentStatus = 'pending' | 'approved' | 'spam' | 'trash'
6
6
 
7
+ // `comments` is the CMS table, `commentables` the one the `commentable` model
8
+ // trait writes. Their ids overlap, so a record is identified by `key` and every
9
+ // write says which table it is for.
10
+ type CommentSource = 'comments' | 'commentables'
11
+
7
12
  interface CommentRecord {
13
+ key: string
14
+ source: CommentSource
8
15
  id: string
9
16
  authorName: string
10
17
  authorEmail: string
@@ -24,15 +31,23 @@ const deletingComment = state<CommentRecord | null>(null)
24
31
  const deleting = state(false)
25
32
  const deleteError = state('')
26
33
 
34
+ function normalizeStatus(value: unknown): CommentStatus | null {
35
+ const status = String(value || '').toLowerCase()
36
+ return ['pending', 'approved', 'spam', 'trash'].includes(status) ? status as CommentStatus : null
37
+ }
38
+
27
39
  function normalize(record: any): CommentRecord {
28
- const rawStatus = String(record?.status || 'pending').toLowerCase()
40
+ const source: CommentSource = record?.source === 'commentables' ? 'commentables' : 'comments'
41
+ const id = String(record?.id || '')
29
42
  return {
30
- id: String(record?.id || ''),
43
+ key: String(record?.key || `${source}:${id}`),
44
+ source,
45
+ id,
31
46
  authorName: String(record?.author_name || ''),
32
47
  authorEmail: String(record?.author_email || ''),
33
48
  content: String(record?.content || ''),
34
49
  postTitle: String(record?.post_title || ''),
35
- status: ['pending', 'approved', 'spam', 'trash'].includes(rawStatus) ? rawStatus as CommentStatus : 'pending',
50
+ status: normalizeStatus(record?.status) ?? 'pending',
36
51
  createdAt: String(record?.created_at || ''),
37
52
  }
38
53
  }
@@ -87,14 +102,17 @@ async function loadComments(): Promise<void> {
87
102
  }
88
103
 
89
104
  async function setStatus(comment: CommentRecord, status: CommentStatus): Promise<void> {
90
- updatingId.set(comment.id)
105
+ updatingId.set(comment.key)
91
106
  try {
92
- await dashboardApi(`/api/dashboard/comments/${comment.id}`, {
107
+ const updated = await dashboardApi<{ status?: string }>(`/api/dashboard/comments/${comment.id}`, {
93
108
  method: 'PATCH',
94
- body: { status },
109
+ body: { status, source: comment.source },
95
110
  })
96
- comments.set(comments().map(record => record.id === comment.id ? { ...record, status } : record))
97
- pushToast('success', `Comment marked ${status}`)
111
+ // The server says what the comment now is: a trait comment has one hidden
112
+ // state, so asking for trash comes back as spam.
113
+ const next = normalizeStatus(updated?.status) ?? status
114
+ comments.set(comments().map(record => record.key === comment.key ? { ...record, status: next } : record))
115
+ pushToast('success', `Comment marked ${next}`)
98
116
  }
99
117
  catch (error) {
100
118
  pushToast('error', 'Could not update comment', {
@@ -127,8 +145,8 @@ async function deleteComment(): Promise<void> {
127
145
  deleting.set(true)
128
146
  deleteError.set('')
129
147
  try {
130
- await dashboardApi(`/api/dashboard/comments/${comment.id}`, { method: 'DELETE' })
131
- comments.set(comments().filter(record => record.id !== comment.id))
148
+ await dashboardApi(`/api/dashboard/comments/${comment.id}?source=${comment.source}`, { method: 'DELETE' })
149
+ comments.set(comments().filter(record => record.key !== comment.key))
132
150
  deletingComment.set(null)
133
151
  pushToast('success', 'Comment permanently deleted')
134
152
  }
@@ -165,7 +183,7 @@ onMount(() => {
165
183
  <div class="divide-gray-200 divide-y dark:divide-neutral-700">
166
184
  <div :if="loading()" class="px-4 py-14 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading comments</div>
167
185
  <div :else-if="visibleComments().length === 0" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-comment-01"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No comments found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the search or status filter.</p></div>
168
- <template :else><template :for="comment in visibleComments()"><article class="p-4"><div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"><div class="min-w-0"><div class="flex flex-wrap gap-2 items-center"><p class="font-semibold text-gray-900 text-sm dark:text-white">{{ comment.authorName || 'Anonymous' }}</p><span :class="statusClass(comment.status)">{{ comment.status }}</span><span class="text-gray-400 text-xs">{{ formatDate(comment.createdAt) }}</span></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ comment.authorEmail }}<span :if="comment.postTitle"> on {{ comment.postTitle }}</span></p><p class="mt-3 max-w-4xl text-gray-700 text-sm whitespace-pre-wrap dark:text-neutral-200">{{ comment.content }}</p></div><div class="flex flex-shrink-0 flex-wrap gap-2"><Button :if="comment.status !== 'approved'" variant="success" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'approved')">Approve</Button><Button :if="comment.status !== 'pending'" variant="secondary" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'pending')">Restore</Button><Button :if="comment.status !== 'spam'" variant="warning" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'spam')">Spam</Button><Button :if="comment.status !== 'trash'" variant="outline" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'trash')">Trash</Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Permanently delete comment by ' + comment.authorName" :disabled="Boolean(updatingId())" @click="openDelete(comment)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></div></article></template></template>
186
+ <template :else><template :for="comment in visibleComments()"><article class="p-4"><div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"><div class="min-w-0"><div class="flex flex-wrap gap-2 items-center"><p class="font-semibold text-gray-900 text-sm dark:text-white">{{ comment.authorName || 'Anonymous' }}</p><span :class="statusClass(comment.status)">{{ comment.status }}</span><span class="text-gray-400 text-xs">{{ formatDate(comment.createdAt) }}</span></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ comment.authorEmail }}<span :if="comment.postTitle"> on {{ comment.postTitle }}</span></p><p class="mt-3 max-w-4xl text-gray-700 text-sm whitespace-pre-wrap dark:text-neutral-200">{{ comment.content }}</p></div><div class="flex flex-shrink-0 flex-wrap gap-2"><Button :if="comment.status !== 'approved'" variant="success" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'approved')">Approve</Button><Button :if="comment.status !== 'pending'" variant="secondary" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'pending')">Restore</Button><Button :if="comment.status !== 'spam'" variant="warning" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'spam')">Spam</Button><Button :if="comment.status !== 'trash' && comment.source === 'comments'" variant="outline" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'trash')">Trash</Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Permanently delete comment by ' + comment.authorName" :disabled="Boolean(updatingId())" @click="openDelete(comment)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></div></article></template></template>
169
187
  </div>
170
188
  </section>
171
189
  </div>