@orion-studios/payload-admin-components 0.2.0-beta.11 → 0.2.0-beta.12

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,134 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+
5
+ import { Logout, useAuth } from '@payloadcms/ui'
6
+
7
+ type LinkItem = {
8
+ href: string
9
+ label: string
10
+ matchPrefixes: string[]
11
+ adminOnly?: boolean
12
+ }
13
+
14
+ const isAdmin = (user: unknown): boolean => {
15
+ if (!user || typeof user !== 'object') return false
16
+ const role = (user as any).role
17
+ return typeof role === 'string' && role === 'admin'
18
+ }
19
+
20
+ const getPropString = (props: unknown, key: string, fallback: string): string => {
21
+ if (!props || typeof props !== 'object') return fallback
22
+
23
+ const direct = (props as any)[key]
24
+ if (typeof direct === 'string' && direct.length > 0) return direct
25
+
26
+ const clientProps = (props as any).clientProps
27
+ if (clientProps && typeof clientProps === 'object') {
28
+ const nested = (clientProps as any)[key]
29
+ if (typeof nested === 'string' && nested.length > 0) return nested
30
+ }
31
+
32
+ return fallback
33
+ }
34
+
35
+ export function AdminStudioNav(props: Record<string, unknown>) {
36
+ const { user } = useAuth()
37
+ const brandName = getPropString(props, 'brandName', 'Orion Studio')
38
+
39
+ const [pathname, setPathname] = useState<string>('')
40
+
41
+ useEffect(() => {
42
+ const update = () => setPathname(window.location.pathname)
43
+ update()
44
+
45
+ window.addEventListener('popstate', update)
46
+ return () => window.removeEventListener('popstate', update)
47
+ }, [])
48
+
49
+ const links: LinkItem[] = useMemo(
50
+ () => [
51
+ { href: '/admin', label: 'Dashboard', matchPrefixes: ['/admin'] },
52
+ {
53
+ href: '/admin/pages',
54
+ label: 'Pages',
55
+ matchPrefixes: ['/admin/pages', '/admin/collections/pages'],
56
+ },
57
+ { href: '/admin/globals', label: 'Globals', matchPrefixes: ['/admin/globals'] },
58
+ {
59
+ href: '/admin/media',
60
+ label: 'Media',
61
+ matchPrefixes: ['/admin/media', '/admin/collections/media'],
62
+ },
63
+ {
64
+ href: '/admin/tools',
65
+ label: 'Admin Tools',
66
+ matchPrefixes: ['/admin/tools'],
67
+ adminOnly: true,
68
+ },
69
+ ],
70
+ [],
71
+ )
72
+
73
+ const linkStyle = (active: boolean): React.CSSProperties => ({
74
+ background: active ? 'var(--theme-elevation-100)' : 'transparent',
75
+ borderRadius: 10,
76
+ color: 'var(--theme-elevation-900)',
77
+ display: 'block',
78
+ fontSize: '0.95rem',
79
+ fontWeight: active ? 800 : 650,
80
+ padding: '0.6rem 0.75rem',
81
+ textDecoration: 'none',
82
+ })
83
+
84
+ return (
85
+ <div
86
+ style={{
87
+ display: 'flex',
88
+ flexDirection: 'column',
89
+ gap: '0.85rem',
90
+ height: '100%',
91
+ padding: '1rem 0.85rem',
92
+ }}
93
+ >
94
+ <div style={{ padding: '0 0.35rem' }}>
95
+ <div style={{ fontSize: '1.05rem', fontWeight: 900, letterSpacing: '-0.01em' }}>
96
+ {brandName}
97
+ </div>
98
+ <div style={{ color: 'var(--theme-elevation-600)', fontSize: '0.85rem' }}>Studio</div>
99
+ </div>
100
+
101
+ <nav style={{ display: 'grid', gap: '0.25rem' }}>
102
+ {links
103
+ .filter((link) => !link.adminOnly || isAdmin(user))
104
+ .map((link) => {
105
+ const active =
106
+ link.href === '/admin'
107
+ ? pathname === '/admin'
108
+ : link.matchPrefixes.some((prefix) => pathname.startsWith(prefix))
109
+
110
+ return (
111
+ <a href={link.href} key={link.href} style={linkStyle(active)}>
112
+ {link.label}
113
+ </a>
114
+ )
115
+ })}
116
+ </nav>
117
+
118
+ <div style={{ flex: 1 }} />
119
+
120
+ <div
121
+ style={{
122
+ borderTop: '1px solid var(--theme-elevation-150)',
123
+ paddingTop: '0.85rem',
124
+ }}
125
+ >
126
+ <div style={{ color: 'var(--theme-elevation-700)', fontSize: '0.85rem' }}>Signed in as</div>
127
+ <div style={{ fontWeight: 800, marginBottom: '0.55rem' }}>
128
+ {typeof (user as any)?.email === 'string' ? (user as any).email : 'User'}
129
+ </div>
130
+ <Logout />
131
+ </div>
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,172 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import { toast, useAuth } from '@payloadcms/ui'
6
+
7
+ type AnyRecord = Record<string, unknown>
8
+
9
+ const isAdmin = (user: unknown): boolean => {
10
+ if (!user || typeof user !== 'object') return false
11
+ const role = (user as any).role
12
+ return typeof role === 'string' && role === 'admin'
13
+ }
14
+
15
+ const isEditor = (user: unknown): boolean => {
16
+ if (!user || typeof user !== 'object') return false
17
+ const role = (user as any).role
18
+ return typeof role === 'string' && role === 'editor'
19
+ }
20
+
21
+ const getPropString = (props: unknown, key: string, fallback: string): string => {
22
+ if (!props || typeof props !== 'object') return fallback
23
+
24
+ const direct = (props as any)[key]
25
+ if (typeof direct === 'string' && direct.length > 0) return direct
26
+
27
+ const clientProps = (props as any).clientProps
28
+ if (clientProps && typeof clientProps === 'object') {
29
+ const nested = (clientProps as any)[key]
30
+ if (typeof nested === 'string' && nested.length > 0) return nested
31
+ }
32
+
33
+ return fallback
34
+ }
35
+
36
+ const getParam = (params: unknown, key: string): string | null => {
37
+ if (!params || typeof params !== 'object') return null
38
+ if (!(key in (params as Record<string, unknown>))) return null
39
+ const value = (params as Record<string, unknown>)[key]
40
+ if (typeof value === 'string') return value
41
+ if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
42
+ return null
43
+ }
44
+
45
+ export function AdminStudioPageEditView(props: AnyRecord) {
46
+ const { user } = useAuth()
47
+ const iframeRef = useRef<HTMLIFrameElement | null>(null)
48
+ const [saving, setSaving] = useState<null | 'draft' | 'published'>(null)
49
+
50
+ const builderBasePath = getPropString(props, 'builderBasePath', '/builder')
51
+ const pageID = useMemo(() => getParam(props.params, 'id'), [props.params])
52
+
53
+ const canPublish = isAdmin(user) || isEditor(user)
54
+
55
+ const requestSave = (status: 'draft' | 'published') => {
56
+ const iframe = iframeRef.current
57
+ if (!iframe?.contentWindow) {
58
+ toast.error('Editor is not ready yet. Please try again.')
59
+ return
60
+ }
61
+
62
+ setSaving(status)
63
+ iframe.contentWindow.postMessage({ source: 'payload-visual-builder-parent', type: 'save', status }, '*')
64
+ }
65
+
66
+ useEffect(() => {
67
+ const onMessage = (event: MessageEvent) => {
68
+ const data = event.data as
69
+ | { source?: string; type?: string; ok?: boolean; message?: string }
70
+ | undefined
71
+
72
+ if (!data || data.source !== 'payload-visual-builder-child' || data.type !== 'save-result') {
73
+ return
74
+ }
75
+
76
+ setSaving(null)
77
+
78
+ if (data.ok) {
79
+ toast.success(typeof data.message === 'string' ? data.message : 'Saved.')
80
+ } else {
81
+ toast.error(typeof data.message === 'string' ? data.message : 'Save failed.')
82
+ }
83
+ }
84
+
85
+ window.addEventListener('message', onMessage)
86
+ return () => window.removeEventListener('message', onMessage)
87
+ }, [])
88
+
89
+ if (!pageID) {
90
+ return (
91
+ <div style={{ padding: '1.2rem' }}>
92
+ <h1 style={{ margin: 0 }}>Page Editor</h1>
93
+ <p style={{ color: 'var(--theme-elevation-600)' }}>Missing page ID.</p>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ return (
99
+ <div style={{ display: 'grid', gridTemplateRows: 'auto 1fr', height: 'calc(100vh - 0px)' }}>
100
+ <div
101
+ style={{
102
+ alignItems: 'center',
103
+ background: 'var(--theme-elevation-0)',
104
+ borderBottom: '1px solid var(--theme-elevation-150)',
105
+ display: 'flex',
106
+ gap: '0.6rem',
107
+ justifyContent: 'space-between',
108
+ padding: '0.65rem 0.9rem',
109
+ position: 'sticky',
110
+ top: 0,
111
+ zIndex: 20,
112
+ }}
113
+ >
114
+ <div style={{ minWidth: 0 }}>
115
+ <div style={{ fontWeight: 900 }}>Page Editor</div>
116
+ <div
117
+ style={{
118
+ color: 'var(--theme-elevation-600)',
119
+ fontSize: '0.85rem',
120
+ overflow: 'hidden',
121
+ textOverflow: 'ellipsis',
122
+ }}
123
+ >
124
+ Editing: {pageID}
125
+ </div>
126
+ </div>
127
+
128
+ <div style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}>
129
+ <button
130
+ disabled={saving !== null}
131
+ onClick={() => requestSave('draft')}
132
+ style={{
133
+ border: '1px solid var(--theme-elevation-300)',
134
+ borderRadius: 12,
135
+ cursor: saving ? 'not-allowed' : 'pointer',
136
+ fontWeight: 800,
137
+ padding: '0.5rem 0.75rem',
138
+ }}
139
+ type="button"
140
+ >
141
+ {saving === 'draft' ? 'Saving…' : 'Save Draft'}
142
+ </button>
143
+
144
+ <button
145
+ disabled={!canPublish || saving !== null}
146
+ onClick={() => requestSave('published')}
147
+ style={{
148
+ background: canPublish ? 'var(--theme-elevation-900)' : 'var(--theme-elevation-300)',
149
+ border: 'none',
150
+ borderRadius: 12,
151
+ color: canPublish ? 'var(--theme-elevation-0)' : 'var(--theme-elevation-700)',
152
+ cursor: !canPublish || saving ? 'not-allowed' : 'pointer',
153
+ fontWeight: 900,
154
+ padding: '0.5rem 0.75rem',
155
+ }}
156
+ type="button"
157
+ title={!canPublish ? 'You do not have publish permissions.' : undefined}
158
+ >
159
+ {saving === 'published' ? 'Publishing…' : 'Publish'}
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ <iframe
165
+ ref={iframeRef}
166
+ src={`${builderBasePath.replace(/\/$/, '')}/${pageID}`}
167
+ style={{ border: 'none', height: '100%', width: '100%' }}
168
+ title="Page Builder"
169
+ />
170
+ </div>
171
+ )
172
+ }
@@ -0,0 +1,208 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+
5
+ import { useAuth } from '@payloadcms/ui'
6
+
7
+ type AnyRecord = Record<string, unknown>
8
+
9
+ type PageDoc = {
10
+ id?: number | string
11
+ title?: unknown
12
+ path?: unknown
13
+ _status?: unknown
14
+ }
15
+
16
+ const isAdmin = (user: unknown): boolean => {
17
+ if (!user || typeof user !== 'object') return false
18
+ const role = (user as any).role
19
+ return typeof role === 'string' && role === 'admin'
20
+ }
21
+
22
+ const getPropString = (props: unknown, key: string, fallback: string): string => {
23
+ if (!props || typeof props !== 'object') return fallback
24
+
25
+ const direct = (props as any)[key]
26
+ if (typeof direct === 'string' && direct.length > 0) return direct
27
+
28
+ const clientProps = (props as any).clientProps
29
+ if (clientProps && typeof clientProps === 'object') {
30
+ const nested = (clientProps as any)[key]
31
+ if (typeof nested === 'string' && nested.length > 0) return nested
32
+ }
33
+
34
+ return fallback
35
+ }
36
+
37
+ export function AdminStudioPagesListView(props: AnyRecord) {
38
+ const { user } = useAuth()
39
+ const pagesCollectionSlug = getPropString(props, 'pagesCollectionSlug', 'pages')
40
+
41
+ const [loading, setLoading] = useState(true)
42
+ const [error, setError] = useState<string | null>(null)
43
+ const [docs, setDocs] = useState<PageDoc[]>([])
44
+
45
+ const apiURL = useMemo(() => {
46
+ const params = new URLSearchParams({
47
+ depth: '0',
48
+ limit: '100',
49
+ sort: '-updatedAt',
50
+ draft: 'true',
51
+ })
52
+
53
+ return `/api/${pagesCollectionSlug}?${params.toString()}`
54
+ }, [pagesCollectionSlug])
55
+
56
+ useEffect(() => {
57
+ let cancelled = false
58
+
59
+ const run = async () => {
60
+ setLoading(true)
61
+ setError(null)
62
+
63
+ try {
64
+ const res = await fetch(apiURL, { credentials: 'include' })
65
+ if (!res.ok) {
66
+ const body = await res.text()
67
+ throw new Error(body || 'Failed to fetch pages')
68
+ }
69
+
70
+ const data = (await res.json()) as { docs?: PageDoc[] }
71
+ const nextDocs = Array.isArray(data.docs) ? data.docs : []
72
+ if (!cancelled) setDocs(nextDocs)
73
+ } catch (err) {
74
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to fetch pages')
75
+ } finally {
76
+ if (!cancelled) setLoading(false)
77
+ }
78
+ }
79
+
80
+ void run()
81
+
82
+ return () => {
83
+ cancelled = true
84
+ }
85
+ }, [apiURL])
86
+
87
+ return (
88
+ <div style={{ padding: '1.2rem 1.2rem 2.5rem' }}>
89
+ <div style={{ alignItems: 'flex-end', display: 'flex', gap: '0.75rem' }}>
90
+ <div style={{ flex: 1 }}>
91
+ <h1 style={{ margin: 0 }}>Pages</h1>
92
+ <p style={{ color: 'var(--theme-elevation-600)', marginTop: '0.35rem' }}>
93
+ Open a page to edit it in the custom editor.
94
+ </p>
95
+ </div>
96
+
97
+ {isAdmin(user) ? (
98
+ <a
99
+ href={`/admin/collections/${pagesCollectionSlug}/create`}
100
+ style={{
101
+ background: 'var(--theme-elevation-900)',
102
+ borderRadius: 12,
103
+ color: 'var(--theme-elevation-0)',
104
+ fontWeight: 800,
105
+ padding: '0.55rem 0.85rem',
106
+ textDecoration: 'none',
107
+ }}
108
+ >
109
+ New Page
110
+ </a>
111
+ ) : null}
112
+ </div>
113
+
114
+ {loading ? (
115
+ <div style={{ color: 'var(--theme-elevation-600)', marginTop: '1rem' }}>Loading…</div>
116
+ ) : null}
117
+
118
+ {error ? (
119
+ <div style={{ color: 'crimson', marginTop: '1rem' }}>{error}</div>
120
+ ) : null}
121
+
122
+ <div style={{ display: 'grid', gap: '0.6rem', marginTop: '1rem' }}>
123
+ {!loading && !error && docs.length === 0 ? (
124
+ <div
125
+ style={{
126
+ border: '1px dashed var(--theme-elevation-300)',
127
+ borderRadius: 16,
128
+ color: 'var(--theme-elevation-700)',
129
+ padding: '1rem',
130
+ }}
131
+ >
132
+ No pages found.
133
+ </div>
134
+ ) : null}
135
+
136
+ {docs.map((doc) => {
137
+ const id = typeof doc.id === 'string' || typeof doc.id === 'number' ? String(doc.id) : ''
138
+ const title = typeof doc.title === 'string' ? doc.title : 'Untitled Page'
139
+ const path = typeof doc.path === 'string' ? doc.path : '/'
140
+ const status = typeof doc._status === 'string' ? doc._status : ''
141
+
142
+ if (!id) return null
143
+
144
+ return (
145
+ <a
146
+ href={`/admin/pages/${id}`}
147
+ key={id}
148
+ style={{
149
+ background: 'var(--theme-elevation-0)',
150
+ border: '1px solid var(--theme-elevation-150)',
151
+ borderRadius: 16,
152
+ color: 'inherit',
153
+ display: 'flex',
154
+ gap: '0.85rem',
155
+ justifyContent: 'space-between',
156
+ padding: '0.85rem 1rem',
157
+ textDecoration: 'none',
158
+ }}
159
+ >
160
+ <div style={{ minWidth: 0 }}>
161
+ <div style={{ fontWeight: 900, overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</div>
162
+ <div
163
+ style={{
164
+ color: 'var(--theme-elevation-600)',
165
+ fontSize: '0.9rem',
166
+ overflow: 'hidden',
167
+ textOverflow: 'ellipsis',
168
+ }}
169
+ >
170
+ {path}
171
+ </div>
172
+ </div>
173
+ <div
174
+ style={{
175
+ alignItems: 'center',
176
+ display: 'flex',
177
+ flexShrink: 0,
178
+ gap: '0.5rem',
179
+ }}
180
+ >
181
+ {status ? (
182
+ <span
183
+ style={{
184
+ background:
185
+ status === 'published'
186
+ ? 'rgba(16, 185, 129, 0.12)'
187
+ : 'rgba(245, 158, 11, 0.14)',
188
+ borderRadius: 999,
189
+ color:
190
+ status === 'published' ? 'rgb(5, 122, 85)' : 'rgb(180, 83, 9)',
191
+ fontSize: '0.82rem',
192
+ fontWeight: 900,
193
+ padding: '0.25rem 0.55rem',
194
+ textTransform: 'capitalize',
195
+ }}
196
+ >
197
+ {status}
198
+ </span>
199
+ ) : null}
200
+ <span style={{ color: 'var(--theme-elevation-600)', fontWeight: 800 }}>Open</span>
201
+ </div>
202
+ </a>
203
+ )
204
+ })}
205
+ </div>
206
+ </div>
207
+ )
208
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useAuth } from '@payloadcms/ui'
4
+
5
+ type AnyRecord = Record<string, unknown>
6
+
7
+ const isAdmin = (user: unknown): boolean => {
8
+ if (!user || typeof user !== 'object') return false
9
+ const role = (user as any).role
10
+ return typeof role === 'string' && role === 'admin'
11
+ }
12
+
13
+ const getPropString = (props: unknown, key: string, fallback: string): string => {
14
+ if (!props || typeof props !== 'object') return fallback
15
+
16
+ const direct = (props as any)[key]
17
+ if (typeof direct === 'string' && direct.length > 0) return direct
18
+
19
+ const clientProps = (props as any).clientProps
20
+ if (clientProps && typeof clientProps === 'object') {
21
+ const nested = (clientProps as any)[key]
22
+ if (typeof nested === 'string' && nested.length > 0) return nested
23
+ }
24
+
25
+ return fallback
26
+ }
27
+
28
+ export function AdminStudioToolsView(props: AnyRecord) {
29
+ const { user } = useAuth()
30
+
31
+ if (!isAdmin(user)) {
32
+ return (
33
+ <div style={{ padding: '1.2rem' }}>
34
+ <h1 style={{ margin: 0 }}>Admin Tools</h1>
35
+ <p style={{ color: 'var(--theme-elevation-600)' }}>You do not have access to this page.</p>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ const pagesCollectionSlug = getPropString(props, 'pagesCollectionSlug', 'pages')
41
+ const mediaCollectionSlug = getPropString(props, 'mediaCollectionSlug', 'media')
42
+
43
+ const links = [
44
+ { href: `/admin/collections/${pagesCollectionSlug}`, label: 'Raw Pages Collection' },
45
+ { href: `/admin/collections/${mediaCollectionSlug}`, label: 'Raw Media Collection' },
46
+ { href: '/admin/globals/site-settings', label: 'Raw Site Settings Global' },
47
+ { href: '/admin/globals/header', label: 'Raw Header Global' },
48
+ { href: '/admin/globals/footer', label: 'Raw Footer Global' },
49
+ { href: '/admin/collections/users', label: 'Users / Roles' },
50
+ ]
51
+
52
+ return (
53
+ <div style={{ padding: '1.2rem 1.2rem 2.5rem' }}>
54
+ <h1 style={{ margin: 0 }}>Admin Tools</h1>
55
+ <p style={{ color: 'var(--theme-elevation-600)', marginTop: '0.35rem' }}>
56
+ Hidden fallback links for administrators.
57
+ </p>
58
+
59
+ <div style={{ display: 'grid', gap: '0.6rem', marginTop: '1rem' }}>
60
+ {links.map((link) => (
61
+ <a
62
+ href={link.href}
63
+ key={link.href}
64
+ style={{
65
+ background: 'var(--theme-elevation-0)',
66
+ border: '1px solid var(--theme-elevation-150)',
67
+ borderRadius: 16,
68
+ color: 'inherit',
69
+ padding: '0.85rem 1rem',
70
+ textDecoration: 'none',
71
+ }}
72
+ >
73
+ <div style={{ fontWeight: 900 }}>{link.label}</div>
74
+ <div style={{ color: 'var(--theme-elevation-600)', fontSize: '0.9rem' }}>{link.href}</div>
75
+ </a>
76
+ ))}
77
+ </div>
78
+ </div>
79
+ )
80
+ }