@actuate-media/cms-admin 0.16.1 → 0.18.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/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- package/dist/__tests__/lib/useApiData.test.js +28 -1
- package/dist/__tests__/lib/useApiData.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/useApiData.d.ts +11 -0
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +41 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +54 -48
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- package/src/__tests__/lib/useApiData.test.ts +36 -1
- package/src/__tests__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/lib/useApiData.ts +52 -7
- package/src/styles/theme.css +37 -0
- package/src/views/Dashboard.tsx +77 -61
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/page-editor/PagePreview.tsx +10 -1
- package/src/views/page-editor/PageSectionEditor.tsx +25 -1
package/src/lib/pages-service.ts
CHANGED
|
@@ -94,13 +94,28 @@ export interface PagesQuery {
|
|
|
94
94
|
|
|
95
95
|
export interface PagesResult {
|
|
96
96
|
pages: Page[]
|
|
97
|
+
/** number of pages matching the active query (post-filter) */
|
|
97
98
|
total: number
|
|
99
|
+
/** total pages in the collection, ignoring the active query/filters */
|
|
100
|
+
grandTotal: number
|
|
98
101
|
page: number
|
|
99
102
|
pageSize: number
|
|
100
103
|
tags: PageTag[]
|
|
101
104
|
authors: PageAuthor[]
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The full, unfiltered page set plus its tags and authors. The list view
|
|
109
|
+
* fetches this once and applies search / sort / group / paging in memory
|
|
110
|
+
* (see `applyPagesQuery` + `groupPages`), so typing in the search box or
|
|
111
|
+
* changing the sort never re-downloads the dataset.
|
|
112
|
+
*/
|
|
113
|
+
export interface PagesDataset {
|
|
114
|
+
pages: Page[]
|
|
115
|
+
tags: PageTag[]
|
|
116
|
+
authors: PageAuthor[]
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
// ─── API row shapes ──────────────────────────────────────────────────
|
|
105
120
|
|
|
106
121
|
interface DocumentApiRow {
|
|
@@ -371,7 +386,13 @@ function statusLabel(s: PageStatus): string {
|
|
|
371
386
|
return s.charAt(0) + s.slice(1).toLowerCase()
|
|
372
387
|
}
|
|
373
388
|
|
|
374
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Fetch the entire page dataset (unfiltered) plus tags and authors. This
|
|
391
|
+
* is the single network read backing the list view; callers apply query
|
|
392
|
+
* shaping in memory. Tag page counts and the author list are derived from
|
|
393
|
+
* the full set so they reflect the dataset, not any active filter.
|
|
394
|
+
*/
|
|
395
|
+
export async function fetchPagesDataset(): Promise<PagesDataset> {
|
|
375
396
|
const authorsMap = await fetchAuthorsMap()
|
|
376
397
|
|
|
377
398
|
const [pagesRes, tagDocs] = await Promise.all([
|
|
@@ -383,8 +404,6 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
|
|
|
383
404
|
docToPage(d, authorsMap),
|
|
384
405
|
)
|
|
385
406
|
|
|
386
|
-
// Tag page counts come from the full (unfiltered) set so chips and the
|
|
387
|
-
// tag manager reflect the dataset, not the active query.
|
|
388
407
|
const counts = new Map<string, number>()
|
|
389
408
|
for (const p of allPages) {
|
|
390
409
|
for (const slug of p.tags) counts.set(slug, (counts.get(slug) ?? 0) + 1)
|
|
@@ -393,16 +412,6 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
|
|
|
393
412
|
.map((d) => docToTag(d, counts.get(d.slug ?? d.id) ?? 0))
|
|
394
413
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
|
395
414
|
|
|
396
|
-
const filtered = applyPagesQuery(allPages, query)
|
|
397
|
-
|
|
398
|
-
// Pagination only applies to the flat (ungrouped) view; grouped views
|
|
399
|
-
// render every match so the buckets stay complete.
|
|
400
|
-
const grouped = query.groupBy && query.groupBy !== 'none'
|
|
401
|
-
const page = Math.max(1, query.page ?? 1)
|
|
402
|
-
const pageSize = Math.max(1, query.pageSize ?? 25)
|
|
403
|
-
const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
|
|
404
|
-
|
|
405
|
-
// Authors present in the dataset (for the Author filter dropdown).
|
|
406
415
|
const seen = new Set<string>()
|
|
407
416
|
const authors: PageAuthor[] = []
|
|
408
417
|
for (const p of allPages) {
|
|
@@ -413,7 +422,35 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
|
|
|
413
422
|
}
|
|
414
423
|
authors.sort((a, b) => a.name.localeCompare(b.name))
|
|
415
424
|
|
|
416
|
-
return { pages:
|
|
425
|
+
return { pages: allPages, tags, authors }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Query a paginated, filtered page list. Retained for non-list callers
|
|
430
|
+
* (tag detach/merge) and tests; the list view uses `fetchPagesDataset`
|
|
431
|
+
* directly and shapes the result in memory.
|
|
432
|
+
*/
|
|
433
|
+
export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
|
|
434
|
+
const { pages: allPages, tags, authors } = await fetchPagesDataset()
|
|
435
|
+
|
|
436
|
+
const filtered = applyPagesQuery(allPages, query)
|
|
437
|
+
|
|
438
|
+
// Pagination only applies to the flat (ungrouped) view; grouped views
|
|
439
|
+
// render every match so the buckets stay complete.
|
|
440
|
+
const grouped = query.groupBy && query.groupBy !== 'none'
|
|
441
|
+
const page = Math.max(1, query.page ?? 1)
|
|
442
|
+
const pageSize = Math.max(1, query.pageSize ?? 25)
|
|
443
|
+
const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
pages: paged,
|
|
447
|
+
total: filtered.length,
|
|
448
|
+
grandTotal: allPages.length,
|
|
449
|
+
page,
|
|
450
|
+
pageSize,
|
|
451
|
+
tags,
|
|
452
|
+
authors,
|
|
453
|
+
}
|
|
417
454
|
}
|
|
418
455
|
|
|
419
456
|
export async function fetchPageById(pageId: string): Promise<Page | null> {
|
|
@@ -432,34 +469,54 @@ export interface BulkResult {
|
|
|
432
469
|
failed: number
|
|
433
470
|
}
|
|
434
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Run `task` over `items` with a bounded number of concurrent requests so
|
|
474
|
+
* a large selection doesn't fire one-at-a-time (slow) or all-at-once
|
|
475
|
+
* (overwhelms the API / rate limiter). Each task resolves to `true` on
|
|
476
|
+
* success; failures are counted, never thrown, so one bad row can't abort
|
|
477
|
+
* the batch.
|
|
478
|
+
*/
|
|
479
|
+
async function mapWithConcurrency<T>(
|
|
480
|
+
items: T[],
|
|
481
|
+
limit: number,
|
|
482
|
+
task: (item: T) => Promise<boolean>,
|
|
483
|
+
): Promise<BulkResult> {
|
|
484
|
+
let ok = 0
|
|
485
|
+
let failed = 0
|
|
486
|
+
let cursor = 0
|
|
487
|
+
const workerCount = Math.max(1, Math.min(limit, items.length))
|
|
488
|
+
const worker = async () => {
|
|
489
|
+
while (cursor < items.length) {
|
|
490
|
+
const item = items[cursor++]!
|
|
491
|
+
const success = await task(item).catch(() => false)
|
|
492
|
+
if (success) ok++
|
|
493
|
+
else failed++
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await Promise.all(Array.from({ length: workerCount }, worker))
|
|
497
|
+
return { ok, failed }
|
|
498
|
+
}
|
|
499
|
+
|
|
435
500
|
export async function bulkUpdatePages(
|
|
436
501
|
ids: string[],
|
|
437
502
|
payload: { status?: PageStatus; data?: Record<string, unknown> },
|
|
438
503
|
): Promise<BulkResult> {
|
|
439
|
-
|
|
440
|
-
let failed = 0
|
|
441
|
-
for (const id of ids) {
|
|
504
|
+
return mapWithConcurrency(ids, 5, async (id) => {
|
|
442
505
|
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
443
506
|
method: 'PUT',
|
|
444
507
|
body: JSON.stringify(payload),
|
|
445
508
|
})
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
return { ok, failed }
|
|
509
|
+
return !res.error
|
|
510
|
+
})
|
|
450
511
|
}
|
|
451
512
|
|
|
452
513
|
export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
|
|
453
|
-
|
|
454
|
-
let failed = 0
|
|
455
|
-
for (const id of ids) {
|
|
514
|
+
return mapWithConcurrency(ids, 5, async (id) => {
|
|
456
515
|
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
457
516
|
method: 'DELETE',
|
|
458
517
|
})
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
return { ok, failed }
|
|
518
|
+
return !res.error
|
|
519
|
+
})
|
|
463
520
|
}
|
|
464
521
|
|
|
465
522
|
/**
|
|
@@ -472,11 +529,9 @@ export async function bulkChangePageTags(
|
|
|
472
529
|
pages: Pick<Page, 'id' | 'tags' | 'primaryTagId'>[],
|
|
473
530
|
change: { add?: string[]; remove?: string[] },
|
|
474
531
|
): Promise<BulkResult> {
|
|
475
|
-
let ok = 0
|
|
476
|
-
let failed = 0
|
|
477
532
|
const add = new Set(change.add ?? [])
|
|
478
533
|
const remove = new Set(change.remove ?? [])
|
|
479
|
-
|
|
534
|
+
return mapWithConcurrency(pages, 5, async (p) => {
|
|
480
535
|
const next = new Set(p.tags)
|
|
481
536
|
for (const t of add) next.add(t)
|
|
482
537
|
for (const t of remove) next.delete(t)
|
|
@@ -487,10 +542,8 @@ export async function bulkChangePageTags(
|
|
|
487
542
|
method: 'PUT',
|
|
488
543
|
body: JSON.stringify({ data: { tags, primaryTag } }),
|
|
489
544
|
})
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
return { ok, failed }
|
|
545
|
+
return !res.error
|
|
546
|
+
})
|
|
494
547
|
}
|
|
495
548
|
|
|
496
549
|
// ─── Tag CRUD ────────────────────────────────────────────────────────
|
package/src/lib/useApiData.ts
CHANGED
|
@@ -6,6 +6,34 @@ export interface UseApiDataOptions {
|
|
|
6
6
|
maxRetries?: number
|
|
7
7
|
/** Base delay in ms for exponential backoff (default 1000). Actual delay = base * 2^attempt. */
|
|
8
8
|
retryBaseMs?: number
|
|
9
|
+
/**
|
|
10
|
+
* When set, successful responses are cached in-memory keyed by endpoint
|
|
11
|
+
* for this many ms and reused on the next mount, skipping the network
|
|
12
|
+
* (cache-first). Opt-in — omit to keep the default always-fetch
|
|
13
|
+
* behaviour. `refetch()` always bypasses the cache. Useful for views
|
|
14
|
+
* that remount frequently (e.g. dashboard navigation) so they don't
|
|
15
|
+
* re-issue the same requests on every visit.
|
|
16
|
+
*/
|
|
17
|
+
cacheMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process-wide response cache for `cacheMs` consumers. Keyed by endpoint
|
|
22
|
+
* (note: the locale query param is appended downstream by `cmsApi`, so a
|
|
23
|
+
* locale switch is not reflected here — only enable `cacheMs` for
|
|
24
|
+
* locale-invariant endpoints like `/stats`).
|
|
25
|
+
*/
|
|
26
|
+
const responseCache = new Map<string, { data: unknown; at: number }>()
|
|
27
|
+
|
|
28
|
+
function readCache<T>(endpoint: string, cacheMs: number): { value: T | null } | null {
|
|
29
|
+
const hit = responseCache.get(endpoint)
|
|
30
|
+
if (hit && Date.now() - hit.at < cacheMs) return { value: hit.data as T | null }
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test-only: drop all cached responses so cases don't bleed into each other. */
|
|
35
|
+
export function __clearApiDataCache(): void {
|
|
36
|
+
responseCache.clear()
|
|
9
37
|
}
|
|
10
38
|
|
|
11
39
|
export interface UseApiDataResult<T> {
|
|
@@ -22,9 +50,12 @@ export function useApiData<T>(
|
|
|
22
50
|
defaultValue: T | null = null,
|
|
23
51
|
options: UseApiDataOptions = {},
|
|
24
52
|
): UseApiDataResult<T> {
|
|
25
|
-
const { maxRetries = 3, retryBaseMs = 1000 } = options
|
|
26
|
-
|
|
27
|
-
|
|
53
|
+
const { maxRetries = 3, retryBaseMs = 1000, cacheMs } = options
|
|
54
|
+
// Read the cache synchronously for the initial state so a warm cache
|
|
55
|
+
// hit paints immediately instead of flashing a loading spinner.
|
|
56
|
+
const cachedInitial = endpoint && cacheMs ? readCache<T>(endpoint, cacheMs) : null
|
|
57
|
+
const [data, setData] = useState<T | null>(cachedInitial ? cachedInitial.value : defaultValue)
|
|
58
|
+
const [loading, setLoading] = useState(endpoint !== null && !cachedInitial)
|
|
28
59
|
const [error, setError] = useState<string | null>(null)
|
|
29
60
|
const [exhausted, setExhausted] = useState(false)
|
|
30
61
|
const abortRef = useRef<AbortController | null>(null)
|
|
@@ -32,12 +63,24 @@ export function useApiData<T>(
|
|
|
32
63
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
33
64
|
|
|
34
65
|
const fetchData = useCallback(
|
|
35
|
-
async (isRetry = false) => {
|
|
66
|
+
async (isRetry = false, forceRefresh = false) => {
|
|
36
67
|
if (!endpoint) {
|
|
37
68
|
setLoading(false)
|
|
38
69
|
return
|
|
39
70
|
}
|
|
40
71
|
|
|
72
|
+
// Cache-first: a fresh cached response short-circuits the network.
|
|
73
|
+
// `refetch()` passes `forceRefresh` to bypass this.
|
|
74
|
+
if (!isRetry && !forceRefresh && cacheMs) {
|
|
75
|
+
const hit = readCache<T>(endpoint, cacheMs)
|
|
76
|
+
if (hit) {
|
|
77
|
+
setData(hit.value)
|
|
78
|
+
setError(null)
|
|
79
|
+
setLoading(false)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
abortRef.current?.abort()
|
|
42
85
|
const controller = new AbortController()
|
|
43
86
|
abortRef.current = controller
|
|
@@ -65,12 +108,14 @@ export function useApiData<T>(
|
|
|
65
108
|
setExhausted(true)
|
|
66
109
|
setLoading(false)
|
|
67
110
|
} else {
|
|
68
|
-
|
|
111
|
+
const value = res.data ?? null
|
|
112
|
+
setData(value)
|
|
69
113
|
setLoading(false)
|
|
70
114
|
retryCountRef.current = 0
|
|
115
|
+
if (cacheMs) responseCache.set(endpoint, { data: value, at: Date.now() })
|
|
71
116
|
}
|
|
72
117
|
},
|
|
73
|
-
[endpoint, maxRetries, retryBaseMs],
|
|
118
|
+
[endpoint, maxRetries, retryBaseMs, cacheMs],
|
|
74
119
|
)
|
|
75
120
|
|
|
76
121
|
const manualRefetch = useCallback(() => {
|
|
@@ -80,7 +125,7 @@ export function useApiData<T>(
|
|
|
80
125
|
}
|
|
81
126
|
retryCountRef.current = 0
|
|
82
127
|
setExhausted(false)
|
|
83
|
-
fetchData(false)
|
|
128
|
+
fetchData(false, true)
|
|
84
129
|
}, [fetchData])
|
|
85
130
|
|
|
86
131
|
useEffect(() => {
|
package/src/styles/theme.css
CHANGED
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
--accent-foreground: #030213;
|
|
19
19
|
--destructive: #d4183d;
|
|
20
20
|
--destructive-foreground: #ffffff;
|
|
21
|
+
/*
|
|
22
|
+
* Semantic status + brand tokens.
|
|
23
|
+
* ----------------------------------------------------------------
|
|
24
|
+
* `destructive` already covers the "error/danger" state; these add
|
|
25
|
+
* the remaining semantic states the admin needs (success / warning /
|
|
26
|
+
* info) plus the violet brand accent used for links and primary
|
|
27
|
+
* affordances. Values are tuned to read as TEXT on both the card
|
|
28
|
+
* surface and their own `/10` tint (badge backgrounds), and as a
|
|
29
|
+
* solid fill (dots, progress bars). The `*-foreground` variants are
|
|
30
|
+
* for text placed on a SOLID fill. Light values match the previous
|
|
31
|
+
* emerald/amber/blue/violet-600 palette so tokenizing is a no-op
|
|
32
|
+
* visually; dark values track the *-300/*-400 tints used before.
|
|
33
|
+
*/
|
|
34
|
+
--success: oklch(0.5 0.13 155);
|
|
35
|
+
--success-foreground: #ffffff;
|
|
36
|
+
--warning: oklch(0.52 0.13 73);
|
|
37
|
+
--warning-foreground: oklch(0.205 0 0);
|
|
38
|
+
--info: oklch(0.5 0.17 254);
|
|
39
|
+
--info-foreground: #ffffff;
|
|
40
|
+
--brand: oklch(0.54 0.23 293);
|
|
41
|
+
--brand-foreground: #ffffff;
|
|
21
42
|
--border: rgba(0, 0, 0, 0.1);
|
|
22
43
|
--input: transparent;
|
|
23
44
|
--input-background: #f3f3f5;
|
|
@@ -59,6 +80,14 @@
|
|
|
59
80
|
--accent-foreground: oklch(0.985 0 0);
|
|
60
81
|
--destructive: oklch(0.396 0.141 25.723);
|
|
61
82
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
83
|
+
--success: oklch(0.72 0.15 155);
|
|
84
|
+
--success-foreground: oklch(0.205 0 0);
|
|
85
|
+
--warning: oklch(0.8 0.13 80);
|
|
86
|
+
--warning-foreground: oklch(0.205 0 0);
|
|
87
|
+
--info: oklch(0.72 0.14 252);
|
|
88
|
+
--info-foreground: oklch(0.205 0 0);
|
|
89
|
+
--brand: oklch(0.72 0.18 293);
|
|
90
|
+
--brand-foreground: oklch(0.205 0 0);
|
|
62
91
|
--border: oklch(0.269 0 0);
|
|
63
92
|
--input: oklch(0.269 0 0);
|
|
64
93
|
--input-background: oklch(0.205 0 0);
|
|
@@ -98,6 +127,14 @@
|
|
|
98
127
|
--color-accent-foreground: var(--accent-foreground);
|
|
99
128
|
--color-destructive: var(--destructive);
|
|
100
129
|
--color-destructive-foreground: var(--destructive-foreground);
|
|
130
|
+
--color-success: var(--success);
|
|
131
|
+
--color-success-foreground: var(--success-foreground);
|
|
132
|
+
--color-warning: var(--warning);
|
|
133
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
134
|
+
--color-info: var(--info);
|
|
135
|
+
--color-info-foreground: var(--info-foreground);
|
|
136
|
+
--color-brand: var(--brand);
|
|
137
|
+
--color-brand-foreground: var(--brand-foreground);
|
|
101
138
|
--color-border: var(--border);
|
|
102
139
|
--color-input: var(--input);
|
|
103
140
|
--color-input-background: var(--input-background);
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -217,33 +217,32 @@ function authorAvatar(name: string | null | undefined): { color: string; initial
|
|
|
217
217
|
return { color, initials }
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Semantic state styling uses theme tokens (`success` / `warning` / `info`
|
|
222
|
+
* / `brand`) so the dashboard tracks the design system in both light and
|
|
223
|
+
* dark mode. Two deliberate exceptions remain as raw values:
|
|
224
|
+
* - Error states keep `text-red-*` / `bg-red-*`. The `--destructive`
|
|
225
|
+
* token's dark value is intentionally dark (low contrast as TEXT on a
|
|
226
|
+
* dark card), so tokenizing error text here would regress contrast.
|
|
227
|
+
* Promoting error to a token is a separate, admin-wide change.
|
|
228
|
+
* - `AVATAR_COLORS` are dynamic per-author hashes (allowed: truly
|
|
229
|
+
* dynamic values, applied via inline style).
|
|
230
|
+
*/
|
|
220
231
|
function statusBadge(status: string): {
|
|
221
232
|
label: string
|
|
222
233
|
cls: string
|
|
223
234
|
} {
|
|
224
235
|
switch (status) {
|
|
225
236
|
case 'PUBLISHED':
|
|
226
|
-
return {
|
|
227
|
-
label: 'Published',
|
|
228
|
-
cls: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',
|
|
229
|
-
}
|
|
237
|
+
return { label: 'Published', cls: 'bg-success/10 text-success' }
|
|
230
238
|
case 'DRAFT':
|
|
231
|
-
return {
|
|
232
|
-
label: 'Draft',
|
|
233
|
-
cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
234
|
-
}
|
|
239
|
+
return { label: 'Draft', cls: 'bg-muted text-muted-foreground' }
|
|
235
240
|
case 'SCHEDULED':
|
|
236
|
-
return {
|
|
237
|
-
label: 'Scheduled',
|
|
238
|
-
cls: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',
|
|
239
|
-
}
|
|
241
|
+
return { label: 'Scheduled', cls: 'bg-warning/10 text-warning' }
|
|
240
242
|
case 'IN_REVIEW':
|
|
241
|
-
return {
|
|
242
|
-
label: 'In Review',
|
|
243
|
-
cls: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300',
|
|
244
|
-
}
|
|
243
|
+
return { label: 'In Review', cls: 'bg-info/10 text-info' }
|
|
245
244
|
default:
|
|
246
|
-
return { label: status, cls: 'bg-
|
|
245
|
+
return { label: status, cls: 'bg-muted text-muted-foreground' }
|
|
247
246
|
}
|
|
248
247
|
}
|
|
249
248
|
|
|
@@ -263,12 +262,31 @@ interface StatCardData {
|
|
|
263
262
|
|
|
264
263
|
export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
265
264
|
const nav = (path: string) => onNavigate?.(path)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
265
|
+
// Cache responses for 30s so navigating away and back doesn't re-fire
|
|
266
|
+
// all four dashboard requests on every visit. First load is unaffected
|
|
267
|
+
// (cold cache) and each request still streams independently; `refetch`
|
|
268
|
+
// (the Retry button) bypasses the cache.
|
|
269
|
+
const DASH_CACHE_MS = 30_000
|
|
270
|
+
const {
|
|
271
|
+
data: stats,
|
|
272
|
+
loading,
|
|
273
|
+
error,
|
|
274
|
+
exhausted,
|
|
275
|
+
refetch,
|
|
276
|
+
} = useApiData<DashboardStats>('/stats', null, {
|
|
277
|
+
cacheMs: DASH_CACHE_MS,
|
|
278
|
+
})
|
|
279
|
+
const { data: health } = useApiData<HealthData>('/health', null, { cacheMs: DASH_CACHE_MS })
|
|
280
|
+
const { data: delivery, loading: deliveryLoading } = useApiData<DeliveryStats>(
|
|
281
|
+
'/stats/api-delivery',
|
|
282
|
+
null,
|
|
283
|
+
{ cacheMs: DASH_CACHE_MS },
|
|
284
|
+
)
|
|
285
|
+
const { data: contentHealthData, loading: contentHealthLoading } = useApiData<ContentHealthData>(
|
|
286
|
+
'/stats/content-health',
|
|
287
|
+
null,
|
|
288
|
+
{ cacheMs: DASH_CACHE_MS },
|
|
289
|
+
)
|
|
272
290
|
|
|
273
291
|
// A slow ticker (60s) so the greeting, calendar date, and "x minutes
|
|
274
292
|
// ago" relative times stay correct on a dashboard left open for hours
|
|
@@ -311,8 +329,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
311
329
|
label: collectionLabel(primary[0]),
|
|
312
330
|
value: String(counts[primary[0].slug] ?? 0),
|
|
313
331
|
icon: primary[0].type === 'page' ? FileIcon : FileText,
|
|
314
|
-
iconBg: 'bg-
|
|
315
|
-
iconColor: 'text-
|
|
332
|
+
iconBg: 'bg-chart-1/15',
|
|
333
|
+
iconColor: 'text-chart-1',
|
|
316
334
|
href: `/${primary[0].slug}`,
|
|
317
335
|
})
|
|
318
336
|
} else {
|
|
@@ -320,8 +338,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
320
338
|
label: 'Documents',
|
|
321
339
|
value: String(stats?.totalDocuments ?? 0),
|
|
322
340
|
icon: FileText,
|
|
323
|
-
iconBg: 'bg-
|
|
324
|
-
iconColor: 'text-
|
|
341
|
+
iconBg: 'bg-chart-1/15',
|
|
342
|
+
iconColor: 'text-chart-1',
|
|
325
343
|
})
|
|
326
344
|
}
|
|
327
345
|
|
|
@@ -330,8 +348,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
330
348
|
label: collectionLabel(primary[1]),
|
|
331
349
|
value: String(counts[primary[1].slug] ?? 0),
|
|
332
350
|
icon: primary[1].type === 'page' ? FileIcon : FileText,
|
|
333
|
-
iconBg: 'bg-
|
|
334
|
-
iconColor: 'text-
|
|
351
|
+
iconBg: 'bg-chart-2/15',
|
|
352
|
+
iconColor: 'text-chart-2',
|
|
335
353
|
href: `/${primary[1].slug}`,
|
|
336
354
|
})
|
|
337
355
|
}
|
|
@@ -344,8 +362,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
344
362
|
? `${stats.totalMedia} file${stats.totalMedia === 1 ? '' : 's'}`
|
|
345
363
|
: undefined,
|
|
346
364
|
icon: ImageIcon,
|
|
347
|
-
iconBg: 'bg-
|
|
348
|
-
iconColor: 'text-
|
|
365
|
+
iconBg: 'bg-chart-3/15',
|
|
366
|
+
iconColor: 'text-chart-3',
|
|
349
367
|
href: '/media',
|
|
350
368
|
})
|
|
351
369
|
|
|
@@ -358,8 +376,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
358
376
|
value: String(formsCount),
|
|
359
377
|
hint: submissions > 0 ? `${submissions} response${submissions === 1 ? '' : 's'}` : undefined,
|
|
360
378
|
icon: ClipboardList,
|
|
361
|
-
iconBg: 'bg-
|
|
362
|
-
iconColor: 'text-
|
|
379
|
+
iconBg: 'bg-chart-4/15',
|
|
380
|
+
iconColor: 'text-chart-4',
|
|
363
381
|
href: '/forms',
|
|
364
382
|
})
|
|
365
383
|
|
|
@@ -371,8 +389,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
371
389
|
hint: seo > 0 ? (seo >= 70 ? 'Good' : seo >= 40 ? 'Fair' : 'Needs work') : 'No content yet',
|
|
372
390
|
hintUp: seo >= 70,
|
|
373
391
|
icon: Search,
|
|
374
|
-
iconBg: 'bg-
|
|
375
|
-
iconColor: 'text-
|
|
392
|
+
iconBg: 'bg-chart-5/15',
|
|
393
|
+
iconColor: 'text-chart-5',
|
|
376
394
|
href: '/seo',
|
|
377
395
|
})
|
|
378
396
|
|
|
@@ -517,7 +535,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
517
535
|
if (loading && !stats) {
|
|
518
536
|
return (
|
|
519
537
|
<div className="flex h-64 items-center justify-center p-4 sm:p-6">
|
|
520
|
-
<Loader2 className="h-6 w-6 animate-spin
|
|
538
|
+
<Loader2 className="text-brand h-6 w-6 animate-spin" />
|
|
521
539
|
</div>
|
|
522
540
|
)
|
|
523
541
|
}
|
|
@@ -526,13 +544,11 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
526
544
|
<div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
|
|
527
545
|
{/* Health banners ────────────────────────────────────────────── */}
|
|
528
546
|
{health && health.status === 'degraded' && (
|
|
529
|
-
<div className="flex items-start gap-3 rounded-lg border
|
|
530
|
-
<Database className="mt-0.5 h-5 w-5 shrink-0
|
|
547
|
+
<div className="border-info/30 bg-info/10 flex items-start gap-3 rounded-lg border p-3">
|
|
548
|
+
<Database className="text-info mt-0.5 h-5 w-5 shrink-0" />
|
|
531
549
|
<div className="min-w-0 flex-1">
|
|
532
|
-
<span className="text-sm font-medium
|
|
533
|
-
|
|
534
|
-
</span>
|
|
535
|
-
<p className="mt-0.5 text-xs text-blue-700 dark:text-blue-300">
|
|
550
|
+
<span className="text-info text-sm font-medium">Database setup required</span>
|
|
551
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
536
552
|
{!health.databaseConnected
|
|
537
553
|
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
538
554
|
: !health.secretConfigured
|
|
@@ -547,14 +563,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
547
563
|
)}
|
|
548
564
|
|
|
549
565
|
{error && exhausted && (
|
|
550
|
-
<div className="flex items-center gap-3 rounded-lg border
|
|
551
|
-
<AlertTriangle className="h-5 w-5 shrink-0
|
|
552
|
-
<span className="flex-1 text-sm
|
|
566
|
+
<div className="border-warning/30 bg-warning/10 flex items-center gap-3 rounded-lg border p-3">
|
|
567
|
+
<AlertTriangle className="text-warning h-5 w-5 shrink-0" />
|
|
568
|
+
<span className="text-warning flex-1 text-sm">
|
|
553
569
|
Some dashboard data may be unavailable.
|
|
554
570
|
</span>
|
|
555
571
|
<button
|
|
556
572
|
onClick={refetch}
|
|
557
|
-
className="rounded-lg border
|
|
573
|
+
className="border-warning/40 text-warning hover:bg-warning/10 rounded-lg border px-3 py-1 text-sm transition-colors"
|
|
558
574
|
>
|
|
559
575
|
Retry
|
|
560
576
|
</button>
|
|
@@ -588,7 +604,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
588
604
|
<button
|
|
589
605
|
key={a.label}
|
|
590
606
|
onClick={a.onClick}
|
|
591
|
-
className="border-border bg-card inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3.5 py-2 text-sm shadow-sm transition-colors
|
|
607
|
+
className="border-border bg-card hover:border-brand/50 hover:bg-brand/10 hover:text-brand inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3.5 py-2 text-sm shadow-sm transition-colors"
|
|
592
608
|
>
|
|
593
609
|
<a.icon className="h-3.5 w-3.5" />
|
|
594
610
|
{a.label}
|
|
@@ -617,7 +633,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
617
633
|
<p className="text-muted-foreground mt-1 text-xs">{card.label}</p>
|
|
618
634
|
{card.hint && (
|
|
619
635
|
<p
|
|
620
|
-
className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-
|
|
636
|
+
className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-success' : 'text-muted-foreground'}`}
|
|
621
637
|
>
|
|
622
638
|
{card.hint}
|
|
623
639
|
</p>
|
|
@@ -653,7 +669,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
653
669
|
</div>
|
|
654
670
|
{(stats?.recentDocuments?.length ?? 0) > 8 && (
|
|
655
671
|
<button
|
|
656
|
-
className="text-xs font-medium
|
|
672
|
+
className="text-brand text-xs font-medium hover:underline"
|
|
657
673
|
onClick={() => setActivityLimit((n) => (n >= 20 ? 8 : 20))}
|
|
658
674
|
>
|
|
659
675
|
{activityLimit >= 20 ? 'Show less' : 'Show more'}
|
|
@@ -721,7 +737,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
721
737
|
</p>
|
|
722
738
|
</div>
|
|
723
739
|
<button
|
|
724
|
-
className="text-xs font-medium
|
|
740
|
+
className="text-brand text-xs font-medium hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50"
|
|
725
741
|
disabled={publishQueue.length === 0}
|
|
726
742
|
onClick={() => {
|
|
727
743
|
// We don't have a dedicated "scheduled" admin page, so deep-
|
|
@@ -750,7 +766,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
750
766
|
onClick={() => nav(q.editPath)}
|
|
751
767
|
className="hover:bg-accent/50 focus-visible:bg-accent/50 flex w-full min-w-0 cursor-pointer items-center gap-2.5 px-4 py-2.5 text-left transition-colors"
|
|
752
768
|
>
|
|
753
|
-
<span className="h-2 w-2 shrink-0 rounded-full
|
|
769
|
+
<span className="bg-warning h-2 w-2 shrink-0 rounded-full" aria-hidden />
|
|
754
770
|
<div className="min-w-0 flex-1">
|
|
755
771
|
<p className="text-foreground truncate text-sm font-medium">{q.title}</p>
|
|
756
772
|
<p className="text-muted-foreground truncate text-[11px]">
|
|
@@ -775,7 +791,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
775
791
|
<p className="text-muted-foreground mt-0.5 text-xs">SEO & quality issues</p>
|
|
776
792
|
</div>
|
|
777
793
|
<button
|
|
778
|
-
className="text-xs font-medium
|
|
794
|
+
className="text-brand text-xs font-medium hover:underline"
|
|
779
795
|
onClick={() => nav('/seo')}
|
|
780
796
|
>
|
|
781
797
|
Fix issues
|
|
@@ -792,9 +808,9 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
792
808
|
<span
|
|
793
809
|
className={`text-xs font-medium ${
|
|
794
810
|
contentHealth.tone === 'ok'
|
|
795
|
-
? 'text-
|
|
811
|
+
? 'text-success'
|
|
796
812
|
: contentHealth.tone === 'warn'
|
|
797
|
-
? 'text-
|
|
813
|
+
? 'text-warning'
|
|
798
814
|
: contentHealth.tone === 'err'
|
|
799
815
|
? 'text-red-600 dark:text-red-400'
|
|
800
816
|
: 'text-muted-foreground'
|
|
@@ -807,9 +823,9 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
807
823
|
<div
|
|
808
824
|
className={`h-full rounded-full transition-all ${
|
|
809
825
|
contentHealth.tone === 'ok'
|
|
810
|
-
? 'bg-
|
|
826
|
+
? 'bg-success'
|
|
811
827
|
: contentHealth.tone === 'warn'
|
|
812
|
-
? 'bg-
|
|
828
|
+
? 'bg-warning'
|
|
813
829
|
: contentHealth.tone === 'err'
|
|
814
830
|
? 'bg-red-500'
|
|
815
831
|
: 'bg-muted-foreground/40'
|
|
@@ -836,7 +852,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
836
852
|
iss.tone === 'err'
|
|
837
853
|
? 'bg-red-500'
|
|
838
854
|
: iss.tone === 'warn'
|
|
839
|
-
? 'bg-
|
|
855
|
+
? 'bg-warning'
|
|
840
856
|
: 'bg-muted-foreground'
|
|
841
857
|
}`}
|
|
842
858
|
aria-hidden
|
|
@@ -849,7 +865,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
849
865
|
iss.tone === 'err'
|
|
850
866
|
? 'text-red-600 dark:text-red-400'
|
|
851
867
|
: iss.tone === 'warn'
|
|
852
|
-
? 'text-
|
|
868
|
+
? 'text-warning'
|
|
853
869
|
: 'text-muted-foreground'
|
|
854
870
|
}`}
|
|
855
871
|
>
|
|
@@ -882,7 +898,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
882
898
|
</p>
|
|
883
899
|
</div>
|
|
884
900
|
<button
|
|
885
|
-
className="inline-flex items-center gap-1 text-xs font-medium
|
|
901
|
+
className="text-brand inline-flex items-center gap-1 text-xs font-medium hover:underline"
|
|
886
902
|
onClick={() => nav('/api-keys')}
|
|
887
903
|
>
|
|
888
904
|
API docs <ExternalLink className="h-3 w-3" />
|
|
@@ -1037,9 +1053,9 @@ function DeliveryTile({
|
|
|
1037
1053
|
}) {
|
|
1038
1054
|
const subTone =
|
|
1039
1055
|
tone === 'ok'
|
|
1040
|
-
? 'text-
|
|
1056
|
+
? 'text-success'
|
|
1041
1057
|
: tone === 'warn'
|
|
1042
|
-
? 'text-
|
|
1058
|
+
? 'text-warning'
|
|
1043
1059
|
: tone === 'err'
|
|
1044
1060
|
? 'text-red-600 dark:text-red-400'
|
|
1045
1061
|
: 'text-muted-foreground'
|