@orion-studios/payload-admin-components 0.1.0 → 0.2.0-beta.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.
@@ -0,0 +1,68 @@
1
+ /* Light Theme - Clean whites, subtle grays, blue accents */
2
+ [data-theme="light"] {
3
+ --admin-bg: #ffffff;
4
+ --admin-surface: #f9fafb;
5
+ --admin-surface-elevated: #ffffff;
6
+ --admin-border: #e5e7eb;
7
+ --admin-border-subtle: #f3f4f6;
8
+
9
+ --admin-text: #111827;
10
+ --admin-text-secondary: #4b5563;
11
+ --admin-text-muted: #9ca3af;
12
+ --admin-text-inverse: #ffffff;
13
+
14
+ --admin-accent: #3b82f6;
15
+ --admin-accent-hover: #2563eb;
16
+ --admin-accent-subtle: #eff6ff;
17
+ --admin-accent-secondary: #8b5cf6;
18
+ --admin-accent-secondary-hover: #7c3aed;
19
+ --admin-accent-secondary-subtle: #f5f3ff;
20
+
21
+ --admin-nav-bg: #f8fafc;
22
+ --admin-nav-text: #374151;
23
+ --admin-nav-text-active: #111827;
24
+ --admin-nav-item-hover: #f1f5f9;
25
+ --admin-nav-item-active: #e0e7ff;
26
+ --admin-nav-group-text: #6b7280;
27
+ --admin-nav-border: #e2e8f0;
28
+
29
+ --admin-input-bg: #ffffff;
30
+ --admin-input-border: #d1d5db;
31
+ --admin-input-border-focus: var(--admin-accent);
32
+ --admin-input-placeholder: #9ca3af;
33
+
34
+ --admin-card-bg: #ffffff;
35
+ --admin-card-border: #e5e7eb;
36
+ --admin-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
37
+ --admin-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
38
+
39
+ --admin-success: #16a34a;
40
+ --admin-success-bg: #f0fdf4;
41
+ --admin-warning: #d97706;
42
+ --admin-warning-bg: #fffbeb;
43
+ --admin-error: #dc2626;
44
+ --admin-error-bg: #fef2f2;
45
+ --admin-info: #0284c7;
46
+ --admin-info-bg: #f0f9ff;
47
+
48
+ --admin-badge-draft-bg: #fef3c7;
49
+ --admin-badge-draft-text: #92400e;
50
+ --admin-badge-published-bg: #dcfce7;
51
+ --admin-badge-published-text: #166534;
52
+ --admin-badge-changed-bg: #dbeafe;
53
+ --admin-badge-changed-text: #1e40af;
54
+
55
+ --admin-tooltip-bg: #1f2937;
56
+ --admin-tooltip-text: #f9fafb;
57
+
58
+ --admin-overlay: rgba(0, 0, 0, 0.3);
59
+ --admin-scrollbar-track: #f1f5f9;
60
+ --admin-scrollbar-thumb: #cbd5e1;
61
+ --admin-scrollbar-thumb-hover: #94a3b8;
62
+
63
+ --admin-focus-ring: 0 0 0 2px var(--admin-accent-subtle), 0 0 0 4px var(--admin-accent);
64
+ --admin-radius-sm: 6px;
65
+ --admin-radius-md: 8px;
66
+ --admin-radius-lg: 12px;
67
+ --admin-radius-xl: 16px;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/payload-admin-components",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Custom admin UI components for Payload CMS",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -15,10 +15,10 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "src/styles"
18
+ "src"
19
19
  ],
20
20
  "scripts": {
21
- "build": "tsup src/index.ts --format cjs,esm --dts && cp src/styles/admin.css dist/admin.css",
21
+ "build": "tsup && node -e \"const fs=require('fs'),path=require('path');function copyDir(s,d){fs.mkdirSync(d,{recursive:true});for(const f of fs.readdirSync(s)){const sp=path.join(s,f),dp=path.join(d,f);fs.statSync(sp).isDirectory()?copyDir(sp,dp):fs.copyFileSync(sp,dp)}}copyDir('src/styles','dist/styles');fs.copyFileSync('src/styles/admin.css','dist/admin.css')\"",
22
22
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
23
23
  "typecheck": "tsc --noEmit"
24
24
  },
@@ -0,0 +1,167 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+
5
+ interface BlockOption {
6
+ slug: string
7
+ label: string
8
+ description?: string
9
+ icon?: React.ReactNode
10
+ imageURL?: string
11
+ }
12
+
13
+ /**
14
+ * Visual card-based block picker. Use as a replacement for Payload's default
15
+ * dropdown block selector. Shows blocks as a grid of cards with icons and descriptions.
16
+ */
17
+ export function BlockPicker({
18
+ blocks,
19
+ onSelect,
20
+ }: {
21
+ blocks: BlockOption[]
22
+ onSelect: (slug: string) => void
23
+ }) {
24
+ const [searchQuery, setSearchQuery] = useState('')
25
+
26
+ const filtered = blocks.filter(
27
+ (b) =>
28
+ b.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
29
+ b.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
30
+ (b.description && b.description.toLowerCase().includes(searchQuery.toLowerCase())),
31
+ )
32
+
33
+ return (
34
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
35
+ {/* Search */}
36
+ {blocks.length > 4 && (
37
+ <input
38
+ type="text"
39
+ placeholder="Search sections..."
40
+ value={searchQuery}
41
+ onChange={(e) => setSearchQuery(e.target.value)}
42
+ style={{
43
+ padding: '10px 14px',
44
+ border: '1px solid var(--admin-input-border)',
45
+ borderRadius: 'var(--admin-radius-md)',
46
+ background: 'var(--admin-input-bg)',
47
+ color: 'var(--admin-text)',
48
+ fontSize: 14,
49
+ outline: 'none',
50
+ width: '100%',
51
+ boxSizing: 'border-box',
52
+ }}
53
+ />
54
+ )}
55
+
56
+ {/* Block Grid */}
57
+ <div
58
+ style={{
59
+ display: 'grid',
60
+ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
61
+ gap: 12,
62
+ }}
63
+ >
64
+ {filtered.map((block) => (
65
+ <button
66
+ key={block.slug}
67
+ type="button"
68
+ onClick={() => onSelect(block.slug)}
69
+ style={{
70
+ display: 'flex',
71
+ flexDirection: 'column',
72
+ alignItems: 'center',
73
+ gap: 10,
74
+ padding: 16,
75
+ background: 'var(--admin-card-bg)',
76
+ border: '1px solid var(--admin-card-border)',
77
+ borderRadius: 'var(--admin-radius-lg)',
78
+ cursor: 'pointer',
79
+ transition: 'all 0.2s ease',
80
+ textAlign: 'center',
81
+ }}
82
+ onMouseEnter={(e) => {
83
+ e.currentTarget.style.borderColor = 'var(--admin-accent)'
84
+ e.currentTarget.style.boxShadow = 'var(--admin-card-shadow-hover)'
85
+ e.currentTarget.style.transform = 'translateY(-2px)'
86
+ }}
87
+ onMouseLeave={(e) => {
88
+ e.currentTarget.style.borderColor = 'var(--admin-card-border)'
89
+ e.currentTarget.style.boxShadow = 'none'
90
+ e.currentTarget.style.transform = 'translateY(0)'
91
+ }}
92
+ >
93
+ {/* Icon or image */}
94
+ <div
95
+ style={{
96
+ width: 48,
97
+ height: 48,
98
+ borderRadius: 'var(--admin-radius-md)',
99
+ background: 'var(--admin-accent-subtle)',
100
+ color: 'var(--admin-accent)',
101
+ display: 'flex',
102
+ alignItems: 'center',
103
+ justifyContent: 'center',
104
+ fontSize: 24,
105
+ overflow: 'hidden',
106
+ }}
107
+ >
108
+ {block.imageURL ? (
109
+ <img
110
+ src={block.imageURL}
111
+ alt={block.label}
112
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
113
+ />
114
+ ) : block.icon ? (
115
+ block.icon
116
+ ) : (
117
+ <BlockDefaultIcon />
118
+ )}
119
+ </div>
120
+
121
+ {/* Label */}
122
+ <span
123
+ style={{
124
+ fontSize: 13,
125
+ fontWeight: 600,
126
+ color: 'var(--admin-text)',
127
+ lineHeight: 1.3,
128
+ }}
129
+ >
130
+ {block.label}
131
+ </span>
132
+
133
+ {/* Description */}
134
+ {block.description && (
135
+ <span
136
+ style={{
137
+ fontSize: 11,
138
+ color: 'var(--admin-text-muted)',
139
+ lineHeight: 1.4,
140
+ }}
141
+ >
142
+ {block.description}
143
+ </span>
144
+ )}
145
+ </button>
146
+ ))}
147
+ </div>
148
+
149
+ {filtered.length === 0 && (
150
+ <p style={{ textAlign: 'center', color: 'var(--admin-text-muted)', fontSize: 14, padding: 20 }}>
151
+ No sections match your search.
152
+ </p>
153
+ )}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ function BlockDefaultIcon() {
159
+ return (
160
+ <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
161
+ <rect x="3" y="3" width="7" height="7" />
162
+ <rect x="14" y="3" width="7" height="7" />
163
+ <rect x="14" y="14" width="7" height="7" />
164
+ <rect x="3" y="14" width="7" height="7" />
165
+ </svg>
166
+ )
167
+ }
@@ -0,0 +1,415 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { ThemeSwitcher } from './ThemeSwitcher'
5
+ import { HelpTooltip } from './HelpTooltip'
6
+ import { StatusBadge } from './StatusBadge'
7
+
8
+ // SVG Icons as components
9
+ function PagesIcon({ size = 24 }: { size?: number }) {
10
+ return (
11
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
12
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
13
+ <polyline points="14 2 14 8 20 8" />
14
+ <line x1="16" y1="13" x2="8" y2="13" />
15
+ <line x1="16" y1="17" x2="8" y2="17" />
16
+ <polyline points="10 9 9 9 8 9" />
17
+ </svg>
18
+ )
19
+ }
20
+
21
+ function MediaIcon({ size = 24 }: { size?: number }) {
22
+ return (
23
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
24
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
25
+ <circle cx="8.5" cy="8.5" r="1.5" />
26
+ <polyline points="21 15 16 10 5 21" />
27
+ </svg>
28
+ )
29
+ }
30
+
31
+ function SettingsIcon({ size = 24 }: { size?: number }) {
32
+ return (
33
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
34
+ <circle cx="12" cy="12" r="3" />
35
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
36
+ </svg>
37
+ )
38
+ }
39
+
40
+ function LayoutIcon({ size = 24 }: { size?: number }) {
41
+ return (
42
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
43
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
44
+ <line x1="3" y1="9" x2="21" y2="9" />
45
+ <line x1="3" y1="15" x2="21" y2="15" />
46
+ </svg>
47
+ )
48
+ }
49
+
50
+ function PlusIcon({ size = 16 }: { size?: number }) {
51
+ return (
52
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
53
+ <line x1="12" y1="5" x2="12" y2="19" />
54
+ <line x1="5" y1="12" x2="19" y2="12" />
55
+ </svg>
56
+ )
57
+ }
58
+
59
+ function ClockIcon({ size = 14 }: { size?: number }) {
60
+ return (
61
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
62
+ <circle cx="12" cy="12" r="10" />
63
+ <polyline points="12 6 12 12 16 14" />
64
+ </svg>
65
+ )
66
+ }
67
+
68
+ function getGreeting(): string {
69
+ const hour = new Date().getHours()
70
+ if (hour < 12) return 'Good morning'
71
+ if (hour < 17) return 'Good afternoon'
72
+ return 'Good evening'
73
+ }
74
+
75
+ function formatRelativeTime(dateStr: string): string {
76
+ const date = new Date(dateStr)
77
+ const now = new Date()
78
+ const diffMs = now.getTime() - date.getTime()
79
+ const diffMins = Math.floor(diffMs / 60000)
80
+ const diffHours = Math.floor(diffMs / 3600000)
81
+ const diffDays = Math.floor(diffMs / 86400000)
82
+
83
+ if (diffMins < 1) return 'Just now'
84
+ if (diffMins < 60) return `${diffMins}m ago`
85
+ if (diffHours < 24) return `${diffHours}h ago`
86
+ if (diffDays < 7) return `${diffDays}d ago`
87
+ return date.toLocaleDateString()
88
+ }
89
+
90
+ interface RecentItem {
91
+ id: string
92
+ title?: string
93
+ slug?: string
94
+ _status?: string
95
+ updatedAt: string
96
+ }
97
+
98
+ export function Dashboard() {
99
+ const [userName, setUserName] = useState('')
100
+ const [recentPages, setRecentPages] = useState<RecentItem[]>([])
101
+ const [pageCount, setPageCount] = useState<number | null>(null)
102
+ const [mediaCount, setMediaCount] = useState<number | null>(null)
103
+
104
+ useEffect(() => {
105
+ // Fetch current user
106
+ fetch('/api/users/me', { credentials: 'include' })
107
+ .then((res) => res.json())
108
+ .then((data) => {
109
+ const user = data?.user || data
110
+ setUserName(user?.fullName || user?.email?.split('@')[0] || '')
111
+ })
112
+ .catch(() => {})
113
+
114
+ // Fetch recent pages
115
+ fetch('/api/pages?limit=5&sort=-updatedAt', { credentials: 'include' })
116
+ .then((res) => res.json())
117
+ .then((data) => {
118
+ setRecentPages(data?.docs || [])
119
+ setPageCount(data?.totalDocs ?? null)
120
+ })
121
+ .catch(() => {})
122
+
123
+ // Fetch media count
124
+ fetch('/api/media?limit=0', { credentials: 'include' })
125
+ .then((res) => res.json())
126
+ .then((data) => {
127
+ setMediaCount(data?.totalDocs ?? null)
128
+ })
129
+ .catch(() => {})
130
+ }, [])
131
+
132
+ return (
133
+ <div style={{ padding: '32px', maxWidth: 1200, margin: '0 auto' }}>
134
+ {/* Welcome Header */}
135
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 32 }}>
136
+ <div>
137
+ <h1 style={{ fontSize: 28, fontWeight: 700, color: 'var(--admin-text)', margin: '0 0 6px' }}>
138
+ {getGreeting()}{userName ? `, ${userName}` : ''}
139
+ </h1>
140
+ <p style={{ fontSize: 15, color: 'var(--admin-text-muted)', margin: 0 }}>
141
+ Manage your website content and settings from here.
142
+ </p>
143
+ </div>
144
+ <ThemeSwitcher />
145
+ </div>
146
+
147
+ {/* Quick Actions */}
148
+ <div style={{ display: 'flex', gap: 10, marginBottom: 32, flexWrap: 'wrap' }}>
149
+ <QuickAction href="/admin/collections/pages/create" icon={<PlusIcon />} label="New Page" />
150
+ <QuickAction href="/admin/collections/media/create" icon={<PlusIcon />} label="Upload Media" />
151
+ <QuickAction href="/admin/globals/header" icon={<LayoutIcon size={16} />} label="Edit Navigation" />
152
+ <QuickAction href="/admin/globals/site-settings" icon={<SettingsIcon size={16} />} label="Website Settings" />
153
+ </div>
154
+
155
+ {/* Content Cards Grid */}
156
+ <div
157
+ style={{
158
+ display: 'grid',
159
+ gap: 20,
160
+ gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
161
+ marginBottom: 32,
162
+ }}
163
+ >
164
+ {/* Pages Card */}
165
+ <ContentCard
166
+ icon={<PagesIcon />}
167
+ title="Pages"
168
+ description="Create and manage your website pages"
169
+ count={pageCount}
170
+ countLabel="pages"
171
+ tooltip="Pages are the individual sections of your website. Each page has a URL and contains content sections."
172
+ actions={[
173
+ { label: 'View All', href: '/admin/collections/pages' },
174
+ { label: 'Create New', href: '/admin/collections/pages/create', primary: true },
175
+ ]}
176
+ />
177
+
178
+ {/* Media Card */}
179
+ <ContentCard
180
+ icon={<MediaIcon />}
181
+ title="Media Library"
182
+ description="Upload and organize images and files"
183
+ count={mediaCount}
184
+ countLabel="files"
185
+ tooltip="Your media library stores all images, documents, and files used across your website."
186
+ actions={[
187
+ { label: 'Browse', href: '/admin/collections/media' },
188
+ { label: 'Upload', href: '/admin/collections/media/create', primary: true },
189
+ ]}
190
+ />
191
+
192
+ {/* Site Design Card */}
193
+ <ContentCard
194
+ icon={<LayoutIcon />}
195
+ title="Site Design"
196
+ description="Customize your header, footer, and site-wide settings"
197
+ tooltip="These settings apply to every page on your website — your navigation menu, footer information, and global SEO settings."
198
+ actions={[
199
+ { label: 'Header & Nav', href: '/admin/globals/header' },
200
+ { label: 'Footer', href: '/admin/globals/footer' },
201
+ { label: 'Settings', href: '/admin/globals/site-settings', primary: true },
202
+ ]}
203
+ />
204
+ </div>
205
+
206
+ {/* Recent Activity */}
207
+ {recentPages.length > 0 && (
208
+ <div
209
+ style={{
210
+ background: 'var(--admin-card-bg)',
211
+ border: '1px solid var(--admin-card-border)',
212
+ borderRadius: 'var(--admin-radius-lg)',
213
+ overflow: 'hidden',
214
+ }}
215
+ >
216
+ <div
217
+ style={{
218
+ padding: '16px 20px',
219
+ borderBottom: '1px solid var(--admin-border-subtle)',
220
+ display: 'flex',
221
+ alignItems: 'center',
222
+ justifyContent: 'space-between',
223
+ }}
224
+ >
225
+ <h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--admin-text)', margin: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
226
+ <ClockIcon /> Recently Edited
227
+ </h3>
228
+ <a
229
+ href="/admin/collections/pages"
230
+ style={{ fontSize: 13, color: 'var(--admin-accent)', textDecoration: 'none', fontWeight: 500 }}
231
+ >
232
+ View all
233
+ </a>
234
+ </div>
235
+ {recentPages.map((page, i) => (
236
+ <a
237
+ key={page.id}
238
+ href={`/admin/collections/pages/${page.id}`}
239
+ style={{
240
+ display: 'flex',
241
+ alignItems: 'center',
242
+ justifyContent: 'space-between',
243
+ padding: '12px 20px',
244
+ textDecoration: 'none',
245
+ borderBottom: i < recentPages.length - 1 ? '1px solid var(--admin-border-subtle)' : 'none',
246
+ transition: 'background-color 0.15s ease',
247
+ }}
248
+ onMouseEnter={(e) => {
249
+ e.currentTarget.style.backgroundColor = 'var(--admin-surface)'
250
+ }}
251
+ onMouseLeave={(e) => {
252
+ e.currentTarget.style.backgroundColor = 'transparent'
253
+ }}
254
+ >
255
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
256
+ <PagesIcon size={16} />
257
+ <span style={{ fontSize: 14, fontWeight: 500, color: 'var(--admin-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
258
+ {page.title || page.slug || 'Untitled'}
259
+ </span>
260
+ </div>
261
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
262
+ {page._status && (
263
+ <StatusBadge status={page._status as 'draft' | 'published' | 'changed'} size="sm" />
264
+ )}
265
+ <span style={{ fontSize: 12, color: 'var(--admin-text-muted)', whiteSpace: 'nowrap' }}>
266
+ {formatRelativeTime(page.updatedAt)}
267
+ </span>
268
+ </div>
269
+ </a>
270
+ ))}
271
+ </div>
272
+ )}
273
+ </div>
274
+ )
275
+ }
276
+
277
+ function QuickAction({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
278
+ return (
279
+ <a
280
+ href={href}
281
+ style={{
282
+ display: 'inline-flex',
283
+ alignItems: 'center',
284
+ gap: 6,
285
+ padding: '8px 16px',
286
+ background: 'var(--admin-surface)',
287
+ border: '1px solid var(--admin-border)',
288
+ borderRadius: 'var(--admin-radius-md)',
289
+ color: 'var(--admin-text)',
290
+ textDecoration: 'none',
291
+ fontSize: 13,
292
+ fontWeight: 500,
293
+ transition: 'all 0.2s ease',
294
+ whiteSpace: 'nowrap',
295
+ }}
296
+ onMouseEnter={(e) => {
297
+ e.currentTarget.style.borderColor = 'var(--admin-accent)'
298
+ e.currentTarget.style.color = 'var(--admin-accent)'
299
+ e.currentTarget.style.background = 'var(--admin-accent-subtle)'
300
+ }}
301
+ onMouseLeave={(e) => {
302
+ e.currentTarget.style.borderColor = 'var(--admin-border)'
303
+ e.currentTarget.style.color = 'var(--admin-text)'
304
+ e.currentTarget.style.background = 'var(--admin-surface)'
305
+ }}
306
+ >
307
+ {icon}
308
+ {label}
309
+ </a>
310
+ )
311
+ }
312
+
313
+ function ContentCard({
314
+ icon,
315
+ title,
316
+ description,
317
+ count,
318
+ countLabel,
319
+ tooltip,
320
+ actions,
321
+ }: {
322
+ icon: React.ReactNode
323
+ title: string
324
+ description: string
325
+ count?: number | null
326
+ countLabel?: string
327
+ tooltip?: string
328
+ actions?: Array<{ label: string; href: string; primary?: boolean }>
329
+ }) {
330
+ return (
331
+ <div
332
+ style={{
333
+ background: 'var(--admin-card-bg)',
334
+ border: '1px solid var(--admin-card-border)',
335
+ borderRadius: 'var(--admin-radius-lg)',
336
+ padding: 24,
337
+ display: 'flex',
338
+ flexDirection: 'column',
339
+ gap: 16,
340
+ boxShadow: 'var(--admin-card-shadow)',
341
+ transition: 'all 0.2s ease',
342
+ }}
343
+ >
344
+ {/* Card Header */}
345
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
346
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
347
+ <div
348
+ style={{
349
+ width: 40,
350
+ height: 40,
351
+ borderRadius: 'var(--admin-radius-md)',
352
+ background: 'var(--admin-accent-subtle)',
353
+ color: 'var(--admin-accent)',
354
+ display: 'flex',
355
+ alignItems: 'center',
356
+ justifyContent: 'center',
357
+ }}
358
+ >
359
+ {icon}
360
+ </div>
361
+ <div>
362
+ <h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--admin-text)', margin: 0, display: 'flex', alignItems: 'center' }}>
363
+ {title}
364
+ {tooltip && <HelpTooltip content={tooltip} position="right" />}
365
+ </h3>
366
+ {count !== undefined && count !== null && (
367
+ <span style={{ fontSize: 12, color: 'var(--admin-text-muted)' }}>
368
+ {count} {countLabel}
369
+ </span>
370
+ )}
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ {/* Description */}
376
+ <p style={{ fontSize: 13, color: 'var(--admin-text-muted)', margin: 0, lineHeight: 1.5 }}>
377
+ {description}
378
+ </p>
379
+
380
+ {/* Actions */}
381
+ {actions && actions.length > 0 && (
382
+ <div style={{ display: 'flex', gap: 8, marginTop: 'auto', flexWrap: 'wrap' }}>
383
+ {actions.map((action) => (
384
+ <a
385
+ key={action.href}
386
+ href={action.href}
387
+ style={{
388
+ display: 'inline-flex',
389
+ alignItems: 'center',
390
+ padding: '7px 14px',
391
+ borderRadius: 'var(--admin-radius-sm)',
392
+ fontSize: 13,
393
+ fontWeight: 500,
394
+ textDecoration: 'none',
395
+ transition: 'all 0.15s ease',
396
+ ...(action.primary
397
+ ? {
398
+ background: 'var(--admin-accent)',
399
+ color: 'var(--admin-text-inverse)',
400
+ }
401
+ : {
402
+ background: 'var(--admin-surface)',
403
+ color: 'var(--admin-text-secondary)',
404
+ border: '1px solid var(--admin-border)',
405
+ }),
406
+ }}
407
+ >
408
+ {action.label}
409
+ </a>
410
+ ))}
411
+ </div>
412
+ )}
413
+ </div>
414
+ )
415
+ }