@actuate-media/cms-admin 0.71.0 → 0.72.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 +12 -0
- package/dist/__tests__/components/author-avatar.test.d.ts +2 -0
- package/dist/__tests__/components/author-avatar.test.d.ts.map +1 -0
- package/dist/__tests__/components/author-avatar.test.js +36 -0
- package/dist/__tests__/components/author-avatar.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +60 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/ui/AuthorAvatar.d.ts +11 -0
- package/dist/components/ui/AuthorAvatar.d.ts.map +1 -0
- package/dist/components/ui/AuthorAvatar.js +47 -0
- package/dist/components/ui/AuthorAvatar.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -1
- package/dist/components/ui/InspectorPane.js +6 -1
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +1 -1
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +3 -29
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +1 -1
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts.map +1 -1
- package/dist/views/MediaSeoAudit.js +4 -2
- package/dist/views/MediaSeoAudit.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +82 -12
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +2 -1
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +2 -1
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/seo/AuditTab.js +1 -1
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/ContentTab.js +1 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.js +1 -1
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +1 -1
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/author-avatar.test.tsx +40 -0
- package/src/__tests__/views/pages-list-view.test.tsx +81 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +2 -2
- package/src/components/ui/AuthorAvatar.tsx +69 -0
- package/src/components/ui/InspectorPane.tsx +7 -2
- package/src/views/ApiKeys.tsx +2 -1
- package/src/views/Dashboard.tsx +3 -32
- package/src/views/MediaBrowser.tsx +3 -2
- package/src/views/MediaSeoAudit.tsx +3 -2
- package/src/views/Pages/PagesListView.tsx +216 -63
- package/src/views/Pages/PagesTreeTable.tsx +2 -6
- package/src/views/Posts/PostsListView.tsx +2 -6
- package/src/views/ScriptTags.tsx +3 -2
- package/src/views/seo/AuditTab.tsx +1 -1
- package/src/views/seo/ContentTab.tsx +1 -1
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +1 -1
- package/src/views/seo/TechnicalTab.tsx +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { cleanup, render } from '@testing-library/react'
|
|
4
|
+
import { AuthorAvatar, authorAvatar } from '../../components/ui/AuthorAvatar.js'
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup)
|
|
7
|
+
|
|
8
|
+
describe('authorAvatar', () => {
|
|
9
|
+
it('derives two initials from first + last name', () => {
|
|
10
|
+
expect(authorAvatar('Maria Kowalski').initials).toBe('MK')
|
|
11
|
+
expect(authorAvatar('Sarah M. Lee').initials).toBe('SL')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('falls back to the first two characters for single-word names', () => {
|
|
15
|
+
expect(authorAvatar('Admin').initials).toBe('AD')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('handles null/empty names without throwing', () => {
|
|
19
|
+
expect(authorAvatar(null).initials).toBe('US')
|
|
20
|
+
expect(authorAvatar(' ').initials).toBe('US')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('assigns a stable color per name', () => {
|
|
24
|
+
const first = authorAvatar('Maria Kowalski').color
|
|
25
|
+
const second = authorAvatar('Maria Kowalski').color
|
|
26
|
+
expect(first).toBe(second)
|
|
27
|
+
expect(first).toMatch(/^#[0-9A-F]{6}$/i)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('AuthorAvatar', () => {
|
|
32
|
+
it('renders the initials in a colored circle, hidden from AT', () => {
|
|
33
|
+
const { container } = render(<AuthorAvatar name="Maria Kowalski" size={28} />)
|
|
34
|
+
const el = container.firstElementChild as HTMLElement
|
|
35
|
+
expect(el.textContent).toBe('MK')
|
|
36
|
+
expect(el.getAttribute('aria-hidden')).toBe('true')
|
|
37
|
+
expect(el.style.background).not.toBe('')
|
|
38
|
+
expect(el.style.width).toBe('28px')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -131,6 +131,87 @@ describe('PagesListView', () => {
|
|
|
131
131
|
})
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
describe('PagesListView column visibility', () => {
|
|
135
|
+
// Radix DropdownMenu opens on pointerdown (not click), which happy-dom
|
|
136
|
+
// doesn't synthesize from fireEvent.click — open via keyboard instead.
|
|
137
|
+
const openColumnsMenu = () =>
|
|
138
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Choose visible columns' }), {
|
|
139
|
+
key: 'Enter',
|
|
140
|
+
})
|
|
141
|
+
// While the (modal) menu is open, Radix aria-hides the rest of the page,
|
|
142
|
+
// so close it before asserting on table headers.
|
|
143
|
+
const closeColumnsMenu = () => fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
|
144
|
+
|
|
145
|
+
it('hides and shows optional columns via the Columns menu, persisted', async () => {
|
|
146
|
+
fetchPagesDataset.mockResolvedValue(
|
|
147
|
+
dataset({ pages: [page('about', { title: 'About', tags: ['a'], primaryTagId: 'a' })] }),
|
|
148
|
+
)
|
|
149
|
+
render(<PagesListView onNavigate={() => {}} />)
|
|
150
|
+
await screen.findByText('About')
|
|
151
|
+
|
|
152
|
+
// All optional columns start visible.
|
|
153
|
+
expect(screen.getByRole('columnheader', { name: /tags/i })).toBeTruthy()
|
|
154
|
+
expect(screen.getByRole('columnheader', { name: /author/i })).toBeTruthy()
|
|
155
|
+
expect(screen.getByRole('columnheader', { name: /seo/i })).toBeTruthy()
|
|
156
|
+
|
|
157
|
+
openColumnsMenu()
|
|
158
|
+
fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /tags/i }))
|
|
159
|
+
closeColumnsMenu()
|
|
160
|
+
|
|
161
|
+
expect(screen.queryByRole('columnheader', { name: /tags/i })).toBeNull()
|
|
162
|
+
expect(screen.getByRole('columnheader', { name: /author/i })).toBeTruthy()
|
|
163
|
+
expect(storage.get('actuate.pages.hiddenColumns')).toBe(JSON.stringify(['tags']))
|
|
164
|
+
|
|
165
|
+
// Toggle back on.
|
|
166
|
+
openColumnsMenu()
|
|
167
|
+
fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /tags/i }))
|
|
168
|
+
closeColumnsMenu()
|
|
169
|
+
expect(screen.getByRole('columnheader', { name: /tags/i })).toBeTruthy()
|
|
170
|
+
expect(storage.get('actuate.pages.hiddenColumns')).toBe(JSON.stringify([]))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('restores hidden columns from localStorage on mount', async () => {
|
|
174
|
+
storage.set('actuate.pages.hiddenColumns', JSON.stringify(['seo', 'author']))
|
|
175
|
+
fetchPagesDataset.mockResolvedValue(dataset({ pages: [page('about', { title: 'About' })] }))
|
|
176
|
+
render(<PagesListView onNavigate={() => {}} />)
|
|
177
|
+
await screen.findByText('About')
|
|
178
|
+
|
|
179
|
+
expect(screen.queryByRole('columnheader', { name: /seo/i })).toBeNull()
|
|
180
|
+
expect(screen.queryByRole('columnheader', { name: /author/i })).toBeNull()
|
|
181
|
+
expect(screen.getByRole('columnheader', { name: /tags/i })).toBeTruthy()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('falls back to the title sort when the active sort column is hidden', async () => {
|
|
185
|
+
fetchPagesDataset.mockResolvedValue(
|
|
186
|
+
dataset({
|
|
187
|
+
pages: [
|
|
188
|
+
page('b', { title: 'Bravo', seoScore: 10 }),
|
|
189
|
+
page('a', { title: 'Alpha', seoScore: 90 }),
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
render(<PagesListView onNavigate={() => {}} />)
|
|
194
|
+
await screen.findByText('Alpha')
|
|
195
|
+
|
|
196
|
+
// Sort by SEO (desc default) → Alpha (90) first either way, but the
|
|
197
|
+
// header carries aria-sort.
|
|
198
|
+
fireEvent.click(screen.getByRole('button', { name: /^SEO$/i }))
|
|
199
|
+
expect(screen.getByRole('columnheader', { name: /seo/i }).getAttribute('aria-sort')).toBe(
|
|
200
|
+
'descending',
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
openColumnsMenu()
|
|
204
|
+
fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /seo score/i }))
|
|
205
|
+
closeColumnsMenu()
|
|
206
|
+
|
|
207
|
+
// SEO column gone; Title carries the (reset) ascending sort.
|
|
208
|
+
expect(screen.queryByRole('columnheader', { name: /seo/i })).toBeNull()
|
|
209
|
+
expect(screen.getByRole('columnheader', { name: /title/i }).getAttribute('aria-sort')).toBe(
|
|
210
|
+
'ascending',
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
134
215
|
describe('PagesListView tree mode', () => {
|
|
135
216
|
function hierarchyDataset() {
|
|
136
217
|
return dataset({
|
|
@@ -34,8 +34,8 @@ function formatValue(value: string | boolean | null): string {
|
|
|
34
34
|
function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
|
|
35
35
|
if (changes.length === 0) return null
|
|
36
36
|
return (
|
|
37
|
-
<div className="border-border overflow-
|
|
38
|
-
<table className="w-full text-sm" aria-label="Proposed SEO changes">
|
|
37
|
+
<div className="border-border overflow-x-auto rounded-md border">
|
|
38
|
+
<table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
|
|
39
39
|
<thead>
|
|
40
40
|
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
|
|
41
41
|
<th scope="col" className="py-2 pr-3 pl-3 font-medium">
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic per-author avatar: a colored circle with the author's
|
|
5
|
+
* initials, hashed from the name so the color is stable across renders
|
|
6
|
+
* and views without storing anything. Shared by the Dashboard activity
|
|
7
|
+
* feed and the Pages/Posts list tables so an author always looks the
|
|
8
|
+
* same everywhere.
|
|
9
|
+
*
|
|
10
|
+
* The palette is intentionally raw hex applied via inline style — the
|
|
11
|
+
* design-system exception for truly dynamic values (see Dashboard.tsx
|
|
12
|
+
* note). All swatches keep ≥4.5:1 contrast with white text.
|
|
13
|
+
*/
|
|
14
|
+
const AVATAR_COLORS = [
|
|
15
|
+
'#7C3AED', // purple
|
|
16
|
+
'#E11D48', // rose
|
|
17
|
+
'#059669', // emerald
|
|
18
|
+
'#0891B2', // cyan
|
|
19
|
+
'#D97706', // amber
|
|
20
|
+
'#4F46E5', // indigo
|
|
21
|
+
'#DC2626', // red
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
function hashString(s: string): number {
|
|
25
|
+
let h = 0
|
|
26
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
|
27
|
+
return Math.abs(h)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function authorAvatar(name: string | null | undefined): {
|
|
31
|
+
color: string
|
|
32
|
+
initials: string
|
|
33
|
+
} {
|
|
34
|
+
const safe = (name ?? '').trim() || 'User'
|
|
35
|
+
const parts = safe.split(/\s+/).filter(Boolean)
|
|
36
|
+
const initials =
|
|
37
|
+
parts.length >= 2
|
|
38
|
+
? `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase()
|
|
39
|
+
: safe.slice(0, 2).toUpperCase()
|
|
40
|
+
const color = AVATAR_COLORS[hashString(safe) % AVATAR_COLORS.length]!
|
|
41
|
+
return { color, initials }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function AuthorAvatar({
|
|
45
|
+
name,
|
|
46
|
+
size = 28,
|
|
47
|
+
className = '',
|
|
48
|
+
}: {
|
|
49
|
+
name: string | null | undefined
|
|
50
|
+
/** Diameter in px. Font size scales with it. */
|
|
51
|
+
size?: number
|
|
52
|
+
className?: string
|
|
53
|
+
}) {
|
|
54
|
+
const { color, initials } = authorAvatar(name)
|
|
55
|
+
return (
|
|
56
|
+
<span
|
|
57
|
+
aria-hidden
|
|
58
|
+
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-white ${className}`}
|
|
59
|
+
style={{
|
|
60
|
+
width: size,
|
|
61
|
+
height: size,
|
|
62
|
+
background: color,
|
|
63
|
+
fontSize: Math.max(9, Math.round(size * 0.4)),
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{initials}
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -53,17 +53,22 @@ export function InspectorPane({
|
|
|
53
53
|
children,
|
|
54
54
|
'aria-label': ariaLabel,
|
|
55
55
|
}: InspectorPaneProps) {
|
|
56
|
+
// Cap at the split row's width (100% of the flex container) so the pane
|
|
57
|
+
// never overflows phones/tablets. Below the cap the pane fills the row
|
|
58
|
+
// master–detail style: the list yields, and the close button returns to
|
|
59
|
+
// it. On desktop the min() resolves to the design's fixed px width, so
|
|
60
|
+
// nothing changes there.
|
|
56
61
|
return (
|
|
57
62
|
<div
|
|
58
63
|
className="shrink-0 overflow-hidden bg-[var(--bg)] motion-safe:transition-[width] motion-safe:duration-200"
|
|
59
|
-
style={{ width: open ? width : 0 }}
|
|
64
|
+
style={{ width: open ? `min(${width}px, 100%)` : 0 }}
|
|
60
65
|
aria-hidden={!open}
|
|
61
66
|
>
|
|
62
67
|
<div
|
|
63
68
|
role="region"
|
|
64
69
|
aria-label={ariaLabel}
|
|
65
70
|
className="my-3 mr-3 ml-4 flex h-[calc(100%-24px)] flex-col overflow-hidden rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--sb)] shadow-[var(--shd2)]"
|
|
66
|
-
style={{ width: width - 28 }}
|
|
71
|
+
style={{ width: `min(${width - 28}px, calc(100% - 28px))` }}
|
|
67
72
|
>
|
|
68
73
|
{open ? children : null}
|
|
69
74
|
</div>
|
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -166,7 +166,8 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
166
166
|
) : (
|
|
167
167
|
<div className="border-border overflow-hidden rounded-lg border">
|
|
168
168
|
<div className="overflow-x-auto">
|
|
169
|
-
|
|
169
|
+
{/* min-w keeps the 7 columns readable; narrow viewports scroll sideways. */}
|
|
170
|
+
<table className="w-full min-w-[760px]">
|
|
170
171
|
<thead className="border-border bg-muted/50 border-b">
|
|
171
172
|
<tr>
|
|
172
173
|
<th className="text-muted-foreground px-4 py-2 text-left text-xs font-medium">
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from 'lucide-react'
|
|
42
42
|
import { useApiData } from '../lib/useApiData.js'
|
|
43
43
|
import { editPathForDoc } from '../lib/collection-routes.js'
|
|
44
|
+
import { authorAvatar } from '../components/ui/AuthorAvatar.js'
|
|
44
45
|
import { OnboardingBlock, shouldShowOnboarding } from './dashboard/OnboardingBlock.js'
|
|
45
46
|
import type { OnboardingSignals } from './dashboard/OnboardingBlock.js'
|
|
46
47
|
|
|
@@ -173,45 +174,15 @@ function todayDateString(): string {
|
|
|
173
174
|
})
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
// Deterministic colour per author so avatars stay stable across renders
|
|
177
|
-
// without storing anything in state or hitting the server.
|
|
178
|
-
const AVATAR_COLORS = [
|
|
179
|
-
'#7C3AED', // purple
|
|
180
|
-
'#E11D48', // rose
|
|
181
|
-
'#059669', // emerald
|
|
182
|
-
'#0891B2', // cyan
|
|
183
|
-
'#D97706', // amber
|
|
184
|
-
'#4F46E5', // indigo
|
|
185
|
-
'#DC2626', // red
|
|
186
|
-
] as const
|
|
187
|
-
|
|
188
|
-
function hashString(s: string): number {
|
|
189
|
-
let h = 0
|
|
190
|
-
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
|
191
|
-
return Math.abs(h)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function authorAvatar(name: string | null | undefined): { color: string; initials: string } {
|
|
195
|
-
const safe = (name ?? '').trim() || 'User'
|
|
196
|
-
const parts = safe.split(/\s+/).filter(Boolean)
|
|
197
|
-
const initials =
|
|
198
|
-
parts.length >= 2
|
|
199
|
-
? `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase()
|
|
200
|
-
: safe.slice(0, 2).toUpperCase()
|
|
201
|
-
const color = AVATAR_COLORS[hashString(safe) % AVATAR_COLORS.length]!
|
|
202
|
-
return { color, initials }
|
|
203
|
-
}
|
|
204
|
-
|
|
205
177
|
/**
|
|
206
178
|
* Semantic state styling uses theme tokens (`success` / `warning` / `info`
|
|
207
179
|
* / `brand`) so the dashboard tracks the design system in both light and
|
|
208
|
-
* dark mode.
|
|
180
|
+
* dark mode. One deliberate exception remains as raw values:
|
|
209
181
|
* - Error states keep `text-red-*` / `bg-red-*`. The `--destructive`
|
|
210
182
|
* token's dark value is intentionally dark (low contrast as TEXT on a
|
|
211
183
|
* dark card), so tokenizing error text here would regress contrast.
|
|
212
184
|
* Promoting error to a token is a separate, admin-wide change.
|
|
213
|
-
*
|
|
214
|
-
* dynamic values, applied via inline style).
|
|
185
|
+
* Author avatar colors moved to the shared `AuthorAvatar` component.
|
|
215
186
|
*/
|
|
216
187
|
function statusBadge(status: string): {
|
|
217
188
|
label: string
|
|
@@ -776,8 +776,9 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
776
776
|
})}
|
|
777
777
|
</div>
|
|
778
778
|
) : (
|
|
779
|
-
<div className="h-full overflow-
|
|
780
|
-
|
|
779
|
+
<div className="h-full overflow-auto">
|
|
780
|
+
{/* min-w keeps the list columns readable; narrow viewports scroll sideways. */}
|
|
781
|
+
<table className="w-full min-w-[640px]">
|
|
781
782
|
<thead className="border-border bg-muted sticky top-0 border-b">
|
|
782
783
|
<tr>
|
|
783
784
|
<th className="w-8 px-3 py-2 text-left">
|
|
@@ -243,7 +243,7 @@ export function MediaSeoAudit({ items, onEdit, error, onRetry }: MediaSeoAuditPr
|
|
|
243
243
|
</span>
|
|
244
244
|
</div>
|
|
245
245
|
|
|
246
|
-
<div className="overflow-
|
|
246
|
+
<div className="overflow-x-auto rounded-[10px] border border-[var(--bdr)] bg-[var(--card)]">
|
|
247
247
|
{filtered.length === 0 ? (
|
|
248
248
|
<div className="flex flex-col items-center justify-center px-4 py-14 text-center">
|
|
249
249
|
<ImageIcon size={24} className="mb-3 text-[var(--muted)]" aria-hidden />
|
|
@@ -257,7 +257,8 @@ export function MediaSeoAudit({ items, onEdit, error, onRetry }: MediaSeoAuditPr
|
|
|
257
257
|
</p>
|
|
258
258
|
</div>
|
|
259
259
|
) : (
|
|
260
|
-
|
|
260
|
+
// min-w keeps the 7 columns readable; narrow viewports scroll sideways.
|
|
261
|
+
<table className="w-full min-w-[820px] border-collapse" aria-label="Media SEO audit">
|
|
261
262
|
<thead>
|
|
262
263
|
<tr>
|
|
263
264
|
{['File', 'Type', 'Alt Text', 'Title', 'Description', 'SEO Score', 'Action'].map(
|