@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
@@ -1,41 +1,56 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Comment } from '@stacksjs/orm'
2
+ import { db } from '@stacksjs/database'
3
+ import { normalizeCommentStatus } from './comment-input'
3
4
 
5
+ interface CommentRow {
6
+ id: number
7
+ author_name: string | null
8
+ author_email: string | null
9
+ content: string | null
10
+ body: string | null
11
+ post_title: string | null
12
+ status: string | null
13
+ is_approved: number | null
14
+ created_at: string | null
15
+ updated_at: string | null
16
+ }
17
+
18
+ /**
19
+ * `GET /api/dashboard/comments` — backs `views/dashboard/content/comments/index.stx`.
20
+ *
21
+ * Reads the `comments` table via `db`. The previous `Comment.orderBy(...)` call
22
+ * threw on every request (the ORM model exposes no query methods) and the catch
23
+ * turned that into an empty list, so a broken read looked like a CMS with no
24
+ * comments. It also mapped `author` / `email` / `ip` / `post_id`, none of which
25
+ * this table has — the columns are `author_name`, `author_email`, `ip_address`,
26
+ * and there is no post foreign key, only the denormalized `post_title`.
27
+ *
28
+ * Column names are returned as-is; the page normalizes them client-side.
29
+ */
4
30
  export default new Action({
5
31
  name: 'CommentIndexAction',
6
- description: 'Returns comments data for the dashboard.',
32
+ description: 'Returns CMS comments for the dashboard.',
7
33
  method: 'GET',
34
+ apiResponse: true,
8
35
  async handle() {
9
- try {
10
- const allComments = await Comment.orderBy('created_at', 'desc').get()
11
- const totalCount = await Comment.count()
12
-
13
- const comments = allComments.map(c => ({
14
- id: Number(c.get('id')),
15
- author: String(c.get('author') || c.get('name') || ''),
16
- email: String(c.get('email') || ''),
17
- content: String(c.get('content') || c.get('body') || ''),
18
- status: String(c.get('status') || 'pending'),
19
- date: String(c.get('created_at') || ''),
20
- postTitle: String(c.get('post_title') || ''),
21
- postId: Number(c.get('post_id') || 0),
22
- ip: String(c.get('ip') || ''),
23
- }))
36
+ const rows = await db
37
+ .selectFrom('comments')
38
+ .selectAll()
39
+ .orderBy('created_at', 'desc')
40
+ .execute() as unknown as CommentRow[]
24
41
 
25
- const pending = comments.filter(c => c.status === 'pending').length
26
- const approved = comments.filter(c => c.status === 'approved').length
27
- const spam = comments.filter(c => c.status === 'spam').length
42
+ const comments = rows.map(row => ({
43
+ id: Number(row.id),
44
+ author_name: String(row.author_name || ''),
45
+ author_email: String(row.author_email || ''),
46
+ content: String(row.content || row.body || ''),
47
+ post_title: String(row.post_title || ''),
48
+ status: normalizeCommentStatus(row.status),
49
+ is_approved: Boolean(row.is_approved),
50
+ created_at: row.created_at || null,
51
+ updated_at: row.updated_at || null,
52
+ }))
28
53
 
29
- return {
30
- comments,
31
- stats: { total: totalCount, pending, approved, spam },
32
- }
33
- }
34
- catch {
35
- return {
36
- comments: [],
37
- stats: { total: 0, pending: 0, approved: 0, spam: 0 },
38
- }
39
- }
54
+ return { comments }
40
55
  },
41
56
  })
@@ -0,0 +1,48 @@
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 { COMMENT_STATUSES, parseCommentStatus } from './comment-input'
6
+ import { findRow, rowExists, rowId, timestamp } from './content-input'
7
+
8
+ /**
9
+ * `PATCH /api/dashboard/comments/{id}` — moderates a comment from the dashboard.
10
+ *
11
+ * Status is the only writable field: the page's approve/spam buttons are the
12
+ * only callers, and comment text belongs to the reader who wrote it.
13
+ *
14
+ * `is_approved` is kept in step with `status` so the two never disagree — the
15
+ * table carries both, and the page falls back to `is_approved` when a row has
16
+ * no status.
17
+ */
18
+ export default new Action({
19
+ name: 'CommentUpdateAction',
20
+ description: 'Updates a CMS comment status from the dashboard.',
21
+ method: 'PATCH',
22
+ async handle(request: RequestInstance) {
23
+ const id = rowId(request)
24
+
25
+ if (!id)
26
+ return response.json({ message: 'A valid comment id is required.' }, 422)
27
+
28
+ const status = parseCommentStatus(request.get('status'))
29
+
30
+ if (!status)
31
+ return response.json({ message: `Status must be one of: ${COMMENT_STATUSES.join(', ')}.` }, 422)
32
+
33
+ if (!await rowExists('comments', id))
34
+ return response.json({ message: 'Comment not found.' }, 404)
35
+
36
+ await db
37
+ .updateTable('comments')
38
+ .set({
39
+ status,
40
+ is_approved: status === 'approved' ? 1 : 0,
41
+ updated_at: timestamp(),
42
+ } as any)
43
+ .where('id', '=', id)
44
+ .execute()
45
+
46
+ return response.json(await findRow('comments', id))
47
+ },
48
+ })
@@ -0,0 +1,25 @@
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
+ /** `DELETE /api/dashboard/pages/{id}` — deletes a CMS page from the dashboard. */
8
+ export default new Action({
9
+ name: 'PageDestroyAction',
10
+ description: 'Deletes a CMS page from the dashboard.',
11
+ method: 'DELETE',
12
+ async handle(request: RequestInstance) {
13
+ const id = rowId(request)
14
+
15
+ if (!id)
16
+ return response.json({ message: 'A valid page id is required.' }, 422)
17
+
18
+ if (!await rowExists('pages', id))
19
+ return response.json({ message: 'Page not found.' }, 404)
20
+
21
+ await db.deleteFrom('pages').where('id', '=', id).execute()
22
+
23
+ return response.json({ message: 'Page deleted.', id })
24
+ },
25
+ })
@@ -1,25 +1,53 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Page } from '@stacksjs/orm'
2
+ import { db } from '@stacksjs/database'
3
3
 
4
+ interface PageRow {
5
+ id: number
6
+ title: string | null
7
+ template: string | null
8
+ views: number | null
9
+ conversions: number | null
10
+ published_at: string | null
11
+ author_id: number | null
12
+ created_at: string | null
13
+ updated_at: string | null
14
+ }
15
+
16
+ /**
17
+ * `GET /api/dashboard/pages` — backs `views/dashboard/content/pages/index.stx`.
18
+ *
19
+ * Reads the `pages` table via `db`. The previous `Page.orderBy(...)` call threw
20
+ * on every request (the ORM model exposes no query methods) and the catch
21
+ * turned that into an empty list, so a broken read looked like a CMS with no
22
+ * pages. It also mapped `slug` and `status`, which this table does not have —
23
+ * a page is a title, a template, and its traffic counters.
24
+ *
25
+ * Ordered newest-first because the page's Newest tile reads the first row.
26
+ */
4
27
  export default new Action({
5
28
  name: 'PageIndexAction',
6
- description: 'Returns pages data for the dashboard.',
29
+ description: 'Returns CMS pages for the dashboard.',
7
30
  method: 'GET',
31
+ apiResponse: true,
8
32
  async handle() {
9
- try {
10
- const allPages = await Page.orderBy('updated_at', 'desc').get()
33
+ const rows = await db
34
+ .selectFrom('pages')
35
+ .selectAll()
36
+ .orderBy('created_at', 'desc')
37
+ .execute() as unknown as PageRow[]
11
38
 
12
- const pages = allPages.map(p => ({
13
- title: String(p.get('title') || ''),
14
- slug: String(p.get('slug') || ''),
15
- status: String(p.get('status') || 'draft'),
16
- updated: String(p.get('updated_at') || ''),
17
- }))
39
+ const pages = rows.map(row => ({
40
+ id: Number(row.id),
41
+ title: String(row.title || ''),
42
+ template: String(row.template || 'default'),
43
+ views: Number(row.views || 0),
44
+ conversions: Number(row.conversions || 0),
45
+ published_at: row.published_at || null,
46
+ author_id: row.author_id ?? null,
47
+ created_at: row.created_at || null,
48
+ updated_at: row.updated_at || null,
49
+ }))
18
50
 
19
- return { pages }
20
- }
21
- catch {
22
- return { pages: [] }
23
- }
51
+ return { pages }
24
52
  },
25
53
  })
@@ -0,0 +1,49 @@
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, str, timestamp } from './content-input'
6
+
7
+ /**
8
+ * `POST /api/dashboard/pages` — creates a CMS page from the dashboard.
9
+ *
10
+ * No `uuid` is written: unlike `posts` / `authors` / `tags`, the `pages` table
11
+ * has no uuid column even though the model declares the `useUuid` trait.
12
+ *
13
+ * `published_at` stays null — the dialog has no publish control, and the page's
14
+ * Published column falls back to `created_at`.
15
+ */
16
+ export default new Action({
17
+ name: 'PageStoreAction',
18
+ description: 'Creates a CMS page from the dashboard.',
19
+ method: 'POST',
20
+ async handle(request: RequestInstance) {
21
+ const title = str(request.get('title')).trim()
22
+ const template = str(request.get('template')).trim() || 'default'
23
+
24
+ if (!title)
25
+ return response.json({ message: 'Title is required.' }, 422)
26
+
27
+ const now = timestamp()
28
+
29
+ const result = await db
30
+ .insertInto('pages')
31
+ .values({
32
+ title,
33
+ template,
34
+ views: 0,
35
+ conversions: 0,
36
+ published_at: null,
37
+ created_at: now,
38
+ updated_at: now,
39
+ } as any)
40
+ .executeTakeFirst()
41
+
42
+ const id = insertedId(result)
43
+
44
+ if (!id)
45
+ return response.json({ message: 'Could not create page.' }, 500)
46
+
47
+ return response.json(await findRow('pages', id), 201)
48
+ },
49
+ })
@@ -0,0 +1,37 @@
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
+
6
+ /**
7
+ * `DELETE /api/dashboard/posts/{id}` — deletes a CMS post from the dashboard.
8
+ *
9
+ * A plain row delete: this schema has no `categorizable_models` /
10
+ * `taggable_models` pivot tables, so there is nothing to cascade to. (The
11
+ * `@stacksjs/cms` `posts.destroy()` helper assumes those tables exist, which is
12
+ * why this action talks to `db` directly instead.)
13
+ */
14
+ export default new Action({
15
+ name: 'PostDestroyAction',
16
+ description: 'Deletes a CMS post from the dashboard.',
17
+ method: 'DELETE',
18
+ async handle(request: RequestInstance) {
19
+ const id = Number(request.getParam('id'))
20
+
21
+ if (!Number.isInteger(id) || id <= 0)
22
+ return response.json({ message: 'A valid post id is required.' }, 422)
23
+
24
+ const existing = await db
25
+ .selectFrom('posts')
26
+ .select(['id'])
27
+ .where('id', '=', id)
28
+ .executeTakeFirst()
29
+
30
+ if (!existing)
31
+ return response.json({ message: 'Post not found.' }, 404)
32
+
33
+ await db.deleteFrom('posts').where('id', '=', id).execute()
34
+
35
+ return response.json({ message: 'Post deleted.', id })
36
+ },
37
+ })
@@ -1,102 +1,83 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Post, Category, Tag } from '@stacksjs/orm'
2
+ import { db } from '@stacksjs/database'
3
3
 
4
- // Mock data used when the ORM is unavailable or has no posts (e.g. dev
5
- // dashboard with no DB seeded). Shape matches the `BlogPost` interface in
6
- // storage/framework/defaults/views/dashboard/content/posts/index.stx so
7
- // the page can hydrate directly from this response.
8
- const mockPosts = [
9
- { id: 1, title: '10 Tips for Better Code Quality', excerpt: 'Improve your code quality with these essential tips.', slug: '10-tips-for-better-code-quality', category: 'Tutorials', tags: ['Coding', 'Best Practices'], author: 'Jane Doe', views: 12450, comments: 78, published: '2023-12-01', status: 'Published', featured: true, poster: 'https://picsum.photos/seed/1/280/160' },
10
- { id: 2, title: 'The Future of JavaScript Frameworks', excerpt: 'Exploring what\'s next for JS frameworks.', slug: 'future-of-javascript-frameworks', category: 'Technology', tags: ['JavaScript', 'Frameworks'], author: 'John Smith', views: 9870, comments: 124, published: '2023-11-28', status: 'Published', featured: true, poster: 'https://picsum.photos/seed/2/280/160' },
11
- { id: 3, title: 'Getting Started with Vue 3', excerpt: 'A comprehensive guide to Vue 3 composition API.', slug: 'getting-started-with-vue-3', category: 'Tutorials', tags: ['Vue', 'JavaScript'], author: 'Jane Doe', views: 8760, comments: 45, published: '2023-11-25', status: 'Published', featured: false, poster: 'https://picsum.photos/seed/3/280/160' },
12
- { id: 4, title: 'Review: Latest MacBook Pro', excerpt: 'In-depth review for developers.', slug: 'review-latest-macbook-pro', category: 'Reviews', tags: ['Hardware', 'Apple'], author: 'Michael Brown', views: 7650, comments: 92, published: '2023-11-20', status: 'Published', featured: false, poster: 'https://picsum.photos/seed/4/280/160' },
13
- { id: 5, title: 'Understanding Web Accessibility', excerpt: 'Why accessibility matters and how to implement it.', slug: 'understanding-web-accessibility', category: 'Tutorials', tags: ['Accessibility', 'UX'], author: 'Emily Davis', views: 6540, comments: 31, published: '2023-11-15', status: 'Published', featured: false, poster: 'https://picsum.photos/seed/5/280/160' },
14
- { id: 6, title: 'Introduction to TypeScript', excerpt: 'Learn the basics of TypeScript.', slug: 'introduction-to-typescript', category: 'Tutorials', tags: ['TypeScript', 'JavaScript'], author: 'Jane Doe', views: 0, comments: 0, published: '', status: 'Draft', featured: false, poster: 'https://picsum.photos/seed/6/280/160' },
15
- { id: 7, title: 'Building a REST API with Node.js', excerpt: 'Step-by-step guide to building REST APIs.', slug: 'building-rest-api-nodejs', category: 'Tutorials', tags: ['Node.js', 'API'], author: 'John Smith', views: 0, comments: 0, published: '', status: 'Draft', featured: false, poster: 'https://picsum.photos/seed/7/280/160' },
16
- { id: 8, title: 'The Impact of AI on Software Development', excerpt: 'How AI is changing software development.', slug: 'ai-impact-software-development', category: 'Technology', tags: ['AI', 'Future Tech'], author: 'Michael Brown', views: 5430, comments: 67, published: '2023-11-10', status: 'Published', featured: false, poster: 'https://picsum.photos/seed/8/280/160' },
17
- ]
4
+ interface PostRow {
5
+ id: number
6
+ title: string
7
+ excerpt: string | null
8
+ content: string | null
9
+ poster: string | null
10
+ status: string | null
11
+ views: number | null
12
+ published_at: string | null
13
+ created_at: string | null
14
+ updated_at: string | null
15
+ author_id: number | null
16
+ is_featured: number | null
17
+ }
18
18
 
19
- const mockCategories = [
20
- { id: 1, name: 'Tutorials', slug: 'tutorials' },
21
- { id: 2, name: 'Technology', slug: 'technology' },
22
- { id: 3, name: 'Reviews', slug: 'reviews' },
23
- ]
19
+ // `status` is stored lowercase (the posts table has a CHECK constraint on
20
+ // 'published' | 'draft' | 'archived'), but older rows and hand-written seeds
21
+ // have been seen with 'Draft'/'Published'. Normalize on read so the dashboard
22
+ // filter and badge styling only ever deal with one casing.
23
+ function normalizeStatus(status: string | null): string {
24
+ const value = String(status || 'draft').toLowerCase()
24
25
 
25
- const mockTags = [
26
- { id: 1, name: 'Coding', slug: 'coding' },
27
- { id: 2, name: 'JavaScript', slug: 'javascript' },
28
- { id: 3, name: 'Vue', slug: 'vue' },
29
- { id: 4, name: 'TypeScript', slug: 'typescript' },
30
- ]
26
+ return value === 'published' || value === 'archived' ? value : 'draft'
27
+ }
31
28
 
32
29
  function counts(posts: Array<{ status: string }>) {
33
30
  return {
34
- publishedCount: posts.filter(p => p.status.toLowerCase() === 'published').length,
35
- draftCount: posts.filter(p => p.status.toLowerCase() === 'draft').length,
36
- scheduledCount: posts.filter(p => p.status.toLowerCase() === 'scheduled').length,
31
+ publishedCount: posts.filter(p => p.status === 'published').length,
32
+ draftCount: posts.filter(p => p.status === 'draft').length,
33
+ archivedCount: posts.filter(p => p.status === 'archived').length,
37
34
  }
38
35
  }
39
36
 
37
+ /**
38
+ * `GET /api/dashboard/posts` — backs `views/dashboard/content/posts/index.stx`.
39
+ *
40
+ * Reads straight from the `posts` table via `db` rather than the ORM model:
41
+ * `Post` from `@stacksjs/orm` exposes no query methods today, so the previous
42
+ * `Post.orderBy(...)` call threw on every request.
43
+ *
44
+ * There is deliberately no mock-data fallback. This action used to catch every
45
+ * error and serve placeholder rows, which made a page that was 404ing against
46
+ * an unregistered endpoint look like it was working. A failure here must
47
+ * surface as a failure.
48
+ */
40
49
  export default new Action({
41
50
  name: 'PostIndexAction',
42
- description: 'Returns posts data for the dashboard.',
51
+ description: 'Returns CMS posts for the dashboard.',
43
52
  method: 'GET',
53
+ apiResponse: true,
44
54
  async handle() {
45
- try {
46
- const [allPosts, allCategories, allTags] = await Promise.all([
47
- Post.orderBy('created_at', 'desc').get(),
48
- Category.all(),
49
- Tag.all(),
50
- ])
51
-
52
- // Empty result from a real ORM still falls through to mock data so
53
- // the dev dashboard always has something to render.
54
- if (!allPosts || allPosts.length === 0) {
55
- return {
56
- posts: mockPosts,
57
- categories: mockCategories,
58
- tags: mockTags,
59
- ...counts(mockPosts),
60
- }
61
- }
62
-
63
- const posts = allPosts.map((p, idx) => ({
64
- id: Number(p.get('id') || idx + 1),
65
- title: String(p.get('title') || ''),
66
- excerpt: String(p.get('excerpt') || ''),
67
- slug: String(p.get('slug') || ''),
68
- category: String(p.get('category') || ''),
69
- tags: Array.isArray(p.get('tags')) ? p.get('tags') as string[] : [],
70
- author: String(p.get('author') || ''),
71
- views: Number(p.get('views') || 0),
72
- comments: Number(p.get('comments') || 0),
73
- published: String(p.get('published_at') || p.get('published') || p.get('created_at') || ''),
74
- status: String(p.get('status') || 'Draft'),
75
- featured: Boolean(p.get('featured')),
76
- poster: String(p.get('poster') || ''),
77
- }))
55
+ const rows = await db
56
+ .selectFrom('posts')
57
+ .selectAll()
58
+ .orderBy('created_at', 'desc')
59
+ .execute() as unknown as PostRow[]
78
60
 
79
- const categories = allCategories.map(c => ({
80
- id: Number(c.get('id')),
81
- name: String(c.get('name') || ''),
82
- slug: String(c.get('slug') || ''),
83
- }))
61
+ const posts = rows.map(row => ({
62
+ id: Number(row.id),
63
+ title: String(row.title || ''),
64
+ excerpt: String(row.excerpt || ''),
65
+ content: String(row.content || ''),
66
+ poster: String(row.poster || ''),
67
+ status: normalizeStatus(row.status),
68
+ views: Number(row.views || 0),
69
+ published_at: row.published_at || null,
70
+ created_at: row.created_at || null,
71
+ updated_at: row.updated_at || null,
72
+ author_id: row.author_id ?? null,
73
+ featured: Boolean(row.is_featured),
74
+ }))
84
75
 
85
- const tags = allTags.map(t => ({
86
- id: Number(t.get('id')),
87
- name: String(t.get('name') || ''),
88
- slug: String(t.get('slug') || ''),
89
- }))
76
+ const [categories, tags] = await Promise.all([
77
+ db.selectFrom('categories').select(['id', 'name', 'slug']).execute(),
78
+ db.selectFrom('tags').select(['id', 'name', 'slug']).execute(),
79
+ ])
90
80
 
91
- return { posts, categories, tags, ...counts(posts) }
92
- }
93
- catch {
94
- return {
95
- posts: mockPosts,
96
- categories: mockCategories,
97
- tags: mockTags,
98
- ...counts(mockPosts),
99
- }
100
- }
81
+ return { posts, categories, tags, ...counts(posts) }
101
82
  },
102
83
  })
@@ -0,0 +1,50 @@
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 { findPost, insertedId, postPayload, publishedAtFor, timestamp } from './post-input'
7
+
8
+ /**
9
+ * `POST /api/dashboard/posts` — creates a CMS post from the dashboard.
10
+ *
11
+ * Deliberately does not reuse `Actions/Cms/PostStoreAction`: that action runs
12
+ * the `Post` model validation, which rejects the empty `excerpt` / `poster`
13
+ * that the dashboard dialog legitimately submits for a bare draft.
14
+ */
15
+ export default new Action({
16
+ name: 'PostStoreAction',
17
+ description: 'Creates a CMS post from the dashboard.',
18
+ method: 'POST',
19
+ async handle(request: RequestInstance) {
20
+ const payload = postPayload(request)
21
+
22
+ if (!payload.title)
23
+ return response.json({ message: 'Title is required.' }, 422)
24
+
25
+ const now = timestamp()
26
+
27
+ const result = await db
28
+ .insertInto('posts')
29
+ .values({
30
+ uuid: randomUUIDv7(),
31
+ title: payload.title,
32
+ excerpt: payload.excerpt,
33
+ content: payload.content,
34
+ poster: payload.poster,
35
+ status: payload.status,
36
+ views: 0,
37
+ published_at: publishedAtFor(payload.status, null, now),
38
+ created_at: now,
39
+ updated_at: now,
40
+ } as any)
41
+ .executeTakeFirst()
42
+
43
+ const id = insertedId(result)
44
+
45
+ if (!id)
46
+ return response.json({ message: 'Could not create post.' }, 500)
47
+
48
+ return response.json(await findPost(id), 201)
49
+ },
50
+ })
@@ -0,0 +1,54 @@
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 { findPost, postPayload, publishedAtFor, timestamp } from './post-input'
6
+
7
+ /**
8
+ * `PATCH /api/dashboard/posts/{id}` — updates a CMS post from the dashboard.
9
+ *
10
+ * The dashboard dialog always submits the full post, so every writable column
11
+ * is replaced. `published_at` is derived from the incoming status rather than
12
+ * accepted from the body — see `publishedAtFor`.
13
+ */
14
+ export default new Action({
15
+ name: 'PostUpdateAction',
16
+ description: 'Updates a CMS post from the dashboard.',
17
+ method: 'PATCH',
18
+ async handle(request: RequestInstance) {
19
+ const id = Number(request.getParam('id'))
20
+
21
+ if (!Number.isInteger(id) || id <= 0)
22
+ return response.json({ message: 'A valid post id is required.' }, 422)
23
+
24
+ const payload = postPayload(request)
25
+
26
+ if (!payload.title)
27
+ return response.json({ message: 'Title is required.' }, 422)
28
+
29
+ const existing = await db
30
+ .selectFrom('posts')
31
+ .select(['id', 'published_at'])
32
+ .where('id', '=', id)
33
+ .executeTakeFirst() as { id: number, published_at: string | null } | undefined
34
+
35
+ if (!existing)
36
+ return response.json({ message: 'Post not found.' }, 404)
37
+
38
+ await db
39
+ .updateTable('posts')
40
+ .set({
41
+ title: payload.title,
42
+ excerpt: payload.excerpt,
43
+ content: payload.content,
44
+ poster: payload.poster,
45
+ status: payload.status,
46
+ published_at: publishedAtFor(payload.status, existing.published_at, timestamp()),
47
+ updated_at: timestamp(),
48
+ } as any)
49
+ .where('id', '=', id)
50
+ .execute()
51
+
52
+ return response.json(await findPost(id))
53
+ },
54
+ })
@@ -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/tags/{id}` — deletes a CMS tag.
9
+ *
10
+ * A plain row delete: this schema has no `taggable_models` pivot table, so
11
+ * there is nothing to cascade to.
12
+ */
13
+ export default new Action({
14
+ name: 'TagDestroyAction',
15
+ description: 'Deletes a CMS tag 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 tag id is required.' }, 422)
22
+
23
+ if (!await rowExists('tags', id))
24
+ return response.json({ message: 'Tag not found.' }, 404)
25
+
26
+ await db.deleteFrom('tags').where('id', '=', id).execute()
27
+
28
+ return response.json({ message: 'Tag deleted.', id })
29
+ },
30
+ })