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