betterstart-cli 0.0.11 → 0.0.13

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 (117) hide show
  1. package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +115 -0
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
  4. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -2
  5. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +2 -2
  6. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -1
  7. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  8. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +2 -2
  10. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
  12. package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -1
  13. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
  14. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
  15. package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
  16. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
  17. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +97 -0
  18. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +17 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +72 -0
  23. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
  28. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
  32. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
  33. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
  34. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
  35. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
  36. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
  37. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +116 -12
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +33 -0
  40. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +96 -0
  41. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +17 -0
  42. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-filters.tsx +152 -0
  43. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +279 -0
  44. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +19 -0
  45. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
  46. package/dist/assets/adapters/next/templates/init/pages/dashboard-page.tsx +1 -1
  47. package/dist/assets/adapters/next/templates/init/pages/forgot-password-page-skeleton.tsx +1 -1
  48. package/dist/assets/adapters/next/templates/init/pages/login-page-skeleton.tsx +1 -1
  49. package/dist/assets/adapters/next/templates/init/pages/reset-password-page-skeleton.tsx +1 -1
  50. package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
  51. package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +0 -24
  52. package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
  53. package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
  54. package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
  55. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-bubble-menu.tsx +1 -1
  56. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
  57. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
  58. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
  59. package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
  60. package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
  61. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
  62. package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
  63. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
  64. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -9
  65. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
  66. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +9 -4
  67. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
  68. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
  69. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
  72. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
  73. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
  74. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
  75. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
  76. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +2 -4
  77. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
  78. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +1 -1
  79. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
  80. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
  81. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
  82. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
  83. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +3 -3
  84. package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +2 -2
  85. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +2 -2
  86. package/dist/assets/shared-assets/react-admin/custom/placeholder-card.tsx +1 -1
  87. package/dist/assets/shared-assets/react-admin/custom/placeholder.tsx +1 -1
  88. package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +1 -1
  89. package/dist/assets/shared-assets/react-admin/schema.json +0 -23
  90. package/dist/assets/shared-assets/react-admin/ui/accordion.tsx +1 -1
  91. package/dist/assets/shared-assets/react-admin/ui/alert.tsx +1 -1
  92. package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
  93. package/dist/assets/shared-assets/react-admin/ui/card.tsx +3 -3
  94. package/dist/assets/shared-assets/react-admin/ui/chart.tsx +1 -1
  95. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +29 -5
  96. package/dist/assets/shared-assets/react-admin/ui/command.tsx +1 -1
  97. package/dist/assets/shared-assets/react-admin/ui/context-menu.tsx +6 -6
  98. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +7 -3
  99. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +32 -17
  100. package/dist/assets/shared-assets/react-admin/ui/empty.tsx +2 -2
  101. package/dist/assets/shared-assets/react-admin/ui/field.tsx +1 -1
  102. package/dist/assets/shared-assets/react-admin/ui/hover-card.tsx +1 -1
  103. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +1 -1
  104. package/dist/assets/shared-assets/react-admin/ui/item.tsx +2 -2
  105. package/dist/assets/shared-assets/react-admin/ui/menubar.tsx +7 -7
  106. package/dist/assets/shared-assets/react-admin/ui/navigation-menu.tsx +4 -4
  107. package/dist/assets/shared-assets/react-admin/ui/popover.tsx +1 -1
  108. package/dist/assets/shared-assets/react-admin/ui/resizable.tsx +1 -1
  109. package/dist/assets/shared-assets/react-admin/ui/select.tsx +2 -2
  110. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +3 -3
  111. package/dist/assets/shared-assets/react-admin/ui/skeleton.tsx +1 -1
  112. package/dist/assets/shared-assets/react-admin/ui/tabs.tsx +2 -2
  113. package/dist/assets/shared-assets/react-admin/ui/textarea.tsx +1 -1
  114. package/dist/assets/shared-assets/react-admin/ui/tooltip.tsx +1 -1
  115. package/dist/cli.js +2779 -810
  116. package/dist/cli.js.map +1 -1
  117. package/package.json +1 -1
@@ -8,7 +8,7 @@ export const blogPlugin: BetterstartPluginDefinition = {
8
8
  version: '1.0.0',
9
9
  dependencies: [],
10
10
  conflicts: [],
11
- schemaFiles: ['posts.json'],
11
+ schemaFiles: ['posts.json', 'menus.json'],
12
12
  files: [],
13
13
  packageDependencies: ['katex', 'markdown-it@^13.0.2', 'markdown-it-dollarmath', 'sanitize-html'],
14
14
  devDependencies: ['@types/markdown-it@13', '@types/sanitize-html'],
@@ -0,0 +1,115 @@
1
+ {
2
+ "name": "menus",
3
+ "label": "Menus",
4
+ "description": "Manage Menus",
5
+ "icon": "Menu",
6
+ "navGroup": {
7
+ "label": "Collections",
8
+ "position": 0
9
+ },
10
+ "navPosition": 1,
11
+ "slot": {
12
+ "main": {
13
+ "fields": [
14
+ {
15
+ "name": "title",
16
+ "type": "string",
17
+ "label": "Title",
18
+ "required": true
19
+ },
20
+ {
21
+ "name": "slug",
22
+ "type": "string",
23
+ "label": "Slug",
24
+ "hint": "URL-friendly identifier (auto-generated from title)"
25
+ },
26
+ {
27
+ "name": "items",
28
+ "type": "list",
29
+ "label": "Items",
30
+ "hint": "Items in the navigation menu",
31
+ "fields": [
32
+ {
33
+ "name": "label",
34
+ "type": "string",
35
+ "label": "Label",
36
+ "required": true
37
+ },
38
+ {
39
+ "name": "href",
40
+ "type": "string",
41
+ "label": "Href",
42
+ "required": true
43
+ },
44
+ {
45
+ "name": "openInNewTab",
46
+ "type": "boolean",
47
+ "label": "Open in New Tab",
48
+ "hint": "Open this link in a new browser tab",
49
+ "default": false
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ },
55
+ "sidebar": {
56
+ "fields": [
57
+ {
58
+ "name": "generalSidebar",
59
+ "type": "section",
60
+ "heading": {
61
+ "icon": "bolt",
62
+ "title": "General"
63
+ },
64
+ "fields": [
65
+ {
66
+ "name": "description",
67
+ "type": "text",
68
+ "label": "Description",
69
+ "hint": "Internal note describing this menu's purpose"
70
+ }
71
+ ]
72
+ }
73
+ ]
74
+ }
75
+ },
76
+ "columns": [
77
+ {
78
+ "accessorKey": "title",
79
+ "header": "Title",
80
+ "type": "text",
81
+ "sortable": true,
82
+ "size": "xl"
83
+ },
84
+ {
85
+ "accessorKey": "published",
86
+ "header": "Status",
87
+ "type": "boolean",
88
+ "sortable": true,
89
+ "size": "sm"
90
+ },
91
+ {
92
+ "accessorKey": "updatedAt",
93
+ "header": "Updated",
94
+ "type": "date",
95
+ "sortable": true,
96
+ "size": "sm"
97
+ },
98
+ {
99
+ "accessorKey": "createdAt",
100
+ "header": "Created",
101
+ "type": "date",
102
+ "sortable": true,
103
+ "size": "sm"
104
+ }
105
+ ],
106
+ "actions": {
107
+ "create": true,
108
+ "edit": true,
109
+ "delete": true,
110
+ "draft": true
111
+ },
112
+ "search": {
113
+ "fields": ["title"]
114
+ }
115
+ }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "posts",
3
3
  "label": "Posts",
4
- "description": "Manage blog posts and articles",
4
+ "description": "Manage blog posts",
5
5
  "icon": "FileText",
6
6
  "navGroup": {
7
- "label": "Blog",
7
+ "label": "Collections",
8
8
  "position": 0
9
9
  },
10
10
  "navPosition": 0,
@@ -160,10 +160,5 @@
160
160
  },
161
161
  "search": {
162
162
  "fields": ["title", "excerpt"]
163
- },
164
- "autoSlugify": {
165
- "enabled": true,
166
- "sourceField": "title",
167
- "targetField": "slug"
168
163
  }
169
164
  }
@@ -35,7 +35,7 @@ export function AdminSidebar(props: ComponentProps<typeof Sidebar>) {
35
35
  <Fragment key={group.label ?? '_ungrouped'}>
36
36
  <SidebarGroup className="py-0">
37
37
  {group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
38
- <SidebarMenu className="gap-px">
38
+ <SidebarMenu className="gap-0.5">
39
39
  {group.items.map((item) => (
40
40
  <SidebarMenuItem key={item.href}>
41
41
  <SidebarNavLink href={item.href}>
@@ -51,7 +51,7 @@ export function AdminSidebar(props: ComponentProps<typeof Sidebar>) {
51
51
 
52
52
  <SidebarGroup className="mt-auto gap-2 px-0">
53
53
  <Separator />
54
- <SidebarMenu className="px-2 gap-px">
54
+ <SidebarMenu className="px-2 gap-0.5">
55
55
  <SidebarMenuItem>
56
56
  <SidebarNavLink href="/admin/users">
57
57
  <Users strokeWidth={2} />
@@ -53,14 +53,14 @@ export function LifecycleHooksTab({ lifecycleHooks }: LifecycleHooksTabProps) {
53
53
 
54
54
  if (!firstHook) {
55
55
  return (
56
- <div className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-border px-6 text-center text-sm text-muted-foreground">
56
+ <div className="flex min-h-80 items-center justify-center rounded-xl border border-dashed border-border px-6 text-center text-sm text-muted-foreground">
57
57
  Lifecycle hook examples are generated for entity schemas in v1.
58
58
  </div>
59
59
  )
60
60
  }
61
61
 
62
62
  return (
63
- <Accordion type="single" defaultValue={firstHook.id} className="min-h-0 shrink-0 rounded-lg">
63
+ <Accordion type="single" defaultValue={firstHook.id} className="min-h-0 shrink-0 rounded-xl">
64
64
  {lifecycleHooks.map((hook) => {
65
65
  const HookIcon = LIFECYCLE_HOOK_ICONS[hook.icon]
66
66
  const phaseLabel = LIFECYCLE_HOOK_PHASE_LABELS[hook.phase]
@@ -56,7 +56,7 @@ export function SnippetsTab({ snippets }: SnippetsTabProps) {
56
56
  }
57
57
 
58
58
  return (
59
- <Accordion type="single" defaultValue={firstSnippet.id} className="min-h-0 shrink-0 rounded-lg">
59
+ <Accordion type="single" defaultValue={firstSnippet.id} className="min-h-0 shrink-0 rounded-xl">
60
60
  {snippets.map((snippet) => {
61
61
  const SnippetIcon = SNIPPET_ICONS[snippet.icon]
62
62
  const snippetKindLabel = SNIPPET_KIND_LABELS[snippet.kind]
@@ -50,7 +50,7 @@ export function DevModeIntegrateButton({
50
50
  <DrawerTrigger asChild>
51
51
  <Button type="button" variant="outline">
52
52
  <Code2 data-icon="inline-start" />
53
- <span className="hidden sm:inline">Integration Snippets</span>
53
+ <span className="hidden sm:inline">Dev Mode</span>
54
54
  </Button>
55
55
  </DrawerTrigger>
56
56
  <DrawerContent className="w-full overflow-hidden data-[vaul-drawer-direction=right]:sm:max-w-2xl data-[vaul-drawer-direction=right]:lg:max-w-4xl">
@@ -44,7 +44,7 @@ export function EntityVersionsDrawer({
44
44
  return (
45
45
  <Drawer direction="right" open={open} onOpenChange={onOpenChange} modal>
46
46
  <DrawerContent className="h-full sm:max-w-lg">
47
- <DrawerHeader className="border-b border-border">
47
+ <DrawerHeader>
48
48
  <DrawerTitle>Versions</DrawerTitle>
49
49
  <DrawerDescription>
50
50
  Review previous {entityType} versions and restore a saved state.
@@ -93,7 +93,7 @@ export function EditMediaDialogContent({
93
93
  <DrawerContent className="h-full w-full overflow-hidden">
94
94
  <div
95
95
  ref={handleDrawerContainerRef}
96
- className="pointer-events-none absolute inset-3 z-20 overflow-hidden rounded-lg"
96
+ className="pointer-events-none absolute inset-3 z-20 overflow-hidden rounded-xl"
97
97
  />
98
98
  <div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden">
99
99
  <DrawerHeader>
@@ -107,7 +107,7 @@ export function EditMediaDialogContent({
107
107
  >
108
108
  <Card>
109
109
  <CardContent className="p-0">
110
- <div className="relative aspect-video overflow-hidden rounded-lg bg-background">
110
+ <div className="relative aspect-video overflow-hidden rounded-xl bg-background">
111
111
  <MediaPreview media={media} showMetadata />
112
112
  </div>
113
113
  </CardContent>
@@ -90,7 +90,7 @@ export function MediaUrlImporter({ onImportFromUrl, accept, className }: MediaUr
90
90
  return (
91
91
  <div
92
92
  className={cn(
93
- 'flex items-center justify-center transition-colors rounded-lg border bg-input border-border overflow-hidden h-full flex-col p-5 gap-5',
93
+ 'flex items-center justify-center transition-colors rounded-xl border bg-input border-border overflow-hidden h-full flex-col p-5 gap-5',
94
94
  className
95
95
  )}
96
96
  >
@@ -22,22 +22,25 @@ export function PageHeader({
22
22
  const router = useRouter()
23
23
 
24
24
  return (
25
- <header className="sticky top-0 border-t right-0 left-(--admin-shell-content-left) z-20 grid h-14 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center border-b border-border/80 px-4 bg-sidebar transition-[left] duration-200 ease-linear">
26
- <div className="flex w-full items-center justify-start gap-2">
27
- {hasBackButton && (
28
- <Button variant="ghost" size="icon" onClick={() => router.back()}>
29
- <ChevronLeft className="size-4" />
30
- </Button>
31
- )}
32
- </div>
33
- <div className="flex min-w-0 items-center justify-center gap-2">
34
- <h2 className="truncate text-sm font-medium tracking-tight">{title}</h2>
35
- </div>
36
- <div className="flex w-full min-w-0 items-center justify-end gap-2">
37
- {children && children}
38
- {search && search}
39
- {actions && actions}
40
- </div>
41
- </header>
25
+ <>
26
+ <header className="fixed top-0 border-t right-0 left-(--admin-shell-content-left) z-20 grid h-14 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center border-b border-border/80 px-4 bg-sidebar transition-[left] duration-200 ease-linear">
27
+ <div className="flex w-full items-center justify-start gap-2">
28
+ {hasBackButton && (
29
+ <Button variant="ghost" size="icon" onClick={() => router.back()}>
30
+ <ChevronLeft className="size-4" />
31
+ </Button>
32
+ )}
33
+ </div>
34
+ <div className="flex min-w-0 items-center justify-center gap-2">
35
+ <h2 className="truncate text-sm font-medium tracking-tight">{title}</h2>
36
+ </div>
37
+ <div className="flex w-full min-w-0 items-center justify-end gap-2">
38
+ {children && children}
39
+ {search && search}
40
+ {actions && actions}
41
+ </div>
42
+ </header>
43
+ <div className="h-14 shrink-0" aria-hidden="true" />
44
+ </>
42
45
  )
43
46
  }
@@ -1,5 +1,6 @@
1
1
  import type { AdminNavigationItem } from '@admin/types/navigation'
2
2
  import { House, ImagePlay } from 'lucide-react'
3
+ import { menusNav } from './navigation/menus'
3
4
  import { postsNav } from './navigation/posts'
4
5
 
5
6
  export type { AdminNavigationItem } from '@admin/types/navigation'
@@ -26,7 +27,7 @@ function comparePositions(a?: number, b?: number): number {
26
27
  return 0
27
28
  }
28
29
 
29
- const combinedNavigation = [...coreNav, ...postsNav]
30
+ const combinedNavigation = [...coreNav, ...menusNav, ...postsNav]
30
31
 
31
32
  const dashboard = combinedNavigation.find((item) => item.href === '/admin')
32
33
  const others = combinedNavigation.filter((item) => item.href !== '/admin')
@@ -15,12 +15,13 @@ import * as React from 'react'
15
15
 
16
16
  export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | null>) {
17
17
  const [slashMenu, setSlashMenu] = React.useState<SlashMenuState | null>(null)
18
+ const [slashSearchQuery, setSlashSearchQuery] = React.useState('')
18
19
  const [activeSlashIndexState, setActiveSlashIndexState] = React.useState(0)
19
20
  const slashMenuRef = React.useRef<SlashMenuState | null>(null)
20
21
  const activeSlashIndexRef = React.useRef(0)
21
22
  const filteredSlashCommands = React.useMemo(
22
- () => getFilteredSlashCommands(slashMenu?.query ?? ''),
23
- [slashMenu]
23
+ () => getFilteredSlashCommands(slashSearchQuery),
24
+ [slashSearchQuery]
24
25
  )
25
26
  const filteredSlashCommandsRef = React.useRef(filteredSlashCommands)
26
27
  const activeSlashIndex =
@@ -37,6 +38,23 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
37
38
  isSameSlashMenuState(current, nextSlashMenu) ? current : nextSlashMenu
38
39
  )
39
40
 
41
+ setSlashSearchQuery((current) => {
42
+ const currentSlashMenu = slashMenuRef.current
43
+
44
+ if (!nextSlashMenu) return ''
45
+
46
+ if (
47
+ currentSlashMenu &&
48
+ currentSlashMenu.from === nextSlashMenu.from &&
49
+ currentSlashMenu.to === nextSlashMenu.to &&
50
+ currentSlashMenu.query === nextSlashMenu.query
51
+ ) {
52
+ return current
53
+ }
54
+
55
+ return nextSlashMenu.query
56
+ })
57
+
40
58
  setActiveSlashIndexState((current) =>
41
59
  slashMenuRef.current?.query === nextSlashMenu?.query && nextSlashMenu ? current : 0
42
60
  )
@@ -44,6 +62,12 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
44
62
 
45
63
  const closeSlashMenu = React.useCallback(() => {
46
64
  setSlashMenu(null)
65
+ setSlashSearchQuery('')
66
+ setActiveSlashIndexState(0)
67
+ }, [])
68
+
69
+ const setSlashSearch = React.useCallback((query: string) => {
70
+ setSlashSearchQuery(query)
47
71
  setActiveSlashIndexState(0)
48
72
  }, [])
49
73
 
@@ -110,7 +134,9 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
110
134
  handleSlashKeyDown,
111
135
  selectSlashCommand,
112
136
  setActiveSlashIndex: setActiveSlashIndexState,
137
+ setSlashSearch,
113
138
  slashMenu,
139
+ slashSearchQuery,
114
140
  updateSlashMenu
115
141
  }
116
142
  }
@@ -66,6 +66,7 @@ export function useContentEditor({
66
66
  const isInternalChange = React.useRef(false)
67
67
  const isSyncingRichContent = React.useRef(false)
68
68
  const editorRef = React.useRef<Editor | null>(null)
69
+ const isSlashMenuOpenRef = React.useRef(false)
69
70
  const {
70
71
  activeSlashIndex,
71
72
  closeSlashMenu,
@@ -73,10 +74,14 @@ export function useContentEditor({
73
74
  handleSlashKeyDown,
74
75
  selectSlashCommand,
75
76
  setActiveSlashIndex,
77
+ setSlashSearch,
76
78
  slashMenu,
79
+ slashSearchQuery,
77
80
  updateSlashMenu
78
81
  } = useContentEditorSlashMenu(editorRef)
79
82
 
83
+ isSlashMenuOpenRef.current = slashMenu !== null
84
+
80
85
  if (value !== lastValuePropRef.current) {
81
86
  lastValuePropRef.current = value
82
87
  latestMarkdownRef.current = value
@@ -158,7 +163,20 @@ export function useContentEditor({
158
163
  updateSlashMenu(nextEditor)
159
164
  },
160
165
  onSelectionUpdate: ({ editor: nextEditor }) => updateSlashMenu(nextEditor),
161
- onBlur: closeSlashMenu
166
+ onBlur: ({ event }) => {
167
+ const nextFocusTarget = event.relatedTarget
168
+
169
+ if (isSlashMenuOpenRef.current && !nextFocusTarget) return
170
+
171
+ if (
172
+ nextFocusTarget instanceof Element &&
173
+ nextFocusTarget.closest('[data-content-editor-slash-menu]')
174
+ ) {
175
+ return
176
+ }
177
+
178
+ closeSlashMenu()
179
+ }
162
180
  })
163
181
 
164
182
  editorRef.current = editor
@@ -278,9 +296,12 @@ export function useContentEditor({
278
296
  handleSourceChange,
279
297
  handleSourceSelectionChange,
280
298
  mode,
299
+ closeSlashMenu,
281
300
  selectSlashCommand,
282
301
  setActiveSlashIndex,
302
+ setSlashSearch,
283
303
  slashMenu,
304
+ slashSearchQuery,
284
305
  sourceValue: latestMarkdownRef.current,
285
306
  sourceSelectionRequest,
286
307
  syncRichContent
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import type { AuditLogPage, AuditLogQuery, CleanupAuditLogsResult } from '@admin/actions/audit'
4
+ import { cleanupAuditLogs, getAuditLogs } from '@admin/actions/audit'
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
6
+
7
+ export function useAuditLogs(query: AuditLogQuery) {
8
+ return useQuery<AuditLogPage>({
9
+ queryKey: ['audit-log', query],
10
+ queryFn: () => getAuditLogs(query)
11
+ })
12
+ }
13
+
14
+ export function useCleanupAuditLogs() {
15
+ const queryClient = useQueryClient()
16
+
17
+ return useMutation<CleanupAuditLogsResult>({
18
+ mutationFn: () => cleanupAuditLogs(),
19
+ onSuccess: () => {
20
+ void queryClient.invalidateQueries({ queryKey: ['audit-log'] })
21
+ }
22
+ })
23
+ }
@@ -0,0 +1,63 @@
1
+ 'use server'
2
+
3
+ import { getSetting } from '@admin/actions/settings'
4
+ import { requireRole, UserRole } from '@admin/auth/middleware'
5
+ import db from '@admin/db'
6
+ import { auditLog } from '@admin/db/schema'
7
+ import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
8
+ import { lt } from 'drizzle-orm'
9
+ import { getAuditRequestMetadata } from './request-metadata'
10
+ import type { CleanupAuditLogsResult } from './types'
11
+
12
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
13
+
14
+ function getRetentionDays(value: unknown): number | null {
15
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
16
+ const days = Math.floor(value)
17
+ return days > 0 ? days : null
18
+ }
19
+
20
+ export async function cleanupAuditLogs(): Promise<CleanupAuditLogsResult> {
21
+ const actor = await requireRole([UserRole.ADMIN])
22
+
23
+ try {
24
+ const settings = await getSetting()
25
+ const retentionEnabled = settings?.auditRetentionEnabled === true
26
+ const retentionDays = getRetentionDays(settings?.auditRetentionDays)
27
+
28
+ if (!retentionEnabled || !retentionDays) {
29
+ return { success: true, deletedCount: 0, skipped: true }
30
+ }
31
+
32
+ const cutoff = new Date(Date.now() - retentionDays * MS_PER_DAY).toISOString()
33
+ const deletedRows = await db
34
+ .delete(auditLog)
35
+ .where(lt(auditLog.createdAt, cutoff))
36
+ .returning({ id: auditLog.id })
37
+
38
+ const requestMetadata = await getAuditRequestMetadata()
39
+ await runAdminEventHooks({
40
+ category: 'system',
41
+ action: 'cleanup',
42
+ targetType: 'audit-log',
43
+ targetLabel: 'Audit log retention cleanup',
44
+ user: actor,
45
+ timestamp: new Date().toISOString(),
46
+ sourceType: 'system_job',
47
+ changedFields: [],
48
+ metadata: {
49
+ cutoff,
50
+ deletedCount: deletedRows.length,
51
+ retentionDays
52
+ },
53
+ ...requestMetadata
54
+ })
55
+
56
+ return { success: true, deletedCount: deletedRows.length, cutoff }
57
+ } catch (error) {
58
+ return {
59
+ success: false,
60
+ error: error instanceof Error ? error.message : 'Failed to clean up audit logs'
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,97 @@
1
+ 'use server'
2
+
3
+ import { requireRole, UserRole } from '@admin/auth/middleware'
4
+ import db from '@admin/db'
5
+ import { auditLog } from '@admin/db/schema'
6
+ import { and, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
7
+ import type { AuditLogEntry, AuditLogPage, AuditLogQuery } from './types'
8
+
9
+ const DEFAULT_AUDIT_LOG_PAGE = 1
10
+ const DEFAULT_AUDIT_LOG_PAGE_SIZE = 25
11
+ const MAX_AUDIT_LOG_PAGE_SIZE = 100
12
+
13
+ function clampPage(value: number | undefined): number {
14
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_AUDIT_LOG_PAGE
15
+ return Math.max(DEFAULT_AUDIT_LOG_PAGE, Math.floor(value))
16
+ }
17
+
18
+ function clampPageSize(value: number | undefined): number {
19
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
20
+ return DEFAULT_AUDIT_LOG_PAGE_SIZE
21
+ }
22
+
23
+ return Math.min(MAX_AUDIT_LOG_PAGE_SIZE, Math.max(1, Math.floor(value)))
24
+ }
25
+
26
+ function getExclusiveDateUpperBound(value: string | undefined): string | null {
27
+ if (!value) return null
28
+ const date = new Date(value)
29
+ if (Number.isNaN(date.getTime())) return null
30
+
31
+ date.setUTCDate(date.getUTCDate() + 1)
32
+ return date.toISOString()
33
+ }
34
+
35
+ async function queryAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
36
+ const page = clampPage(query.page)
37
+ const pageSize = clampPageSize(query.pageSize)
38
+ const offset = (page - 1) * pageSize
39
+ const conditions = []
40
+
41
+ if (query.category) conditions.push(eq(auditLog.category, query.category))
42
+ if (query.action) conditions.push(eq(auditLog.action, query.action))
43
+ if (query.targetType) conditions.push(ilike(auditLog.targetType, `%${query.targetType}%`))
44
+ if (query.actor) {
45
+ const actorPattern = `%${query.actor}%`
46
+ conditions.push(
47
+ or(ilike(auditLog.actorEmail, actorPattern), ilike(auditLog.actorName, actorPattern))
48
+ )
49
+ }
50
+ if (query.createdAtFrom) {
51
+ conditions.push(gte(auditLog.createdAt, query.createdAtFrom))
52
+ }
53
+ const createdAtExclusiveTo = getExclusiveDateUpperBound(query.createdAtTo)
54
+ if (createdAtExclusiveTo) {
55
+ conditions.push(lt(auditLog.createdAt, createdAtExclusiveTo))
56
+ }
57
+ if (query.search) {
58
+ const searchPattern = `%${query.search}%`
59
+ conditions.push(
60
+ or(
61
+ ilike(auditLog.targetId, searchPattern),
62
+ ilike(auditLog.targetLabel, searchPattern),
63
+ ilike(auditLog.actorEmail, searchPattern),
64
+ ilike(auditLog.actorName, searchPattern),
65
+ ilike(auditLog.sourceType, searchPattern),
66
+ ilike(auditLog.ipAddress, searchPattern),
67
+ ilike(auditLog.userAgent, searchPattern)
68
+ )
69
+ )
70
+ }
71
+
72
+ const where = conditions.length > 0 ? and(...conditions) : undefined
73
+ const [rows, countResult] = await Promise.all([
74
+ db
75
+ .select()
76
+ .from(auditLog)
77
+ .where(where)
78
+ .orderBy(desc(auditLog.createdAt))
79
+ .limit(pageSize)
80
+ .offset(offset),
81
+ db.select({ count: sql<number>`count(*)` }).from(auditLog).where(where)
82
+ ])
83
+ const total = Number(countResult[0]?.count ?? 0)
84
+
85
+ return {
86
+ auditLogs: rows as AuditLogEntry[],
87
+ total,
88
+ page,
89
+ pageSize,
90
+ totalPages: Math.max(1, Math.ceil(total / pageSize))
91
+ }
92
+ }
93
+
94
+ export async function getAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
95
+ await requireRole([UserRole.ADMIN])
96
+ return queryAuditLogs(query)
97
+ }
@@ -0,0 +1,17 @@
1
+ export { cleanupAuditLogs } from './cleanup-audit-logs'
2
+ export { getAuditLogs } from './get-audit-logs'
3
+ export type {
4
+ AuditLogAction,
5
+ AuditLogCategory,
6
+ AuditLogEntry,
7
+ AuditLogPage,
8
+ AuditLogQuery,
9
+ AuditLogSourceType,
10
+ CleanupAuditLogsResult
11
+ } from './types'
12
+ export {
13
+ AUDIT_LOG_ACTIONS,
14
+ AUDIT_LOG_CATEGORIES,
15
+ AUDIT_LOG_SOURCE_TYPES,
16
+ auditLogCacheTags
17
+ } from './types'
@@ -0,0 +1,37 @@
1
+ import db from '@admin/db'
2
+ import { auditLog } from '@admin/db/schema'
3
+ import type { AdminEventContext } from '@admin/lib/lifecycle-hooks/types'
4
+
5
+ const MAX_TARGET_TYPE_LENGTH = 100
6
+ const MAX_SOURCE_TYPE_LENGTH = 50
7
+ const MAX_IP_LENGTH = 100
8
+ const MAX_USER_AGENT_LENGTH = 500
9
+
10
+ function truncate(value: string | null | undefined, maxLength: number): string | null {
11
+ if (!value) return null
12
+ return value.length > maxLength ? value.slice(0, maxLength) : value
13
+ }
14
+
15
+ function normalizeChangedFields(value: readonly string[] | undefined): string[] {
16
+ return [...new Set(value ?? [])].filter(Boolean).sort((a, b) => a.localeCompare(b))
17
+ }
18
+
19
+ export async function recordAuditEvent(event: AdminEventContext): Promise<void> {
20
+ await db.insert(auditLog).values({
21
+ category: event.category,
22
+ action: event.action,
23
+ targetType: truncate(event.targetType, MAX_TARGET_TYPE_LENGTH) ?? 'unknown',
24
+ targetId: event.targetId ?? null,
25
+ targetLabel: event.targetLabel ?? null,
26
+ actorUserId: event.user?.id ?? null,
27
+ actorEmail: event.user?.email ?? null,
28
+ actorName: event.user?.name ?? null,
29
+ actorRole: typeof event.user?.role === 'string' ? event.user.role : null,
30
+ sourceType: truncate(event.sourceType, MAX_SOURCE_TYPE_LENGTH) ?? 'server_action',
31
+ ipAddress: truncate(event.ipAddress, MAX_IP_LENGTH),
32
+ userAgent: truncate(event.userAgent, MAX_USER_AGENT_LENGTH),
33
+ changedFields: normalizeChangedFields(event.changedFields),
34
+ metadata: event.metadata ?? null,
35
+ createdAt: event.timestamp
36
+ })
37
+ }