@actuate-media/cms-admin 0.65.0 → 0.67.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 (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  10. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  12. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  13. package/dist/__tests__/views/dashboard.test.js +7 -3
  14. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  18. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  19. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  22. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  23. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  24. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  27. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  28. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  29. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  30. package/dist/components/ui/InspectorPane.d.ts +51 -0
  31. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  32. package/dist/components/ui/InspectorPane.js +42 -0
  33. package/dist/components/ui/InspectorPane.js.map +1 -0
  34. package/dist/components/ui/RowActions.d.ts +19 -0
  35. package/dist/components/ui/RowActions.d.ts.map +1 -0
  36. package/dist/components/ui/RowActions.js +16 -0
  37. package/dist/components/ui/RowActions.js.map +1 -0
  38. package/dist/components/ui/index.d.ts +4 -0
  39. package/dist/components/ui/index.d.ts.map +1 -1
  40. package/dist/components/ui/index.js +2 -0
  41. package/dist/components/ui/index.js.map +1 -1
  42. package/dist/layout/Header.js +1 -1
  43. package/dist/layout/Header.js.map +1 -1
  44. package/dist/layout/Layout.js +8 -38
  45. package/dist/layout/Layout.js.map +1 -1
  46. package/dist/lib/page-editor-service.d.ts +11 -2
  47. package/dist/lib/page-editor-service.d.ts.map +1 -1
  48. package/dist/lib/page-editor-service.js +8 -2
  49. package/dist/lib/page-editor-service.js.map +1 -1
  50. package/dist/lib/pages-service.d.ts +9 -0
  51. package/dist/lib/pages-service.d.ts.map +1 -1
  52. package/dist/lib/pages-service.js +14 -0
  53. package/dist/lib/pages-service.js.map +1 -1
  54. package/dist/lib/pages-tree.d.ts +44 -0
  55. package/dist/lib/pages-tree.d.ts.map +1 -0
  56. package/dist/lib/pages-tree.js +106 -0
  57. package/dist/lib/pages-tree.js.map +1 -0
  58. package/dist/lib/posts-service.d.ts +11 -0
  59. package/dist/lib/posts-service.d.ts.map +1 -1
  60. package/dist/lib/posts-service.js +13 -0
  61. package/dist/lib/posts-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -0
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/Dashboard.d.ts.map +1 -1
  66. package/dist/views/Dashboard.js +40 -3
  67. package/dist/views/Dashboard.js.map +1 -1
  68. package/dist/views/MediaBrowser.d.ts.map +1 -1
  69. package/dist/views/MediaBrowser.js +55 -2
  70. package/dist/views/MediaBrowser.js.map +1 -1
  71. package/dist/views/MediaSeoAudit.d.ts +25 -0
  72. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  73. package/dist/views/MediaSeoAudit.js +97 -0
  74. package/dist/views/MediaSeoAudit.js.map +1 -0
  75. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  76. package/dist/views/Pages/PagesListView.js +145 -16
  77. package/dist/views/Pages/PagesListView.js.map +1 -1
  78. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  79. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  80. package/dist/views/Pages/PagesTreeTable.js +28 -0
  81. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  82. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  83. package/dist/views/Posts/PostsListView.js +66 -9
  84. package/dist/views/Posts/PostsListView.js.map +1 -1
  85. package/dist/views/ScriptTags.d.ts +5 -1
  86. package/dist/views/ScriptTags.d.ts.map +1 -1
  87. package/dist/views/ScriptTags.js +32 -7
  88. package/dist/views/ScriptTags.js.map +1 -1
  89. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  90. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  91. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  92. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  93. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  94. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  95. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  96. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  97. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  98. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  99. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  100. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  101. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  102. package/dist/views/seo/TechnicalTab.js +15 -0
  103. package/dist/views/seo/TechnicalTab.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/AdminRoot.tsx +7 -3
  106. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  107. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  108. package/src/__tests__/views/dashboard.test.tsx +7 -3
  109. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  110. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  111. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  112. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  113. package/src/components/ui/InspectorPane.tsx +129 -0
  114. package/src/components/ui/RowActions.tsx +50 -0
  115. package/src/components/ui/index.ts +12 -0
  116. package/src/layout/Header.tsx +2 -2
  117. package/src/layout/Layout.tsx +8 -80
  118. package/src/lib/page-editor-service.ts +16 -2
  119. package/src/lib/pages-service.ts +18 -0
  120. package/src/lib/pages-tree.ts +145 -0
  121. package/src/lib/posts-service.ts +26 -0
  122. package/src/lib/seo-service.ts +3 -0
  123. package/src/views/Dashboard.tsx +286 -214
  124. package/src/views/MediaBrowser.tsx +673 -512
  125. package/src/views/MediaSeoAudit.tsx +320 -0
  126. package/src/views/Pages/PagesListView.tsx +459 -185
  127. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  128. package/src/views/Posts/PostsListView.tsx +135 -10
  129. package/src/views/ScriptTags.tsx +204 -129
  130. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  131. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  132. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  133. package/src/views/seo/TechnicalTab.tsx +15 -0
  134. package/dist/views/ScriptTagEditor.d.ts +0 -6
  135. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  136. package/dist/views/ScriptTagEditor.js +0 -111
  137. package/dist/views/ScriptTagEditor.js.map +0 -1
  138. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -31,6 +31,8 @@ import { useApiData } from '../lib/useApiData.js'
31
31
  import { cmsApi } from '../lib/api.js'
32
32
  import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
33
33
  import { StatusBadge } from '../components/ui/StatusBadge.js'
34
+ import { StatPill } from '../components/ui/StatPill.js'
35
+ import { MediaSeoAudit, type MediaSeoAuditItem } from './MediaSeoAudit.js'
34
36
 
35
37
  interface MediaItem {
36
38
  id: number | string
@@ -44,6 +46,11 @@ interface MediaItem {
44
46
  format?: string
45
47
  altTag?: string
46
48
  title?: string
49
+ caption?: string
50
+ description?: string
51
+ focusKeyword?: string
52
+ /** Derived server-side by cms-core's `calculateMediaSeoScore()`. */
53
+ seoScore?: number
47
54
  usedOn?: { page: string; path: string }[]
48
55
  }
49
56
 
@@ -63,6 +70,14 @@ function matchesMediaType(item: MediaItem, filterType: string): boolean {
63
70
  return item.type === filterType
64
71
  }
65
72
 
73
+ function formatStorage(bytes: number): string {
74
+ if (bytes <= 0) return '0 B'
75
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
76
+ const i = Math.min(units.length - 1, Math.floor(Math.log2(bytes) / 10))
77
+ const value = bytes / 1024 ** i
78
+ return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
79
+ }
80
+
66
81
  function MediaPreview({ item }: { item: MediaItem }) {
67
82
  if (isImageMedia(item)) {
68
83
  return (
@@ -110,6 +125,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
110
125
  '/media?pageSize=1&folderId=none',
111
126
  )
112
127
 
128
+ const [activeTab, setActiveTab] = useState<'library' | 'audit'>('library')
113
129
  const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
114
130
  const [searchQuery, setSearchQuery] = useState('')
115
131
  const [filterType, setFilterType] = useState('all')
@@ -120,6 +136,9 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
120
136
  const [editAlt, setEditAlt] = useState('')
121
137
  const [editTitle, setEditTitle] = useState('')
122
138
  const [editFilename, setEditFilename] = useState('')
139
+ const [editCaption, setEditCaption] = useState('')
140
+ const [editDescription, setEditDescription] = useState('')
141
+ const [editFocusKeyword, setEditFocusKeyword] = useState('')
123
142
  const [saving, setSaving] = useState(false)
124
143
  const [aiGenerating, setAiGenerating] = useState<string | null>(null)
125
144
  const [uploading, setUploading] = useState(false)
@@ -150,11 +169,43 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
150
169
  return results
151
170
  }, [mediaItems, searchQuery, filterType, sortConfig])
152
171
 
172
+ // Header stat pills (design `.mc-stats`): counts over the current folder
173
+ // scope, before search/type filters, so the numbers describe the library.
174
+ const mediaStats = useMemo(() => {
175
+ let images = 0
176
+ let videos = 0
177
+ let documents = 0
178
+ let bytes = 0
179
+ for (const item of mediaItems) {
180
+ if (item.type?.startsWith('image/') || isImageMedia(item)) images++
181
+ else if (item.type?.startsWith('video/')) videos++
182
+ else documents++
183
+ bytes += item.sizeBytes || 0
184
+ }
185
+ return { total: mediaItems.length, images, videos, documents, bytes }
186
+ }, [mediaItems])
187
+
153
188
  const openDetail = (item: MediaItem) => {
154
189
  setActiveItem(item)
155
190
  setEditAlt(item.altTag ?? '')
156
191
  setEditTitle(item.title ?? '')
157
192
  setEditFilename(item.name)
193
+ setEditCaption(item.caption ?? '')
194
+ setEditDescription(item.description ?? '')
195
+ setEditFocusKeyword(item.focusKeyword ?? '')
196
+ }
197
+
198
+ // Count of items missing alt OR title — drives the SEO Audit tab badge.
199
+ const auditAttentionCount = useMemo(
200
+ () => mediaItems.filter((item) => !item.altTag?.trim() || !item.title?.trim()).length,
201
+ [mediaItems],
202
+ )
203
+
204
+ const handleAuditEdit = (auditItem: MediaSeoAuditItem) => {
205
+ const item = mediaItems.find((m) => m.id === auditItem.id)
206
+ if (!item) return
207
+ setActiveTab('library')
208
+ openDetail(item)
158
209
  }
159
210
 
160
211
  const closeDetail = () => {
@@ -183,6 +234,9 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
183
234
  alt: editAlt,
184
235
  title: editTitle,
185
236
  filename: editFilename,
237
+ caption: editCaption,
238
+ description: editDescription,
239
+ focusKeyword: editFocusKeyword,
186
240
  }),
187
241
  })
188
242
  setSaving(false)
@@ -413,7 +467,13 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
413
467
  </button>
414
468
  <div>
415
469
  <h1 className="text-foreground mb-1">Media Library</h1>
416
- <p className="text-muted-foreground text-sm">{filteredAndSorted.length} files</p>
470
+ <div className="flex flex-wrap items-center gap-1.5">
471
+ <StatPill value={mediaStats.total} label="Total Files" />
472
+ <StatPill value={mediaStats.images} label="Images" />
473
+ <StatPill value={mediaStats.videos} label="Videos" />
474
+ <StatPill value={mediaStats.documents} label="Documents" />
475
+ <StatPill value={formatStorage(mediaStats.bytes)} label="Storage used" />
476
+ </div>
417
477
  </div>
418
478
  </div>
419
479
  <div>
@@ -440,585 +500,686 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
440
500
  </div>
441
501
  </div>
442
502
 
443
- <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
444
- {sidebarOpen && (
445
- <div className="border-border bg-card flex w-56 shrink-0 flex-col overflow-hidden rounded-lg border">
446
- <FolderTree
447
- scope="media"
448
- selected={folderSel}
449
- onSelect={(sel) => {
450
- setFolderSel(sel)
451
- setSelectedMedia([])
452
- }}
453
- totalCount={allData.data?.total}
454
- uncategorizedCount={uncatData.data?.total}
455
- onDropItem={handleDropItem}
456
- />
457
- </div>
458
- )}
503
+ <div
504
+ role="tablist"
505
+ aria-label="Media views"
506
+ className="mb-4 flex border-b border-[var(--bdr)]"
507
+ >
508
+ <button
509
+ type="button"
510
+ role="tab"
511
+ aria-selected={activeTab === 'library'}
512
+ onClick={() => setActiveTab('library')}
513
+ className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
514
+ activeTab === 'library'
515
+ ? 'border-[var(--acc)] text-[var(--acc)]'
516
+ : 'border-transparent text-[var(--muted)] hover:text-[var(--txt)]'
517
+ }`}
518
+ >
519
+ <ImageIcon size={14} aria-hidden />
520
+ Library
521
+ </button>
522
+ <button
523
+ type="button"
524
+ role="tab"
525
+ aria-selected={activeTab === 'audit'}
526
+ onClick={() => setActiveTab('audit')}
527
+ className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
528
+ activeTab === 'audit'
529
+ ? 'border-[var(--acc)] text-[var(--acc)]'
530
+ : 'border-transparent text-[var(--muted)] hover:text-[var(--txt)]'
531
+ }`}
532
+ >
533
+ <Search size={14} aria-hidden />
534
+ SEO Audit
535
+ <span
536
+ className={`rounded-[4px] px-[5px] py-px text-[10px] font-semibold ${
537
+ auditAttentionCount === 0
538
+ ? 'bg-[var(--suc-l)] text-[var(--suc)]'
539
+ : 'bg-[var(--err-l)] text-[var(--err)]'
540
+ }`}
541
+ aria-label={`${auditAttentionCount} items need attention`}
542
+ >
543
+ {auditAttentionCount}
544
+ </span>
545
+ </button>
546
+ </div>
459
547
 
460
- <div className="flex min-w-0 flex-1 flex-col">
461
- <div className="border-border bg-card mb-4 rounded-lg border">
462
- <div className="flex items-center justify-between p-3">
463
- <div className="flex flex-1 items-center gap-3">
464
- <div className="relative max-w-md flex-1">
465
- <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
466
- <input
467
- type="text"
468
- placeholder="Search media..."
469
- value={searchQuery}
470
- onChange={(e) => setSearchQuery(e.target.value)}
471
- className="border-input focus:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
472
- />
473
- </div>
474
- <select
475
- value={filterType}
476
- onChange={(e) => setFilterType(e.target.value)}
477
- className="border-input focus:ring-ring rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
478
- >
479
- <option value="all">All Types</option>
480
- <option value="image">Images</option>
481
- <option value="video">Videos</option>
482
- <option value="document">Documents</option>
483
- </select>
484
- </div>
485
- <div className="bg-muted flex items-center gap-1 rounded-lg p-1">
486
- <button
487
- onClick={() => setViewMode('grid')}
488
- className={`rounded p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
489
- >
490
- <Grid3x3 className="h-4 w-4" />
491
- </button>
492
- <button
493
- onClick={() => setViewMode('list')}
494
- className={`rounded p-1.5 transition-colors ${viewMode === 'list' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
495
- >
496
- <List className="h-4 w-4" />
497
- </button>
498
- </div>
548
+ {activeTab === 'audit' ? (
549
+ <MediaSeoAudit items={mediaItems} onEdit={handleAuditEdit} />
550
+ ) : (
551
+ <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
552
+ {sidebarOpen && (
553
+ <div className="border-border bg-card flex w-56 shrink-0 flex-col overflow-hidden rounded-lg border">
554
+ <FolderTree
555
+ scope="media"
556
+ selected={folderSel}
557
+ onSelect={(sel) => {
558
+ setFolderSel(sel)
559
+ setSelectedMedia([])
560
+ }}
561
+ totalCount={allData.data?.total}
562
+ uncategorizedCount={uncatData.data?.total}
563
+ onDropItem={handleDropItem}
564
+ />
499
565
  </div>
500
- </div>
566
+ )}
501
567
 
502
- {selectedMedia.length > 0 && (
503
- <div className="border-primary/30 bg-primary/10 mb-4 rounded-lg border p-3">
504
- <div className="flex items-center justify-between">
505
- <span className="text-primary text-sm">
506
- {selectedMedia.length} file{selectedMedia.length !== 1 ? 's' : ''} selected
507
- </span>
508
- <div className="flex items-center gap-2">
568
+ <div className="flex min-w-0 flex-1 flex-col">
569
+ <div className="border-border bg-card mb-4 rounded-lg border">
570
+ <div className="flex items-center justify-between p-3">
571
+ <div className="flex flex-1 items-center gap-3">
572
+ <div className="relative max-w-md flex-1">
573
+ <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
574
+ <input
575
+ type="text"
576
+ placeholder="Search media..."
577
+ value={searchQuery}
578
+ onChange={(e) => setSearchQuery(e.target.value)}
579
+ className="border-input focus:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
580
+ />
581
+ </div>
582
+ <select
583
+ value={filterType}
584
+ onChange={(e) => setFilterType(e.target.value)}
585
+ className="border-input focus:ring-ring rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
586
+ >
587
+ <option value="all">All Types</option>
588
+ <option value="image">Images</option>
589
+ <option value="video">Videos</option>
590
+ <option value="document">Documents</option>
591
+ </select>
592
+ </div>
593
+ <div className="bg-muted flex items-center gap-1 rounded-lg p-1">
509
594
  <button
510
- onClick={async () => {
511
- for (const id of selectedMedia) await deleteMedia(id)
512
- setSelectedMedia([])
513
- }}
514
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-lg px-3 py-1.5 text-sm transition-colors"
595
+ onClick={() => setViewMode('grid')}
596
+ className={`rounded p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
515
597
  >
516
- Delete Selected
598
+ <Grid3x3 className="h-4 w-4" />
517
599
  </button>
518
600
  <button
519
- onClick={() => setSelectedMedia([])}
520
- className="border-input bg-card hover:bg-muted rounded-lg border px-3 py-1.5 text-sm transition-colors"
601
+ onClick={() => setViewMode('list')}
602
+ className={`rounded p-1.5 transition-colors ${viewMode === 'list' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
521
603
  >
522
- Cancel
604
+ <List className="h-4 w-4" />
523
605
  </button>
524
606
  </div>
525
607
  </div>
526
608
  </div>
527
- )}
528
609
 
529
- {filteredAndSorted.length === 0 && !loading ? (
530
- <div className="flex flex-col items-center justify-center py-16 text-center">
531
- <div className="bg-muted mb-4 flex h-12 w-12 items-center justify-center rounded-full">
532
- <ImageIcon className="text-muted-foreground h-6 w-6" />
610
+ {selectedMedia.length > 0 && (
611
+ <div className="border-primary/30 bg-primary/10 mb-4 rounded-lg border p-3">
612
+ <div className="flex items-center justify-between">
613
+ <span className="text-primary text-sm">
614
+ {selectedMedia.length} file{selectedMedia.length !== 1 ? 's' : ''} selected
615
+ </span>
616
+ <div className="flex items-center gap-2">
617
+ <button
618
+ onClick={async () => {
619
+ for (const id of selectedMedia) await deleteMedia(id)
620
+ setSelectedMedia([])
621
+ }}
622
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-lg px-3 py-1.5 text-sm transition-colors"
623
+ >
624
+ Delete Selected
625
+ </button>
626
+ <button
627
+ onClick={() => setSelectedMedia([])}
628
+ className="border-input bg-card hover:bg-muted rounded-lg border px-3 py-1.5 text-sm transition-colors"
629
+ >
630
+ Cancel
631
+ </button>
632
+ </div>
633
+ </div>
533
634
  </div>
534
- <h3 className="text-foreground mb-1 text-sm font-medium">
535
- {folderSel.type === 'smart' && folderSel.smart === 'uncategorized'
536
- ? 'No uncategorized media'
537
- : folderSel.type === 'folder'
538
- ? 'No media in this folder'
539
- : 'No media yet'}
540
- </h3>
541
- <p className="text-muted-foreground text-sm">
542
- Upload your first file to get started.
543
- </p>
544
- </div>
545
- ) : (
546
- <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
547
- <div
548
- className={`border-border bg-card overflow-hidden rounded-lg border transition-all duration-200 ${panelOpen ? 'min-w-0 flex-1' : 'w-full'}`}
549
- >
550
- {viewMode === 'grid' ? (
551
- <div
552
- className={`grid h-full auto-rows-min content-start gap-3 overflow-y-auto p-2 sm:gap-4 sm:p-3 ${
553
- panelOpen
554
- ? 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
555
- : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
556
- }`}
557
- >
558
- {filteredAndSorted.map((item) => {
559
- const isActive = activeItem?.id === item.id
560
- const hasIssues = !item.altTag || !item.title || item.usedOn?.length === 0
561
- const typeLabel = (
562
- item.name.includes('.')
563
- ? item.name.split('.').pop()!
564
- : item.format || item.type || ''
565
- )
566
- .replace(/^.*\//, '')
567
- .toUpperCase()
568
- return (
569
- <div
570
- key={item.id}
571
- className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 transition-all ${
572
- isActive
573
- ? 'border-primary ring-primary/30 ring-2'
574
- : selectedMedia.includes(item.id)
575
- ? 'border-primary/60 ring-primary/20 ring-1'
576
- : 'border-border hover:border-input'
577
- }`}
578
- onClick={() => openDetail(item)}
579
- draggable
580
- onDragStart={(e) => handleDragStart(e, item.id)}
581
- >
582
- <div className="relative aspect-square w-full overflow-hidden">
583
- <div className="bg-muted flex h-full w-full items-center justify-center">
584
- <MediaPreview item={item} />
585
- </div>
586
- {hasIssues && (
587
- <div
588
- className="bg-warning absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full"
589
- title="Needs attention"
590
- >
591
- <AlertTriangle className="text-warning-foreground h-3 w-3" />
635
+ )}
636
+
637
+ {filteredAndSorted.length === 0 && !loading ? (
638
+ <div className="flex flex-col items-center justify-center py-16 text-center">
639
+ <div className="bg-muted mb-4 flex h-12 w-12 items-center justify-center rounded-full">
640
+ <ImageIcon className="text-muted-foreground h-6 w-6" />
641
+ </div>
642
+ <h3 className="text-foreground mb-1 text-sm font-medium">
643
+ {folderSel.type === 'smart' && folderSel.smart === 'uncategorized'
644
+ ? 'No uncategorized media'
645
+ : folderSel.type === 'folder'
646
+ ? 'No media in this folder'
647
+ : 'No media yet'}
648
+ </h3>
649
+ <p className="text-muted-foreground text-sm">
650
+ Upload your first file to get started.
651
+ </p>
652
+ </div>
653
+ ) : (
654
+ <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
655
+ <div
656
+ className={`border-border bg-card overflow-hidden rounded-lg border transition-all duration-200 ${panelOpen ? 'min-w-0 flex-1' : 'w-full'}`}
657
+ >
658
+ {viewMode === 'grid' ? (
659
+ <div
660
+ className={`grid h-full auto-rows-min content-start gap-3 overflow-y-auto p-2 sm:gap-4 sm:p-3 ${
661
+ panelOpen
662
+ ? 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
663
+ : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
664
+ }`}
665
+ >
666
+ {filteredAndSorted.map((item) => {
667
+ const isActive = activeItem?.id === item.id
668
+ const hasIssues = !item.altTag || !item.title || item.usedOn?.length === 0
669
+ const typeLabel = (
670
+ item.name.includes('.')
671
+ ? item.name.split('.').pop()!
672
+ : item.format || item.type || ''
673
+ )
674
+ .replace(/^.*\//, '')
675
+ .toUpperCase()
676
+ return (
677
+ <div
678
+ key={item.id}
679
+ className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 transition-all ${
680
+ isActive
681
+ ? 'border-primary ring-primary/30 ring-2'
682
+ : selectedMedia.includes(item.id)
683
+ ? 'border-primary/60 ring-primary/20 ring-1'
684
+ : 'border-border hover:border-input'
685
+ }`}
686
+ onClick={() => openDetail(item)}
687
+ draggable
688
+ onDragStart={(e) => handleDragStart(e, item.id)}
689
+ >
690
+ <div className="relative aspect-square w-full overflow-hidden">
691
+ <div className="bg-muted flex h-full w-full items-center justify-center">
692
+ <MediaPreview item={item} />
592
693
  </div>
593
- )}
594
- <div
595
- className="absolute top-1.5 right-1.5 opacity-0 transition-opacity group-hover:opacity-100"
596
- onClick={(e) => handleCheckbox(e, item.id)}
597
- >
694
+ {hasIssues && (
695
+ <div
696
+ className="bg-warning absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full"
697
+ title="Needs attention"
698
+ >
699
+ <AlertTriangle className="text-warning-foreground h-3 w-3" />
700
+ </div>
701
+ )}
598
702
  <div
599
- className={`flex h-5 w-5 items-center justify-center rounded border-2 transition-colors ${
600
- selectedMedia.includes(item.id)
601
- ? 'border-primary bg-primary'
602
- : 'border-input bg-card/80'
603
- }`}
703
+ className="absolute top-1.5 right-1.5 opacity-0 transition-opacity group-hover:opacity-100"
704
+ onClick={(e) => handleCheckbox(e, item.id)}
604
705
  >
605
- {selectedMedia.includes(item.id) && (
606
- <svg
607
- className="text-primary-foreground h-3 w-3"
608
- fill="none"
609
- viewBox="0 0 12 12"
610
- >
611
- <path
612
- d="M10 3L4.5 8.5L2 6"
613
- stroke="currentColor"
614
- strokeWidth="2"
615
- strokeLinecap="round"
616
- strokeLinejoin="round"
617
- />
618
- </svg>
619
- )}
706
+ <div
707
+ className={`flex h-5 w-5 items-center justify-center rounded border-2 transition-colors ${
708
+ selectedMedia.includes(item.id)
709
+ ? 'border-primary bg-primary'
710
+ : 'border-input bg-card/80'
711
+ }`}
712
+ >
713
+ {selectedMedia.includes(item.id) && (
714
+ <svg
715
+ className="text-primary-foreground h-3 w-3"
716
+ fill="none"
717
+ viewBox="0 0 12 12"
718
+ >
719
+ <path
720
+ d="M10 3L4.5 8.5L2 6"
721
+ stroke="currentColor"
722
+ strokeWidth="2"
723
+ strokeLinecap="round"
724
+ strokeLinejoin="round"
725
+ />
726
+ </svg>
727
+ )}
728
+ </div>
620
729
  </div>
621
730
  </div>
622
- </div>
623
- <div className="border-border bg-card flex items-center gap-2 border-t px-2 py-1.5">
624
- <span
625
- className={`mt-0.5 h-2 w-2 shrink-0 self-start rounded-full ${
626
- hasIssues ? 'bg-warning' : 'bg-success'
627
- }`}
628
- title={hasIssues ? 'Needs attention' : 'SEO complete'}
629
- />
630
- <div className="min-w-0 flex-1">
631
- <p className="text-foreground truncate text-xs font-medium">
632
- {item.name}
633
- </p>
634
- <p className="text-muted-foreground truncate text-xs">
635
- {[typeLabel, item.size].filter(Boolean).join(' · ')}
636
- </p>
731
+ <div className="border-border bg-card flex items-center gap-2 border-t px-2 py-1.5">
732
+ <span
733
+ className={`mt-0.5 h-2 w-2 shrink-0 self-start rounded-full ${
734
+ hasIssues ? 'bg-warning' : 'bg-success'
735
+ }`}
736
+ title={hasIssues ? 'Needs attention' : 'SEO complete'}
737
+ />
738
+ <div className="min-w-0 flex-1">
739
+ <p className="text-foreground truncate text-xs font-medium">
740
+ {item.name}
741
+ </p>
742
+ <p className="text-muted-foreground truncate text-xs">
743
+ {[typeLabel, item.size].filter(Boolean).join(' · ')}
744
+ </p>
745
+ </div>
637
746
  </div>
638
747
  </div>
639
- </div>
640
- )
641
- })}
642
- </div>
643
- ) : (
644
- <div className="h-full overflow-y-auto">
645
- <table className="w-full">
646
- <thead className="border-border bg-muted sticky top-0 border-b">
647
- <tr>
648
- <th className="w-8 px-3 py-2 text-left">
649
- <input
650
- type="checkbox"
651
- checked={
652
- selectedMedia.length === filteredAndSorted.length &&
653
- filteredAndSorted.length > 0
654
- }
655
- onChange={handleSelectAll}
656
- className="border-input rounded"
657
- />
658
- </th>
659
- <th className="w-6 px-1 py-2"></th>
660
- <th className="px-3 py-2 text-left">
661
- <SortHeader label="Name" sortKey="name" />
662
- </th>
663
- <th className="px-3 py-2 text-left">
664
- <SortHeader label="Type" sortKey="type" />
665
- </th>
666
- <th className="px-3 py-2 text-left">
667
- <SortHeader label="Size" sortKey="size" />
668
- </th>
669
- <th className="px-3 py-2 text-left">
670
- <SortHeader label="Uploaded" sortKey="date" />
671
- </th>
672
- <th className="text-foreground px-3 py-2 text-left text-xs font-medium">
673
- Status
674
- </th>
675
- </tr>
676
- </thead>
677
- <tbody className="divide-border divide-y">
678
- {filteredAndSorted.map((item) => {
679
- const isActive = activeItem?.id === item.id
680
- const hasIssues = !item.altTag || !item.title || item.usedOn?.length === 0
681
- return (
682
- <tr
683
- key={item.id}
684
- className={`cursor-pointer transition-colors ${isActive ? 'bg-primary/10' : 'hover:bg-muted'}`}
685
- onClick={() => openDetail(item)}
686
- draggable
687
- onDragStart={(e) => handleDragStart(e, item.id)}
688
- >
689
- <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
690
- <input
691
- type="checkbox"
692
- checked={selectedMedia.includes(item.id)}
693
- onChange={() =>
694
- handleCheckbox(
695
- { stopPropagation: () => {} } as React.MouseEvent,
696
- item.id,
697
- )
698
- }
699
- className="border-input rounded"
700
- />
701
- </td>
702
- <td className="cursor-grab px-1 py-2">
703
- <GripVertical className="text-muted-foreground h-4 w-4" />
704
- </td>
705
- <td className="px-3 py-2">
706
- <div className="flex items-center gap-3">
707
- <div className="bg-muted flex h-10 w-10 items-center justify-center overflow-hidden rounded">
708
- <MediaPreview item={item} />
748
+ )
749
+ })}
750
+ </div>
751
+ ) : (
752
+ <div className="h-full overflow-y-auto">
753
+ <table className="w-full">
754
+ <thead className="border-border bg-muted sticky top-0 border-b">
755
+ <tr>
756
+ <th className="w-8 px-3 py-2 text-left">
757
+ <input
758
+ type="checkbox"
759
+ checked={
760
+ selectedMedia.length === filteredAndSorted.length &&
761
+ filteredAndSorted.length > 0
762
+ }
763
+ onChange={handleSelectAll}
764
+ className="border-input rounded"
765
+ />
766
+ </th>
767
+ <th className="w-6 px-1 py-2"></th>
768
+ <th className="px-3 py-2 text-left">
769
+ <SortHeader label="Name" sortKey="name" />
770
+ </th>
771
+ <th className="px-3 py-2 text-left">
772
+ <SortHeader label="Type" sortKey="type" />
773
+ </th>
774
+ <th className="px-3 py-2 text-left">
775
+ <SortHeader label="Size" sortKey="size" />
776
+ </th>
777
+ <th className="px-3 py-2 text-left">
778
+ <SortHeader label="Uploaded" sortKey="date" />
779
+ </th>
780
+ <th className="text-foreground px-3 py-2 text-left text-xs font-medium">
781
+ Status
782
+ </th>
783
+ </tr>
784
+ </thead>
785
+ <tbody className="divide-border divide-y">
786
+ {filteredAndSorted.map((item) => {
787
+ const isActive = activeItem?.id === item.id
788
+ const hasIssues =
789
+ !item.altTag || !item.title || item.usedOn?.length === 0
790
+ return (
791
+ <tr
792
+ key={item.id}
793
+ className={`cursor-pointer transition-colors ${isActive ? 'bg-primary/10' : 'hover:bg-muted'}`}
794
+ onClick={() => openDetail(item)}
795
+ draggable
796
+ onDragStart={(e) => handleDragStart(e, item.id)}
797
+ >
798
+ <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
799
+ <input
800
+ type="checkbox"
801
+ checked={selectedMedia.includes(item.id)}
802
+ onChange={() =>
803
+ handleCheckbox(
804
+ { stopPropagation: () => {} } as React.MouseEvent,
805
+ item.id,
806
+ )
807
+ }
808
+ className="border-input rounded"
809
+ />
810
+ </td>
811
+ <td className="cursor-grab px-1 py-2">
812
+ <GripVertical className="text-muted-foreground h-4 w-4" />
813
+ </td>
814
+ <td className="px-3 py-2">
815
+ <div className="flex items-center gap-3">
816
+ <div className="bg-muted flex h-10 w-10 items-center justify-center overflow-hidden rounded">
817
+ <MediaPreview item={item} />
818
+ </div>
819
+ <span className="text-foreground text-sm font-medium">
820
+ {item.name}
821
+ </span>
709
822
  </div>
710
- <span className="text-foreground text-sm font-medium">
711
- {item.name}
712
- </span>
713
- </div>
714
- </td>
715
- <td className="text-muted-foreground px-3 py-2 text-sm">
716
- {item.format ?? item.type}
717
- </td>
718
- <td className="text-muted-foreground px-3 py-2 text-sm">
719
- {item.size}
720
- </td>
721
- <td className="text-muted-foreground px-3 py-2 text-sm">
722
- {item.date}
723
- </td>
724
- <td className="px-3 py-2">
725
- {hasIssues ? (
726
- <StatusBadge tone="warning">Needs attention</StatusBadge>
727
- ) : (
728
- <StatusBadge tone="success">Complete</StatusBadge>
729
- )}
730
- </td>
731
- </tr>
732
- )
733
- })}
734
- </tbody>
735
- </table>
736
- </div>
737
- )}
738
- </div>
823
+ </td>
824
+ <td className="text-muted-foreground px-3 py-2 text-sm">
825
+ {item.format ?? item.type}
826
+ </td>
827
+ <td className="text-muted-foreground px-3 py-2 text-sm">
828
+ {item.size}
829
+ </td>
830
+ <td className="text-muted-foreground px-3 py-2 text-sm">
831
+ {item.date}
832
+ </td>
833
+ <td className="px-3 py-2">
834
+ {hasIssues ? (
835
+ <StatusBadge tone="warning">Needs attention</StatusBadge>
836
+ ) : (
837
+ <StatusBadge tone="success">Complete</StatusBadge>
838
+ )}
839
+ </td>
840
+ </tr>
841
+ )
842
+ })}
843
+ </tbody>
844
+ </table>
845
+ </div>
846
+ )}
847
+ </div>
739
848
 
740
- {panelOpen && activeItem && (
741
- <div className="border-border bg-card flex w-80 shrink-0 flex-col overflow-y-auto rounded-lg border lg:w-96">
742
- <div className="border-border bg-card sticky top-0 z-10 flex items-center justify-between border-b p-4">
743
- <h3 className="text-foreground truncate text-sm font-medium">
744
- {activeItem.name}
745
- </h3>
746
- <button
747
- onClick={closeDetail}
748
- className="hover:bg-muted rounded p-1 transition-colors"
749
- aria-label="Close panel"
750
- >
751
- <X className="text-muted-foreground h-4 w-4" />
752
- </button>
753
- </div>
849
+ {panelOpen && activeItem && (
850
+ <div className="border-border bg-card flex w-80 shrink-0 flex-col overflow-y-auto rounded-lg border lg:w-96">
851
+ <div className="border-border bg-card sticky top-0 z-10 flex items-center justify-between border-b p-4">
852
+ <h3 className="text-foreground truncate text-sm font-medium">
853
+ {activeItem.name}
854
+ </h3>
855
+ <button
856
+ onClick={closeDetail}
857
+ className="hover:bg-muted rounded p-1 transition-colors"
858
+ aria-label="Close panel"
859
+ >
860
+ <X className="text-muted-foreground h-4 w-4" />
861
+ </button>
862
+ </div>
754
863
 
755
- <div className="flex-1 overflow-y-auto">
756
- <div className="border-border border-b p-4">
757
- <div className="bg-muted flex aspect-video items-center justify-center overflow-hidden rounded-lg">
758
- {isImageMedia(activeItem) ? (
759
- <MediaPreview item={activeItem} />
760
- ) : (
761
- <ImageIcon className="text-muted-foreground h-12 w-12" />
762
- )}
864
+ <div className="flex-1 overflow-y-auto">
865
+ <div className="border-border border-b p-4">
866
+ <div className="bg-muted flex aspect-video items-center justify-center overflow-hidden rounded-lg">
867
+ {isImageMedia(activeItem) ? (
868
+ <MediaPreview item={activeItem} />
869
+ ) : (
870
+ <ImageIcon className="text-muted-foreground h-12 w-12" />
871
+ )}
872
+ </div>
763
873
  </div>
764
- </div>
765
874
 
766
- {issues.length > 0 && (
767
- <div className="border-warning/30 bg-warning/10 mx-4 mt-4 rounded-lg border p-3">
768
- <div className="flex items-start gap-2">
769
- <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
875
+ {issues.length > 0 && (
876
+ <div className="border-warning/30 bg-warning/10 mx-4 mt-4 rounded-lg border p-3">
877
+ <div className="flex items-start gap-2">
878
+ <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
879
+ <div>
880
+ <p className="text-warning mb-1 text-xs font-medium">
881
+ {issues.length} issue{issues.length !== 1 ? 's' : ''} found
882
+ </p>
883
+ <ul className="space-y-0.5">
884
+ {issues.map((issue, i) => (
885
+ <li key={i} className="text-warning text-xs">
886
+ • {issue}
887
+ </li>
888
+ ))}
889
+ </ul>
890
+ </div>
891
+ </div>
892
+ <button
893
+ type="button"
894
+ onClick={async () => {
895
+ if (!activeItem.altTag) await handleAiGenerate('alt')
896
+ if (!activeItem.title) await handleAiGenerate('title')
897
+ if (activeItem.sizeBytes > 2000000) await handleAiGenerate('optimize')
898
+ }}
899
+ className="bg-warning text-warning-foreground hover:bg-warning/90 mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors"
900
+ >
901
+ <Sparkles className="h-3.5 w-3.5" />
902
+ AI Fix All Issues
903
+ </button>
904
+ </div>
905
+ )}
906
+
907
+ <div className="border-border space-y-3 border-b p-4">
908
+ <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
909
+ File Information
910
+ </h4>
911
+ <div className="grid grid-cols-2 gap-3">
770
912
  <div>
771
- <p className="text-warning mb-1 text-xs font-medium">
772
- {issues.length} issue{issues.length !== 1 ? 's' : ''} found
773
- </p>
774
- <ul className="space-y-0.5">
775
- {issues.map((issue, i) => (
776
- <li key={i} className="text-warning text-xs">
777
- {issue}
778
- </li>
779
- ))}
780
- </ul>
913
+ <div className="text-muted-foreground mb-0.5 text-xs">Format</div>
914
+ <div className="text-foreground text-sm">
915
+ {activeItem.format ?? 'Unknown'}
916
+ </div>
917
+ </div>
918
+ <div>
919
+ <div className="text-muted-foreground mb-0.5 text-xs">File Size</div>
920
+ <div className="text-foreground flex items-center gap-1 text-sm">
921
+ {activeItem.size}
922
+ {activeItem.sizeBytes > 2000000 && (
923
+ <span className="text-warning text-xs">(large)</span>
924
+ )}
925
+ </div>
926
+ </div>
927
+ <div>
928
+ <div className="text-muted-foreground mb-0.5 text-xs">Dimensions</div>
929
+ <div className="text-foreground text-sm">
930
+ {activeItem.dimensions ?? '—'}
931
+ </div>
932
+ </div>
933
+ <div>
934
+ <div className="text-muted-foreground mb-0.5 text-xs">Uploaded</div>
935
+ <div className="text-foreground text-sm">{activeItem.date}</div>
781
936
  </div>
782
937
  </div>
783
- <button
784
- type="button"
785
- onClick={async () => {
786
- if (!activeItem.altTag) await handleAiGenerate('alt')
787
- if (!activeItem.title) await handleAiGenerate('title')
788
- if (activeItem.sizeBytes > 2000000) await handleAiGenerate('optimize')
789
- }}
790
- className="bg-warning text-warning-foreground hover:bg-warning/90 mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors"
791
- >
792
- <Sparkles className="h-3.5 w-3.5" />
793
- AI Fix All Issues
794
- </button>
795
- </div>
796
- )}
797
938
 
798
- <div className="border-border space-y-3 border-b p-4">
799
- <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
800
- File Information
801
- </h4>
802
- <div className="grid grid-cols-2 gap-3">
803
939
  <div>
804
- <div className="text-muted-foreground mb-0.5 text-xs">Format</div>
805
- <div className="text-foreground text-sm">
806
- {activeItem.format ?? 'Unknown'}
940
+ <div className="text-muted-foreground mb-1 text-xs">URL</div>
941
+ <div className="flex items-center gap-1">
942
+ <code className="border-border bg-muted text-foreground flex-1 truncate rounded border px-2 py-1.5 text-xs">
943
+ {activeItem.url}
944
+ </code>
945
+ <button
946
+ onClick={handleCopyUrl}
947
+ className="hover:bg-muted shrink-0 rounded p-1.5 transition-colors"
948
+ title="Copy URL"
949
+ >
950
+ <Copy className="text-muted-foreground h-3.5 w-3.5" />
951
+ </button>
807
952
  </div>
808
953
  </div>
954
+ </div>
955
+
956
+ <div className="border-border space-y-4 border-b p-4">
957
+ <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
958
+ SEO & Accessibility
959
+ </h4>
960
+
809
961
  <div>
810
- <div className="text-muted-foreground mb-0.5 text-xs">File Size</div>
811
- <div className="text-foreground flex items-center gap-1 text-sm">
812
- {activeItem.size}
813
- {activeItem.sizeBytes > 2000000 && (
814
- <span className="text-warning text-xs">(large)</span>
815
- )}
962
+ <div className="mb-1 flex items-center justify-between">
963
+ <label className="text-foreground text-sm font-medium">Alt Tag</label>
964
+ <button
965
+ type="button"
966
+ onClick={() => handleAiGenerate('alt')}
967
+ disabled={aiGenerating === 'alt'}
968
+ className="text-primary hover:text-primary/80 flex items-center gap-1 text-xs transition-colors disabled:opacity-50"
969
+ >
970
+ <Bot className="h-3.5 w-3.5" />
971
+ {aiGenerating === 'alt' ? 'Generating...' : 'AI Generate'}
972
+ </button>
816
973
  </div>
974
+ <textarea
975
+ value={editAlt}
976
+ onChange={(e) => setEditAlt(e.target.value)}
977
+ placeholder="Describe this image for accessibility..."
978
+ rows={2}
979
+ className="border-input focus:ring-ring w-full resize-none rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
980
+ />
981
+ {!editAlt && (
982
+ <p className="text-destructive mt-1 text-xs">
983
+ Required for accessibility and SEO
984
+ </p>
985
+ )}
817
986
  </div>
987
+
818
988
  <div>
819
- <div className="text-muted-foreground mb-0.5 text-xs">Dimensions</div>
820
- <div className="text-foreground text-sm">
821
- {activeItem.dimensions ?? '—'}
989
+ <div className="mb-1 flex items-center justify-between">
990
+ <label className="text-foreground text-sm font-medium">Title</label>
991
+ <button
992
+ type="button"
993
+ onClick={() => handleAiGenerate('title')}
994
+ disabled={aiGenerating === 'title'}
995
+ className="text-primary hover:text-primary/80 flex items-center gap-1 text-xs transition-colors disabled:opacity-50"
996
+ >
997
+ <Bot className="h-3.5 w-3.5" />
998
+ {aiGenerating === 'title' ? 'Generating...' : 'AI Generate'}
999
+ </button>
822
1000
  </div>
1001
+ <input
1002
+ type="text"
1003
+ value={editTitle}
1004
+ onChange={(e) => setEditTitle(e.target.value)}
1005
+ placeholder="Image title..."
1006
+ className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1007
+ />
823
1008
  </div>
1009
+
824
1010
  <div>
825
- <div className="text-muted-foreground mb-0.5 text-xs">Uploaded</div>
826
- <div className="text-foreground text-sm">{activeItem.date}</div>
1011
+ <label
1012
+ htmlFor="media-detail-caption"
1013
+ className="text-foreground mb-1 block text-sm font-medium"
1014
+ >
1015
+ Caption
1016
+ </label>
1017
+ <input
1018
+ id="media-detail-caption"
1019
+ type="text"
1020
+ value={editCaption}
1021
+ onChange={(e) => setEditCaption(e.target.value)}
1022
+ placeholder="Short caption shown alongside the asset..."
1023
+ className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1024
+ />
827
1025
  </div>
828
- </div>
829
1026
 
830
- <div>
831
- <div className="text-muted-foreground mb-1 text-xs">URL</div>
832
- <div className="flex items-center gap-1">
833
- <code className="border-border bg-muted text-foreground flex-1 truncate rounded border px-2 py-1.5 text-xs">
834
- {activeItem.url}
835
- </code>
836
- <button
837
- onClick={handleCopyUrl}
838
- className="hover:bg-muted shrink-0 rounded p-1.5 transition-colors"
839
- title="Copy URL"
1027
+ <div>
1028
+ <label
1029
+ htmlFor="media-detail-description"
1030
+ className="text-foreground mb-1 block text-sm font-medium"
840
1031
  >
841
- <Copy className="text-muted-foreground h-3.5 w-3.5" />
842
- </button>
1032
+ Description
1033
+ </label>
1034
+ <textarea
1035
+ id="media-detail-description"
1036
+ value={editDescription}
1037
+ onChange={(e) => setEditDescription(e.target.value)}
1038
+ placeholder="Longer description for search and reuse..."
1039
+ rows={2}
1040
+ className="border-input focus:ring-ring w-full resize-none rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1041
+ />
843
1042
  </div>
844
- </div>
845
- </div>
846
-
847
- <div className="border-border space-y-4 border-b p-4">
848
- <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
849
- SEO & Accessibility
850
- </h4>
851
1043
 
852
- <div>
853
- <div className="mb-1 flex items-center justify-between">
854
- <label className="text-foreground text-sm font-medium">Alt Tag</label>
855
- <button
856
- type="button"
857
- onClick={() => handleAiGenerate('alt')}
858
- disabled={aiGenerating === 'alt'}
859
- className="text-primary hover:text-primary/80 flex items-center gap-1 text-xs transition-colors disabled:opacity-50"
1044
+ <div>
1045
+ <label
1046
+ htmlFor="media-detail-focus-keyword"
1047
+ className="text-foreground mb-1 block text-sm font-medium"
860
1048
  >
861
- <Bot className="h-3.5 w-3.5" />
862
- {aiGenerating === 'alt' ? 'Generating...' : 'AI Generate'}
863
- </button>
1049
+ Focus Keyword
1050
+ </label>
1051
+ <input
1052
+ id="media-detail-focus-keyword"
1053
+ type="text"
1054
+ value={editFocusKeyword}
1055
+ onChange={(e) => setEditFocusKeyword(e.target.value)}
1056
+ placeholder="Primary keyword this asset targets..."
1057
+ className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1058
+ />
864
1059
  </div>
865
- <textarea
866
- value={editAlt}
867
- onChange={(e) => setEditAlt(e.target.value)}
868
- placeholder="Describe this image for accessibility..."
869
- rows={2}
870
- className="border-input focus:ring-ring w-full resize-none rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
871
- />
872
- {!editAlt && (
873
- <p className="text-destructive mt-1 text-xs">
874
- Required for accessibility and SEO
875
- </p>
876
- )}
877
- </div>
878
1060
 
879
- <div>
880
- <div className="mb-1 flex items-center justify-between">
881
- <label className="text-foreground text-sm font-medium">Title</label>
882
- <button
883
- type="button"
884
- onClick={() => handleAiGenerate('title')}
885
- disabled={aiGenerating === 'title'}
886
- className="text-primary hover:text-primary/80 flex items-center gap-1 text-xs transition-colors disabled:opacity-50"
887
- >
888
- <Bot className="h-3.5 w-3.5" />
889
- {aiGenerating === 'title' ? 'Generating...' : 'AI Generate'}
890
- </button>
1061
+ <div>
1062
+ <label className="text-foreground mb-1 block text-sm font-medium">
1063
+ File Name
1064
+ </label>
1065
+ <input
1066
+ type="text"
1067
+ value={editFilename}
1068
+ onChange={(e) => setEditFilename(e.target.value)}
1069
+ className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1070
+ />
891
1071
  </div>
892
- <input
893
- type="text"
894
- value={editTitle}
895
- onChange={(e) => setEditTitle(e.target.value)}
896
- placeholder="Image title..."
897
- className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
898
- />
899
1072
  </div>
900
1073
 
901
- <div>
902
- <label className="text-foreground mb-1 block text-sm font-medium">
903
- File Name
904
- </label>
905
- <input
906
- type="text"
907
- value={editFilename}
908
- onChange={(e) => setEditFilename(e.target.value)}
909
- className="border-input focus:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
910
- />
1074
+ <div className="border-border border-b p-4">
1075
+ <h4 className="text-muted-foreground mb-3 text-xs font-medium tracking-wide uppercase">
1076
+ Used On {activeItem.usedOn && `(${activeItem.usedOn.length})`}
1077
+ </h4>
1078
+ {activeItem.usedOn && activeItem.usedOn.length > 0 ? (
1079
+ <div className="space-y-2">
1080
+ {activeItem.usedOn.map((usage, i) => (
1081
+ <button
1082
+ key={i}
1083
+ type="button"
1084
+ onClick={() => onNavigate?.(usage.path)}
1085
+ className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-2 text-left transition-colors"
1086
+ >
1087
+ <Link2 className="text-muted-foreground h-4 w-4 shrink-0" />
1088
+ <span className="text-foreground flex-1 truncate text-sm">
1089
+ {usage.page}
1090
+ </span>
1091
+ <ExternalLink className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
1092
+ </button>
1093
+ ))}
1094
+ </div>
1095
+ ) : (
1096
+ <div className="border-warning/30 bg-warning/10 rounded-lg border p-3">
1097
+ <div className="flex items-start gap-2">
1098
+ <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
1099
+ <div>
1100
+ <p className="text-warning text-xs font-medium">Orphaned media</p>
1101
+ <p className="text-warning mt-0.5 text-xs">
1102
+ This file isn&apos;t used on any page. Consider deleting it to
1103
+ save storage.
1104
+ </p>
1105
+ </div>
1106
+ </div>
1107
+ </div>
1108
+ )}
911
1109
  </div>
912
- </div>
913
1110
 
914
- <div className="border-border border-b p-4">
915
- <h4 className="text-muted-foreground mb-3 text-xs font-medium tracking-wide uppercase">
916
- Used On {activeItem.usedOn && `(${activeItem.usedOn.length})`}
917
- </h4>
918
- {activeItem.usedOn && activeItem.usedOn.length > 0 ? (
919
- <div className="space-y-2">
920
- {activeItem.usedOn.map((usage, i) => (
921
- <button
922
- key={i}
923
- type="button"
924
- onClick={() => onNavigate?.(usage.path)}
925
- className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-2 text-left transition-colors"
926
- >
927
- <Link2 className="text-muted-foreground h-4 w-4 shrink-0" />
928
- <span className="text-foreground flex-1 truncate text-sm">
929
- {usage.page}
930
- </span>
931
- <ExternalLink className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
932
- </button>
933
- ))}
934
- </div>
935
- ) : (
936
- <div className="border-warning/30 bg-warning/10 rounded-lg border p-3">
937
- <div className="flex items-start gap-2">
938
- <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
939
- <div>
940
- <p className="text-warning text-xs font-medium">Orphaned media</p>
941
- <p className="text-warning mt-0.5 text-xs">
942
- This file isn&apos;t used on any page. Consider deleting it to save
943
- storage.
944
- </p>
1111
+ <div className="space-y-3 p-4">
1112
+ <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
1113
+ AI Optimization
1114
+ </h4>
1115
+ <button
1116
+ type="button"
1117
+ onClick={() => handleAiGenerate('optimize')}
1118
+ disabled={aiGenerating === 'optimize'}
1119
+ className="border-primary/30 bg-primary/10 hover:bg-primary/15 flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50"
1120
+ >
1121
+ <Sparkles
1122
+ className={`text-primary h-5 w-5 shrink-0 ${aiGenerating === 'optimize' ? 'animate-spin' : ''}`}
1123
+ />
1124
+ <div className="flex-1">
1125
+ <div className="text-primary text-sm font-medium">
1126
+ {aiGenerating === 'optimize' ? 'Optimizing...' : 'Optimize Image'}
1127
+ </div>
1128
+ <div className="text-primary mt-0.5 text-xs">
1129
+ Compress and convert to modern format (WebP/AVIF)
945
1130
  </div>
946
1131
  </div>
947
- </div>
948
- )}
1132
+ </button>
1133
+ <button
1134
+ type="button"
1135
+ className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors"
1136
+ >
1137
+ <Bot className="text-muted-foreground h-5 w-5 shrink-0" />
1138
+ <div className="flex-1">
1139
+ <div className="text-foreground text-sm font-medium">
1140
+ AI Content Analysis
1141
+ </div>
1142
+ <div className="text-muted-foreground mt-0.5 text-xs">
1143
+ Detect objects, faces, text, and suggest categories
1144
+ </div>
1145
+ </div>
1146
+ </button>
1147
+ </div>
949
1148
  </div>
950
1149
 
951
- <div className="space-y-3 p-4">
952
- <h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
953
- AI Optimization
954
- </h4>
1150
+ <div className="border-border bg-card sticky bottom-0 flex items-center gap-2 border-t p-4">
955
1151
  <button
956
1152
  type="button"
957
- onClick={() => handleAiGenerate('optimize')}
958
- disabled={aiGenerating === 'optimize'}
959
- className="border-primary/30 bg-primary/10 hover:bg-primary/15 flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50"
1153
+ onClick={handleSaveDetails}
1154
+ disabled={saving}
1155
+ className="bg-primary text-primary-foreground hover:bg-primary/90 flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50"
960
1156
  >
961
- <Sparkles
962
- className={`text-primary h-5 w-5 shrink-0 ${aiGenerating === 'optimize' ? 'animate-spin' : ''}`}
963
- />
964
- <div className="flex-1">
965
- <div className="text-primary text-sm font-medium">
966
- {aiGenerating === 'optimize' ? 'Optimizing...' : 'Optimize Image'}
967
- </div>
968
- <div className="text-primary mt-0.5 text-xs">
969
- Compress and convert to modern format (WebP/AVIF)
970
- </div>
971
- </div>
1157
+ {saving && <Loader2 className="h-4 w-4 animate-spin" />}
1158
+ {saving ? 'Saving...' : 'Save Changes'}
972
1159
  </button>
973
1160
  <button
974
1161
  type="button"
975
- className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors"
1162
+ className="hover:bg-muted rounded-lg p-2 transition-colors"
1163
+ title="Download"
976
1164
  >
977
- <Bot className="text-muted-foreground h-5 w-5 shrink-0" />
978
- <div className="flex-1">
979
- <div className="text-foreground text-sm font-medium">
980
- AI Content Analysis
981
- </div>
982
- <div className="text-muted-foreground mt-0.5 text-xs">
983
- Detect objects, faces, text, and suggest categories
984
- </div>
985
- </div>
1165
+ <Download className="text-muted-foreground h-4 w-4" />
1166
+ </button>
1167
+ <button
1168
+ type="button"
1169
+ onClick={() => deleteMedia(activeItem.id)}
1170
+ className="hover:bg-muted rounded-lg p-2 transition-colors"
1171
+ title="Delete"
1172
+ >
1173
+ <Trash2 className="text-destructive h-4 w-4" />
986
1174
  </button>
987
1175
  </div>
988
1176
  </div>
989
-
990
- <div className="border-border bg-card sticky bottom-0 flex items-center gap-2 border-t p-4">
991
- <button
992
- type="button"
993
- onClick={handleSaveDetails}
994
- disabled={saving}
995
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50"
996
- >
997
- {saving && <Loader2 className="h-4 w-4 animate-spin" />}
998
- {saving ? 'Saving...' : 'Save Changes'}
999
- </button>
1000
- <button
1001
- type="button"
1002
- className="hover:bg-muted rounded-lg p-2 transition-colors"
1003
- title="Download"
1004
- >
1005
- <Download className="text-muted-foreground h-4 w-4" />
1006
- </button>
1007
- <button
1008
- type="button"
1009
- onClick={() => deleteMedia(activeItem.id)}
1010
- className="hover:bg-muted rounded-lg p-2 transition-colors"
1011
- title="Delete"
1012
- >
1013
- <Trash2 className="text-destructive h-4 w-4" />
1014
- </button>
1015
- </div>
1016
- </div>
1017
- )}
1018
- </div>
1019
- )}
1177
+ )}
1178
+ </div>
1179
+ )}
1180
+ </div>
1020
1181
  </div>
1021
- </div>
1182
+ )}
1022
1183
  </div>
1023
1184
  )
1024
1185
  }