@stacksjs/defaults 0.75.1 → 0.75.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.
- package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +12 -3
- package/app/Actions/Dashboard/Content/CommentIndexAction.ts +80 -2
- package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +48 -1
- package/app/Actions/Dashboard/Content/comment-input.ts +134 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Content/CommentsDashboard.stx +29 -11
|
@@ -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(
|
|
37
|
+
if (!await rowExists(source, id, trx))
|
|
29
38
|
return false
|
|
30
|
-
await trx.deleteFrom(
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
+
}
|
package/ide/vscode/package.json
CHANGED
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.
|
|
5
|
+
"version": "0.75.3",
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.3",
|
|
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
|
|
40
|
+
const source: CommentSource = record?.source === 'commentables' ? 'commentables' : 'comments'
|
|
41
|
+
const id = String(record?.id || '')
|
|
29
42
|
return {
|
|
30
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
97
|
-
|
|
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.
|
|
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>
|