@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.
- package/CHANGELOG.md +39 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/RowActions.d.ts +19 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -0
- package/dist/components/ui/RowActions.js +16 -0
- package/dist/components/ui/RowActions.js.map +1 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.js +8 -38
- package/dist/layout/Layout.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +9 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/posts-service.d.ts +11 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +13 -0
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +40 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +55 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +145 -16
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +66 -9
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +15 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +7 -3
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/RowActions.tsx +50 -0
- package/src/components/ui/index.ts +12 -0
- package/src/layout/Header.tsx +2 -2
- package/src/layout/Layout.tsx +8 -80
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-service.ts +18 -0
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/posts-service.ts +26 -0
- package/src/lib/seo-service.ts +3 -0
- package/src/views/Dashboard.tsx +286 -214
- package/src/views/MediaBrowser.tsx +673 -512
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +459 -185
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostsListView.tsx +135 -10
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/TechnicalTab.tsx +15 -0
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- 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
|
-
<
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
566
|
+
)}
|
|
501
567
|
|
|
502
|
-
|
|
503
|
-
<div className="border-
|
|
504
|
-
<div className="flex items-center justify-between">
|
|
505
|
-
<
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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={
|
|
511
|
-
|
|
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
|
-
|
|
598
|
+
<Grid3x3 className="h-4 w-4" />
|
|
517
599
|
</button>
|
|
518
600
|
<button
|
|
519
|
-
onClick={() =>
|
|
520
|
-
className=
|
|
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
|
-
|
|
604
|
+
<List className="h-4 w-4" />
|
|
523
605
|
</button>
|
|
524
606
|
</div>
|
|
525
607
|
</div>
|
|
526
608
|
</div>
|
|
527
|
-
)}
|
|
528
609
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
className=
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
)
|
|
566
|
-
.
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
>
|
|
591
|
-
<
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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=
|
|
600
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
</
|
|
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
|
-
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
</
|
|
675
|
-
</
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
</
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
<
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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-
|
|
805
|
-
<div className="
|
|
806
|
-
|
|
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="
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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="
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
<
|
|
826
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
842
|
-
</
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
<
|
|
903
|
-
|
|
904
|
-
</
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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'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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
{
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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'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
|
-
</
|
|
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="
|
|
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={
|
|
958
|
-
disabled={
|
|
959
|
-
className="
|
|
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
|
-
<
|
|
962
|
-
|
|
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="
|
|
1162
|
+
className="hover:bg-muted rounded-lg p-2 transition-colors"
|
|
1163
|
+
title="Download"
|
|
976
1164
|
>
|
|
977
|
-
<
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1182
|
+
)}
|
|
1022
1183
|
</div>
|
|
1023
1184
|
)
|
|
1024
1185
|
}
|