@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
@@ -16,6 +16,7 @@ import {
16
16
  } from '../signals'
17
17
  import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
18
18
  import { ColorField, ComboBoxField, ImageField } from './fields'
19
+ import { CloseButton, ModalBackdrop } from './modal-shell'
19
20
 
20
21
  const OG_TYPE_OPTIONS = [
21
22
  { value: 'website', label: 'Website', description: 'Default type for most pages' },
@@ -256,341 +257,322 @@ export function SeoEditor() {
256
257
  )
257
258
 
258
259
  return (
259
- <div
260
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
261
- onClick={handleClose}
262
- data-cms-ui
263
- >
264
- <div
265
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-2xl w-full max-h-[85vh] flex flex-col border border-white/10"
266
- onClick={(e) => e.stopPropagation()}
267
- data-cms-ui
268
- >
269
- {/* Header */}
270
- <div class="flex items-center justify-between p-5 border-b border-white/10">
271
- <div class="flex items-center gap-3">
272
- <h2 class="text-lg font-semibold text-white">SEO Settings</h2>
273
- {dirtyCount > 0 && (
274
- <span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
275
- {dirtyCount} change{dirtyCount !== 1 ? 's' : ''}
276
- </span>
277
- )}
278
- </div>
279
- <button
280
- type="button"
281
- onClick={handleClose}
282
- class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
283
- data-cms-ui
284
- >
285
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
286
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
287
- </svg>
288
- </button>
260
+ <ModalBackdrop onClose={handleClose} maxWidth="max-w-2xl" extraClass="max-h-[85vh] flex flex-col">
261
+ {/* Header */}
262
+ <div class="flex items-center justify-between p-5 border-b border-white/10">
263
+ <div class="flex items-center gap-3">
264
+ <h2 class="text-lg font-semibold text-white">SEO Settings</h2>
265
+ {dirtyCount > 0 && (
266
+ <span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
267
+ {dirtyCount} change{dirtyCount !== 1 ? 's' : ''}
268
+ </span>
269
+ )}
289
270
  </div>
271
+ <CloseButton onClick={handleClose} />
272
+ </div>
290
273
 
291
- {/* Content */}
292
- <div class="flex-1 overflow-auto p-5">
293
- {!hasSeoData
294
- ? (
295
- <div class="flex flex-col items-center justify-center h-48 text-white/50">
296
- <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
297
- <path
298
- stroke-linecap="round"
299
- stroke-linejoin="round"
300
- stroke-width="1.5"
301
- d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
274
+ {/* Content */}
275
+ <div class="flex-1 overflow-auto p-5">
276
+ {!hasSeoData
277
+ ? (
278
+ <div class="flex flex-col items-center justify-center h-48 text-white/50">
279
+ <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
280
+ <path
281
+ stroke-linecap="round"
282
+ stroke-linejoin="round"
283
+ stroke-width="1.5"
284
+ d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
285
+ />
286
+ </svg>
287
+ <p class="text-sm">No SEO data found for this page.</p>
288
+ <p class="text-xs text-white/40 mt-1">Add title, meta tags, or Open Graph tags to your page.</p>
289
+ </div>
290
+ )
291
+ : (
292
+ <div class="space-y-8">
293
+ {/* Basic SEO */}
294
+ <SeoSection title="Basic SEO">
295
+ {seoData.title && (
296
+ <SeoField
297
+ label="Page Title"
298
+ id={seoData.title.id}
299
+ value={seoData.title.content}
300
+ placeholder="Enter page title..."
301
+ onChange={handleFieldChange}
302
+ />
303
+ )}
304
+ {seoData.description && (
305
+ <SeoField
306
+ label="Meta Description"
307
+ id={seoData.description.id}
308
+ value={seoData.description.content}
309
+ placeholder="Enter meta description..."
310
+ multiline
311
+ onChange={handleFieldChange}
302
312
  />
303
- </svg>
304
- <p class="text-sm">No SEO data found for this page.</p>
305
- <p class="text-xs text-white/40 mt-1">Add title, meta tags, or Open Graph tags to your page.</p>
306
- </div>
307
- )
308
- : (
309
- <div class="space-y-8">
310
- {/* Basic SEO */}
311
- <SeoSection title="Basic SEO">
312
- {seoData.title && (
313
+ )}
314
+ {seoData.keywords && (
315
+ <SeoField
316
+ label="Keywords"
317
+ id={seoData.keywords.id}
318
+ value={seoData.keywords.content}
319
+ placeholder="keyword1, keyword2, keyword3..."
320
+ onChange={handleFieldChange}
321
+ />
322
+ )}
323
+ {seoData.canonical && (
324
+ <SeoField
325
+ label="Canonical URL"
326
+ id={seoData.canonical.id}
327
+ value={seoData.canonical.href}
328
+ placeholder="https://example.com/page"
329
+ onChange={handleFieldChange}
330
+ />
331
+ )}
332
+ {seoData.robots && (
333
+ <ComboBoxField
334
+ label="Robots"
335
+ value={robots.current}
336
+ placeholder="index, follow"
337
+ options={ROBOTS_OPTIONS}
338
+ onChange={(v) => {
339
+ if (robots.id) handleFieldChange(robots.id, v, robots.original)
340
+ }}
341
+ isDirty={robots.dirty}
342
+ />
343
+ )}
344
+ {seoData.themeColor && (
345
+ <ColorField
346
+ label="Theme Color"
347
+ value={themeColor.current}
348
+ placeholder="#000000"
349
+ onChange={(v) => {
350
+ if (themeColor.id) handleFieldChange(themeColor.id, v, themeColor.original)
351
+ }}
352
+ isDirty={themeColor.dirty}
353
+ />
354
+ )}
355
+ </SeoSection>
356
+
357
+ {/* Favicons */}
358
+ {seoData.favicons && seoData.favicons.length > 0 && (
359
+ <SeoSection title="Favicons">
360
+ {seoData.favicons.map((favicon, index) => {
361
+ const faviconId = favicon.id
362
+ const originalValue = favicon.href
363
+ const pendingChange = faviconId ? getPendingSeoChange(faviconId) : undefined
364
+ const currentValue = pendingChange?.newValue ?? originalValue ?? ''
365
+ const isDirty = pendingChange?.isDirty ?? false
366
+
367
+ const label = favicon.sizes
368
+ ? `Favicon (${favicon.sizes})`
369
+ : favicon.type
370
+ ? `Favicon (${favicon.type.replace('image/', '')})`
371
+ : `Favicon${seoData.favicons!.length > 1 ? ` ${index + 1}` : ''}`
372
+
373
+ return (
374
+ <div key={faviconId || index} class="space-y-1.5">
375
+ <ImageField
376
+ label={label}
377
+ value={currentValue}
378
+ placeholder="/favicon.svg"
379
+ onChange={(v) => {
380
+ if (faviconId) handleFieldChange(faviconId, v, originalValue)
381
+ }}
382
+ onBrowse={() => {
383
+ openMediaLibraryWithCallback((url: string) => {
384
+ if (faviconId) handleFieldChange(faviconId, url, originalValue)
385
+ })
386
+ }}
387
+ isDirty={isDirty}
388
+ />
389
+ </div>
390
+ )
391
+ })}
392
+ </SeoSection>
393
+ )}
394
+
395
+ {/* Open Graph */}
396
+ {seoData.openGraph && Object.keys(seoData.openGraph).length > 0 && (
397
+ <SeoSection title="Open Graph">
398
+ {seoData.openGraph.title && (
313
399
  <SeoField
314
- label="Page Title"
315
- id={seoData.title.id}
316
- value={seoData.title.content}
317
- placeholder="Enter page title..."
400
+ label="OG Title"
401
+ id={seoData.openGraph.title.id}
402
+ value={seoData.openGraph.title.content}
403
+ placeholder="Enter Open Graph title..."
318
404
  onChange={handleFieldChange}
319
405
  />
320
406
  )}
321
- {seoData.description && (
407
+ {seoData.openGraph.description && (
322
408
  <SeoField
323
- label="Meta Description"
324
- id={seoData.description.id}
325
- value={seoData.description.content}
326
- placeholder="Enter meta description..."
409
+ label="OG Description"
410
+ id={seoData.openGraph.description.id}
411
+ value={seoData.openGraph.description.content}
412
+ placeholder="Enter Open Graph description..."
327
413
  multiline
328
414
  onChange={handleFieldChange}
329
415
  />
330
416
  )}
331
- {seoData.keywords && (
417
+ {seoData.openGraph.image && (
418
+ <ImageField
419
+ label="OG Image"
420
+ value={ogImage.current}
421
+ placeholder="/images/og-image.jpg"
422
+ onChange={(v) => {
423
+ if (ogImage.id) handleFieldChange(ogImage.id, v, ogImage.original)
424
+ }}
425
+ onBrowse={() => {
426
+ openMediaLibraryWithCallback((url: string) => {
427
+ if (ogImage.id) handleFieldChange(ogImage.id, url, ogImage.original)
428
+ })
429
+ }}
430
+ isDirty={ogImage.dirty}
431
+ />
432
+ )}
433
+ {seoData.openGraph.url && (
332
434
  <SeoField
333
- label="Keywords"
334
- id={seoData.keywords.id}
335
- value={seoData.keywords.content}
336
- placeholder="keyword1, keyword2, keyword3..."
435
+ label="OG URL"
436
+ id={seoData.openGraph.url.id}
437
+ value={seoData.openGraph.url.content}
438
+ placeholder="https://example.com/page"
337
439
  onChange={handleFieldChange}
338
440
  />
339
441
  )}
340
- {seoData.canonical && (
442
+ {seoData.openGraph.type && (
443
+ <ComboBoxField
444
+ label="OG Type"
445
+ value={ogType.current}
446
+ placeholder="website"
447
+ options={OG_TYPE_OPTIONS}
448
+ onChange={(v) => {
449
+ if (ogType.id) handleFieldChange(ogType.id, v, ogType.original)
450
+ }}
451
+ isDirty={ogType.dirty}
452
+ />
453
+ )}
454
+ {seoData.openGraph.siteName && (
341
455
  <SeoField
342
- label="Canonical URL"
343
- id={seoData.canonical.id}
344
- value={seoData.canonical.href}
345
- placeholder="https://example.com/page"
456
+ label="OG Site Name"
457
+ id={seoData.openGraph.siteName.id}
458
+ value={seoData.openGraph.siteName.content}
459
+ placeholder="My Website"
346
460
  onChange={handleFieldChange}
347
461
  />
348
462
  )}
349
- {seoData.robots && (
463
+ </SeoSection>
464
+ )}
465
+
466
+ {/* Twitter Card */}
467
+ {seoData.twitterCard && Object.keys(seoData.twitterCard).length > 0 && (
468
+ <SeoSection title="Twitter Card">
469
+ {seoData.twitterCard.card && (
350
470
  <ComboBoxField
351
- label="Robots"
352
- value={robots.current}
353
- placeholder="index, follow"
354
- options={ROBOTS_OPTIONS}
471
+ label="Card Type"
472
+ value={twitterCard.current}
473
+ placeholder="summary_large_image"
474
+ options={TWITTER_CARD_OPTIONS}
355
475
  onChange={(v) => {
356
- if (robots.id) handleFieldChange(robots.id, v, robots.original)
476
+ if (twitterCard.id) handleFieldChange(twitterCard.id, v, twitterCard.original)
357
477
  }}
358
- isDirty={robots.dirty}
478
+ isDirty={twitterCard.dirty}
359
479
  />
360
480
  )}
361
- {seoData.themeColor && (
362
- <ColorField
363
- label="Theme Color"
364
- value={themeColor.current}
365
- placeholder="#000000"
481
+ {seoData.twitterCard.title && (
482
+ <SeoField
483
+ label="Twitter Title"
484
+ id={seoData.twitterCard.title.id}
485
+ value={seoData.twitterCard.title.content}
486
+ placeholder="Enter Twitter title..."
487
+ onChange={handleFieldChange}
488
+ />
489
+ )}
490
+ {seoData.twitterCard.description && (
491
+ <SeoField
492
+ label="Twitter Description"
493
+ id={seoData.twitterCard.description.id}
494
+ value={seoData.twitterCard.description.content}
495
+ placeholder="Enter Twitter description..."
496
+ multiline
497
+ onChange={handleFieldChange}
498
+ />
499
+ )}
500
+ {seoData.twitterCard.image && (
501
+ <ImageField
502
+ label="Twitter Image"
503
+ value={twitterImage.current}
504
+ placeholder="/images/twitter-image.jpg"
366
505
  onChange={(v) => {
367
- if (themeColor.id) handleFieldChange(themeColor.id, v, themeColor.original)
506
+ if (twitterImage.id) handleFieldChange(twitterImage.id, v, twitterImage.original)
507
+ }}
508
+ onBrowse={() => {
509
+ openMediaLibraryWithCallback((url: string) => {
510
+ if (twitterImage.id) handleFieldChange(twitterImage.id, url, twitterImage.original)
511
+ })
368
512
  }}
369
- isDirty={themeColor.dirty}
513
+ isDirty={twitterImage.dirty}
514
+ />
515
+ )}
516
+ {seoData.twitterCard.site && (
517
+ <SeoField
518
+ label="Twitter Site"
519
+ id={seoData.twitterCard.site.id}
520
+ value={seoData.twitterCard.site.content}
521
+ placeholder="@username"
522
+ onChange={handleFieldChange}
370
523
  />
371
524
  )}
372
525
  </SeoSection>
373
-
374
- {/* Favicons */}
375
- {seoData.favicons && seoData.favicons.length > 0 && (
376
- <SeoSection title="Favicons">
377
- {seoData.favicons.map((favicon, index) => {
378
- const faviconId = favicon.id
379
- const originalValue = favicon.href
380
- const pendingChange = faviconId ? getPendingSeoChange(faviconId) : undefined
381
- const currentValue = pendingChange?.newValue ?? originalValue ?? ''
382
- const isDirty = pendingChange?.isDirty ?? false
383
-
384
- const label = favicon.sizes
385
- ? `Favicon (${favicon.sizes})`
386
- : favicon.type
387
- ? `Favicon (${favicon.type.replace('image/', '')})`
388
- : `Favicon${seoData.favicons!.length > 1 ? ` ${index + 1}` : ''}`
389
-
390
- return (
391
- <div key={faviconId || index} class="space-y-1.5">
392
- <ImageField
393
- label={label}
394
- value={currentValue}
395
- placeholder="/favicon.svg"
396
- onChange={(v) => {
397
- if (faviconId) handleFieldChange(faviconId, v, originalValue)
398
- }}
399
- onBrowse={() => {
400
- openMediaLibraryWithCallback((url: string) => {
401
- if (faviconId) handleFieldChange(faviconId, url, originalValue)
402
- })
403
- }}
404
- isDirty={isDirty}
405
- />
526
+ )}
527
+
528
+ {/* JSON-LD Preview */}
529
+ {seoData.jsonLd && seoData.jsonLd.length > 0 && (
530
+ <SeoSection title="Structured Data (JSON-LD)">
531
+ <div class="space-y-3">
532
+ {seoData.jsonLd.map((entry, index) => (
533
+ <div key={index} class="p-3 bg-white/5 rounded-cms-md border border-white/10">
534
+ <div class="flex items-center justify-between mb-2">
535
+ <span class="text-sm font-medium text-white/80">@type: {entry.type}</span>
406
536
  </div>
407
- )
408
- })}
409
- </SeoSection>
410
- )}
411
-
412
- {/* Open Graph */}
413
- {seoData.openGraph && Object.keys(seoData.openGraph).length > 0 && (
414
- <SeoSection title="Open Graph">
415
- {seoData.openGraph.title && (
416
- <SeoField
417
- label="OG Title"
418
- id={seoData.openGraph.title.id}
419
- value={seoData.openGraph.title.content}
420
- placeholder="Enter Open Graph title..."
421
- onChange={handleFieldChange}
422
- />
423
- )}
424
- {seoData.openGraph.description && (
425
- <SeoField
426
- label="OG Description"
427
- id={seoData.openGraph.description.id}
428
- value={seoData.openGraph.description.content}
429
- placeholder="Enter Open Graph description..."
430
- multiline
431
- onChange={handleFieldChange}
432
- />
433
- )}
434
- {seoData.openGraph.image && (
435
- <ImageField
436
- label="OG Image"
437
- value={ogImage.current}
438
- placeholder="/images/og-image.jpg"
439
- onChange={(v) => {
440
- if (ogImage.id) handleFieldChange(ogImage.id, v, ogImage.original)
441
- }}
442
- onBrowse={() => {
443
- openMediaLibraryWithCallback((url: string) => {
444
- if (ogImage.id) handleFieldChange(ogImage.id, url, ogImage.original)
445
- })
446
- }}
447
- isDirty={ogImage.dirty}
448
- />
449
- )}
450
- {seoData.openGraph.url && (
451
- <SeoField
452
- label="OG URL"
453
- id={seoData.openGraph.url.id}
454
- value={seoData.openGraph.url.content}
455
- placeholder="https://example.com/page"
456
- onChange={handleFieldChange}
457
- />
458
- )}
459
- {seoData.openGraph.type && (
460
- <ComboBoxField
461
- label="OG Type"
462
- value={ogType.current}
463
- placeholder="website"
464
- options={OG_TYPE_OPTIONS}
465
- onChange={(v) => {
466
- if (ogType.id) handleFieldChange(ogType.id, v, ogType.original)
467
- }}
468
- isDirty={ogType.dirty}
469
- />
470
- )}
471
- {seoData.openGraph.siteName && (
472
- <SeoField
473
- label="OG Site Name"
474
- id={seoData.openGraph.siteName.id}
475
- value={seoData.openGraph.siteName.content}
476
- placeholder="My Website"
477
- onChange={handleFieldChange}
478
- />
479
- )}
480
- </SeoSection>
481
- )}
482
-
483
- {/* Twitter Card */}
484
- {seoData.twitterCard && Object.keys(seoData.twitterCard).length > 0 && (
485
- <SeoSection title="Twitter Card">
486
- {seoData.twitterCard.card && (
487
- <ComboBoxField
488
- label="Card Type"
489
- value={twitterCard.current}
490
- placeholder="summary_large_image"
491
- options={TWITTER_CARD_OPTIONS}
492
- onChange={(v) => {
493
- if (twitterCard.id) handleFieldChange(twitterCard.id, v, twitterCard.original)
494
- }}
495
- isDirty={twitterCard.dirty}
496
- />
497
- )}
498
- {seoData.twitterCard.title && (
499
- <SeoField
500
- label="Twitter Title"
501
- id={seoData.twitterCard.title.id}
502
- value={seoData.twitterCard.title.content}
503
- placeholder="Enter Twitter title..."
504
- onChange={handleFieldChange}
505
- />
506
- )}
507
- {seoData.twitterCard.description && (
508
- <SeoField
509
- label="Twitter Description"
510
- id={seoData.twitterCard.description.id}
511
- value={seoData.twitterCard.description.content}
512
- placeholder="Enter Twitter description..."
513
- multiline
514
- onChange={handleFieldChange}
515
- />
516
- )}
517
- {seoData.twitterCard.image && (
518
- <ImageField
519
- label="Twitter Image"
520
- value={twitterImage.current}
521
- placeholder="/images/twitter-image.jpg"
522
- onChange={(v) => {
523
- if (twitterImage.id) handleFieldChange(twitterImage.id, v, twitterImage.original)
524
- }}
525
- onBrowse={() => {
526
- openMediaLibraryWithCallback((url: string) => {
527
- if (twitterImage.id) handleFieldChange(twitterImage.id, url, twitterImage.original)
528
- })
529
- }}
530
- isDirty={twitterImage.dirty}
531
- />
532
- )}
533
- {seoData.twitterCard.site && (
534
- <SeoField
535
- label="Twitter Site"
536
- id={seoData.twitterCard.site.id}
537
- value={seoData.twitterCard.site.content}
538
- placeholder="@username"
539
- onChange={handleFieldChange}
540
- />
541
- )}
542
- </SeoSection>
543
- )}
544
-
545
- {/* JSON-LD Preview */}
546
- {seoData.jsonLd && seoData.jsonLd.length > 0 && (
547
- <SeoSection title="Structured Data (JSON-LD)">
548
- <div class="space-y-3">
549
- {seoData.jsonLd.map((entry, index) => (
550
- <div key={index} class="p-3 bg-white/5 rounded-cms-md border border-white/10">
551
- <div class="flex items-center justify-between mb-2">
552
- <span class="text-sm font-medium text-white/80">@type: {entry.type}</span>
553
- </div>
554
- <pre class="text-xs text-white/60 overflow-auto max-h-32 p-2 bg-black/30 rounded">
537
+ <pre class="text-xs text-white/60 overflow-auto max-h-32 p-2 bg-black/30 rounded">
555
538
  {JSON.stringify(entry.data, null, 2)}
556
- </pre>
557
- </div>
558
- ))}
559
- </div>
560
- </SeoSection>
561
- )}
562
- </div>
563
- )}
564
- </div>
565
-
566
- {/* Footer */}
567
- {hasSeoData && (
568
- <div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
569
- <button
570
- type="button"
571
- onClick={handleClose}
572
- class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
573
- data-cms-ui
574
- >
575
- Cancel
576
- </button>
577
- <button
578
- type="button"
579
- onClick={handleSaveAll}
580
- disabled={dirtyCount === 0 || isSaving}
581
- class={`px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2 ${
582
- dirtyCount > 0 && !isSaving
583
- ? 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover'
584
- : 'bg-white/10 text-white/40 cursor-not-allowed'
585
- }`}
586
- data-cms-ui
587
- >
588
- {isSaving && <span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />}
589
- {isSaving ? 'Saving...' : 'Save Changes'}
590
- </button>
591
- </div>
592
- )}
539
+ </pre>
540
+ </div>
541
+ ))}
542
+ </div>
543
+ </SeoSection>
544
+ )}
545
+ </div>
546
+ )}
593
547
  </div>
594
- </div>
548
+
549
+ {/* Footer */}
550
+ {hasSeoData && (
551
+ <div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
552
+ <button
553
+ type="button"
554
+ onClick={handleClose}
555
+ class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
556
+ data-cms-ui
557
+ >
558
+ Cancel
559
+ </button>
560
+ <button
561
+ type="button"
562
+ onClick={handleSaveAll}
563
+ disabled={dirtyCount === 0 || isSaving}
564
+ class={`px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2 ${
565
+ dirtyCount > 0 && !isSaving
566
+ ? 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover'
567
+ : 'bg-white/10 text-white/40 cursor-not-allowed'
568
+ }`}
569
+ data-cms-ui
570
+ >
571
+ {isSaving && <span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />}
572
+ {isSaving ? 'Saving...' : 'Save Changes'}
573
+ </button>
574
+ </div>
575
+ )}
576
+ </ModalBackdrop>
595
577
  )
596
578
  }
@@ -1,3 +1,4 @@
1
+ import { Z_INDEX } from '../../constants'
1
2
  import { Toast } from './toast'
2
3
  import type { ToastMessage } from './types'
3
4
 
@@ -8,7 +9,7 @@ export interface ToastContainerProps {
8
9
 
9
10
  export const ToastContainer = ({ toasts, onRemove }: ToastContainerProps) => {
10
11
  return (
11
- <div class="fixed left-1/2 -translate-x-1/2 bottom-28 z-2147483648 flex flex-col gap-2 items-center">
12
+ <div style={{ zIndex: Z_INDEX.TOAST }} class="fixed left-1/2 -translate-x-1/2 bottom-28 flex flex-col gap-2 items-center">
12
13
  {toasts.map(toast => <Toast key={toast.id} {...toast} onRemove={onRemove} />)}
13
14
  </div>
14
15
  )