@actuate-media/cms-admin 0.12.0 → 0.12.1

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 (152) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +200 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +45 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +90 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/coauthor-client.d.ts +72 -0
  99. package/dist/lib/coauthor-client.d.ts.map +1 -0
  100. package/dist/lib/coauthor-client.js +98 -0
  101. package/dist/lib/coauthor-client.js.map +1 -0
  102. package/dist/lib/collaboration-provider.d.ts +142 -0
  103. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  104. package/dist/lib/collaboration-provider.js +168 -0
  105. package/dist/lib/collaboration-provider.js.map +1 -0
  106. package/dist/lib/comment-anchor.d.ts +86 -0
  107. package/dist/lib/comment-anchor.d.ts.map +1 -0
  108. package/dist/lib/comment-anchor.js +139 -0
  109. package/dist/lib/comment-anchor.js.map +1 -0
  110. package/dist/lib/comments-client.d.ts +106 -0
  111. package/dist/lib/comments-client.d.ts.map +1 -0
  112. package/dist/lib/comments-client.js +138 -0
  113. package/dist/lib/comments-client.js.map +1 -0
  114. package/dist/lib/notifications-client.d.ts +68 -0
  115. package/dist/lib/notifications-client.d.ts.map +1 -0
  116. package/dist/lib/notifications-client.js +85 -0
  117. package/dist/lib/notifications-client.js.map +1 -0
  118. package/dist/lib/slash-command.d.ts +39 -0
  119. package/dist/lib/slash-command.d.ts.map +1 -0
  120. package/dist/lib/slash-command.js +50 -0
  121. package/dist/lib/slash-command.js.map +1 -0
  122. package/package.json +12 -3
  123. package/src/__tests__/components/comment-mark.test.ts +99 -0
  124. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  125. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  126. package/src/__tests__/components/offline-status.test.tsx +66 -0
  127. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  128. package/src/__tests__/lib/active-editor.test.ts +70 -0
  129. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  130. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  131. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  132. package/src/__tests__/lib/comments-client.test.ts +239 -0
  133. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  134. package/src/__tests__/lib/slash-command.test.ts +146 -0
  135. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  136. package/src/components/CoauthorResultPopover.tsx +193 -0
  137. package/src/components/CommandPalette.tsx +397 -209
  138. package/src/components/CommentMark.ts +138 -0
  139. package/src/components/CommentSidePanel.tsx +521 -0
  140. package/src/components/LivePreview.tsx +117 -11
  141. package/src/components/NotificationBell.tsx +242 -0
  142. package/src/components/OfflineStatus.tsx +90 -0
  143. package/src/components/PresenceChips.tsx +150 -0
  144. package/src/components/TipTapEditor.tsx +310 -12
  145. package/src/index.ts +20 -0
  146. package/src/lib/active-editor.ts +39 -0
  147. package/src/lib/coauthor-client.ts +160 -0
  148. package/src/lib/collaboration-provider.ts +306 -0
  149. package/src/lib/comment-anchor.ts +179 -0
  150. package/src/lib/comments-client.ts +208 -0
  151. package/src/lib/notifications-client.ts +146 -0
  152. package/src/lib/slash-command.ts +67 -0
@@ -16,8 +16,29 @@ import {
16
16
  Upload,
17
17
  BarChart3,
18
18
  Clock,
19
+ Maximize2,
20
+ Minimize2,
21
+ Sparkles,
22
+ SpellCheck,
23
+ Wand2,
19
24
  } from 'lucide-react'
25
+ import { toast } from 'sonner'
20
26
  import { cmsApi } from '../lib/api.js'
27
+ import {
28
+ coauthorActionDescription,
29
+ coauthorActionLabel,
30
+ coauthorText,
31
+ type CoauthorAction,
32
+ type ProofreadChange,
33
+ } from '../lib/coauthor-client.js'
34
+ import {
35
+ isAiSlashCommandInput,
36
+ orderedAiActions,
37
+ parseAiSlashCommand,
38
+ } from '../lib/slash-command.js'
39
+ import { getActiveEditor } from '../lib/active-editor.js'
40
+ import { CoauthorResultPopover } from './CoauthorResultPopover.js'
41
+ import type { Editor } from '@tiptap/react'
21
42
 
22
43
  interface SearchResultDocument {
23
44
  id: string
@@ -80,6 +101,39 @@ const staticActions = [
80
101
  { id: 'action-open-settings', label: 'Open settings', icon: Settings, action: '/settings' },
81
102
  ]
82
103
 
104
+ const AI_ACTION_ICONS: Record<CoauthorAction, typeof Sparkles> = {
105
+ rewrite: Wand2,
106
+ expand: Maximize2,
107
+ compress: Minimize2,
108
+ proofread: SpellCheck,
109
+ }
110
+
111
+ interface AiResultState {
112
+ open: boolean
113
+ loading: boolean
114
+ action: CoauthorAction
115
+ before: string
116
+ after: string
117
+ error?: string
118
+ changes?: ProofreadChange[]
119
+ /** Editor captured at submission time; null means clipboard-only. */
120
+ target: Editor | null
121
+ }
122
+
123
+ const INITIAL_AI_RESULT: AiResultState = {
124
+ open: false,
125
+ loading: false,
126
+ action: 'rewrite',
127
+ before: '',
128
+ after: '',
129
+ target: null,
130
+ }
131
+
132
+ function truncatePreview(text: string, max = 60): string {
133
+ if (text.length <= max) return text
134
+ return `${text.slice(0, max - 1)}…`
135
+ }
136
+
83
137
  const navigationCommands = [
84
138
  { id: 'nav-dashboard', label: 'Dashboard', icon: LayoutDashboard, action: '/' },
85
139
  { id: 'nav-posts', label: 'Posts', icon: FileText, action: '/posts' },
@@ -104,6 +158,7 @@ export function CommandPalette({ open, onOpenChange, onNavigate }: CommandPalett
104
158
  const [users, setUsers] = useState<SearchResultUser[]>([])
105
159
  const [recentItems, setRecentItems] = useState<RecentItem[]>([])
106
160
  const [loading, setLoading] = useState(false)
161
+ const [aiResult, setAiResult] = useState<AiResultState>(INITIAL_AI_RESULT)
107
162
  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
108
163
 
109
164
  useEffect(() => {
@@ -153,7 +208,7 @@ export function CommandPalette({ open, onOpenChange, onNavigate }: CommandPalett
153
208
 
154
209
  useEffect(() => {
155
210
  if (debounceRef.current) clearTimeout(debounceRef.current)
156
- if (!search.trim()) {
211
+ if (!search.trim() || isAiSlashCommandInput(search)) {
157
212
  setDocuments([])
158
213
  setMedia([])
159
214
  setUsers([])
@@ -174,227 +229,360 @@ export function CommandPalette({ open, onOpenChange, onNavigate }: CommandPalett
174
229
  setSearch('')
175
230
  }
176
231
 
177
- if (!open) return null
232
+ const runSlashAction = useCallback(
233
+ async (action: CoauthorAction, text: string) => {
234
+ if (!text.trim()) {
235
+ toast.message(`Provide text after the command: /ai ${action} <your text>`)
236
+ return
237
+ }
238
+ const target = getActiveEditor()
239
+ onOpenChange(false)
240
+ setSearch('')
241
+ setAiResult({
242
+ open: true,
243
+ loading: true,
244
+ action,
245
+ before: text,
246
+ after: '',
247
+ target,
248
+ })
249
+
250
+ const outcome = await coauthorText(action, text)
251
+ if (!outcome.ok) {
252
+ setAiResult((prev) => ({ ...prev, loading: false, error: outcome.error }))
253
+ toast.error(outcome.error)
254
+ return
255
+ }
256
+ setAiResult((prev) => ({
257
+ ...prev,
258
+ loading: false,
259
+ after: outcome.result.text,
260
+ changes: outcome.result.changes,
261
+ error: undefined,
262
+ }))
263
+ },
264
+ [onOpenChange],
265
+ )
266
+
267
+ const handleAiApply = useCallback(() => {
268
+ const { target, after, action } = aiResult
269
+ if (!target) {
270
+ void navigator.clipboard
271
+ ?.writeText(after)
272
+ .then(() => toast.success('Copied to clipboard.'))
273
+ .catch(() => toast.error('Clipboard unavailable — copy manually.'))
274
+ setAiResult(INITIAL_AI_RESULT)
275
+ return
276
+ }
277
+ const { from, to, empty } = target.state.selection
278
+ const chain = target.chain().focus()
279
+ if (!empty) {
280
+ chain.insertContentAt({ from, to }, after).run()
281
+ } else {
282
+ chain.insertContent(after).run()
283
+ }
284
+ toast.success(`${coauthorActionLabel(action)} applied.`)
285
+ setAiResult(INITIAL_AI_RESULT)
286
+ }, [aiResult])
287
+
288
+ const handleAiCopy = useCallback(async () => {
289
+ try {
290
+ await navigator.clipboard.writeText(aiResult.after)
291
+ toast.success('Copied to clipboard.')
292
+ } catch {
293
+ toast.error('Clipboard unavailable — copy manually.')
294
+ }
295
+ }, [aiResult.after])
296
+
297
+ const handleAiDiscard = useCallback(() => {
298
+ setAiResult(INITIAL_AI_RESULT)
299
+ }, [])
178
300
 
179
301
  const hasQuery = search.trim().length > 0
180
302
  const hasResults = documents.length > 0 || media.length > 0 || users.length > 0
303
+ const isAiMode = isAiSlashCommandInput(search)
304
+ const aiCommand = parseAiSlashCommand(search)
305
+ const aiPayload = aiCommand?.text ?? ''
306
+ const aiActions = orderedAiActions(search)
307
+
308
+ const popover = (
309
+ <CoauthorResultPopover
310
+ open={aiResult.open}
311
+ loading={aiResult.loading}
312
+ action={aiResult.action}
313
+ before={aiResult.before}
314
+ after={aiResult.after}
315
+ error={aiResult.error}
316
+ changes={aiResult.changes}
317
+ applyLabel={aiResult.target ? 'Apply to editor' : 'Copy to clipboard'}
318
+ onApply={handleAiApply}
319
+ onCopy={handleAiCopy}
320
+ onDiscard={handleAiDiscard}
321
+ />
322
+ )
323
+
324
+ if (!open) return popover
181
325
 
182
326
  return (
183
- <div
184
- className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[20vh]"
185
- onClick={() => {
186
- onOpenChange(false)
187
- setSearch('')
188
- }}
189
- role="presentation"
190
- >
327
+ <>
191
328
  <div
192
- role="dialog"
193
- aria-modal="true"
194
- className="w-full max-w-2xl"
195
- onClick={(e) => e.stopPropagation()}
329
+ className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[20vh]"
330
+ onClick={() => {
331
+ onOpenChange(false)
332
+ setSearch('')
333
+ }}
334
+ role="presentation"
196
335
  >
197
- <Command
198
- className="w-full overflow-hidden rounded-lg bg-white shadow-2xl"
199
- onKeyDown={(e) => {
200
- if (e.key === 'Escape') {
201
- onOpenChange(false)
202
- setSearch('')
203
- }
204
- }}
205
- shouldFilter={!hasQuery}
336
+ <div
337
+ role="dialog"
338
+ aria-modal="true"
339
+ className="w-full max-w-2xl"
340
+ onClick={(e) => e.stopPropagation()}
206
341
  >
207
- <div className="flex items-center border-b border-gray-200 px-4">
208
- <Search className="mr-3 h-5 w-5 shrink-0 text-gray-400" />
209
- <Command.Input
210
- value={search}
211
- onValueChange={setSearch}
212
- placeholder="Search or jump to..."
213
- className="w-full bg-transparent py-4 text-base placeholder:text-gray-400 focus:outline-none"
214
- />
215
- {loading && (
216
- <div className="mr-2 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
217
- )}
218
- <kbd className="hidden shrink-0 rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-600 sm:inline-block">
219
- ESC
220
- </kbd>
221
- </div>
222
-
223
- <Command.List className="max-h-[400px] overflow-y-auto p-2">
224
- <Command.Empty className="py-6 text-center text-sm text-gray-500">
225
- {loading ? 'Searching...' : 'No results found.'}
226
- </Command.Empty>
227
-
228
- {!hasQuery && recentItems.length > 0 && (
229
- <Command.Group heading="Recent" className="px-2 py-2">
230
- <div className="mb-2 text-xs font-medium text-gray-500">RECENT</div>
231
- {recentItems.map((item) => (
232
- <Command.Item
233
- key={`recent-${item.id}`}
234
- value={`recent ${item.label}`}
235
- onSelect={() => handleSelect(item.path, item)}
236
- className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
237
- >
238
- <Clock className="h-4 w-4 text-gray-400" />
239
- <span>{item.label}</span>
240
- <span className="ml-auto text-xs text-gray-400 capitalize">{item.type}</span>
241
- </Command.Item>
242
- ))}
243
- </Command.Group>
244
- )}
245
-
246
- {hasQuery && documents.length > 0 && (
247
- <Command.Group heading="Documents" className="px-2 py-2">
248
- <div className="mb-2 text-xs font-medium text-gray-500">DOCUMENTS</div>
249
- {documents.map((doc) => (
250
- <Command.Item
251
- key={`doc-${doc.id}`}
252
- value={doc.title ?? doc.slug ?? doc.id}
253
- onSelect={() =>
254
- handleSelect(`/collections/${doc.collection}/${doc.id}`, {
255
- id: `doc-${doc.id}`,
256
- label: doc.title ?? doc.slug ?? doc.id,
257
- path: `/collections/${doc.collection}/${doc.id}`,
258
- type: 'document',
259
- })
260
- }
261
- className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
262
- >
263
- <FileText className="h-4 w-4 text-gray-500" />
264
- <div className="flex min-w-0 flex-col">
265
- <span className="truncate">{doc.title ?? doc.slug ?? doc.id}</span>
266
- <span className="text-xs text-gray-400">
267
- {doc.collection} &middot; {doc.status}
268
- </span>
269
- </div>
270
- </Command.Item>
271
- ))}
272
- </Command.Group>
273
- )}
274
-
275
- {hasQuery && media.length > 0 && (
276
- <Command.Group heading="Media" className="mt-1 px-2 py-2">
277
- <div className="mb-2 text-xs font-medium text-gray-500">MEDIA</div>
278
- {media.map((m) => (
279
- <Command.Item
280
- key={`media-${m.id}`}
281
- value={m.filename}
282
- onSelect={() =>
283
- handleSelect(`/media?selected=${m.id}`, {
284
- id: `media-${m.id}`,
285
- label: m.filename,
286
- path: `/media?selected=${m.id}`,
287
- type: 'media',
288
- })
289
- }
290
- className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
291
- >
292
- <Image className="h-4 w-4 text-gray-500" />
293
- <div className="flex min-w-0 flex-col">
294
- <span className="truncate">{m.filename}</span>
295
- <span className="text-xs text-gray-400">{m.mimeType}</span>
296
- </div>
297
- </Command.Item>
298
- ))}
299
- </Command.Group>
300
- )}
301
-
302
- {hasQuery && users.length > 0 && (
303
- <Command.Group heading="Users" className="mt-1 px-2 py-2">
304
- <div className="mb-2 text-xs font-medium text-gray-500">USERS</div>
305
- {users.map((u) => (
306
- <Command.Item
307
- key={`user-${u.id}`}
308
- value={`${u.name} ${u.email}`}
309
- onSelect={() =>
310
- handleSelect(`/users?selected=${u.id}`, {
311
- id: `user-${u.id}`,
312
- label: u.name,
313
- path: `/users?selected=${u.id}`,
314
- type: 'user',
315
- })
316
- }
317
- className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
318
- >
319
- <Users className="h-4 w-4 text-gray-500" />
320
- <div className="flex min-w-0 flex-col">
321
- <span className="truncate">{u.name}</span>
322
- <span className="text-xs text-gray-400">
323
- {u.email} &middot; {u.role}
324
- </span>
325
- </div>
326
- </Command.Item>
327
- ))}
328
- </Command.Group>
329
- )}
330
-
331
- {(!hasQuery || !hasResults) && (
332
- <Command.Group heading="Navigation" className="px-2 py-2">
333
- <div className="mb-2 text-xs font-medium text-gray-500">NAVIGATION</div>
334
- {navigationCommands.map((cmd) => {
335
- const Icon = cmd.icon
336
- return (
342
+ <Command
343
+ className="w-full overflow-hidden rounded-lg bg-white shadow-2xl"
344
+ onKeyDown={(e) => {
345
+ if (e.key === 'Escape') {
346
+ onOpenChange(false)
347
+ setSearch('')
348
+ }
349
+ }}
350
+ shouldFilter={!hasQuery}
351
+ >
352
+ <div className="flex items-center border-b border-gray-200 px-4">
353
+ <Search className="mr-3 h-5 w-5 shrink-0 text-gray-400" />
354
+ <Command.Input
355
+ value={search}
356
+ onValueChange={setSearch}
357
+ placeholder="Search, jump to, or type /ai to use the AI co-author…"
358
+ className="w-full bg-transparent py-4 text-base placeholder:text-gray-400 focus:outline-none"
359
+ />
360
+ {loading && (
361
+ <div className="mr-2 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
362
+ )}
363
+ <kbd className="hidden shrink-0 rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-600 sm:inline-block">
364
+ ESC
365
+ </kbd>
366
+ </div>
367
+
368
+ <Command.List className="max-h-[400px] overflow-y-auto p-2">
369
+ <Command.Empty className="py-6 text-center text-sm text-gray-500">
370
+ {loading ? 'Searching...' : 'No results found.'}
371
+ </Command.Empty>
372
+
373
+ {isAiMode && (
374
+ <Command.Group heading="AI co-author" className="px-2 py-2">
375
+ <div className="mb-2 flex items-center gap-1 text-xs font-medium text-gray-500">
376
+ <Sparkles className="h-3 w-3" aria-hidden /> AI CO-AUTHOR
377
+ </div>
378
+ {aiActions.map((action) => {
379
+ const Icon = AI_ACTION_ICONS[action]
380
+ const isExactMatch = aiCommand?.action === action
381
+ const previewable = isExactMatch && aiPayload.length > 0
382
+ return (
383
+ <Command.Item
384
+ key={`ai-${action}`}
385
+ value={`ai ${action}`}
386
+ onSelect={() => runSlashAction(action, aiPayload)}
387
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
388
+ >
389
+ <Icon className="h-4 w-4 text-blue-600" />
390
+ <div className="flex min-w-0 flex-col">
391
+ <span className="font-medium">
392
+ {coauthorActionLabel(action)}
393
+ {previewable && (
394
+ <span className="ml-1 font-normal text-gray-500">
395
+ “{truncatePreview(aiPayload)}
396
+ </span>
397
+ )}
398
+ </span>
399
+ <span className="text-xs text-gray-400">
400
+ {coauthorActionDescription(action)}
401
+ </span>
402
+ </div>
403
+ <kbd className="ml-auto rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-500">
404
+ /ai {action}
405
+ </kbd>
406
+ </Command.Item>
407
+ )
408
+ })}
409
+ </Command.Group>
410
+ )}
411
+
412
+ {!isAiMode && !hasQuery && recentItems.length > 0 && (
413
+ <Command.Group heading="Recent" className="px-2 py-2">
414
+ <div className="mb-2 text-xs font-medium text-gray-500">RECENT</div>
415
+ {recentItems.map((item) => (
337
416
  <Command.Item
338
- key={cmd.id}
339
- value={cmd.label}
340
- onSelect={() => handleSelect(cmd.action)}
417
+ key={`recent-${item.id}`}
418
+ value={`recent ${item.label}`}
419
+ onSelect={() => handleSelect(item.path, item)}
341
420
  className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
342
421
  >
343
- <Icon className="h-4 w-4 text-gray-500" />
344
- <span>{cmd.label}</span>
422
+ <Clock className="h-4 w-4 text-gray-400" />
423
+ <span>{item.label}</span>
424
+ <span className="ml-auto text-xs text-gray-400 capitalize">{item.type}</span>
345
425
  </Command.Item>
346
- )
347
- })}
348
- </Command.Group>
349
- )}
350
-
351
- <Command.Group heading="Actions" className="mt-2 px-2 py-2">
352
- <div className="mb-2 text-xs font-medium text-gray-500">ACTIONS</div>
353
- {staticActions.map((cmd) => {
354
- const Icon = cmd.icon
355
- return (
356
- <Command.Item
357
- key={cmd.id}
358
- value={cmd.label}
359
- onSelect={() =>
360
- handleSelect(cmd.action, {
361
- id: cmd.id,
362
- label: cmd.label,
363
- path: cmd.action,
364
- type: 'action',
365
- })
366
- }
367
- className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
368
- >
369
- <Icon className="h-4 w-4 text-gray-500" />
370
- <span>{cmd.label}</span>
371
- </Command.Item>
372
- )
373
- })}
374
- </Command.Group>
375
- </Command.List>
376
-
377
- <div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-500">
378
- <div className="flex items-center gap-4">
379
- <span className="flex items-center gap-1">
380
- <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
381
-
382
- </kbd>
383
- <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
384
-
385
- </kbd>
386
- to navigate
387
- </span>
388
- <span className="flex items-center gap-1">
389
- <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
390
-
391
- </kbd>
392
- to select
393
- </span>
426
+ ))}
427
+ </Command.Group>
428
+ )}
429
+
430
+ {!isAiMode && hasQuery && documents.length > 0 && (
431
+ <Command.Group heading="Documents" className="px-2 py-2">
432
+ <div className="mb-2 text-xs font-medium text-gray-500">DOCUMENTS</div>
433
+ {documents.map((doc) => (
434
+ <Command.Item
435
+ key={`doc-${doc.id}`}
436
+ value={doc.title ?? doc.slug ?? doc.id}
437
+ onSelect={() =>
438
+ handleSelect(`/collections/${doc.collection}/${doc.id}`, {
439
+ id: `doc-${doc.id}`,
440
+ label: doc.title ?? doc.slug ?? doc.id,
441
+ path: `/collections/${doc.collection}/${doc.id}`,
442
+ type: 'document',
443
+ })
444
+ }
445
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
446
+ >
447
+ <FileText className="h-4 w-4 text-gray-500" />
448
+ <div className="flex min-w-0 flex-col">
449
+ <span className="truncate">{doc.title ?? doc.slug ?? doc.id}</span>
450
+ <span className="text-xs text-gray-400">
451
+ {doc.collection} &middot; {doc.status}
452
+ </span>
453
+ </div>
454
+ </Command.Item>
455
+ ))}
456
+ </Command.Group>
457
+ )}
458
+
459
+ {!isAiMode && hasQuery && media.length > 0 && (
460
+ <Command.Group heading="Media" className="mt-1 px-2 py-2">
461
+ <div className="mb-2 text-xs font-medium text-gray-500">MEDIA</div>
462
+ {media.map((m) => (
463
+ <Command.Item
464
+ key={`media-${m.id}`}
465
+ value={m.filename}
466
+ onSelect={() =>
467
+ handleSelect(`/media?selected=${m.id}`, {
468
+ id: `media-${m.id}`,
469
+ label: m.filename,
470
+ path: `/media?selected=${m.id}`,
471
+ type: 'media',
472
+ })
473
+ }
474
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
475
+ >
476
+ <Image className="h-4 w-4 text-gray-500" />
477
+ <div className="flex min-w-0 flex-col">
478
+ <span className="truncate">{m.filename}</span>
479
+ <span className="text-xs text-gray-400">{m.mimeType}</span>
480
+ </div>
481
+ </Command.Item>
482
+ ))}
483
+ </Command.Group>
484
+ )}
485
+
486
+ {!isAiMode && hasQuery && users.length > 0 && (
487
+ <Command.Group heading="Users" className="mt-1 px-2 py-2">
488
+ <div className="mb-2 text-xs font-medium text-gray-500">USERS</div>
489
+ {users.map((u) => (
490
+ <Command.Item
491
+ key={`user-${u.id}`}
492
+ value={`${u.name} ${u.email}`}
493
+ onSelect={() =>
494
+ handleSelect(`/users?selected=${u.id}`, {
495
+ id: `user-${u.id}`,
496
+ label: u.name,
497
+ path: `/users?selected=${u.id}`,
498
+ type: 'user',
499
+ })
500
+ }
501
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
502
+ >
503
+ <Users className="h-4 w-4 text-gray-500" />
504
+ <div className="flex min-w-0 flex-col">
505
+ <span className="truncate">{u.name}</span>
506
+ <span className="text-xs text-gray-400">
507
+ {u.email} &middot; {u.role}
508
+ </span>
509
+ </div>
510
+ </Command.Item>
511
+ ))}
512
+ </Command.Group>
513
+ )}
514
+
515
+ {!isAiMode && (!hasQuery || !hasResults) && (
516
+ <Command.Group heading="Navigation" className="px-2 py-2">
517
+ <div className="mb-2 text-xs font-medium text-gray-500">NAVIGATION</div>
518
+ {navigationCommands.map((cmd) => {
519
+ const Icon = cmd.icon
520
+ return (
521
+ <Command.Item
522
+ key={cmd.id}
523
+ value={cmd.label}
524
+ onSelect={() => handleSelect(cmd.action)}
525
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
526
+ >
527
+ <Icon className="h-4 w-4 text-gray-500" />
528
+ <span>{cmd.label}</span>
529
+ </Command.Item>
530
+ )
531
+ })}
532
+ </Command.Group>
533
+ )}
534
+
535
+ {!isAiMode && (
536
+ <Command.Group heading="Actions" className="mt-2 px-2 py-2">
537
+ <div className="mb-2 text-xs font-medium text-gray-500">ACTIONS</div>
538
+ {staticActions.map((cmd) => {
539
+ const Icon = cmd.icon
540
+ return (
541
+ <Command.Item
542
+ key={cmd.id}
543
+ value={cmd.label}
544
+ onSelect={() =>
545
+ handleSelect(cmd.action, {
546
+ id: cmd.id,
547
+ label: cmd.label,
548
+ path: cmd.action,
549
+ type: 'action',
550
+ })
551
+ }
552
+ className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-100 data-[selected=true]:bg-gray-100"
553
+ >
554
+ <Icon className="h-4 w-4 text-gray-500" />
555
+ <span>{cmd.label}</span>
556
+ </Command.Item>
557
+ )
558
+ })}
559
+ </Command.Group>
560
+ )}
561
+ </Command.List>
562
+
563
+ <div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-500">
564
+ <div className="flex items-center gap-4">
565
+ <span className="flex items-center gap-1">
566
+ <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
567
+
568
+ </kbd>
569
+ <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
570
+
571
+ </kbd>
572
+ to navigate
573
+ </span>
574
+ <span className="flex items-center gap-1">
575
+ <kbd className="rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px]">
576
+
577
+ </kbd>
578
+ to select
579
+ </span>
580
+ </div>
394
581
  </div>
395
- </div>
396
- </Command>
582
+ </Command>
583
+ </div>
397
584
  </div>
398
- </div>
585
+ {popover}
586
+ </>
399
587
  )
400
588
  }