@actuate-media/cms-admin 0.71.1 → 0.72.1
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 +24 -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__/lib/cv.test.js +9 -0
- package/dist/__tests__/lib/cv.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +11 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- 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/__tests__/views/post-section-editor.test.js +14 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/actuate-admin.css +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/Modal.d.ts +8 -2
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +9 -2
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/lib/cv.d.ts.map +1 -1
- package/dist/lib/cv.js +9 -1
- package/dist/lib/cv.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/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/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +4 -0
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +8 -2
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +4 -2
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +5 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/author-avatar.test.tsx +40 -0
- package/src/__tests__/lib/cv.test.ts +10 -0
- package/src/__tests__/views/page-section-editor.test.tsx +15 -0
- package/src/__tests__/views/pages-list-view.test.tsx +81 -0
- package/src/__tests__/views/post-section-editor.test.tsx +26 -0
- package/src/components/ui/AuthorAvatar.tsx +69 -0
- package/src/components/ui/Modal.tsx +29 -3
- package/src/lib/cv.ts +8 -1
- package/src/views/Dashboard.tsx +3 -32
- 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/page-editor/PageSectionEditor.tsx +4 -0
- package/src/views/page-editor/PageSettingsModal.tsx +27 -4
- package/src/views/post-editor/PostFieldsModal.tsx +6 -1
- package/src/views/post-editor/PostSectionEditor.tsx +6 -1
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
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import {
|
|
5
|
+
Check,
|
|
5
6
|
ChevronDown,
|
|
6
7
|
ChevronLeft,
|
|
7
8
|
ChevronRight,
|
|
8
9
|
ChevronUp,
|
|
10
|
+
Columns3,
|
|
9
11
|
Copy,
|
|
10
12
|
List,
|
|
11
13
|
ListTree,
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
} from '../../lib/pages-tree.js'
|
|
45
47
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
46
48
|
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
|
|
49
|
+
import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
|
|
47
50
|
import { Button } from '../../components/ui/Button.js'
|
|
48
51
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
49
52
|
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
@@ -69,8 +72,17 @@ type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
|
|
|
69
72
|
type Dir = 'asc' | 'desc'
|
|
70
73
|
type ViewMode = 'list' | 'tree'
|
|
71
74
|
|
|
75
|
+
/** Optional flat-list columns the user can hide (design's "Tweaks" panel). */
|
|
76
|
+
type ToggleableColumn = 'tags' | 'author' | 'seo'
|
|
77
|
+
const TOGGLEABLE_COLUMNS: Array<{ key: ToggleableColumn; label: string }> = [
|
|
78
|
+
{ key: 'tags', label: 'Tags' },
|
|
79
|
+
{ key: 'author', label: 'Author' },
|
|
80
|
+
{ key: 'seo', label: 'SEO score' },
|
|
81
|
+
]
|
|
82
|
+
|
|
72
83
|
const PAGE_SIZE = 25
|
|
73
84
|
const VIEW_MODE_KEY = 'actuate.pages.viewMode'
|
|
85
|
+
const HIDDEN_COLUMNS_KEY = 'actuate.pages.hiddenColumns'
|
|
74
86
|
|
|
75
87
|
function readStoredViewMode(): ViewMode {
|
|
76
88
|
if (typeof window === 'undefined') return 'list'
|
|
@@ -81,6 +93,20 @@ function readStoredViewMode(): ViewMode {
|
|
|
81
93
|
}
|
|
82
94
|
}
|
|
83
95
|
|
|
96
|
+
function readStoredHiddenColumns(): Set<ToggleableColumn> {
|
|
97
|
+
if (typeof window === 'undefined') return new Set()
|
|
98
|
+
try {
|
|
99
|
+
const raw = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
|
100
|
+
if (!raw) return new Set()
|
|
101
|
+
const known = new Set(TOGGLEABLE_COLUMNS.map((c) => c.key))
|
|
102
|
+
const parsed: unknown = JSON.parse(raw)
|
|
103
|
+
if (!Array.isArray(parsed)) return new Set()
|
|
104
|
+
return new Set(parsed.filter((v): v is ToggleableColumn => known.has(v as ToggleableColumn)))
|
|
105
|
+
} catch {
|
|
106
|
+
return new Set()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
85
111
|
const [search, setSearch] = useState('')
|
|
86
112
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
@@ -92,6 +118,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
92
118
|
const [page, setPage] = useState(1)
|
|
93
119
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
94
120
|
const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode)
|
|
121
|
+
const [hiddenCols, setHiddenCols] = useState<Set<ToggleableColumn>>(readStoredHiddenColumns)
|
|
95
122
|
// null = "not yet touched" → falls back to the default expand state
|
|
96
123
|
// (all root nodes with children open) computed from the current tree.
|
|
97
124
|
const [expandedOverride, setExpandedOverride] = useState<Set<string> | null>(null)
|
|
@@ -223,6 +250,38 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
223
250
|
}
|
|
224
251
|
}
|
|
225
252
|
|
|
253
|
+
const toggleColumn = (key: ToggleableColumn) => {
|
|
254
|
+
setHiddenCols((prev) => {
|
|
255
|
+
const next = new Set(prev)
|
|
256
|
+
if (next.has(key)) next.delete(key)
|
|
257
|
+
else next.add(key)
|
|
258
|
+
try {
|
|
259
|
+
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...next]))
|
|
260
|
+
} catch {
|
|
261
|
+
/* storage unavailable — the choice still applies for the session */
|
|
262
|
+
}
|
|
263
|
+
return next
|
|
264
|
+
})
|
|
265
|
+
// Hiding the column you're sorted by would leave an invisible sort —
|
|
266
|
+
// fall back to the default title sort instead.
|
|
267
|
+
if ((key === 'author' && sortBy === 'author') || (key === 'seo' && sortBy === 'seo')) {
|
|
268
|
+
setSortBy('title')
|
|
269
|
+
setDir('asc')
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const showTags = !hiddenCols.has('tags')
|
|
274
|
+
const showAuthor = !hiddenCols.has('author')
|
|
275
|
+
const showSeo = !hiddenCols.has('seo')
|
|
276
|
+
// checkbox + title + status + modified + actions are always visible.
|
|
277
|
+
const columnCount = 5 + (showTags ? 1 : 0) + (showAuthor ? 1 : 0) + (showSeo ? 1 : 0)
|
|
278
|
+
// The table is `table-fixed`, so the flexible Title column only gets
|
|
279
|
+
// whatever the min-width leaves after the fixed columns (400px always
|
|
280
|
+
// visible + optional ones). Keep ≥240px for the title or it collapses
|
|
281
|
+
// and headers overlap on narrow viewports.
|
|
282
|
+
const tableMinWidth =
|
|
283
|
+
400 + 240 + (showTags ? 200 : 0) + (showAuthor ? 170 : 0) + (showSeo ? 80 : 0)
|
|
284
|
+
|
|
226
285
|
const handleSort = useCallback(
|
|
227
286
|
(field: SortBy) => {
|
|
228
287
|
if (sortBy === field) {
|
|
@@ -520,6 +579,8 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
520
579
|
|
|
521
580
|
<ViewModeToggle mode={viewMode} onChange={changeViewMode} />
|
|
522
581
|
|
|
582
|
+
{!treeMode && <ColumnsMenu hidden={hiddenCols} onToggle={toggleColumn} />}
|
|
583
|
+
|
|
523
584
|
<span className="ml-auto text-sm text-[var(--sub)] tabular-nums">
|
|
524
585
|
{treeMode
|
|
525
586
|
? treeFiltersActive
|
|
@@ -611,15 +672,20 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
611
672
|
{/* Horizontal scroll on narrow viewports: the fixed columns keep their
|
|
612
673
|
width and the table scrolls sideways instead of crushing content. */}
|
|
613
674
|
<div className="overflow-x-auto">
|
|
614
|
-
<table
|
|
675
|
+
<table
|
|
676
|
+
className="w-full table-fixed"
|
|
677
|
+
style={{ minWidth: tableMinWidth }}
|
|
678
|
+
role="table"
|
|
679
|
+
aria-label="Pages"
|
|
680
|
+
>
|
|
615
681
|
<colgroup>
|
|
616
682
|
<col style={{ width: '40px' }} />
|
|
617
683
|
<col />
|
|
618
|
-
<col style={{ width: '200px' }} />
|
|
619
|
-
<col style={{ width: '170px' }} />
|
|
684
|
+
{showTags && <col style={{ width: '200px' }} />}
|
|
685
|
+
{showAuthor && <col style={{ width: '170px' }} />}
|
|
620
686
|
<col style={{ width: '120px' }} />
|
|
621
687
|
<col style={{ width: '130px' }} />
|
|
622
|
-
<col style={{ width: '80px' }} />
|
|
688
|
+
{showSeo && <col style={{ width: '80px' }} />}
|
|
623
689
|
<col style={{ width: '110px' }} />
|
|
624
690
|
</colgroup>
|
|
625
691
|
<thead className="bg-muted/40 border-b border-[var(--bdr)]">
|
|
@@ -646,22 +712,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
646
712
|
onSort={handleSort}
|
|
647
713
|
/>
|
|
648
714
|
</th>
|
|
649
|
-
|
|
650
|
-
<
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
715
|
+
{showTags && (
|
|
716
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
717
|
+
<span className="text-xs font-medium tracking-wider uppercase">Tags</span>
|
|
718
|
+
</th>
|
|
719
|
+
)}
|
|
720
|
+
{showAuthor && (
|
|
721
|
+
<th
|
|
722
|
+
scope="col"
|
|
723
|
+
aria-sort={ariaSortFor('author')}
|
|
724
|
+
className="px-3 py-2.5 text-left"
|
|
725
|
+
>
|
|
726
|
+
<SortHeader
|
|
727
|
+
label="Author"
|
|
728
|
+
field="author"
|
|
729
|
+
current={sortBy}
|
|
730
|
+
dir={dir}
|
|
731
|
+
onSort={handleSort}
|
|
732
|
+
/>
|
|
733
|
+
</th>
|
|
734
|
+
)}
|
|
665
735
|
<th
|
|
666
736
|
scope="col"
|
|
667
737
|
aria-sort={ariaSortFor('status')}
|
|
@@ -688,29 +758,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
688
758
|
onSort={handleSort}
|
|
689
759
|
/>
|
|
690
760
|
</th>
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
761
|
+
{showSeo && (
|
|
762
|
+
<th
|
|
763
|
+
scope="col"
|
|
764
|
+
aria-sort={ariaSortFor('seo')}
|
|
765
|
+
className="px-3 py-2.5 text-left"
|
|
766
|
+
>
|
|
767
|
+
<SortHeader
|
|
768
|
+
label="SEO"
|
|
769
|
+
field="seo"
|
|
770
|
+
current={sortBy}
|
|
771
|
+
dir={dir}
|
|
772
|
+
onSort={handleSort}
|
|
773
|
+
/>
|
|
774
|
+
</th>
|
|
775
|
+
)}
|
|
704
776
|
<th scope="col" className="px-3 py-2.5 text-right">
|
|
705
777
|
<span className="sr-only">Actions</span>
|
|
706
778
|
</th>
|
|
707
779
|
</tr>
|
|
708
780
|
</thead>
|
|
709
781
|
<tbody>
|
|
710
|
-
{loading && pages.length === 0 && <LoadingRows />}
|
|
782
|
+
{loading && pages.length === 0 && <LoadingRows columns={columnCount} />}
|
|
711
783
|
{!loading && error && (
|
|
712
784
|
<tr>
|
|
713
|
-
<td colSpan={
|
|
785
|
+
<td colSpan={columnCount} className="px-3 py-12 text-center">
|
|
714
786
|
<div className="mb-2 text-sm font-medium text-[var(--txt)]">{error}</div>
|
|
715
787
|
<button
|
|
716
788
|
onClick={() => setRefreshToken((t) => t + 1)}
|
|
@@ -723,6 +795,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
723
795
|
)}
|
|
724
796
|
{!loading && !error && pages.length === 0 && (
|
|
725
797
|
<EmptyRow
|
|
798
|
+
columns={columnCount}
|
|
726
799
|
filtersActive={filtersAreActive}
|
|
727
800
|
onClear={clearFilters}
|
|
728
801
|
onNewPage={() => onNavigate('/pages/new')}
|
|
@@ -737,12 +810,16 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
737
810
|
label={group.label}
|
|
738
811
|
color={group.color}
|
|
739
812
|
count={group.pages.length}
|
|
813
|
+
columns={columnCount}
|
|
740
814
|
>
|
|
741
815
|
{group.pages.map((p) => (
|
|
742
816
|
<PageRow
|
|
743
817
|
key={p.id}
|
|
744
818
|
page={p}
|
|
745
819
|
selected={selected.has(p.id)}
|
|
820
|
+
showTags={showTags}
|
|
821
|
+
showAuthor={showAuthor}
|
|
822
|
+
showSeo={showSeo}
|
|
746
823
|
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
747
824
|
onSelect={() => toggleOne(p.id)}
|
|
748
825
|
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
@@ -880,6 +957,70 @@ function ViewModeToggle({
|
|
|
880
957
|
)
|
|
881
958
|
}
|
|
882
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Column visibility menu — the product translation of the design's
|
|
962
|
+
* floating "Tweaks" panel. Optional columns can be hidden per user;
|
|
963
|
+
* the choice persists in localStorage alongside the view mode.
|
|
964
|
+
*/
|
|
965
|
+
function ColumnsMenu({
|
|
966
|
+
hidden,
|
|
967
|
+
onToggle,
|
|
968
|
+
}: {
|
|
969
|
+
hidden: Set<ToggleableColumn>
|
|
970
|
+
onToggle: (key: ToggleableColumn) => void
|
|
971
|
+
}) {
|
|
972
|
+
return (
|
|
973
|
+
<DropdownMenu.Root>
|
|
974
|
+
<DropdownMenu.Trigger asChild>
|
|
975
|
+
<button
|
|
976
|
+
type="button"
|
|
977
|
+
aria-label="Choose visible columns"
|
|
978
|
+
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-sm text-[var(--txt)] shadow-sm transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
979
|
+
>
|
|
980
|
+
<Columns3 size={15} aria-hidden />
|
|
981
|
+
Columns
|
|
982
|
+
<ChevronDown className="h-3 w-3 text-[var(--sub)]" aria-hidden />
|
|
983
|
+
</button>
|
|
984
|
+
</DropdownMenu.Trigger>
|
|
985
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
986
|
+
<DropdownMenu.Content
|
|
987
|
+
align="end"
|
|
988
|
+
sideOffset={4}
|
|
989
|
+
className="bg-popover z-50 w-44 rounded-lg border border-[var(--bdr)] p-1 shadow-md"
|
|
990
|
+
>
|
|
991
|
+
<DropdownMenu.Label className="px-2 py-1.5 text-[10px] font-medium tracking-wider text-[var(--sub)] uppercase">
|
|
992
|
+
Columns
|
|
993
|
+
</DropdownMenu.Label>
|
|
994
|
+
{TOGGLEABLE_COLUMNS.map((col) => {
|
|
995
|
+
const visible = !hidden.has(col.key)
|
|
996
|
+
return (
|
|
997
|
+
<DropdownMenu.CheckboxItem
|
|
998
|
+
key={col.key}
|
|
999
|
+
checked={visible}
|
|
1000
|
+
onCheckedChange={() => onToggle(col.key)}
|
|
1001
|
+
onSelect={(e) => e.preventDefault()}
|
|
1002
|
+
className="focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-[var(--txt)] outline-none"
|
|
1003
|
+
>
|
|
1004
|
+
<span
|
|
1005
|
+
aria-hidden
|
|
1006
|
+
className={`flex h-4 w-4 items-center justify-center rounded border ${
|
|
1007
|
+
visible
|
|
1008
|
+
? 'bg-primary border-transparent text-white'
|
|
1009
|
+
: 'border-[var(--bdr)] bg-[var(--card)]'
|
|
1010
|
+
}`}
|
|
1011
|
+
>
|
|
1012
|
+
{visible && <Check size={11} strokeWidth={3} />}
|
|
1013
|
+
</span>
|
|
1014
|
+
{col.label}
|
|
1015
|
+
</DropdownMenu.CheckboxItem>
|
|
1016
|
+
)
|
|
1017
|
+
})}
|
|
1018
|
+
</DropdownMenu.Content>
|
|
1019
|
+
</DropdownMenu.Portal>
|
|
1020
|
+
</DropdownMenu.Root>
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
883
1024
|
function FilterSelect({
|
|
884
1025
|
label,
|
|
885
1026
|
value,
|
|
@@ -914,19 +1055,21 @@ function GroupBlock({
|
|
|
914
1055
|
label,
|
|
915
1056
|
color,
|
|
916
1057
|
count,
|
|
1058
|
+
columns,
|
|
917
1059
|
children,
|
|
918
1060
|
}: {
|
|
919
1061
|
showHeader: boolean
|
|
920
1062
|
label: string
|
|
921
1063
|
color: string | null
|
|
922
1064
|
count: number
|
|
1065
|
+
columns: number
|
|
923
1066
|
children: React.ReactNode
|
|
924
1067
|
}) {
|
|
925
1068
|
return (
|
|
926
1069
|
<>
|
|
927
1070
|
{showHeader && (
|
|
928
1071
|
<tr className="bg-muted/30 border-b border-[var(--bdr)]">
|
|
929
|
-
<td colSpan={
|
|
1072
|
+
<td colSpan={columns} className="px-3 py-2">
|
|
930
1073
|
<span className="inline-flex items-center gap-2 text-xs font-medium tracking-wider text-[var(--txt)] uppercase">
|
|
931
1074
|
{color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
|
|
932
1075
|
{label}
|
|
@@ -1066,6 +1209,9 @@ function TagMenu({
|
|
|
1066
1209
|
function PageRow({
|
|
1067
1210
|
page,
|
|
1068
1211
|
selected,
|
|
1212
|
+
showTags,
|
|
1213
|
+
showAuthor,
|
|
1214
|
+
showSeo,
|
|
1069
1215
|
resolveTag,
|
|
1070
1216
|
onSelect,
|
|
1071
1217
|
onOpen,
|
|
@@ -1074,6 +1220,9 @@ function PageRow({
|
|
|
1074
1220
|
}: {
|
|
1075
1221
|
page: Page
|
|
1076
1222
|
selected: boolean
|
|
1223
|
+
showTags: boolean
|
|
1224
|
+
showAuthor: boolean
|
|
1225
|
+
showSeo: boolean
|
|
1077
1226
|
resolveTag: (slug: string) => { name: string; color: string | null } | undefined
|
|
1078
1227
|
onSelect: () => void
|
|
1079
1228
|
onOpen: () => void
|
|
@@ -1096,39 +1245,41 @@ function PageRow({
|
|
|
1096
1245
|
/>
|
|
1097
1246
|
</td>
|
|
1098
1247
|
<td className="px-3 py-3">
|
|
1099
|
-
<button type="button" onClick={onOpen} className="
|
|
1100
|
-
<span
|
|
1101
|
-
|
|
1102
|
-
<span className="block truncate text-sm font-medium text-[var(--txt)] group-hover:text-[var(--acc)]">
|
|
1103
|
-
{page.title}
|
|
1104
|
-
</span>
|
|
1105
|
-
<span className="block truncate text-xs text-[var(--sub)]">{page.path}</span>
|
|
1248
|
+
<button type="button" onClick={onOpen} className="block w-full min-w-0 text-left">
|
|
1249
|
+
<span className="block truncate text-sm font-medium text-[var(--txt)] group-hover:text-[var(--acc)]">
|
|
1250
|
+
{page.title}
|
|
1106
1251
|
</span>
|
|
1252
|
+
<span className="block truncate text-xs text-[var(--sub)]">{page.path}</span>
|
|
1107
1253
|
</button>
|
|
1108
1254
|
</td>
|
|
1109
|
-
|
|
1110
|
-
<
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
<
|
|
1121
|
-
|
|
1122
|
-
|
|
1255
|
+
{showTags && (
|
|
1256
|
+
<td className="px-3 py-3">
|
|
1257
|
+
<PageTagsCell
|
|
1258
|
+
tagSlugs={page.tags}
|
|
1259
|
+
primaryTagId={page.primaryTagId}
|
|
1260
|
+
resolve={resolveTag}
|
|
1261
|
+
/>
|
|
1262
|
+
</td>
|
|
1263
|
+
)}
|
|
1264
|
+
{showAuthor && (
|
|
1265
|
+
<td className="px-3 py-3">
|
|
1266
|
+
<div className="flex items-center gap-2">
|
|
1267
|
+
<AuthorAvatar name={page.authorName} size={28} />
|
|
1268
|
+
<span className="truncate text-sm text-[var(--txt)]">{page.authorName}</span>
|
|
1269
|
+
</div>
|
|
1270
|
+
</td>
|
|
1271
|
+
)}
|
|
1123
1272
|
<td className="px-3 py-3">
|
|
1124
1273
|
<PostStatusBadge status={page.status} />
|
|
1125
1274
|
</td>
|
|
1126
1275
|
<td className="px-3 py-3 text-sm text-[var(--sub)] tabular-nums">
|
|
1127
1276
|
{formatDateShort(page.lastModified)}
|
|
1128
1277
|
</td>
|
|
1129
|
-
|
|
1130
|
-
<
|
|
1131
|
-
|
|
1278
|
+
{showSeo && (
|
|
1279
|
+
<td className="px-3 py-3">
|
|
1280
|
+
<SeoScoreIndicator score={page.seoScore} />
|
|
1281
|
+
</td>
|
|
1282
|
+
)}
|
|
1132
1283
|
<td className="px-3 py-3">
|
|
1133
1284
|
<RowActions>
|
|
1134
1285
|
<RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
|
|
@@ -1156,12 +1307,12 @@ function PageRow({
|
|
|
1156
1307
|
)
|
|
1157
1308
|
}
|
|
1158
1309
|
|
|
1159
|
-
function LoadingRows() {
|
|
1310
|
+
function LoadingRows({ columns }: { columns: number }) {
|
|
1160
1311
|
return (
|
|
1161
1312
|
<>
|
|
1162
1313
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
1163
1314
|
<tr key={i} className="border-b border-[var(--bdr)] last:border-b-0">
|
|
1164
|
-
{Array.from({ length:
|
|
1315
|
+
{Array.from({ length: columns }).map((__, j) => (
|
|
1165
1316
|
<td key={j} className="px-3 py-3">
|
|
1166
1317
|
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
1167
1318
|
</td>
|
|
@@ -1173,17 +1324,19 @@ function LoadingRows() {
|
|
|
1173
1324
|
}
|
|
1174
1325
|
|
|
1175
1326
|
function EmptyRow({
|
|
1327
|
+
columns,
|
|
1176
1328
|
filtersActive,
|
|
1177
1329
|
onClear,
|
|
1178
1330
|
onNewPage,
|
|
1179
1331
|
}: {
|
|
1332
|
+
columns: number
|
|
1180
1333
|
filtersActive: boolean
|
|
1181
1334
|
onClear: () => void
|
|
1182
1335
|
onNewPage: () => void
|
|
1183
1336
|
}) {
|
|
1184
1337
|
return (
|
|
1185
1338
|
<tr>
|
|
1186
|
-
<td colSpan={
|
|
1339
|
+
<td colSpan={columns} className="px-3 py-16 text-center">
|
|
1187
1340
|
<div className="mb-2 text-sm font-medium text-[var(--txt)]">
|
|
1188
1341
|
{filtersActive ? 'No pages match your filters' : 'No pages yet'}
|
|
1189
1342
|
</div>
|
|
@@ -17,6 +17,7 @@ import { ChevronDown, Copy, GripVertical, PenLine, Plus, Trash2 } from 'lucide-r
|
|
|
17
17
|
import type { Page } from '../../lib/pages-service.js'
|
|
18
18
|
import type { PageTreeRow } from '../../lib/pages-tree.js'
|
|
19
19
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
20
|
+
import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
|
|
20
21
|
import { Button } from '../../components/ui/Button.js'
|
|
21
22
|
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
22
23
|
|
|
@@ -354,12 +355,7 @@ function TreeRow({
|
|
|
354
355
|
</td>
|
|
355
356
|
<td className="px-3 align-middle">
|
|
356
357
|
<div className="flex items-center gap-[7px]">
|
|
357
|
-
<
|
|
358
|
-
aria-hidden
|
|
359
|
-
className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[var(--acc-l)] text-[9px] font-medium text-[var(--acc)]"
|
|
360
|
-
>
|
|
361
|
-
{page.authorInitials}
|
|
362
|
-
</span>
|
|
358
|
+
<AuthorAvatar name={page.authorName} size={22} />
|
|
363
359
|
<span className="truncate text-[13px] text-[var(--txt)]">{page.authorName}</span>
|
|
364
360
|
</div>
|
|
365
361
|
</td>
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
SeoScoreIndicator,
|
|
34
34
|
paletteFor,
|
|
35
35
|
} from '../../components/posts/badges.js'
|
|
36
|
+
import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
|
|
36
37
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
37
38
|
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
38
39
|
|
|
@@ -812,12 +813,7 @@ function PostRow({
|
|
|
812
813
|
</td>
|
|
813
814
|
<td className="px-3 py-3">
|
|
814
815
|
<div className="flex items-center gap-2">
|
|
815
|
-
<
|
|
816
|
-
aria-hidden
|
|
817
|
-
className="bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-medium"
|
|
818
|
-
>
|
|
819
|
-
{post.authorInitials}
|
|
820
|
-
</span>
|
|
816
|
+
<AuthorAvatar name={post.authorName} size={28} />
|
|
821
817
|
<span className="text-foreground truncate text-sm">{post.authorName}</span>
|
|
822
818
|
</div>
|
|
823
819
|
</td>
|
|
@@ -145,6 +145,10 @@ export function PageSectionEditor({
|
|
|
145
145
|
if (!documentId) {
|
|
146
146
|
setPage(emptyPage(initialParentId ?? null))
|
|
147
147
|
setLoading(false)
|
|
148
|
+
// Start a brand-new page the way WordPress does: name it first. The
|
|
149
|
+
// settings modal opens with the title focused and the slug/path
|
|
150
|
+
// deriving live, so the required fields are done before building.
|
|
151
|
+
setSettingsOpen(true)
|
|
148
152
|
return
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
6
|
import { Button } from '../../components/ui/Button.js'
|
|
@@ -49,6 +49,9 @@ export function PageSettingsModal({
|
|
|
49
49
|
// Auto-derive slug/path from title until the user edits slug manually.
|
|
50
50
|
const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
|
|
51
51
|
const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
|
|
52
|
+
// Land the user in the Title field (WordPress-style "name it first"),
|
|
53
|
+
// rather than Radix's default focus on the header close button.
|
|
54
|
+
const titleRef = useRef<HTMLInputElement | null>(null)
|
|
52
55
|
|
|
53
56
|
useEffect(() => {
|
|
54
57
|
if (!open) return
|
|
@@ -118,29 +121,49 @@ export function PageSettingsModal({
|
|
|
118
121
|
open={open}
|
|
119
122
|
onClose={onClose}
|
|
120
123
|
title="Page settings"
|
|
124
|
+
initialFocusRef={titleRef}
|
|
121
125
|
actions={
|
|
122
126
|
<>
|
|
123
127
|
<Button variant="ghost" onClick={onClose}>
|
|
124
128
|
Cancel
|
|
125
129
|
</Button>
|
|
126
|
-
<Button
|
|
130
|
+
<Button
|
|
131
|
+
variant="primary"
|
|
132
|
+
form="page-settings-form"
|
|
133
|
+
type="submit"
|
|
134
|
+
disabled={!canEdit || Boolean(slugError)}
|
|
135
|
+
>
|
|
127
136
|
Done
|
|
128
137
|
</Button>
|
|
129
138
|
</>
|
|
130
139
|
}
|
|
131
140
|
>
|
|
132
|
-
|
|
141
|
+
{/* A real form so Enter in any field confirms the dialog — matters
|
|
142
|
+
for the new-page flow where this modal is the first stop. */}
|
|
143
|
+
<form
|
|
144
|
+
id="page-settings-form"
|
|
145
|
+
className="space-y-3"
|
|
146
|
+
onSubmit={(e) => {
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
handleSave()
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
133
151
|
<div>
|
|
134
152
|
<label className={LABEL} htmlFor="ps-title">
|
|
135
153
|
Title <span className="text-destructive">*</span>
|
|
136
154
|
</label>
|
|
137
155
|
<input
|
|
138
156
|
id="ps-title"
|
|
157
|
+
ref={titleRef}
|
|
139
158
|
className={INPUT}
|
|
140
159
|
value={title}
|
|
141
160
|
disabled={!canEdit}
|
|
161
|
+
placeholder="e.g. About Us"
|
|
142
162
|
onChange={(e) => handleTitle(e.target.value)}
|
|
143
163
|
/>
|
|
164
|
+
<p className="text-muted-foreground mt-1 text-xs">
|
|
165
|
+
The slug and path below follow the title until you edit them.
|
|
166
|
+
</p>
|
|
144
167
|
</div>
|
|
145
168
|
<div className="grid grid-cols-2 gap-3">
|
|
146
169
|
<div>
|
|
@@ -240,7 +263,7 @@ export function PageSettingsModal({
|
|
|
240
263
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
241
264
|
/>
|
|
242
265
|
</div>
|
|
243
|
-
</
|
|
266
|
+
</form>
|
|
244
267
|
</Modal>
|
|
245
268
|
)
|
|
246
269
|
}
|