@nuasite/cms 0.18.0 → 0.19.0

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 (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -1,5 +1,5 @@
1
1
  import { signal } from '@preact/signals'
2
- import { useMemo } from 'preact/hooks'
2
+ import { useMemo, useState } from 'preact/hooks'
3
3
  import { deleteMarkdownPage } from '../markdown-api'
4
4
  import {
5
5
  closeCollectionsBrowser,
@@ -12,10 +12,14 @@ import {
12
12
  selectedBrowserCollection,
13
13
  } from '../signals'
14
14
  import { savePendingEntryNavigation } from '../storage'
15
+ import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
16
+ import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
15
17
 
16
18
  const deletingEntry = signal<string | null>(null)
17
19
  const confirmDeleteSlug = signal<string | null>(null)
18
20
 
21
+ const EMPTY_ENTRIES: never[] = []
22
+
19
23
  export function CollectionsBrowser() {
20
24
  const visible = isCollectionsBrowserOpen.value
21
25
  const selected = selectedBrowserCollection.value
@@ -26,23 +30,27 @@ export function CollectionsBrowser() {
26
30
  return Object.values(collectionDefinitions).sort((a, b) => a.label.localeCompare(b.label))
27
31
  }, [collectionDefinitions])
28
32
 
33
+ const [search, setSearch] = useState('')
34
+ const selectedDef = selected ? collectionDefinitions[selected] : undefined
35
+ const entries = selectedDef?.entries ?? EMPTY_ENTRIES
36
+
37
+ const filteredEntries = useMemo(() => {
38
+ if (!search) return entries
39
+ const q = search.toLowerCase()
40
+ return entries.filter(e => (e.title || '').toLowerCase().includes(q) || e.slug.toLowerCase().includes(q))
41
+ }, [entries, search])
42
+
29
43
  if (!visible) return null
30
44
 
31
45
  const handleClose = () => {
32
46
  closeCollectionsBrowser()
33
47
  }
34
48
 
35
- const handleBackdropClick = (e: Event) => {
36
- handleClose()
37
- }
38
-
39
49
  // View 2: Entry list for selected collection
40
50
  if (selected) {
41
- const def = collectionDefinitions[selected]
51
+ const def = selectedDef
42
52
  if (!def) return null
43
53
 
44
- const entries = def.entries ?? []
45
-
46
54
  const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
47
55
  closeCollectionsBrowser()
48
56
  if (pathname) {
@@ -99,228 +107,183 @@ export function CollectionsBrowser() {
99
107
  }
100
108
 
101
109
  return (
102
- <div
103
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
104
- onClick={handleBackdropClick}
105
- data-cms-ui
106
- >
107
- <div
108
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-md w-full border border-white/10 flex flex-col max-h-[80vh]"
109
- onClick={(e) => e.stopPropagation()}
110
- data-cms-ui
111
- >
112
- {/* Header */}
113
- <div class="flex items-center justify-between p-5 border-b border-white/10 shrink-0">
114
- <div class="flex items-center gap-3">
115
- <button
116
- type="button"
117
- onClick={() => selectBrowserCollection(null)}
118
- class="text-white/50 hover:text-white p-1 hover:bg-white/10 rounded-full transition-colors"
110
+ <ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
111
+ <div class="flex items-center justify-between p-5 border-b border-white/10 shrink-0">
112
+ <div class="flex items-center gap-3">
113
+ <button
114
+ type="button"
115
+ onClick={() => {
116
+ setSearch('')
117
+ selectBrowserCollection(null)
118
+ }}
119
+ class="text-white/50 hover:text-white p-1 hover:bg-white/10 rounded-full transition-colors"
120
+ data-cms-ui
121
+ >
122
+ <BackArrowIcon />
123
+ </button>
124
+ <h2 class="text-lg font-semibold text-white">{def.label}</h2>
125
+ </div>
126
+ <div class="flex items-center gap-2">
127
+ <button
128
+ type="button"
129
+ onClick={handleAddNew}
130
+ class="px-3 py-1.5 text-sm font-medium text-black bg-cms-primary hover:bg-cms-primary/80 rounded-cms-pill transition-colors"
131
+ data-cms-ui
132
+ >
133
+ + Add New
134
+ </button>
135
+ <CloseButton onClick={handleClose} />
136
+ </div>
137
+ </div>
138
+
139
+ {entries.length > 0 && (
140
+ <div class="px-5 pt-4 pb-2 shrink-0">
141
+ <div class="relative">
142
+ <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
143
+ <circle cx="11" cy="11" r="8" />
144
+ <path stroke-linecap="round" stroke-width="2" d="m21 21-4.3-4.3" />
145
+ </svg>
146
+ <input
147
+ type="text"
148
+ placeholder="Search..."
149
+ value={search}
150
+ onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
151
+ class="w-full pl-9 pr-3 py-2 text-sm text-white bg-white/5 border border-white/10 rounded-cms-lg placeholder:text-white/30 focus:outline-none focus:border-white/20"
119
152
  data-cms-ui
120
- >
121
- <BackArrowIcon />
122
- </button>
123
- <h2 class="text-lg font-semibold text-white">{def.label}</h2>
153
+ />
124
154
  </div>
125
- <div class="flex items-center gap-2">
126
- <button
127
- type="button"
128
- onClick={handleAddNew}
129
- class="px-3 py-1.5 text-sm font-medium text-black bg-cms-primary hover:bg-cms-primary/80 rounded-cms-pill transition-colors"
130
- data-cms-ui
131
- >
132
- + Add New
133
- </button>
134
- <CloseButton onClick={handleClose} />
155
+ <div class="text-white/30 text-xs mt-2">
156
+ {search
157
+ ? `${filteredEntries.length} of ${entries.length}`
158
+ : `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`}
135
159
  </div>
136
160
  </div>
161
+ )}
137
162
 
138
- {/* Entry list */}
139
- <div class="p-5 space-y-1 overflow-y-auto flex-1 min-h-0">
140
- {entries.length === 0 && (
141
- <div class="text-white/50 text-sm text-center py-8">
142
- No entries yet. Click "Add New" to create one.
143
- </div>
144
- )}
145
- {entries.map((entry) => (
146
- <div key={entry.slug} class="relative" data-cms-ui>
147
- {confirmDeleteSlug.value === entry.slug
148
- ? (
149
- <div class="flex items-center gap-2 px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-cms-lg" data-cms-ui>
150
- <div class="flex-1 min-w-0 text-sm text-white/70">
151
- Delete "{entry.title || entry.slug}"?
152
- </div>
153
- <button
154
- type="button"
155
- onClick={() => handleConfirmDelete(entry.slug, entry.sourcePath)}
156
- disabled={deletingEntry.value === entry.slug}
157
- class="px-3 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-cms-pill transition-colors disabled:opacity-50"
158
- data-cms-ui
159
- >
160
- {deletingEntry.value === entry.slug ? 'Deleting...' : 'Delete'}
161
- </button>
162
- <button
163
- type="button"
164
- onClick={handleCancelDelete}
165
- class="px-3 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/20 rounded-cms-pill transition-colors"
166
- data-cms-ui
167
- >
168
- Cancel
169
- </button>
163
+ <div class="px-5 pb-5 space-y-1 overflow-y-auto flex-1 min-h-0">
164
+ {entries.length === 0 && (
165
+ <div class="text-white/50 text-sm text-center py-8">
166
+ No entries yet. Click "Add New" to create one.
167
+ </div>
168
+ )}
169
+ {search && filteredEntries.length === 0 && entries.length > 0 && (
170
+ <div class="text-white/50 text-sm text-center py-8">
171
+ No matches for "{search}"
172
+ </div>
173
+ )}
174
+ {filteredEntries.map((entry) => (
175
+ <div key={entry.slug} class="relative" data-cms-ui>
176
+ {confirmDeleteSlug.value === entry.slug
177
+ ? (
178
+ <div class="flex items-center gap-2 px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-cms-lg" data-cms-ui>
179
+ <div class="flex-1 min-w-0 text-sm text-white/70">
180
+ Delete "{entry.title || entry.slug}"?
170
181
  </div>
171
- )
172
- : (
173
182
  <button
174
183
  type="button"
175
- onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
176
- class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
184
+ onClick={() => handleConfirmDelete(entry.slug, entry.sourcePath)}
185
+ disabled={deletingEntry.value === entry.slug}
186
+ class="px-3 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-cms-pill transition-colors disabled:opacity-50"
187
+ data-cms-ui
188
+ >
189
+ {deletingEntry.value === entry.slug ? 'Deleting...' : 'Delete'}
190
+ </button>
191
+ <button
192
+ type="button"
193
+ onClick={handleCancelDelete}
194
+ class="px-3 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/20 rounded-cms-pill transition-colors"
177
195
  data-cms-ui
178
196
  >
179
- <div class="flex-1 min-w-0">
180
- <div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
181
- {entry.title || entry.slug}
182
- </div>
183
- {entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
197
+ Cancel
198
+ </button>
199
+ </div>
200
+ )
201
+ : (
202
+ <button
203
+ type="button"
204
+ onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
205
+ class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
206
+ data-cms-ui
207
+ >
208
+ <div class="flex-1 min-w-0">
209
+ <div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
210
+ {entry.title || entry.slug}
184
211
  </div>
185
- {entry.draft && (
186
- <span class="shrink-0 px-2 py-0.5 text-xs font-medium text-amber-400/80 bg-amber-400/10 rounded-full border border-amber-400/20">
187
- Draft
188
- </span>
189
- )}
190
- <button
191
- type="button"
192
- onClick={(e) => handleDeleteClick(e, entry.slug)}
193
- class="shrink-0 p-1 text-white/0 group-hover:text-white/30 hover:!text-red-400 rounded transition-colors"
194
- title="Delete entry"
195
- data-cms-ui
196
- >
197
- <TrashIcon />
198
- </button>
199
- <svg
200
- class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
201
- fill="none"
202
- stroke="currentColor"
203
- viewBox="0 0 24 24"
204
- >
205
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
206
- </svg>
212
+ {entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
213
+ </div>
214
+ {entry.draft && (
215
+ <span class="shrink-0 px-2 py-0.5 text-xs font-medium text-amber-400/80 bg-amber-400/10 rounded-full border border-amber-400/20">
216
+ Draft
217
+ </span>
218
+ )}
219
+ <button
220
+ type="button"
221
+ onClick={(e) => handleDeleteClick(e, entry.slug)}
222
+ class="shrink-0 p-1 text-white/0 group-hover:text-white/30 hover:!text-red-400 rounded transition-colors"
223
+ title="Delete entry"
224
+ data-cms-ui
225
+ >
226
+ <TrashIcon />
207
227
  </button>
208
- )}
209
- </div>
210
- ))}
211
- </div>
228
+ <svg
229
+ class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
230
+ fill="none"
231
+ stroke="currentColor"
232
+ viewBox="0 0 24 24"
233
+ >
234
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
235
+ </svg>
236
+ </button>
237
+ )}
238
+ </div>
239
+ ))}
212
240
  </div>
213
- </div>
241
+ </ModalBackdrop>
214
242
  )
215
243
  }
216
244
 
217
- // View 1: Collection list
245
+ // Empty state
218
246
  if (collections.length === 0) {
219
247
  return (
220
- <div
221
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
222
- onClick={handleBackdropClick}
223
- data-cms-ui
224
- >
225
- <div
226
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-md w-full border border-white/10"
227
- onClick={(e) => e.stopPropagation()}
228
- data-cms-ui
229
- >
230
- <div class="flex items-center justify-between p-5 border-b border-white/10">
231
- <h2 class="text-lg font-semibold text-white">Collections</h2>
232
- <CloseButton onClick={handleClose} />
233
- </div>
234
- <div class="p-8 text-center">
235
- <div class="text-white/60 mb-4">No content collections found.</div>
236
- <p class="text-white/40 text-sm">
237
- Add markdown files to <code class="bg-white/10 px-1.5 py-0.5 rounded">src/content/</code> subdirectories to enable collections.
238
- </p>
239
- </div>
248
+ <ModalBackdrop onClose={handleClose}>
249
+ <ModalHeader title="Collections" onClose={handleClose} />
250
+ <div class="p-8 text-center">
251
+ <div class="text-white/60 mb-4">No content collections found.</div>
252
+ <p class="text-white/40 text-sm">
253
+ Add markdown files to <code class="bg-white/10 px-1.5 py-0.5 rounded">src/content/</code> subdirectories to enable collections.
254
+ </p>
240
255
  </div>
241
- </div>
256
+ </ModalBackdrop>
242
257
  )
243
258
  }
244
259
 
260
+ // Collection list
245
261
  return (
246
- <div
247
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
248
- onClick={handleBackdropClick}
249
- data-cms-ui
250
- >
251
- <div
252
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-md w-full border border-white/10 flex flex-col max-h-[80vh]"
253
- onClick={(e) => e.stopPropagation()}
254
- data-cms-ui
255
- >
256
- <div class="flex items-center justify-between p-5 border-b border-white/10 shrink-0">
257
- <h2 class="text-lg font-semibold text-white">Collections</h2>
258
- <CloseButton onClick={handleClose} />
259
- </div>
260
- <div class="p-5 space-y-2 overflow-y-auto flex-1 min-h-0">
261
- {collections.map((col) => (
262
- <button
263
- key={col.name}
264
- type="button"
265
- onClick={() => selectBrowserCollection(col.name)}
266
- class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left"
267
- data-cms-ui
268
- >
269
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
270
- <CollectionIcon />
271
- </div>
272
- <div class="flex-1 min-w-0">
273
- <div class="text-white font-medium">{col.label}</div>
274
- <div class="text-white/50 text-sm">
275
- {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'}
276
- </div>
262
+ <ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
263
+ <ModalHeader title="Collections" onClose={handleClose} />
264
+ <div class="p-5 space-y-2 overflow-y-auto flex-1 min-h-0">
265
+ {collections.map((col) => (
266
+ <button
267
+ key={col.name}
268
+ type="button"
269
+ onClick={() => selectBrowserCollection(col.name)}
270
+ class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left"
271
+ data-cms-ui
272
+ >
273
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
274
+ <CollectionIcon />
275
+ </div>
276
+ <div class="flex-1 min-w-0">
277
+ <div class="text-white font-medium">{col.label}</div>
278
+ <div class="text-white/50 text-sm">
279
+ {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'}
277
280
  </div>
278
- <ChevronRightIcon />
279
- </button>
280
- ))}
281
- </div>
281
+ </div>
282
+ <ChevronRightIcon />
283
+ </button>
284
+ ))}
282
285
  </div>
283
- </div>
284
- )
285
- }
286
-
287
- // ============================================================================
288
- // Icons
289
- // ============================================================================
290
-
291
- function CloseButton({ onClick }: { onClick: () => void }) {
292
- return (
293
- <button
294
- type="button"
295
- onClick={onClick}
296
- class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
297
- data-cms-ui
298
- >
299
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
300
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
301
- </svg>
302
- </button>
303
- )
304
- }
305
-
306
- function CollectionIcon() {
307
- return (
308
- <svg class="w-5 h-5 text-cms-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
- <path
310
- stroke-linecap="round"
311
- stroke-linejoin="round"
312
- stroke-width="2"
313
- d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
314
- />
315
- </svg>
316
- )
317
- }
318
-
319
- function ChevronRightIcon() {
320
- return (
321
- <svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
322
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
323
- </svg>
286
+ </ModalBackdrop>
324
287
  )
325
288
  }
326
289
 
@@ -0,0 +1,49 @@
1
+ import type { ComponentDefinition } from '../types'
2
+
3
+ export function getDefaultProps(definition: ComponentDefinition): Record<string, any> {
4
+ const defaultProps: Record<string, any> = {}
5
+ for (const prop of definition.props) {
6
+ if (prop.defaultValue !== undefined) {
7
+ defaultProps[prop.name] = prop.defaultValue
8
+ } else if (prop.required) {
9
+ defaultProps[prop.name] = ''
10
+ }
11
+ }
12
+ return defaultProps
13
+ }
14
+
15
+ export function ComponentCard({ def, onClick }: { def: ComponentDefinition; onClick: () => void }) {
16
+ return (
17
+ <button
18
+ onClick={onClick}
19
+ class="p-4 bg-white/5 border border-white/10 rounded-cms-md cursor-pointer text-left transition-all hover:border-cms-primary/50 hover:bg-white/10 group"
20
+ >
21
+ {def.previewUrl && (
22
+ <div class="mb-3 rounded overflow-hidden bg-white h-30 relative">
23
+ <ComponentPreviewIframe previewUrl={def.previewUrl} previewWidth={def.previewWidth} />
24
+ </div>
25
+ )}
26
+ <div class="font-medium text-white">{def.name}</div>
27
+ {def.description && <div class="text-xs text-white/50 mt-1">{def.description}</div>}
28
+ <div class="text-[11px] text-white/40 mt-2 font-mono">
29
+ {def.props.length} props
30
+ {def.slots && def.slots.length > 0 && ` · ${def.slots.length} slots`}
31
+ </div>
32
+ </button>
33
+ )
34
+ }
35
+
36
+ function ComponentPreviewIframe({ previewUrl, previewWidth }: { previewUrl: string; previewWidth?: number }) {
37
+ const pw = previewWidth ?? 1280
38
+ const scale = 320 / pw
39
+ return (
40
+ <iframe
41
+ src={previewUrl}
42
+ class="border-none pointer-events-none"
43
+ style={{ width: `${pw}px`, height: `${Math.round(120 / scale)}px`, transform: `scale(${scale})`, transformOrigin: 'top left' }}
44
+ sandbox="allow-same-origin"
45
+ loading="lazy"
46
+ tabIndex={-1}
47
+ />
48
+ )
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { cn } from '../lib/cn'
2
2
  import { confirmDialogState } from '../signals'
3
+ import { ModalBackdrop } from './modal-shell'
3
4
 
4
5
  export function ConfirmDialog() {
5
6
  const state = confirmDialogState.value
@@ -14,56 +15,42 @@ export function ConfirmDialog() {
14
15
  state.onCancel?.()
15
16
  }
16
17
 
17
- const handleBackdropClick = () => {
18
- handleCancel()
19
- }
20
-
21
18
  return (
22
- <div
23
- class="fixed inset-0 z-[2147483647] flex items-center justify-center bg-black/60 backdrop-blur-sm"
24
- onClick={handleBackdropClick}
25
- data-cms-ui
26
- >
27
- <div
28
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-sm w-full border border-white/10 mx-4"
29
- onClick={(e) => e.stopPropagation()}
30
- data-cms-ui
31
- >
32
- {/* Header */}
33
- <div class="p-5 pb-3">
34
- <h2 class="text-lg font-semibold text-white">{state.title}</h2>
35
- </div>
19
+ <ModalBackdrop onClose={handleCancel} maxWidth="max-w-sm" extraClass="mx-4">
20
+ {/* Header */}
21
+ <div class="p-5 pb-3">
22
+ <h2 class="text-lg font-semibold text-white">{state.title}</h2>
23
+ </div>
36
24
 
37
- {/* Body */}
38
- <div class="px-5 pb-5">
39
- <p class="text-sm text-white/70 leading-relaxed">{state.message}</p>
40
- </div>
25
+ {/* Body */}
26
+ <div class="px-5 pb-5">
27
+ <p class="text-sm text-white/70 leading-relaxed">{state.message}</p>
28
+ </div>
41
29
 
42
- {/* Footer */}
43
- <div class="flex items-center justify-end gap-3 p-5 pt-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
44
- <button
45
- type="button"
46
- onClick={handleCancel}
47
- class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
48
- data-cms-ui
49
- >
50
- {state.cancelLabel}
51
- </button>
52
- <button
53
- type="button"
54
- onClick={handleConfirm}
55
- class={cn(
56
- 'px-5 py-2.5 rounded-cms-pill text-sm font-medium transition-colors cursor-pointer',
57
- state.variant === 'danger' && 'bg-cms-error text-white hover:bg-red-600',
58
- state.variant === 'warning' && 'bg-amber-500 text-white hover:bg-amber-600',
59
- state.variant === 'info' && 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover',
60
- )}
61
- data-cms-ui
62
- >
63
- {state.confirmLabel}
64
- </button>
65
- </div>
30
+ {/* Footer */}
31
+ <div class="flex items-center justify-end gap-3 p-5 pt-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
32
+ <button
33
+ type="button"
34
+ onClick={handleCancel}
35
+ class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
36
+ data-cms-ui
37
+ >
38
+ {state.cancelLabel}
39
+ </button>
40
+ <button
41
+ type="button"
42
+ onClick={handleConfirm}
43
+ class={cn(
44
+ 'px-5 py-2.5 rounded-cms-pill text-sm font-medium transition-colors cursor-pointer',
45
+ state.variant === 'danger' && 'bg-cms-error text-white hover:bg-red-600',
46
+ state.variant === 'warning' && 'bg-amber-500 text-white hover:bg-amber-600',
47
+ state.variant === 'info' && 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover',
48
+ )}
49
+ data-cms-ui
50
+ >
51
+ {state.confirmLabel}
52
+ </button>
66
53
  </div>
67
- </div>
54
+ </ModalBackdrop>
68
55
  )
69
56
  }