@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/author-avatar.test.d.ts +2 -0
  3. package/dist/__tests__/components/author-avatar.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/author-avatar.test.js +36 -0
  5. package/dist/__tests__/components/author-avatar.test.js.map +1 -0
  6. package/dist/__tests__/views/pages-list-view.test.js +60 -0
  7. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/seo/SeoIssueFixPanel.js +1 -1
  10. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  11. package/dist/components/ui/AuthorAvatar.d.ts +11 -0
  12. package/dist/components/ui/AuthorAvatar.d.ts.map +1 -0
  13. package/dist/components/ui/AuthorAvatar.js +47 -0
  14. package/dist/components/ui/AuthorAvatar.js.map +1 -0
  15. package/dist/components/ui/InspectorPane.d.ts.map +1 -1
  16. package/dist/components/ui/InspectorPane.js +6 -1
  17. package/dist/components/ui/InspectorPane.js.map +1 -1
  18. package/dist/views/ApiKeys.d.ts.map +1 -1
  19. package/dist/views/ApiKeys.js +1 -1
  20. package/dist/views/ApiKeys.js.map +1 -1
  21. package/dist/views/Dashboard.d.ts.map +1 -1
  22. package/dist/views/Dashboard.js +3 -29
  23. package/dist/views/Dashboard.js.map +1 -1
  24. package/dist/views/MediaBrowser.d.ts.map +1 -1
  25. package/dist/views/MediaBrowser.js +1 -1
  26. package/dist/views/MediaBrowser.js.map +1 -1
  27. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  28. package/dist/views/MediaSeoAudit.js +4 -2
  29. package/dist/views/MediaSeoAudit.js.map +1 -1
  30. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  31. package/dist/views/Pages/PagesListView.js +82 -12
  32. package/dist/views/Pages/PagesListView.js.map +1 -1
  33. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  34. package/dist/views/Pages/PagesTreeTable.js +2 -1
  35. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  36. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  37. package/dist/views/Posts/PostsListView.js +2 -1
  38. package/dist/views/Posts/PostsListView.js.map +1 -1
  39. package/dist/views/ScriptTags.d.ts.map +1 -1
  40. package/dist/views/ScriptTags.js +1 -1
  41. package/dist/views/ScriptTags.js.map +1 -1
  42. package/dist/views/seo/AuditTab.js +1 -1
  43. package/dist/views/seo/AuditTab.js.map +1 -1
  44. package/dist/views/seo/ContentTab.js +1 -1
  45. package/dist/views/seo/ContentTab.js.map +1 -1
  46. package/dist/views/seo/OverviewTab.js +1 -1
  47. package/dist/views/seo/OverviewTab.js.map +1 -1
  48. package/dist/views/seo/RedirectsTab.js +1 -1
  49. package/dist/views/seo/RedirectsTab.js.map +1 -1
  50. package/dist/views/seo/TechnicalTab.js +1 -1
  51. package/dist/views/seo/TechnicalTab.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/components/author-avatar.test.tsx +40 -0
  54. package/src/__tests__/views/pages-list-view.test.tsx +81 -0
  55. package/src/components/seo/SeoIssueFixPanel.tsx +2 -2
  56. package/src/components/ui/AuthorAvatar.tsx +69 -0
  57. package/src/components/ui/InspectorPane.tsx +7 -2
  58. package/src/views/ApiKeys.tsx +2 -1
  59. package/src/views/Dashboard.tsx +3 -32
  60. package/src/views/MediaBrowser.tsx +3 -2
  61. package/src/views/MediaSeoAudit.tsx +3 -2
  62. package/src/views/Pages/PagesListView.tsx +216 -63
  63. package/src/views/Pages/PagesTreeTable.tsx +2 -6
  64. package/src/views/Posts/PostsListView.tsx +2 -6
  65. package/src/views/ScriptTags.tsx +3 -2
  66. package/src/views/seo/AuditTab.tsx +1 -1
  67. package/src/views/seo/ContentTab.tsx +1 -1
  68. package/src/views/seo/OverviewTab.tsx +1 -1
  69. package/src/views/seo/RedirectsTab.tsx +1 -1
  70. 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-hidden rounded-md border">
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>
@@ -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
- <table className="w-full">
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">
@@ -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. Two deliberate exceptions remain as raw values:
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
- * - `AVATAR_COLORS` are dynamic per-author hashes (allowed: truly
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-y-auto">
780
- <table className="w-full">
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-hidden rounded-[10px] border border-[var(--bdr)] bg-[var(--card)]">
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
- <table className="w-full border-collapse" aria-label="Media SEO audit">
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(