@actuate-media/cms-admin 0.9.0 → 0.11.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/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +8 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/primitives.test.d.ts +2 -0
- package/dist/__tests__/layout/primitives.test.d.ts.map +1 -0
- package/dist/__tests__/layout/primitives.test.js +34 -0
- package/dist/__tests__/layout/primitives.test.js.map +1 -0
- package/dist/__tests__/lib/cv.test.d.ts +2 -0
- package/dist/__tests__/lib/cv.test.d.ts.map +1 -0
- package/dist/__tests__/lib/cv.test.js +66 -0
- package/dist/__tests__/lib/cv.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/assets/actuate-logo.d.ts +36 -0
- package/dist/assets/actuate-logo.d.ts.map +1 -0
- package/dist/assets/actuate-logo.js +15 -0
- package/dist/assets/actuate-logo.js.map +1 -0
- package/dist/components/Breadcrumbs.js +2 -2
- package/dist/components/CommandPalette.js +10 -10
- package/dist/components/ContentOverviewChart.js +3 -3
- package/dist/components/ErrorBoundary.js +1 -1
- package/dist/components/FocalPointPicker.js +2 -2
- package/dist/components/FolderTree.js +20 -20
- package/dist/components/LivePreview.js +3 -3
- package/dist/components/LocaleSwitcher.js +1 -1
- package/dist/components/MediaPickerModal.js +4 -4
- package/dist/components/PresenceIndicator.js +1 -1
- package/dist/components/SEOConfigPanel.d.ts +2 -0
- package/dist/components/SEOConfigPanel.d.ts.map +1 -0
- package/dist/components/SEOConfigPanel.js +174 -0
- package/dist/components/SEOConfigPanel.js.map +1 -0
- package/dist/components/SEOPanel.js +9 -9
- package/dist/components/SEOPerformance.js +2 -2
- package/dist/components/SchedulePublishDialog.d.ts +18 -0
- package/dist/components/SchedulePublishDialog.d.ts.map +1 -0
- package/dist/components/SchedulePublishDialog.js +106 -0
- package/dist/components/SchedulePublishDialog.js.map +1 -0
- package/dist/components/SharePreviewLinkDialog.d.ts +17 -0
- package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -0
- package/dist/components/SharePreviewLinkDialog.js +83 -0
- package/dist/components/SharePreviewLinkDialog.js.map +1 -0
- package/dist/components/TipTapEditor.js +5 -5
- package/dist/components/VersionHistory.js +2 -2
- package/dist/components/ui/Badge.d.ts +33 -3
- package/dist/components/ui/Badge.d.ts.map +1 -1
- package/dist/components/ui/Badge.js +42 -8
- package/dist/components/ui/Badge.js.map +1 -1
- package/dist/components/ui/Button.d.ts +19 -8
- package/dist/components/ui/Button.d.ts.map +1 -1
- package/dist/components/ui/Button.js +35 -14
- package/dist/components/ui/Button.js.map +1 -1
- package/dist/components/ui/Card.d.ts +26 -0
- package/dist/components/ui/Card.d.ts.map +1 -0
- package/dist/components/ui/Card.js +45 -0
- package/dist/components/ui/Card.js.map +1 -0
- package/dist/components/ui/DataTable.js +1 -1
- package/dist/components/ui/Input.d.ts +15 -0
- package/dist/components/ui/Input.d.ts.map +1 -0
- package/dist/components/ui/Input.js +23 -0
- package/dist/components/ui/Input.js.map +1 -0
- package/dist/components/ui/SearchInput.js +1 -1
- package/dist/components/ui/Select.d.ts +16 -0
- package/dist/components/ui/Select.d.ts.map +1 -0
- package/dist/components/ui/Select.js +25 -0
- package/dist/components/ui/Select.js.map +1 -0
- package/dist/components/ui/Toast.js +1 -1
- package/dist/components/ui/index.d.ts +10 -4
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +5 -2
- package/dist/components/ui/index.js.map +1 -1
- package/dist/fields/BlockBuilderField.js +3 -3
- package/dist/fields/DateField.js +1 -1
- package/dist/fields/RelationshipField.js +3 -3
- package/dist/fields/TextField.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Layout.d.ts +14 -0
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +17 -11
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +21 -11
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts +43 -0
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -0
- package/dist/layout/primitives/AdminShell.js +51 -0
- package/dist/layout/primitives/AdminShell.js.map +1 -0
- package/dist/layout/primitives/Box.d.ts +19 -0
- package/dist/layout/primitives/Box.d.ts.map +1 -0
- package/dist/layout/primitives/Box.js +12 -0
- package/dist/layout/primitives/Box.js.map +1 -0
- package/dist/layout/primitives/Cluster.d.ts +27 -0
- package/dist/layout/primitives/Cluster.d.ts.map +1 -0
- package/dist/layout/primitives/Cluster.js +37 -0
- package/dist/layout/primitives/Cluster.js.map +1 -0
- package/dist/layout/primitives/Grid.d.ts +45 -0
- package/dist/layout/primitives/Grid.d.ts.map +1 -0
- package/dist/layout/primitives/Grid.js +59 -0
- package/dist/layout/primitives/Grid.js.map +1 -0
- package/dist/layout/primitives/PageContainer.d.ts +36 -0
- package/dist/layout/primitives/PageContainer.d.ts.map +1 -0
- package/dist/layout/primitives/PageContainer.js +41 -0
- package/dist/layout/primitives/PageContainer.js.map +1 -0
- package/dist/layout/primitives/Split.d.ts +34 -0
- package/dist/layout/primitives/Split.d.ts.map +1 -0
- package/dist/layout/primitives/Split.js +27 -0
- package/dist/layout/primitives/Split.js.map +1 -0
- package/dist/layout/primitives/Stack.d.ts +23 -0
- package/dist/layout/primitives/Stack.d.ts.map +1 -0
- package/dist/layout/primitives/Stack.js +34 -0
- package/dist/layout/primitives/Stack.js.map +1 -0
- package/dist/layout/primitives/index.d.ts +30 -0
- package/dist/layout/primitives/index.d.ts.map +1 -0
- package/dist/layout/primitives/index.js +22 -0
- package/dist/layout/primitives/index.js.map +1 -0
- package/dist/layout/primitives/tokens.d.ts +48 -0
- package/dist/layout/primitives/tokens.d.ts.map +1 -0
- package/dist/layout/primitives/tokens.js +54 -0
- package/dist/layout/primitives/tokens.js.map +1 -0
- package/dist/lib/cv.d.ts +53 -0
- package/dist/lib/cv.d.ts.map +1 -0
- package/dist/lib/cv.js +39 -0
- package/dist/lib/cv.js.map +1 -0
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +13 -11
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/CollectionList.js +8 -8
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +333 -78
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +17 -5
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/ForgotPassword.js +2 -2
- package/dist/views/FormEditor.js +5 -5
- package/dist/views/FormSubmissions.js +6 -6
- package/dist/views/Forms.js +2 -2
- package/dist/views/Login.d.ts +16 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +17 -7
- package/dist/views/Login.js.map +1 -1
- package/dist/views/MediaBrowser.js +16 -16
- package/dist/views/PageEditor.js +2 -2
- package/dist/views/Pages.js +10 -10
- package/dist/views/PostEditor.js +2 -2
- package/dist/views/Posts.js +4 -4
- package/dist/views/Redirects.js +4 -4
- package/dist/views/ResetPassword.js +2 -2
- package/dist/views/SEO.js +6 -6
- package/dist/views/ScriptTagEditor.js +4 -4
- package/dist/views/ScriptTags.js +2 -2
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -8
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/SetupWizard.js +2 -2
- package/dist/views/Users.js +4 -4
- package/dist/views/page-builder/AIBlockAssist.js +1 -1
- package/dist/views/page-builder/AIGenerateDialog.js +10 -10
- package/dist/views/page-builder/BlockEditor.js +10 -10
- package/dist/views/page-builder/BlockPicker.js +4 -4
- package/dist/views/page-builder/BottomBar.js +1 -1
- package/dist/views/page-builder/BuilderToolbar.js +2 -2
- package/dist/views/page-builder/ContextPanel.js +2 -2
- package/dist/views/page-builder/DesignScore.js +9 -9
- package/dist/views/page-builder/NodeSettings.js +8 -8
- package/dist/views/page-builder/PageBuilder.js +3 -3
- package/dist/views/page-builder/PageSettings.js +1 -1
- package/dist/views/page-builder/PageTemplates.js +2 -2
- package/dist/views/page-builder/SEOPanel.js +13 -13
- package/dist/views/page-builder/SavedSections.js +5 -5
- package/dist/views/page-builder/TemplatePicker.js +2 -2
- package/dist/views/page-builder/block-renderers/CTAPreview.js +5 -5
- package/dist/views/page-builder/block-renderers/CardsPreview.js +1 -1
- package/dist/views/page-builder/block-renderers/CodePreview.js +1 -1
- package/dist/views/page-builder/block-renderers/FAQPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/FallbackPreview.js +1 -1
- package/dist/views/page-builder/block-renderers/FormPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/GalleryPreview.js +5 -5
- package/dist/views/page-builder/block-renderers/HeroPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/ImagePreview.js +3 -3
- package/dist/views/page-builder/block-renderers/TextPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/VideoPreview.js +4 -4
- package/dist/views/page-builder/canvas/BlockRenderer.js +1 -1
- package/dist/views/page-builder/canvas/BuilderCanvas.js +3 -3
- package/dist/views/page-builder/canvas/ColumnRenderer.js +2 -2
- package/dist/views/page-builder/canvas/ContainerRenderer.js +2 -2
- package/dist/views/page-builder/canvas/RowRenderer.js +2 -2
- package/dist/views/page-builder/canvas/SectionRenderer.js +2 -2
- package/package.json +6 -2
- package/src/AdminRoot.tsx +21 -11
- package/src/__tests__/layout/primitives.test.ts +37 -0
- package/src/__tests__/lib/cv.test.ts +74 -0
- package/src/assets/actuate-logo.tsx +72 -0
- package/src/components/Breadcrumbs.tsx +6 -6
- package/src/components/CommandPalette.tsx +34 -34
- package/src/components/ContentOverviewChart.tsx +3 -3
- package/src/components/ErrorBoundary.tsx +3 -3
- package/src/components/FocalPointPicker.tsx +4 -4
- package/src/components/FolderTree.tsx +38 -38
- package/src/components/LivePreview.tsx +16 -16
- package/src/components/LocaleSwitcher.tsx +7 -7
- package/src/components/MediaPickerModal.tsx +21 -21
- package/src/components/PresenceIndicator.tsx +2 -2
- package/src/components/SEOConfigPanel.tsx +582 -0
- package/src/components/SEOPanel.tsx +46 -46
- package/src/components/SEOPerformance.tsx +21 -21
- package/src/components/SchedulePublishDialog.tsx +241 -0
- package/src/components/SharePreviewLinkDialog.tsx +227 -0
- package/src/components/TipTapEditor.tsx +33 -33
- package/src/components/VersionHistory.tsx +16 -16
- package/src/components/ui/Badge.tsx +66 -14
- package/src/components/ui/Button.tsx +70 -33
- package/src/components/ui/Card.tsx +101 -0
- package/src/components/ui/DataTable.tsx +1 -1
- package/src/components/ui/Input.tsx +35 -0
- package/src/components/ui/SearchInput.tsx +4 -4
- package/src/components/ui/Select.tsx +56 -0
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +18 -4
- package/src/fields/BlockBuilderField.tsx +3 -3
- package/src/fields/DateField.tsx +1 -1
- package/src/fields/RelationshipField.tsx +10 -10
- package/src/fields/TextField.tsx +1 -1
- package/src/index.ts +32 -0
- package/src/layout/Header.tsx +28 -28
- package/src/layout/Layout.tsx +39 -46
- package/src/layout/Sidebar.tsx +37 -64
- package/src/layout/primitives/AdminShell.tsx +118 -0
- package/src/layout/primitives/Box.tsx +30 -0
- package/src/layout/primitives/Cluster.tsx +74 -0
- package/src/layout/primitives/Grid.tsx +120 -0
- package/src/layout/primitives/PageContainer.tsx +96 -0
- package/src/layout/primitives/Split.tsx +73 -0
- package/src/layout/primitives/Stack.tsx +67 -0
- package/src/layout/primitives/index.ts +36 -0
- package/src/layout/primitives/tokens.ts +76 -0
- package/src/lib/cv.ts +96 -0
- package/src/styles/build-input.css +1 -1
- package/src/views/ApiKeys.tsx +57 -57
- package/src/views/CollectionList.tsx +30 -30
- package/src/views/Dashboard.tsx +737 -186
- package/src/views/DocumentEdit.tsx +90 -10
- package/src/views/ForgotPassword.tsx +18 -18
- package/src/views/FormEditor.tsx +75 -75
- package/src/views/FormSubmissions.tsx +76 -76
- package/src/views/Forms.tsx +27 -27
- package/src/views/Login.tsx +65 -25
- package/src/views/MediaBrowser.tsx +127 -127
- package/src/views/PageEditor.tsx +25 -25
- package/src/views/Pages.tsx +59 -59
- package/src/views/PostEditor.tsx +37 -37
- package/src/views/Posts.tsx +48 -48
- package/src/views/Redirects.tsx +21 -21
- package/src/views/ResetPassword.tsx +28 -28
- package/src/views/SEO.tsx +144 -144
- package/src/views/ScriptTagEditor.tsx +24 -24
- package/src/views/ScriptTags.tsx +10 -10
- package/src/views/Settings.tsx +88 -80
- package/src/views/SetupWizard.tsx +28 -28
- package/src/views/Users.tsx +20 -20
- package/src/views/page-builder/AIBlockAssist.tsx +1 -1
- package/src/views/page-builder/AIGenerateDialog.tsx +63 -63
- package/src/views/page-builder/BlockEditor.tsx +26 -26
- package/src/views/page-builder/BlockPicker.tsx +22 -22
- package/src/views/page-builder/BottomBar.tsx +8 -8
- package/src/views/page-builder/BuilderToolbar.tsx +17 -17
- package/src/views/page-builder/ContextPanel.tsx +3 -3
- package/src/views/page-builder/DesignScore.tsx +21 -21
- package/src/views/page-builder/NodeSettings.tsx +27 -27
- package/src/views/page-builder/PageBuilder.tsx +11 -11
- package/src/views/page-builder/PageSettings.tsx +4 -4
- package/src/views/page-builder/PageTemplates.tsx +18 -18
- package/src/views/page-builder/SEOPanel.tsx +53 -53
- package/src/views/page-builder/SavedSections.tsx +37 -37
- package/src/views/page-builder/TemplatePicker.tsx +17 -17
- package/src/views/page-builder/block-renderers/CTAPreview.tsx +13 -13
- package/src/views/page-builder/block-renderers/CardsPreview.tsx +5 -5
- package/src/views/page-builder/block-renderers/CodePreview.tsx +6 -6
- package/src/views/page-builder/block-renderers/FAQPreview.tsx +13 -13
- package/src/views/page-builder/block-renderers/FallbackPreview.tsx +3 -3
- package/src/views/page-builder/block-renderers/FormPreview.tsx +20 -20
- package/src/views/page-builder/block-renderers/GalleryPreview.tsx +8 -8
- package/src/views/page-builder/block-renderers/HeroPreview.tsx +16 -16
- package/src/views/page-builder/block-renderers/ImagePreview.tsx +4 -4
- package/src/views/page-builder/block-renderers/TextPreview.tsx +14 -14
- package/src/views/page-builder/block-renderers/VideoPreview.tsx +12 -12
- package/src/views/page-builder/canvas/BlockRenderer.tsx +4 -4
- package/src/views/page-builder/canvas/BuilderCanvas.tsx +6 -6
- package/src/views/page-builder/canvas/ColumnRenderer.tsx +3 -3
- package/src/views/page-builder/canvas/ContainerRenderer.tsx +2 -2
- package/src/views/page-builder/canvas/RowRenderer.tsx +2 -2
- package/src/views/page-builder/canvas/SectionRenderer.tsx +2 -2
package/src/views/Dashboard.tsx
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Dashboard
|
|
5
|
+
* ----------
|
|
6
|
+
* Single-file, lean dashboard implementation. Two API calls only
|
|
7
|
+
* (`/stats` + `/health`, both shared with the rest of the admin), everything
|
|
8
|
+
* else derived client-side via `useMemo`. No charting libraries on this view
|
|
9
|
+
* (the design uses plain progress bars + colored dots), keeping the bundle
|
|
10
|
+
* and TTI cost low.
|
|
11
|
+
*
|
|
12
|
+
* Responsive layout:
|
|
13
|
+
* < 640px (mobile): stat cards 2-col, main grid stacks, quick actions scroll-x
|
|
14
|
+
* 640-1023 (tablet): stat cards 3-col, main grid stacks
|
|
15
|
+
* >= 1024 (desktop): stat cards 5-col, main grid is `1fr 320px`
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo, useState } from 'react'
|
|
3
19
|
import {
|
|
4
20
|
FileText,
|
|
5
|
-
File,
|
|
6
|
-
Image,
|
|
7
|
-
Users,
|
|
21
|
+
File as FileIcon,
|
|
22
|
+
Image as ImageIcon,
|
|
8
23
|
ClipboardList,
|
|
9
24
|
Search,
|
|
10
|
-
|
|
25
|
+
Plus,
|
|
26
|
+
Upload,
|
|
27
|
+
Globe,
|
|
28
|
+
ExternalLink,
|
|
29
|
+
Activity,
|
|
11
30
|
AlertTriangle,
|
|
31
|
+
Clock,
|
|
32
|
+
Zap,
|
|
12
33
|
Database,
|
|
13
|
-
ChevronLeft,
|
|
14
34
|
ChevronRight,
|
|
35
|
+
Loader2,
|
|
36
|
+
type LucideIcon,
|
|
15
37
|
} from 'lucide-react'
|
|
16
|
-
import { useState } from 'react'
|
|
17
38
|
import { useApiData } from '../lib/useApiData.js'
|
|
18
|
-
import { ContentOverviewChart } from '../components/ContentOverviewChart.js'
|
|
19
|
-
import { SEOPerformance } from '../components/SEOPerformance.js'
|
|
20
39
|
|
|
21
40
|
interface DashboardStats {
|
|
22
41
|
totalDocuments: number
|
|
@@ -24,6 +43,8 @@ interface DashboardStats {
|
|
|
24
43
|
totalUsers: number
|
|
25
44
|
formCount: number
|
|
26
45
|
avgSeoScore: number
|
|
46
|
+
webhookCount: number
|
|
47
|
+
webhookActiveCount: number
|
|
27
48
|
collectionCounts: Record<string, number>
|
|
28
49
|
statusCounts: Record<string, number>
|
|
29
50
|
recentDocuments: {
|
|
@@ -38,10 +59,9 @@ interface DashboardStats {
|
|
|
38
59
|
|
|
39
60
|
interface HealthData {
|
|
40
61
|
status: 'healthy' | 'degraded'
|
|
41
|
-
|
|
62
|
+
databaseConnected: boolean
|
|
42
63
|
secretConfigured: boolean
|
|
43
64
|
models: Record<string, boolean>
|
|
44
|
-
databaseConnected: boolean
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
interface CollectionMeta {
|
|
@@ -56,124 +76,354 @@ export interface DashboardProps {
|
|
|
56
76
|
onNavigate?: (path: string) => void
|
|
57
77
|
}
|
|
58
78
|
|
|
79
|
+
// ─── helpers (kept top-level so they aren't re-created on every render) ──────
|
|
80
|
+
|
|
59
81
|
function resolveCollections(config: any): CollectionMeta[] {
|
|
60
82
|
if (!config?.collections) return []
|
|
61
83
|
const raw = config.collections
|
|
62
84
|
const list: any[] = Array.isArray(raw) ? raw : Object.values(raw)
|
|
63
85
|
return list
|
|
64
|
-
.filter((c) => !c
|
|
86
|
+
.filter((c) => !c?.admin?.hidden)
|
|
65
87
|
.map((c) => ({ slug: c.slug, type: c.type, labels: c.labels }))
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
69
|
-
|
|
70
|
-
return col.labels?.
|
|
91
|
+
const fallback = col.slug.charAt(0).toUpperCase() + col.slug.slice(1)
|
|
92
|
+
if (plural) return col.labels?.plural ?? fallback
|
|
93
|
+
return col.labels?.singular ?? fallback
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
function relativeTime(dateStr: string): string {
|
|
74
|
-
const now = Date.now()
|
|
75
97
|
const then = new Date(dateStr).getTime()
|
|
76
|
-
const diff = now - then
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (mins < 60) return `${mins}
|
|
98
|
+
const diff = Date.now() - then
|
|
99
|
+
if (diff < 60_000) return 'just now'
|
|
100
|
+
const mins = Math.floor(diff / 60_000)
|
|
101
|
+
if (mins < 60) return `${mins}m ago`
|
|
80
102
|
const hours = Math.floor(mins / 60)
|
|
81
|
-
if (hours < 24) return `${hours}
|
|
103
|
+
if (hours < 24) return `${hours}h ago`
|
|
82
104
|
const days = Math.floor(hours / 24)
|
|
83
|
-
if (days
|
|
105
|
+
if (days === 1) return 'Yesterday'
|
|
106
|
+
if (days < 30) return `${days}d ago`
|
|
84
107
|
return new Date(dateStr).toLocaleDateString()
|
|
85
108
|
}
|
|
86
109
|
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
function timeOfDayGreeting(): string {
|
|
111
|
+
const h = new Date().getHours()
|
|
112
|
+
if (h < 12) return 'Good morning'
|
|
113
|
+
if (h < 17) return 'Good afternoon'
|
|
114
|
+
return 'Good evening'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function todayDateString(): string {
|
|
118
|
+
return new Date().toLocaleDateString('en-US', {
|
|
119
|
+
weekday: 'long',
|
|
120
|
+
month: 'long',
|
|
121
|
+
day: 'numeric',
|
|
122
|
+
year: 'numeric',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Deterministic colour per author so avatars stay stable across renders
|
|
127
|
+
// without storing anything in state or hitting the server.
|
|
128
|
+
const AVATAR_COLORS = [
|
|
129
|
+
'#7C3AED', // purple
|
|
130
|
+
'#E11D48', // rose
|
|
131
|
+
'#059669', // emerald
|
|
132
|
+
'#0891B2', // cyan
|
|
133
|
+
'#D97706', // amber
|
|
134
|
+
'#4F46E5', // indigo
|
|
135
|
+
'#DC2626', // red
|
|
136
|
+
] as const
|
|
137
|
+
|
|
138
|
+
function hashString(s: string): number {
|
|
139
|
+
let h = 0
|
|
140
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
|
141
|
+
return Math.abs(h)
|
|
100
142
|
}
|
|
101
143
|
|
|
102
|
-
function
|
|
144
|
+
function authorAvatar(name: string | null | undefined): { color: string; initials: string } {
|
|
145
|
+
const safe = (name ?? '').trim() || 'User'
|
|
146
|
+
const parts = safe.split(/\s+/).filter(Boolean)
|
|
147
|
+
const initials =
|
|
148
|
+
parts.length >= 2
|
|
149
|
+
? `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase()
|
|
150
|
+
: safe.slice(0, 2).toUpperCase()
|
|
151
|
+
const color = AVATAR_COLORS[hashString(safe) % AVATAR_COLORS.length]!
|
|
152
|
+
return { color, initials }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function statusBadge(status: string): {
|
|
156
|
+
label: string
|
|
157
|
+
cls: string
|
|
158
|
+
} {
|
|
103
159
|
switch (status) {
|
|
104
160
|
case 'PUBLISHED':
|
|
105
|
-
return
|
|
161
|
+
return {
|
|
162
|
+
label: 'Published',
|
|
163
|
+
cls: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',
|
|
164
|
+
}
|
|
106
165
|
case 'DRAFT':
|
|
107
|
-
return
|
|
166
|
+
return {
|
|
167
|
+
label: 'Draft',
|
|
168
|
+
cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
169
|
+
}
|
|
108
170
|
case 'SCHEDULED':
|
|
109
|
-
return
|
|
171
|
+
return {
|
|
172
|
+
label: 'Scheduled',
|
|
173
|
+
cls: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',
|
|
174
|
+
}
|
|
110
175
|
case 'IN_REVIEW':
|
|
111
|
-
return
|
|
176
|
+
return {
|
|
177
|
+
label: 'In Review',
|
|
178
|
+
cls: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300',
|
|
179
|
+
}
|
|
112
180
|
default:
|
|
113
|
-
return status
|
|
181
|
+
return { label: status, cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' }
|
|
114
182
|
}
|
|
115
183
|
}
|
|
116
184
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
interface StatCardData {
|
|
186
|
+
label: string
|
|
187
|
+
value: string
|
|
188
|
+
unit?: string
|
|
189
|
+
hint?: string
|
|
190
|
+
hintUp?: boolean
|
|
191
|
+
icon: LucideIcon
|
|
192
|
+
iconBg: string
|
|
193
|
+
iconColor: string
|
|
194
|
+
href?: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
|
124
198
|
|
|
125
199
|
export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
126
200
|
const nav = (path: string) => onNavigate?.(path)
|
|
127
|
-
const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
|
|
201
|
+
const { data: stats, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
|
|
128
202
|
const { data: health } = useApiData<HealthData>('/health')
|
|
129
|
-
const [activityPage, setActivityPage] = useState(0)
|
|
130
203
|
|
|
131
|
-
const
|
|
132
|
-
const
|
|
204
|
+
const greeting = useMemo(() => timeOfDayGreeting(), [])
|
|
205
|
+
const dateStr = useMemo(() => todayDateString(), [])
|
|
206
|
+
const userName = session?.name ?? session?.email?.split('@')[0] ?? 'there'
|
|
207
|
+
|
|
208
|
+
const collections = useMemo(() => resolveCollections(config), [config])
|
|
133
209
|
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
210
|
+
// ── Stat cards ──────────────────────────────────────────────────────────
|
|
211
|
+
const statCards: StatCardData[] = useMemo(() => {
|
|
212
|
+
const counts = stats?.collectionCounts ?? {}
|
|
137
213
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
214
|
+
// Prefer two real collections (the user's primary content types) over
|
|
215
|
+
// hard-coded Posts/Pages so the dashboard adapts to admin-managed types.
|
|
216
|
+
const primary: CollectionMeta[] = []
|
|
217
|
+
const posts = collections.find((c) => c.slug === 'posts' || c.type === 'post')
|
|
218
|
+
const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
|
|
219
|
+
if (posts) primary.push(posts)
|
|
220
|
+
if (pages) primary.push(pages)
|
|
221
|
+
if (primary.length < 2) {
|
|
222
|
+
for (const c of collections) {
|
|
223
|
+
if (primary.length >= 2) break
|
|
224
|
+
if (!primary.includes(c)) primary.push(c)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
141
227
|
|
|
142
|
-
|
|
228
|
+
const cards: StatCardData[] = []
|
|
143
229
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
230
|
+
if (primary[0]) {
|
|
231
|
+
cards.push({
|
|
232
|
+
label: collectionLabel(primary[0]),
|
|
233
|
+
value: String(counts[primary[0].slug] ?? 0),
|
|
234
|
+
icon: primary[0].type === 'page' ? FileIcon : FileText,
|
|
235
|
+
iconBg: 'bg-violet-100 dark:bg-violet-950/60',
|
|
236
|
+
iconColor: 'text-violet-600 dark:text-violet-300',
|
|
237
|
+
href: `/${primary[0].slug}`,
|
|
238
|
+
})
|
|
239
|
+
} else {
|
|
240
|
+
cards.push({
|
|
241
|
+
label: 'Documents',
|
|
242
|
+
value: String(stats?.totalDocuments ?? 0),
|
|
243
|
+
icon: FileText,
|
|
244
|
+
iconBg: 'bg-violet-100 dark:bg-violet-950/60',
|
|
245
|
+
iconColor: 'text-violet-600 dark:text-violet-300',
|
|
150
246
|
})
|
|
151
247
|
}
|
|
152
|
-
} else {
|
|
153
|
-
statCards.push({ label: 'Pages', value: collectionCounts['pages'] ?? 0, icon: File })
|
|
154
|
-
statCards.push({ label: 'Posts', value: collectionCounts['posts'] ?? 0, icon: FileText })
|
|
155
|
-
}
|
|
156
248
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
249
|
+
if (primary[1]) {
|
|
250
|
+
cards.push({
|
|
251
|
+
label: collectionLabel(primary[1]),
|
|
252
|
+
value: String(counts[primary[1].slug] ?? 0),
|
|
253
|
+
icon: primary[1].type === 'page' ? FileIcon : FileText,
|
|
254
|
+
iconBg: 'bg-cyan-100 dark:bg-cyan-950/60',
|
|
255
|
+
iconColor: 'text-cyan-600 dark:text-cyan-300',
|
|
256
|
+
href: `/${primary[1].slug}`,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
160
259
|
|
|
161
|
-
|
|
260
|
+
cards.push({
|
|
261
|
+
label: 'Media',
|
|
262
|
+
value: String(stats?.totalMedia ?? 0),
|
|
263
|
+
hint:
|
|
264
|
+
stats && stats.totalMedia > 0
|
|
265
|
+
? `${stats.totalMedia} file${stats.totalMedia === 1 ? '' : 's'}`
|
|
266
|
+
: undefined,
|
|
267
|
+
icon: ImageIcon,
|
|
268
|
+
iconBg: 'bg-emerald-100 dark:bg-emerald-950/60',
|
|
269
|
+
iconColor: 'text-emerald-600 dark:text-emerald-300',
|
|
270
|
+
href: '/media',
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// `formCount` in /stats is the *submission* count; the number of
|
|
274
|
+
// forms is the count of `collection: forms` documents.
|
|
275
|
+
const formsCount = counts['forms'] ?? 0
|
|
276
|
+
const submissions = stats?.formCount ?? 0
|
|
277
|
+
cards.push({
|
|
278
|
+
label: 'Forms',
|
|
279
|
+
value: String(formsCount),
|
|
280
|
+
hint: submissions > 0 ? `${submissions} response${submissions === 1 ? '' : 's'}` : undefined,
|
|
281
|
+
icon: ClipboardList,
|
|
282
|
+
iconBg: 'bg-amber-100 dark:bg-amber-950/60',
|
|
283
|
+
iconColor: 'text-amber-600 dark:text-amber-300',
|
|
284
|
+
href: '/forms',
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const seo = stats?.avgSeoScore ?? 0
|
|
288
|
+
cards.push({
|
|
289
|
+
label: 'SEO Score',
|
|
290
|
+
value: seo > 0 ? String(seo) : '—',
|
|
291
|
+
unit: seo > 0 ? '/100' : undefined,
|
|
292
|
+
hint: seo > 0 ? (seo >= 70 ? 'Good' : seo >= 40 ? 'Fair' : 'Needs work') : 'No content yet',
|
|
293
|
+
hintUp: seo >= 70,
|
|
294
|
+
icon: Search,
|
|
295
|
+
iconBg: 'bg-indigo-100 dark:bg-indigo-950/60',
|
|
296
|
+
iconColor: 'text-indigo-600 dark:text-indigo-300',
|
|
297
|
+
href: '/seo',
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
return cards
|
|
301
|
+
}, [stats, collections])
|
|
302
|
+
|
|
303
|
+
// ── Quick actions ───────────────────────────────────────────────────────
|
|
304
|
+
// "New Post" lives only in the hero CTA above; this row is the secondary
|
|
305
|
+
// surface and intentionally omits the primary action to avoid the obvious
|
|
306
|
+
// duplication. Order mirrors the natural authoring flow.
|
|
307
|
+
const quickActions = useMemo(() => {
|
|
308
|
+
const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
|
|
309
|
+
const items: { label: string; icon: LucideIcon; onClick: () => void }[] = []
|
|
310
|
+
if (pages)
|
|
311
|
+
items.push({ label: 'New Page', icon: Plus, onClick: () => nav(`/${pages.slug}/new`) })
|
|
312
|
+
items.push({ label: 'Upload Media', icon: Upload, onClick: () => nav('/media') })
|
|
313
|
+
items.push({ label: 'New Form', icon: Plus, onClick: () => nav('/forms') })
|
|
314
|
+
items.push({ label: 'Manage SEO', icon: Search, onClick: () => nav('/seo') })
|
|
315
|
+
items.push({ label: 'View API', icon: Globe, onClick: () => nav('/api-keys') })
|
|
316
|
+
return items
|
|
317
|
+
}, [collections, onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
318
|
+
|
|
319
|
+
// ── Recent activity ─────────────────────────────────────────────────────
|
|
320
|
+
const [activityLimit, setActivityLimit] = useState(8)
|
|
321
|
+
const activity = useMemo(() => {
|
|
322
|
+
const docs = stats?.recentDocuments ?? []
|
|
323
|
+
return docs.slice(0, activityLimit).map((d) => {
|
|
324
|
+
const col = collections.find((c) => c.slug === d.collection)
|
|
325
|
+
return {
|
|
326
|
+
...d,
|
|
327
|
+
typeLabel: col ? collectionLabel(col, false) : d.collection,
|
|
328
|
+
relTime: relativeTime(d.updatedAt),
|
|
329
|
+
avatar: authorAvatar(d.author),
|
|
330
|
+
statusInfo: statusBadge(d.status),
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
}, [stats, collections, activityLimit])
|
|
334
|
+
|
|
335
|
+
// ── Publishing queue (scheduled docs only) ──────────────────────────────
|
|
336
|
+
const publishQueue = useMemo(() => {
|
|
337
|
+
const docs = stats?.recentDocuments ?? []
|
|
338
|
+
return docs
|
|
339
|
+
.filter((d) => d.status === 'SCHEDULED')
|
|
340
|
+
.slice(0, 5)
|
|
341
|
+
.map((d) => {
|
|
342
|
+
const col = collections.find((c) => c.slug === d.collection)
|
|
343
|
+
return {
|
|
344
|
+
id: d.id,
|
|
345
|
+
collection: d.collection,
|
|
346
|
+
title: d.title || 'Untitled',
|
|
347
|
+
type: col ? collectionLabel(col, false) : d.collection,
|
|
348
|
+
date: relativeTime(d.updatedAt),
|
|
349
|
+
author: d.author,
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
}, [stats, collections])
|
|
353
|
+
|
|
354
|
+
// ── Content health summary (derived from /stats) ────────────────────────
|
|
355
|
+
const contentHealth = useMemo(() => {
|
|
356
|
+
const score = stats?.avgSeoScore ?? 0
|
|
357
|
+
const counts = stats?.statusCounts ?? {}
|
|
358
|
+
const totalDrafts = counts['DRAFT'] ?? 0
|
|
359
|
+
const totalScheduled = counts['SCHEDULED'] ?? 0
|
|
360
|
+
const totalInReview = counts['IN_REVIEW'] ?? 0
|
|
361
|
+
const issues: { label: string; count: number; tone: 'warn' | 'err' | 'muted' }[] = []
|
|
362
|
+
if (totalDrafts > 0) {
|
|
363
|
+
issues.push({ label: 'Drafts pending publish', count: totalDrafts, tone: 'warn' })
|
|
364
|
+
}
|
|
365
|
+
if (totalInReview > 0) {
|
|
366
|
+
issues.push({ label: 'Awaiting review', count: totalInReview, tone: 'warn' })
|
|
367
|
+
}
|
|
368
|
+
if (totalScheduled > 0) {
|
|
369
|
+
issues.push({ label: 'Scheduled to publish', count: totalScheduled, tone: 'muted' })
|
|
370
|
+
}
|
|
371
|
+
if (score > 0 && score < 70) {
|
|
372
|
+
issues.push({
|
|
373
|
+
label: 'Pages with weak SEO',
|
|
374
|
+
count: Math.max(1, Math.round((70 - score) / 10)),
|
|
375
|
+
tone: score < 40 ? 'err' : 'warn',
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
score,
|
|
380
|
+
label: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : score > 0 ? 'Poor' : 'No data',
|
|
381
|
+
tone: score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted',
|
|
382
|
+
issues,
|
|
383
|
+
} as const
|
|
384
|
+
}, [stats])
|
|
385
|
+
|
|
386
|
+
// ── Content delivery tiles ──────────────────────────────────────────────
|
|
387
|
+
const delivery = useMemo(() => {
|
|
388
|
+
const sched = stats?.statusCounts?.['SCHEDULED'] ?? 0
|
|
389
|
+
return {
|
|
390
|
+
totalDocs: stats?.totalDocuments ?? 0,
|
|
391
|
+
forms: stats?.formCount ?? 0,
|
|
392
|
+
scheduled: sched,
|
|
393
|
+
webhooks: {
|
|
394
|
+
total: stats?.webhookCount ?? 0,
|
|
395
|
+
active: stats?.webhookActiveCount ?? 0,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
}, [stats])
|
|
399
|
+
|
|
400
|
+
const totalIssues = contentHealth.issues.reduce((s, i) => s + i.count, 0)
|
|
401
|
+
|
|
402
|
+
// ── Render ──────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
if (loading && !stats) {
|
|
162
405
|
return (
|
|
163
|
-
<div className="
|
|
164
|
-
<Loader2 className="
|
|
406
|
+
<div className="flex h-64 items-center justify-center p-4 sm:p-6">
|
|
407
|
+
<Loader2 className="h-6 w-6 animate-spin text-violet-600" />
|
|
165
408
|
</div>
|
|
166
409
|
)
|
|
167
410
|
}
|
|
168
411
|
|
|
412
|
+
const heroPostSlug =
|
|
413
|
+
collections.find((c) => c.slug === 'posts' || c.type === 'post')?.slug ?? 'posts'
|
|
414
|
+
const siteUrl = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
415
|
+
|
|
169
416
|
return (
|
|
170
|
-
<div className="p-4 sm:p-6
|
|
417
|
+
<div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
|
|
418
|
+
{/* Health banners ────────────────────────────────────────────── */}
|
|
171
419
|
{health && health.status === 'degraded' && (
|
|
172
|
-
<div className="flex items-
|
|
173
|
-
<Database className="
|
|
174
|
-
<div className="flex-1">
|
|
175
|
-
<span className="text-sm font-medium text-blue-900">
|
|
176
|
-
|
|
420
|
+
<div className="flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/40">
|
|
421
|
+
<Database className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
|
|
422
|
+
<div className="min-w-0 flex-1">
|
|
423
|
+
<span className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
|
424
|
+
Database setup required
|
|
425
|
+
</span>
|
|
426
|
+
<p className="mt-0.5 text-xs text-blue-700 dark:text-blue-300">
|
|
177
427
|
{!health.databaseConnected
|
|
178
428
|
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
179
429
|
: !health.secretConfigured
|
|
@@ -188,143 +438,444 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
188
438
|
)}
|
|
189
439
|
|
|
190
440
|
{error && exhausted && (
|
|
191
|
-
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
192
|
-
<AlertTriangle className="
|
|
193
|
-
<span className="text-sm text-amber-800
|
|
441
|
+
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950/40">
|
|
442
|
+
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
443
|
+
<span className="flex-1 text-sm text-amber-800 dark:text-amber-200">
|
|
194
444
|
Some dashboard data may be unavailable.
|
|
195
445
|
</span>
|
|
196
446
|
<button
|
|
197
447
|
onClick={refetch}
|
|
198
|
-
className="px-3 py-1 text-sm text-amber-700
|
|
448
|
+
className="rounded-lg border border-amber-300 px-3 py-1 text-sm text-amber-700 transition-colors hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
|
|
199
449
|
>
|
|
200
450
|
Retry
|
|
201
451
|
</button>
|
|
202
452
|
</div>
|
|
203
453
|
)}
|
|
204
454
|
|
|
205
|
-
{/* Header */}
|
|
206
|
-
<div>
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
455
|
+
{/* Header ─────────────────────────────────────────────────────── */}
|
|
456
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
457
|
+
<div className="min-w-0">
|
|
458
|
+
<h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
|
|
459
|
+
{greeting}, {userName} <span aria-hidden>👋</span>
|
|
460
|
+
</h1>
|
|
461
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
462
|
+
{dateStr}
|
|
463
|
+
{totalIssues > 0 && (
|
|
464
|
+
<span className="hidden sm:inline">
|
|
465
|
+
{' · '}
|
|
466
|
+
{totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
|
|
467
|
+
</span>
|
|
468
|
+
)}
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
472
|
+
<button
|
|
473
|
+
onClick={() => nav(`/${heroPostSlug}/new`)}
|
|
474
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
|
475
|
+
>
|
|
476
|
+
<Plus className="h-3.5 w-3.5" /> New Post
|
|
477
|
+
</button>
|
|
478
|
+
{siteUrl && (
|
|
479
|
+
<a
|
|
480
|
+
href={siteUrl}
|
|
481
|
+
target="_blank"
|
|
482
|
+
rel="noopener noreferrer"
|
|
483
|
+
className="border-border bg-card hover:bg-accent hover:text-accent-foreground hidden items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors sm:inline-flex"
|
|
484
|
+
>
|
|
485
|
+
<ExternalLink className="h-3.5 w-3.5" /> View Site
|
|
486
|
+
</a>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
213
489
|
</div>
|
|
214
490
|
|
|
215
|
-
{/*
|
|
216
|
-
<div className="
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
491
|
+
{/* Quick actions ──────────────────────────────────────────────── */}
|
|
492
|
+
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:thin]">
|
|
493
|
+
{quickActions.map((a) => (
|
|
494
|
+
<button
|
|
495
|
+
key={a.label}
|
|
496
|
+
onClick={a.onClick}
|
|
497
|
+
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 hover:border-violet-400 hover:bg-violet-50 hover:text-violet-700 dark:hover:bg-violet-950/40 dark:hover:text-violet-300"
|
|
498
|
+
>
|
|
499
|
+
<a.icon className="h-3.5 w-3.5" />
|
|
500
|
+
{a.label}
|
|
501
|
+
</button>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
{/* Stat cards ─────────────────────────────────────────────────── */}
|
|
506
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
507
|
+
{statCards.map((card) => {
|
|
508
|
+
const Inner = (
|
|
509
|
+
<>
|
|
510
|
+
<div
|
|
511
|
+
className={`mb-3 flex h-8 w-8 items-center justify-center rounded-lg ${card.iconBg}`}
|
|
512
|
+
>
|
|
513
|
+
<card.icon className={`h-4 w-4 ${card.iconColor}`} />
|
|
228
514
|
</div>
|
|
229
|
-
<
|
|
230
|
-
|
|
515
|
+
<div className="flex items-baseline gap-1">
|
|
516
|
+
<span className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
|
517
|
+
{card.value}
|
|
518
|
+
</span>
|
|
519
|
+
{card.unit && (
|
|
520
|
+
<span className="text-muted-foreground text-sm font-medium">{card.unit}</span>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
<p className="text-muted-foreground mt-1 text-xs">{card.label}</p>
|
|
524
|
+
{card.hint && (
|
|
525
|
+
<p
|
|
526
|
+
className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}
|
|
527
|
+
>
|
|
528
|
+
{card.hint}
|
|
529
|
+
</p>
|
|
530
|
+
)}
|
|
531
|
+
</>
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
return card.href ? (
|
|
535
|
+
<button
|
|
536
|
+
key={card.label}
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => nav(card.href!)}
|
|
539
|
+
className="bg-card border-border rounded-xl border p-4 text-left shadow-sm transition-shadow hover:shadow-md"
|
|
540
|
+
>
|
|
541
|
+
{Inner}
|
|
542
|
+
</button>
|
|
543
|
+
) : (
|
|
544
|
+
<div key={card.label} className="bg-card border-border rounded-xl border p-4 shadow-sm">
|
|
545
|
+
{Inner}
|
|
231
546
|
</div>
|
|
232
547
|
)
|
|
233
548
|
})}
|
|
234
549
|
</div>
|
|
235
550
|
|
|
236
|
-
{/*
|
|
237
|
-
<div className="grid grid-cols-1 lg:grid-cols-
|
|
238
|
-
{/* Recent Activity */}
|
|
239
|
-
<
|
|
240
|
-
<
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
551
|
+
{/* Main grid ──────────────────────────────────────────────────── */}
|
|
552
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
|
553
|
+
{/* Recent Activity ───────────────────────────────────────── */}
|
|
554
|
+
<section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
555
|
+
<header className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
556
|
+
<div className="min-w-0">
|
|
557
|
+
<h2 className="text-foreground text-sm font-semibold">Recent Activity</h2>
|
|
558
|
+
<p className="text-muted-foreground mt-0.5 text-xs">Last 7 days across all content</p>
|
|
559
|
+
</div>
|
|
560
|
+
<button
|
|
561
|
+
className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
|
|
562
|
+
onClick={() => setActivityLimit((n) => (n >= 20 ? 8 : 20))}
|
|
563
|
+
>
|
|
564
|
+
{activity.length >= 20 ? 'Show less' : 'View all'}
|
|
565
|
+
</button>
|
|
566
|
+
</header>
|
|
567
|
+
{activity.length === 0 ? (
|
|
568
|
+
<EmptyState
|
|
569
|
+
icon={Activity}
|
|
570
|
+
title="No activity yet"
|
|
571
|
+
subtitle="Content changes will appear here as your team works."
|
|
572
|
+
/>
|
|
573
|
+
) : (
|
|
574
|
+
<ul role="list" className="divide-border divide-y">
|
|
575
|
+
{activity.map((it) => (
|
|
576
|
+
<li
|
|
577
|
+
key={it.id}
|
|
578
|
+
className="hover:bg-accent/50 cursor-pointer px-4 py-3 transition-colors"
|
|
579
|
+
onClick={() => nav(`/${it.collection}/${it.id}`)}
|
|
580
|
+
>
|
|
581
|
+
<div className="flex min-w-0 items-start gap-3">
|
|
582
|
+
<div
|
|
583
|
+
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
|
|
584
|
+
style={{ background: it.avatar.color }}
|
|
585
|
+
aria-hidden
|
|
586
|
+
>
|
|
587
|
+
{it.avatar.initials}
|
|
588
|
+
</div>
|
|
589
|
+
<div className="min-w-0 flex-1">
|
|
590
|
+
<p className="text-foreground truncate text-sm leading-snug">
|
|
591
|
+
<span className="font-semibold">
|
|
592
|
+
“{it.title || 'Untitled'}”
|
|
593
|
+
</span>{' '}
|
|
594
|
+
<span className="text-muted-foreground">— {it.typeLabel}</span>
|
|
595
|
+
</p>
|
|
596
|
+
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-[11px]">
|
|
597
|
+
<span
|
|
598
|
+
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${it.statusInfo.cls}`}
|
|
599
|
+
>
|
|
600
|
+
{it.statusInfo.label}
|
|
601
|
+
</span>
|
|
602
|
+
<span className="max-w-[120px] truncate">{it.author}</span>
|
|
603
|
+
<span aria-hidden>·</span>
|
|
604
|
+
<span>{it.relTime}</span>
|
|
268
605
|
</div>
|
|
269
|
-
<span
|
|
270
|
-
className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColor(doc.status)}`}
|
|
271
|
-
>
|
|
272
|
-
{statusLabel(doc.status)}
|
|
273
|
-
</span>
|
|
274
606
|
</div>
|
|
275
607
|
</div>
|
|
276
|
-
|
|
277
|
-
}
|
|
608
|
+
</li>
|
|
609
|
+
))}
|
|
610
|
+
</ul>
|
|
611
|
+
)}
|
|
612
|
+
</section>
|
|
613
|
+
|
|
614
|
+
{/* Right column ─────────────────────────────────────────── */}
|
|
615
|
+
<aside className="flex min-w-0 flex-col gap-4">
|
|
616
|
+
{/* Publishing Queue */}
|
|
617
|
+
<section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
618
|
+
<header className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
619
|
+
<div className="min-w-0">
|
|
620
|
+
<h2 className="text-foreground text-sm font-semibold">Publishing Queue</h2>
|
|
621
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
622
|
+
{publishQueue.length} item{publishQueue.length === 1 ? '' : 's'} scheduled
|
|
623
|
+
</p>
|
|
624
|
+
</div>
|
|
625
|
+
<button
|
|
626
|
+
className="text-xs font-medium text-violet-600 hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50 dark:text-violet-400"
|
|
627
|
+
disabled={publishQueue.length === 0}
|
|
628
|
+
onClick={() => {
|
|
629
|
+
// We don't have a dedicated "scheduled" admin page, so deep-
|
|
630
|
+
// link to the first scheduled item — that's where authors
|
|
631
|
+
// typically need to land to reschedule or cancel.
|
|
632
|
+
const first = publishQueue[0]
|
|
633
|
+
if (first) nav(`/${first.collection}/${first.id}`)
|
|
634
|
+
}}
|
|
635
|
+
>
|
|
636
|
+
Manage
|
|
637
|
+
</button>
|
|
638
|
+
</header>
|
|
639
|
+
{publishQueue.length === 0 ? (
|
|
640
|
+
<EmptyState
|
|
641
|
+
icon={Clock}
|
|
642
|
+
title="No scheduled content"
|
|
643
|
+
subtitle="Scheduled posts and pages appear here."
|
|
644
|
+
compact
|
|
645
|
+
/>
|
|
646
|
+
) : (
|
|
647
|
+
<ul role="list" className="divide-border divide-y">
|
|
648
|
+
{publishQueue.map((q) => (
|
|
649
|
+
<li
|
|
650
|
+
key={q.id}
|
|
651
|
+
className="hover:bg-accent/50 flex min-w-0 cursor-pointer items-center gap-2.5 px-4 py-2.5 transition-colors"
|
|
652
|
+
onClick={() => nav(`/${q.collection}/${q.id}`)}
|
|
653
|
+
>
|
|
654
|
+
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" aria-hidden />
|
|
655
|
+
<div className="min-w-0 flex-1">
|
|
656
|
+
<p className="text-foreground truncate text-sm font-medium">{q.title}</p>
|
|
657
|
+
<p className="text-muted-foreground truncate text-[11px]">
|
|
658
|
+
{q.date} · {q.author}
|
|
659
|
+
</p>
|
|
660
|
+
</div>
|
|
661
|
+
<span className="border-border bg-background text-muted-foreground shrink-0 rounded border px-1.5 py-0.5 text-[10px] capitalize">
|
|
662
|
+
{q.type}
|
|
663
|
+
</span>
|
|
664
|
+
</li>
|
|
665
|
+
))}
|
|
666
|
+
</ul>
|
|
278
667
|
)}
|
|
279
|
-
</
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
668
|
+
</section>
|
|
669
|
+
|
|
670
|
+
{/* Content Health */}
|
|
671
|
+
<section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
672
|
+
<header className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
673
|
+
<div className="min-w-0">
|
|
674
|
+
<h2 className="text-foreground text-sm font-semibold">Content Health</h2>
|
|
675
|
+
<p className="text-muted-foreground mt-0.5 text-xs">SEO & quality issues</p>
|
|
676
|
+
</div>
|
|
677
|
+
<button
|
|
678
|
+
className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
|
|
679
|
+
onClick={() => nav('/seo')}
|
|
680
|
+
>
|
|
681
|
+
Fix issues
|
|
682
|
+
</button>
|
|
683
|
+
</header>
|
|
684
|
+
<div className="px-4 pt-3.5 pb-2.5">
|
|
685
|
+
<div className="mb-1.5 flex items-baseline justify-between">
|
|
686
|
+
<p className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
|
687
|
+
{contentHealth.score > 0 ? contentHealth.score : '—'}
|
|
688
|
+
{contentHealth.score > 0 && (
|
|
689
|
+
<span className="text-muted-foreground ml-0.5 text-sm font-normal">/100</span>
|
|
690
|
+
)}
|
|
691
|
+
</p>
|
|
692
|
+
<span
|
|
693
|
+
className={`text-xs font-medium ${
|
|
694
|
+
contentHealth.tone === 'ok'
|
|
695
|
+
? 'text-emerald-600 dark:text-emerald-400'
|
|
696
|
+
: contentHealth.tone === 'warn'
|
|
697
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
698
|
+
: contentHealth.tone === 'err'
|
|
699
|
+
? 'text-red-600 dark:text-red-400'
|
|
700
|
+
: 'text-muted-foreground'
|
|
701
|
+
}`}
|
|
291
702
|
>
|
|
292
|
-
|
|
293
|
-
</button>
|
|
294
|
-
<span>
|
|
295
|
-
Page {activityPage + 1} of {totalActivityPages}
|
|
703
|
+
{contentHealth.label}
|
|
296
704
|
</span>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
705
|
+
</div>
|
|
706
|
+
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
|
707
|
+
<div
|
|
708
|
+
className={`h-full rounded-full transition-all ${
|
|
709
|
+
contentHealth.tone === 'ok'
|
|
710
|
+
? 'bg-emerald-500'
|
|
711
|
+
: contentHealth.tone === 'warn'
|
|
712
|
+
? 'bg-amber-500'
|
|
713
|
+
: contentHealth.tone === 'err'
|
|
714
|
+
? 'bg-red-500'
|
|
715
|
+
: 'bg-muted-foreground/40'
|
|
716
|
+
}`}
|
|
717
|
+
style={{ width: `${Math.max(0, Math.min(100, contentHealth.score))}%` }}
|
|
718
|
+
/>
|
|
306
719
|
</div>
|
|
307
720
|
</div>
|
|
308
|
-
|
|
309
|
-
|
|
721
|
+
{contentHealth.issues.length === 0 ? (
|
|
722
|
+
<div className="text-muted-foreground px-4 pt-1 pb-4 text-xs">
|
|
723
|
+
No outstanding issues. Nice work.
|
|
724
|
+
</div>
|
|
725
|
+
) : (
|
|
726
|
+
<ul role="list" className="divide-border divide-y">
|
|
727
|
+
{contentHealth.issues.map((iss, i) => (
|
|
728
|
+
<li
|
|
729
|
+
key={i}
|
|
730
|
+
className="hover:bg-accent/50 flex cursor-pointer items-center gap-2.5 px-4 py-2 transition-colors"
|
|
731
|
+
onClick={() => nav('/seo')}
|
|
732
|
+
>
|
|
733
|
+
<span
|
|
734
|
+
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
735
|
+
iss.tone === 'err'
|
|
736
|
+
? 'bg-red-500'
|
|
737
|
+
: iss.tone === 'warn'
|
|
738
|
+
? 'bg-amber-500'
|
|
739
|
+
: 'bg-muted-foreground'
|
|
740
|
+
}`}
|
|
741
|
+
aria-hidden
|
|
742
|
+
/>
|
|
743
|
+
<span className="text-muted-foreground flex-1 truncate text-xs">
|
|
744
|
+
{iss.label}
|
|
745
|
+
</span>
|
|
746
|
+
<span
|
|
747
|
+
className={`shrink-0 text-sm font-semibold ${
|
|
748
|
+
iss.tone === 'err'
|
|
749
|
+
? 'text-red-600 dark:text-red-400'
|
|
750
|
+
: iss.tone === 'warn'
|
|
751
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
752
|
+
: 'text-muted-foreground'
|
|
753
|
+
}`}
|
|
754
|
+
>
|
|
755
|
+
{iss.count}
|
|
756
|
+
</span>
|
|
757
|
+
<ChevronRight className="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
|
758
|
+
</li>
|
|
759
|
+
))}
|
|
760
|
+
</ul>
|
|
761
|
+
)}
|
|
762
|
+
</section>
|
|
763
|
+
</aside>
|
|
764
|
+
</div>
|
|
310
765
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
<ContentOverviewChart
|
|
318
|
-
published={statusCounts['PUBLISHED'] ?? 0}
|
|
319
|
-
drafts={statusCounts['DRAFT'] ?? 0}
|
|
320
|
-
scheduled={statusCounts['SCHEDULED'] ?? 0}
|
|
321
|
-
/>
|
|
766
|
+
{/* Content Delivery ──────────────────────────────────────────── */}
|
|
767
|
+
<section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
768
|
+
<header className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
769
|
+
<div className="min-w-0">
|
|
770
|
+
<h2 className="text-foreground text-sm font-semibold">Content Delivery</h2>
|
|
771
|
+
<p className="text-muted-foreground mt-0.5 text-xs">Real-time platform activity</p>
|
|
322
772
|
</div>
|
|
773
|
+
<button
|
|
774
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
|
|
775
|
+
onClick={() => nav('/api-keys')}
|
|
776
|
+
>
|
|
777
|
+
API docs <ExternalLink className="h-3 w-3" />
|
|
778
|
+
</button>
|
|
779
|
+
</header>
|
|
780
|
+
<div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
|
|
781
|
+
<DeliveryTile
|
|
782
|
+
icon={Activity}
|
|
783
|
+
label="Total Documents"
|
|
784
|
+
value={delivery.totalDocs.toLocaleString()}
|
|
785
|
+
sub="across all collections"
|
|
786
|
+
/>
|
|
787
|
+
<DeliveryTile
|
|
788
|
+
icon={Clock}
|
|
789
|
+
label="Scheduled Posts"
|
|
790
|
+
value={delivery.scheduled.toLocaleString()}
|
|
791
|
+
sub={delivery.scheduled > 0 ? 'in publishing queue' : 'none queued'}
|
|
792
|
+
tone={delivery.scheduled > 0 ? 'ok' : 'muted'}
|
|
793
|
+
/>
|
|
794
|
+
<DeliveryTile
|
|
795
|
+
icon={ClipboardList}
|
|
796
|
+
label="Form Responses"
|
|
797
|
+
value={delivery.forms.toLocaleString()}
|
|
798
|
+
sub={delivery.forms > 0 ? 'total received' : 'none yet'}
|
|
799
|
+
tone={delivery.forms > 0 ? 'ok' : 'muted'}
|
|
800
|
+
/>
|
|
801
|
+
<DeliveryTile
|
|
802
|
+
icon={Zap}
|
|
803
|
+
label="Active Webhooks"
|
|
804
|
+
value={`${delivery.webhooks.active} / ${delivery.webhooks.total || 0}`}
|
|
805
|
+
sub={
|
|
806
|
+
delivery.webhooks.total === 0
|
|
807
|
+
? 'none configured'
|
|
808
|
+
: delivery.webhooks.active === delivery.webhooks.total
|
|
809
|
+
? 'all active'
|
|
810
|
+
: `${delivery.webhooks.total - delivery.webhooks.active} paused`
|
|
811
|
+
}
|
|
812
|
+
tone={
|
|
813
|
+
delivery.webhooks.total === 0
|
|
814
|
+
? 'muted'
|
|
815
|
+
: delivery.webhooks.active === delivery.webhooks.total
|
|
816
|
+
? 'ok'
|
|
817
|
+
: 'warn'
|
|
818
|
+
}
|
|
819
|
+
/>
|
|
323
820
|
</div>
|
|
324
|
-
</
|
|
821
|
+
</section>
|
|
822
|
+
</div>
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ─── Sub-components (kept in-file: smaller bundle, no extra module hops) ────
|
|
325
827
|
|
|
326
|
-
|
|
327
|
-
|
|
828
|
+
function EmptyState({
|
|
829
|
+
icon: Icon,
|
|
830
|
+
title,
|
|
831
|
+
subtitle,
|
|
832
|
+
compact = false,
|
|
833
|
+
}: {
|
|
834
|
+
icon: LucideIcon
|
|
835
|
+
title: string
|
|
836
|
+
subtitle: string
|
|
837
|
+
compact?: boolean
|
|
838
|
+
}) {
|
|
839
|
+
return (
|
|
840
|
+
<div
|
|
841
|
+
className={`flex flex-col items-center justify-center gap-1.5 text-center ${compact ? 'px-4 py-6' : 'px-6 py-10'}`}
|
|
842
|
+
>
|
|
843
|
+
<Icon className="text-muted-foreground/50 mb-1 h-7 w-7" aria-hidden />
|
|
844
|
+
<p className="text-foreground text-sm font-semibold">{title}</p>
|
|
845
|
+
<p className="text-muted-foreground max-w-xs text-xs">{subtitle}</p>
|
|
846
|
+
</div>
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function DeliveryTile({
|
|
851
|
+
icon: Icon,
|
|
852
|
+
label,
|
|
853
|
+
value,
|
|
854
|
+
sub,
|
|
855
|
+
tone = 'muted',
|
|
856
|
+
}: {
|
|
857
|
+
icon: LucideIcon
|
|
858
|
+
label: string
|
|
859
|
+
value: string
|
|
860
|
+
sub?: string
|
|
861
|
+
tone?: 'ok' | 'warn' | 'err' | 'muted'
|
|
862
|
+
}) {
|
|
863
|
+
const subTone =
|
|
864
|
+
tone === 'ok'
|
|
865
|
+
? 'text-emerald-600 dark:text-emerald-400'
|
|
866
|
+
: tone === 'warn'
|
|
867
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
868
|
+
: tone === 'err'
|
|
869
|
+
? 'text-red-600 dark:text-red-400'
|
|
870
|
+
: 'text-muted-foreground'
|
|
871
|
+
return (
|
|
872
|
+
<div className="px-4 py-3.5">
|
|
873
|
+
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
|
|
874
|
+
<Icon className="h-3.5 w-3.5" aria-hidden />
|
|
875
|
+
<span className="text-[11px] font-medium">{label}</span>
|
|
876
|
+
</div>
|
|
877
|
+
<p className="text-foreground text-xl leading-tight font-semibold tracking-tight">{value}</p>
|
|
878
|
+
{sub && <p className={`mt-0.5 text-[11px] ${subTone}`}>{sub}</p>}
|
|
328
879
|
</div>
|
|
329
880
|
)
|
|
330
881
|
}
|