@stacksjs/defaults 0.70.88 → 0.70.91

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 (36) hide show
  1. package/app/Actions/Blog/BlogDestroyAction.ts +25 -0
  2. package/app/Actions/Blog/BlogIndexAction.ts +18 -0
  3. package/app/Actions/Blog/BlogShowAction.ts +25 -0
  4. package/app/Actions/Blog/BlogStoreAction.ts +21 -0
  5. package/app/Actions/Blog/BlogUpdateAction.ts +23 -0
  6. package/app/Actions/Blog/payload.ts +56 -0
  7. package/app/Actions/Cms/PostStoreAction.ts +10 -5
  8. package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +38 -0
  9. package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +53 -39
  10. package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +61 -0
  11. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +30 -0
  12. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +40 -17
  13. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +58 -0
  14. package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +30 -0
  15. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +46 -31
  16. package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +48 -0
  17. package/app/Actions/Dashboard/Content/PageDestroyAction.ts +25 -0
  18. package/app/Actions/Dashboard/Content/PageIndexAction.ts +43 -15
  19. package/app/Actions/Dashboard/Content/PageStoreAction.ts +49 -0
  20. package/app/Actions/Dashboard/Content/PostDestroyAction.ts +37 -0
  21. package/app/Actions/Dashboard/Content/PostIndexAction.ts +64 -83
  22. package/app/Actions/Dashboard/Content/PostStoreAction.ts +50 -0
  23. package/app/Actions/Dashboard/Content/PostUpdateAction.ts +54 -0
  24. package/app/Actions/Dashboard/Content/TagDestroyAction.ts +30 -0
  25. package/app/Actions/Dashboard/Content/TagIndexAction.ts +39 -13
  26. package/app/Actions/Dashboard/Content/TagStoreAction.ts +60 -0
  27. package/app/Actions/Dashboard/Content/comment-input.ts +35 -0
  28. package/app/Actions/Dashboard/Content/content-input.ts +65 -0
  29. package/app/Actions/Dashboard/Content/post-input.ts +54 -0
  30. package/app/Models/Content/Post.ts +4 -1
  31. package/package.json +1 -1
  32. package/resources/components/Dashboard/FileViewer/MediaCard.stx +119 -0
  33. package/resources/components/Dashboard/FileViewer/MediaInfoPanel.stx +143 -0
  34. package/resources/components/Dashboard/SidebarModern.stx +1 -0
  35. package/resources/functions/dashboard/files-tree.ts +162 -0
  36. package/resources/functions/dashboard/sidebar.ts +1 -0
@@ -0,0 +1,25 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action, BlogAdminError, deleteBlogPost } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'Blog Destroy',
7
+ description: 'Deletes a markdown post from content/blog.',
8
+ method: 'DELETE',
9
+ async handle(request: RequestInstance) {
10
+ try {
11
+ const slug = String(request.getParam('slug') ?? '')
12
+
13
+ if (!deleteBlogPost(slug))
14
+ return response.json({ message: 'Post not found.' }, 404)
15
+
16
+ return response.json({ message: 'Post deleted.', slug })
17
+ }
18
+ catch (error) {
19
+ if (error instanceof BlogAdminError)
20
+ return response.json({ message: error.message }, error.status)
21
+
22
+ throw error
23
+ }
24
+ },
25
+ })
@@ -0,0 +1,18 @@
1
+ import { Action, listBlogPosts } from '@stacksjs/actions'
2
+ import { response } from '@stacksjs/router'
3
+
4
+ export default new Action({
5
+ name: 'Blog Index',
6
+ description: 'Lists every markdown post in content/blog, drafts included.',
7
+ method: 'GET',
8
+ async handle() {
9
+ const posts = listBlogPosts()
10
+
11
+ return response.json({
12
+ posts,
13
+ publishedCount: posts.filter(p => !p.draft).length,
14
+ draftCount: posts.filter(p => p.draft).length,
15
+ featuredCount: posts.filter(p => p.featured).length,
16
+ })
17
+ },
18
+ })
@@ -0,0 +1,25 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action, BlogAdminError, getBlogPost } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'Blog Show',
7
+ description: 'Returns one markdown post from content/blog, body included.',
8
+ method: 'GET',
9
+ async handle(request: RequestInstance) {
10
+ try {
11
+ const post = getBlogPost(String(request.getParam('slug') ?? ''))
12
+
13
+ if (!post)
14
+ return response.json({ message: 'Post not found.' }, 404)
15
+
16
+ return response.json(post)
17
+ }
18
+ catch (error) {
19
+ if (error instanceof BlogAdminError)
20
+ return response.json({ message: error.message }, error.status)
21
+
22
+ throw error
23
+ }
24
+ },
25
+ })
@@ -0,0 +1,21 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action, BlogAdminError, saveBlogPost } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { blogPayload } from './payload'
5
+
6
+ export default new Action({
7
+ name: 'Blog Store',
8
+ description: 'Creates a markdown post in content/blog.',
9
+ method: 'POST',
10
+ async handle(request: RequestInstance) {
11
+ try {
12
+ return response.json(saveBlogPost(blogPayload(request)), 201)
13
+ }
14
+ catch (error) {
15
+ if (error instanceof BlogAdminError)
16
+ return response.json({ message: error.message }, error.status)
17
+
18
+ throw error
19
+ }
20
+ },
21
+ })
@@ -0,0 +1,23 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action, BlogAdminError, saveBlogPost } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { blogPayload } from './payload'
5
+
6
+ export default new Action({
7
+ name: 'Blog Update',
8
+ description: 'Updates a markdown post in content/blog, renaming the file when the slug changes.',
9
+ method: 'PATCH',
10
+ async handle(request: RequestInstance) {
11
+ try {
12
+ const originalSlug = String(request.getParam('slug') ?? '')
13
+
14
+ return response.json(saveBlogPost(blogPayload(request, originalSlug)))
15
+ }
16
+ catch (error) {
17
+ if (error instanceof BlogAdminError)
18
+ return response.json({ message: error.message }, error.status)
19
+
20
+ throw error
21
+ }
22
+ },
23
+ })
@@ -0,0 +1,56 @@
1
+ import type { BlogPostInput } from '@stacksjs/actions'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+
4
+ /**
5
+ * `true` only for values that actually mean true over the wire.
6
+ *
7
+ * A JSON body gives real booleans, but a form post gives `"true"` / `"on"` and
8
+ * an unchecked box gives nothing at all. Without this the string `"false"` would
9
+ * be truthy and every post would save as a draft.
10
+ */
11
+ function bool(value: unknown): boolean {
12
+ if (typeof value === 'boolean')
13
+ return value
14
+
15
+ return value === 'true' || value === 'on' || value === 1 || value === '1'
16
+ }
17
+
18
+ function str(value: unknown): string {
19
+ return value == null ? '' : String(value)
20
+ }
21
+
22
+ /**
23
+ * The slug the caller wants the post to HAVE, read from the request body only.
24
+ *
25
+ * `request.get('slug')` cannot be used here: the update route is `/blog/{slug}`,
26
+ * and `get()` resolves the route param, which shadows the body field of the same
27
+ * name. That silently turned every rename into a no-op, because the "new" slug
28
+ * always came back as the current one.
29
+ */
30
+ function bodySlug(request: RequestInstance): string {
31
+ const body = (request.jsonBody ?? request.formBody) as Record<string, unknown> | undefined
32
+
33
+ return str(body?.slug)
34
+ }
35
+
36
+ /**
37
+ * Maps a request body onto the blog-admin input shape.
38
+ *
39
+ * `originalSlug` is passed in by the update action (from the route param) rather
40
+ * than read from the body, so a create can never be mistaken for a rename.
41
+ */
42
+ export function blogPayload(request: RequestInstance, originalSlug = ''): BlogPostInput {
43
+ return {
44
+ slug: bodySlug(request),
45
+ originalSlug,
46
+ title: str(request.get('title')),
47
+ description: str(request.get('description')),
48
+ date: str(request.get('date')),
49
+ author: str(request.get('author')),
50
+ authorBio: str(request.get('authorBio')),
51
+ poster: str(request.get('poster')),
52
+ featured: bool(request.get('featured')),
53
+ draft: bool(request.get('draft')),
54
+ body: str(request.get('body')),
55
+ }
56
+ }
@@ -13,21 +13,26 @@ export default new Action({
13
13
  async handle(request) {
14
14
  await request.validate()
15
15
 
16
+ const user = await request.user()
17
+
18
+ if (!user)
19
+ return response.unauthorized('Authentication required')
20
+
16
21
  const author = await authors.findOrCreate({
17
- name: 'Current User',
18
- email: 'current@user.com',
22
+ name: user.name,
23
+ email: user.email,
19
24
  })
20
25
 
26
+ const status = request.get('status') || 'draft'
27
+
21
28
  const data = {
22
29
  author_id: author.id,
23
30
  title: request.get('title'),
24
31
  excerpt: request.get('excerpt'),
25
- slug: request.get('slug'),
26
32
  content: request.get('content'),
27
- status: request.get('status'),
33
+ status,
28
34
  poster: request.get('poster'),
29
35
  views: 0,
30
- published_at: Date.now(),
31
36
  }
32
37
 
33
38
  const model = await posts.store(data)
@@ -0,0 +1,38 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { response } from '@stacksjs/router'
5
+ import { rowExists, rowId, timestamp } from './content-input'
6
+
7
+ /**
8
+ * `DELETE /api/dashboard/authors/{id}` — deletes a CMS author from the dashboard.
9
+ *
10
+ * Posts written by the author are kept and unassigned rather than deleted:
11
+ * losing an author should not silently take their posts with it. The schema
12
+ * declares no foreign key on `posts.author_id`, so without this the rows would
13
+ * be left pointing at an id that no longer resolves.
14
+ */
15
+ export default new Action({
16
+ name: 'AuthorDestroyAction',
17
+ description: 'Deletes a CMS author from the dashboard.',
18
+ method: 'DELETE',
19
+ async handle(request: RequestInstance) {
20
+ const id = rowId(request)
21
+
22
+ if (!id)
23
+ return response.json({ message: 'A valid author id is required.' }, 422)
24
+
25
+ if (!await rowExists('authors', id))
26
+ return response.json({ message: 'Author not found.' }, 404)
27
+
28
+ await db
29
+ .updateTable('posts')
30
+ .set({ author_id: null, updated_at: timestamp() } as any)
31
+ .where('author_id', '=', id)
32
+ .execute()
33
+
34
+ await db.deleteFrom('authors').where('id', '=', id).execute()
35
+
36
+ return response.json({ message: 'Author deleted.', id })
37
+ },
38
+ })
@@ -1,51 +1,65 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Author } from '@stacksjs/orm'
2
+ import { db } from '@stacksjs/database'
3
3
 
4
- // Mock data used when the ORM is unavailable (e.g. dashboard dev with no DB
5
- // seeded). Shape matches the `Author` interface in
6
- // storage/framework/defaults/views/dashboard/content/authors/index.stx so
7
- // the page can hydrate directly from this response.
8
- const mockAuthors = [
9
- { id: 1, name: 'Jane Doe', email: 'jane@example.com', role: 'Editor', avatar: 'https://i.pravatar.cc/150?img=1', bio: 'Tech writer with 10+ years of experience covering web frameworks and performance.', postCount: 24, totalViews: 145000, totalComments: 312, created_at: '2022-01-15' },
10
- { id: 2, name: 'John Smith', email: 'john@example.com', role: 'Author', avatar: 'https://i.pravatar.cc/150?img=2', bio: 'Specialist in TypeScript and frontend tooling.', postCount: 18, totalViews: 98000, totalComments: 220, created_at: '2022-04-08' },
11
- { id: 3, name: 'Michael Brown', email: 'michael@example.com', role: 'Author', avatar: 'https://i.pravatar.cc/150?img=3', bio: 'Reviews hardware and software for developers.', postCount: 12, totalViews: 64000, totalComments: 145, created_at: '2022-09-21' },
12
- { id: 4, name: 'Emily Davis', email: 'emily@example.com', role: 'Author', avatar: 'https://i.pravatar.cc/150?img=4', bio: 'Accessibility advocate and design systems engineer.', postCount: 9, totalViews: 41000, totalComments: 88, created_at: '2023-02-14' },
13
- { id: 5, name: 'Sarah Williams', email: 'sarah@example.com', role: 'Author', avatar: 'https://i.pravatar.cc/150?img=5', bio: 'Backend engineer focused on API design and databases.', postCount: 6, totalViews: 22000, totalComments: 47, created_at: '2023-06-30' },
14
- ]
4
+ interface AuthorRow {
5
+ id: number
6
+ name: string | null
7
+ email: string | null
8
+ bio: string | null
9
+ avatar: string | null
10
+ created_at: string | null
11
+ updated_at: string | null
12
+ }
15
13
 
14
+ /**
15
+ * `GET /api/dashboard/authors` — backs `views/dashboard/content/authors/index.stx`.
16
+ *
17
+ * Reads straight from the `authors` table via `db` rather than the ORM model:
18
+ * `Author` from `@stacksjs/orm` exposes no query methods today, so the previous
19
+ * `Author.all()` call threw on every request — and the action caught it and
20
+ * served five hardcoded "Jane Doe" rows, which made an empty (or broken)
21
+ * dashboard look populated. There is deliberately no fallback now: a failure
22
+ * here must surface as a failure.
23
+ *
24
+ * `postCount` is derived here rather than read off the row. The `authors` table
25
+ * has no counter column, and nothing would maintain one if it did.
26
+ */
16
27
  export default new Action({
17
28
  name: 'AuthorIndexAction',
18
- description: 'Returns authors data for the dashboard.',
29
+ description: 'Returns CMS authors for the dashboard.',
19
30
  method: 'GET',
31
+ apiResponse: true,
20
32
  async handle() {
21
- try {
22
- const allAuthors = await Author.all()
33
+ const [rows, postAuthors] = await Promise.all([
34
+ db.selectFrom('authors').selectAll().orderBy('created_at', 'desc').execute() as unknown as Promise<AuthorRow[]>,
35
+ db.selectFrom('posts').select(['author_id']).execute() as unknown as Promise<Array<{ author_id: number | null }>>,
36
+ ])
23
37
 
24
- // Empty result from a real ORM still falls through to mock data so the
25
- // dev dashboard always has something to render.
26
- if (!allAuthors || allAuthors.length === 0)
27
- return { authors: mockAuthors }
38
+ const postCounts = new Map<number, number>()
39
+ for (const post of postAuthors) {
40
+ if (post.author_id == null)
41
+ continue
28
42
 
29
- const authors = allAuthors.map((a, idx) => {
30
- const name = String(a.get('name') || '')
31
- return {
32
- id: Number(a.get('id') || idx + 1),
33
- name,
34
- email: String(a.get('email') || ''),
35
- role: String(a.get('role') || 'Writer'),
36
- avatar: String(a.get('avatar') || `https://i.pravatar.cc/150?img=${idx + 1}`),
37
- bio: String(a.get('bio') || ''),
38
- postCount: Number(a.get('post_count') || a.get('posts') || 0),
39
- totalViews: Number(a.get('total_views') || 0),
40
- totalComments: Number(a.get('total_comments') || 0),
41
- created_at: String(a.get('created_at') || ''),
42
- }
43
- })
44
-
45
- return { authors }
46
- }
47
- catch {
48
- return { authors: mockAuthors }
43
+ const id = Number(post.author_id)
44
+ postCounts.set(id, (postCounts.get(id) || 0) + 1)
49
45
  }
46
+
47
+ const authors = rows.map(row => ({
48
+ id: Number(row.id),
49
+ name: String(row.name || ''),
50
+ email: String(row.email || ''),
51
+ bio: String(row.bio || ''),
52
+ avatar: String(row.avatar || ''),
53
+ postCount: postCounts.get(Number(row.id)) || 0,
54
+ created_at: row.created_at || null,
55
+ updated_at: row.updated_at || null,
56
+ }))
57
+
58
+ // Posts whose author no longer exists are not counted: the stat means
59
+ // "posts reachable from this list", which is what the page's Assigned Posts
60
+ // tile claims to show.
61
+ const assignedPostCount = authors.reduce((sum, author) => sum + author.postCount, 0)
62
+
63
+ return { authors, assignedPostCount }
50
64
  },
51
65
  })
@@ -0,0 +1,61 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { response } from '@stacksjs/router'
5
+ import { randomUUIDv7 } from 'bun'
6
+ import { findRow, insertedId, str, timestamp } from './content-input'
7
+
8
+ /**
9
+ * `POST /api/dashboard/authors` — creates a CMS author from the dashboard.
10
+ *
11
+ * Does not reuse `Actions/Cms/AuthorStoreAction`: that runs the `Author` model
12
+ * validation, which requires a name of at least five characters and rejects the
13
+ * empty bio/avatar the dashboard dialog submits.
14
+ *
15
+ * The email check is done here because `authors_email_name_index` is not a
16
+ * unique index — nothing in the schema stops a duplicate.
17
+ */
18
+ export default new Action({
19
+ name: 'AuthorStoreAction',
20
+ description: 'Creates a CMS author from the dashboard.',
21
+ method: 'POST',
22
+ async handle(request: RequestInstance) {
23
+ const name = str(request.get('name')).trim()
24
+ const email = str(request.get('email')).trim()
25
+
26
+ if (!name)
27
+ return response.json({ message: 'Name is required.' }, 422)
28
+
29
+ if (!email)
30
+ return response.json({ message: 'Email is required.' }, 422)
31
+
32
+ const duplicate = await db
33
+ .selectFrom('authors')
34
+ .select(['id'])
35
+ .where('email', '=', email)
36
+ .executeTakeFirst()
37
+
38
+ if (duplicate)
39
+ return response.json({ message: 'An author with that email already exists.' }, 422)
40
+
41
+ const now = timestamp()
42
+
43
+ const result = await db
44
+ .insertInto('authors')
45
+ .values({
46
+ uuid: randomUUIDv7(),
47
+ name,
48
+ email,
49
+ created_at: now,
50
+ updated_at: now,
51
+ } as any)
52
+ .executeTakeFirst()
53
+
54
+ const id = insertedId(result)
55
+
56
+ if (!id)
57
+ return response.json({ message: 'Could not create author.' }, 500)
58
+
59
+ return response.json(await findRow('authors', id), 201)
60
+ },
61
+ })
@@ -0,0 +1,30 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { response } from '@stacksjs/router'
5
+ import { rowExists, rowId } from './content-input'
6
+
7
+ /**
8
+ * `DELETE /api/dashboard/categories/{id}` — deletes a CMS category.
9
+ *
10
+ * A plain row delete: this schema has no `categorizable_models` pivot table, so
11
+ * there is nothing to cascade to.
12
+ */
13
+ export default new Action({
14
+ name: 'CategoryDestroyAction',
15
+ description: 'Deletes a CMS category from the dashboard.',
16
+ method: 'DELETE',
17
+ async handle(request: RequestInstance) {
18
+ const id = rowId(request)
19
+
20
+ if (!id)
21
+ return response.json({ message: 'A valid category id is required.' }, 422)
22
+
23
+ if (!await rowExists('categories', id))
24
+ return response.json({ message: 'Category not found.' }, 404)
25
+
26
+ await db.deleteFrom('categories').where('id', '=', id).execute()
27
+
28
+ return response.json({ message: 'Category deleted.', id })
29
+ },
30
+ })
@@ -1,27 +1,50 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Category } from '@stacksjs/orm'
2
+ import { db } from '@stacksjs/database'
3
3
 
4
+ interface CategoryRow {
5
+ id: number
6
+ name: string | null
7
+ slug: string | null
8
+ description: string | null
9
+ is_active: number | null
10
+ created_at: string | null
11
+ updated_at: string | null
12
+ }
13
+
14
+ /**
15
+ * `GET /api/dashboard/categories` — backs `views/dashboard/content/categories/index.stx`.
16
+ *
17
+ * Reads the `categories` table via `db`. The previous `Category.all()` call
18
+ * threw on every request (the ORM model exposes no query methods) and the catch
19
+ * turned that into an empty list, so a broken read was indistinguishable from a
20
+ * CMS with no categories. It also mapped `parent_id` and `post_count`, neither
21
+ * of which this table has.
22
+ *
23
+ * Note this is the same `categories` table the commerce `Category` model owns —
24
+ * CMS and commerce categories are not separated in the schema today.
25
+ */
4
26
  export default new Action({
5
27
  name: 'CategoryIndexAction',
6
- description: 'Returns categories data for the dashboard.',
28
+ description: 'Returns CMS categories for the dashboard.',
7
29
  method: 'GET',
30
+ apiResponse: true,
8
31
  async handle() {
9
- try {
10
- const allCategories = await Category.all()
32
+ const rows = await db
33
+ .selectFrom('categories')
34
+ .selectAll()
35
+ .orderBy('created_at', 'desc')
36
+ .execute() as unknown as CategoryRow[]
11
37
 
12
- const categories = allCategories.map(c => ({
13
- id: Number(c.get('id')),
14
- name: String(c.get('name') || ''),
15
- slug: String(c.get('slug') || ''),
16
- description: String(c.get('description') || ''),
17
- parentId: c.get('parent_id') || null,
18
- postCount: Number(c.get('post_count') || 0),
19
- }))
38
+ const categories = rows.map(row => ({
39
+ id: Number(row.id),
40
+ name: String(row.name || ''),
41
+ slug: String(row.slug || ''),
42
+ description: String(row.description || ''),
43
+ is_active: row.is_active == null ? true : Boolean(row.is_active),
44
+ created_at: row.created_at || null,
45
+ updated_at: row.updated_at || null,
46
+ }))
20
47
 
21
- return { categories }
22
- }
23
- catch {
24
- return { categories: [] }
25
- }
48
+ return { categories }
26
49
  },
27
50
  })
@@ -0,0 +1,58 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { response } from '@stacksjs/router'
5
+ import { findRow, insertedId, slugify, str, timestamp } from './content-input'
6
+
7
+ /**
8
+ * `POST /api/dashboard/categories` — creates a CMS category from the dashboard.
9
+ *
10
+ * The dialog pre-fills the slug from the name but lets it be edited or cleared,
11
+ * so an empty slug is derived here rather than rejected.
12
+ */
13
+ export default new Action({
14
+ name: 'CategoryStoreAction',
15
+ description: 'Creates a CMS category from the dashboard.',
16
+ method: 'POST',
17
+ async handle(request: RequestInstance) {
18
+ const name = str(request.get('name')).trim()
19
+ const description = str(request.get('description'))
20
+ const slug = slugify(str(request.get('slug')) || name)
21
+
22
+ if (!name)
23
+ return response.json({ message: 'Name is required.' }, 422)
24
+
25
+ if (!slug)
26
+ return response.json({ message: 'Slug could not be derived from the name; enter one.' }, 422)
27
+
28
+ const duplicate = await db
29
+ .selectFrom('categories')
30
+ .select(['id'])
31
+ .where('slug', '=', slug)
32
+ .executeTakeFirst()
33
+
34
+ if (duplicate)
35
+ return response.json({ message: 'A category with that slug already exists.' }, 422)
36
+
37
+ const now = timestamp()
38
+
39
+ const result = await db
40
+ .insertInto('categories')
41
+ .values({
42
+ name,
43
+ slug,
44
+ description,
45
+ is_active: 1,
46
+ created_at: now,
47
+ updated_at: now,
48
+ } as any)
49
+ .executeTakeFirst()
50
+
51
+ const id = insertedId(result)
52
+
53
+ if (!id)
54
+ return response.json({ message: 'Could not create category.' }, 500)
55
+
56
+ return response.json(await findRow('categories', id), 201)
57
+ },
58
+ })
@@ -0,0 +1,30 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { response } from '@stacksjs/router'
5
+ import { rowExists, rowId } from './content-input'
6
+
7
+ /**
8
+ * `DELETE /api/dashboard/comments/{id}` — deletes a comment from the dashboard.
9
+ *
10
+ * A hard delete. The `trash` status exists for the reversible case, and the
11
+ * page's delete button confirms first.
12
+ */
13
+ export default new Action({
14
+ name: 'CommentDestroyAction',
15
+ description: 'Deletes a CMS comment from the dashboard.',
16
+ method: 'DELETE',
17
+ async handle(request: RequestInstance) {
18
+ const id = rowId(request)
19
+
20
+ if (!id)
21
+ return response.json({ message: 'A valid comment id is required.' }, 422)
22
+
23
+ if (!await rowExists('comments', id))
24
+ return response.json({ message: 'Comment not found.' }, 404)
25
+
26
+ await db.deleteFrom('comments').where('id', '=', id).execute()
27
+
28
+ return response.json({ message: 'Comment deleted.', id })
29
+ },
30
+ })