@actuate-media/cms-admin 0.8.2 → 0.10.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- 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/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -2
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +5 -0
- package/dist/views/ApiKeys.d.ts.map +1 -0
- package/dist/views/ApiKeys.js +154 -0
- package/dist/views/ApiKeys.js.map +1 -0
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +15 -3
- package/dist/views/DocumentEdit.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +5 -0
- package/src/components/SchedulePublishDialog.tsx +241 -0
- package/src/components/SharePreviewLinkDialog.tsx +227 -0
- package/src/index.ts +5 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/views/ApiKeys.tsx +512 -0
- package/src/views/DocumentEdit.tsx +81 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Check, Copy, Link2, Loader2, Share2, X } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { cmsApi } from '../lib/api.js'
|
|
7
|
+
|
|
8
|
+
export interface SharePreviewLinkDialogProps {
|
|
9
|
+
collectionSlug: string
|
|
10
|
+
documentId: string
|
|
11
|
+
/** Document slug, used to build a friendly preview URL. */
|
|
12
|
+
documentSlug?: string | null
|
|
13
|
+
/**
|
|
14
|
+
* Optional site URL (https://...) to prepend. If omitted we just emit the
|
|
15
|
+
* relative path and ask the admin to share it however they want.
|
|
16
|
+
*/
|
|
17
|
+
siteUrl?: string
|
|
18
|
+
/** Custom URL prefix for this collection (e.g. /blog). Defaults to slug. */
|
|
19
|
+
urlPrefix?: string
|
|
20
|
+
open: boolean
|
|
21
|
+
onClose: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TTLChoice {
|
|
25
|
+
label: string
|
|
26
|
+
seconds: number
|
|
27
|
+
hint: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TTL_CHOICES: TTLChoice[] = [
|
|
31
|
+
{ label: '1 hour', seconds: 60 * 60, hint: 'Quick review' },
|
|
32
|
+
{ label: '1 day', seconds: 24 * 60 * 60, hint: 'Single workday' },
|
|
33
|
+
{ label: '7 days', seconds: 7 * 24 * 60 * 60, hint: 'Client review' },
|
|
34
|
+
{ label: '30 days', seconds: 30 * 24 * 60 * 60, hint: 'Long-running staging' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function buildPreviewUrl({
|
|
38
|
+
siteUrl,
|
|
39
|
+
urlPrefix,
|
|
40
|
+
collectionSlug,
|
|
41
|
+
documentSlug,
|
|
42
|
+
token,
|
|
43
|
+
}: {
|
|
44
|
+
siteUrl?: string
|
|
45
|
+
urlPrefix?: string
|
|
46
|
+
collectionSlug: string
|
|
47
|
+
documentSlug?: string | null
|
|
48
|
+
token: string
|
|
49
|
+
}): string {
|
|
50
|
+
const base = (siteUrl ?? '').replace(/\/+$/, '')
|
|
51
|
+
// Build the same path /resolve would match: <urlPrefix>/<slug>, or
|
|
52
|
+
// /<slug> for top-level page collections. If there's no slug yet we
|
|
53
|
+
// fall back to /admin's preview endpoint as a safety net.
|
|
54
|
+
const slug = documentSlug?.toString().trim() ?? ''
|
|
55
|
+
if (!slug) {
|
|
56
|
+
return `${base}/preview/${collectionSlug}/_?token=${encodeURIComponent(token)}`
|
|
57
|
+
}
|
|
58
|
+
const prefix = (urlPrefix ?? collectionSlug).replace(/^\/+|\/+$/g, '')
|
|
59
|
+
const path = prefix ? `/${prefix}/${slug}` : `/${slug}`
|
|
60
|
+
return `${base}${path}?preview=${encodeURIComponent(token)}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function SharePreviewLinkDialog({
|
|
64
|
+
collectionSlug,
|
|
65
|
+
documentId,
|
|
66
|
+
documentSlug,
|
|
67
|
+
siteUrl,
|
|
68
|
+
urlPrefix,
|
|
69
|
+
open,
|
|
70
|
+
onClose,
|
|
71
|
+
}: SharePreviewLinkDialogProps) {
|
|
72
|
+
const [ttl, setTtl] = useState<number>(TTL_CHOICES[2]!.seconds)
|
|
73
|
+
const [token, setToken] = useState<string>('')
|
|
74
|
+
const [previewUrl, setPreviewUrl] = useState<string>('')
|
|
75
|
+
const [expiresAt, setExpiresAt] = useState<string>('')
|
|
76
|
+
const [generating, setGenerating] = useState(false)
|
|
77
|
+
const [copied, setCopied] = useState(false)
|
|
78
|
+
|
|
79
|
+
async function generate() {
|
|
80
|
+
setGenerating(true)
|
|
81
|
+
const res = await cmsApi<{
|
|
82
|
+
token: string
|
|
83
|
+
expiresAt: string
|
|
84
|
+
slug?: string | null
|
|
85
|
+
}>('/preview/token', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
collection: collectionSlug,
|
|
89
|
+
documentId,
|
|
90
|
+
ttlSeconds: ttl,
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
setGenerating(false)
|
|
94
|
+
if (res.error || !res.data) {
|
|
95
|
+
toast.error(res.error ?? 'Failed to create preview token')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const data = res.data
|
|
99
|
+
setToken(data.token)
|
|
100
|
+
setExpiresAt(data.expiresAt ?? '')
|
|
101
|
+
const url = buildPreviewUrl({
|
|
102
|
+
siteUrl,
|
|
103
|
+
urlPrefix,
|
|
104
|
+
collectionSlug,
|
|
105
|
+
documentSlug: data.slug ?? documentSlug,
|
|
106
|
+
token: data.token,
|
|
107
|
+
})
|
|
108
|
+
setPreviewUrl(url)
|
|
109
|
+
setCopied(false)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function copyToClipboard() {
|
|
113
|
+
if (!previewUrl) return
|
|
114
|
+
try {
|
|
115
|
+
await navigator.clipboard.writeText(previewUrl)
|
|
116
|
+
setCopied(true)
|
|
117
|
+
toast.success('Preview link copied')
|
|
118
|
+
setTimeout(() => setCopied(false), 2000)
|
|
119
|
+
} catch {
|
|
120
|
+
toast.error("Couldn't copy to clipboard")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!open) return null
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
128
|
+
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
|
129
|
+
<div className="relative w-full max-w-lg rounded-lg bg-white shadow-xl">
|
|
130
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
<Share2 className="h-5 w-5 text-gray-600" />
|
|
133
|
+
<h2 className="text-lg font-semibold text-gray-900">Share preview link</h2>
|
|
134
|
+
</div>
|
|
135
|
+
<button
|
|
136
|
+
onClick={onClose}
|
|
137
|
+
className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
|
|
138
|
+
>
|
|
139
|
+
<X className="h-5 w-5 text-gray-500" />
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="space-y-4 px-4 py-4">
|
|
144
|
+
<p className="text-sm text-gray-600">
|
|
145
|
+
Generate a signed URL that lets anyone with the link view the current draft of this
|
|
146
|
+
document — even if it's never been published.
|
|
147
|
+
</p>
|
|
148
|
+
|
|
149
|
+
<div className="space-y-2">
|
|
150
|
+
<label className="text-sm font-medium text-gray-700">Link lifetime</label>
|
|
151
|
+
<div className="grid grid-cols-2 gap-2">
|
|
152
|
+
{TTL_CHOICES.map((choice) => {
|
|
153
|
+
const selected = ttl === choice.seconds
|
|
154
|
+
return (
|
|
155
|
+
<button
|
|
156
|
+
key={choice.seconds}
|
|
157
|
+
onClick={() => setTtl(choice.seconds)}
|
|
158
|
+
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
|
159
|
+
selected
|
|
160
|
+
? 'border-blue-500 bg-blue-50 text-blue-900'
|
|
161
|
+
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
|
162
|
+
}`}
|
|
163
|
+
>
|
|
164
|
+
<div className="font-medium">{choice.label}</div>
|
|
165
|
+
<div className="text-xs text-gray-500">{choice.hint}</div>
|
|
166
|
+
</button>
|
|
167
|
+
)
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
<p className="text-xs text-gray-500">
|
|
171
|
+
Preview tokens can't be revoked individually — pick the shortest TTL that fits.
|
|
172
|
+
</p>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{previewUrl ? (
|
|
176
|
+
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
177
|
+
<label className="text-xs font-medium uppercase tracking-wide text-gray-500">
|
|
178
|
+
Preview URL
|
|
179
|
+
</label>
|
|
180
|
+
<div className="flex items-stretch gap-2">
|
|
181
|
+
<input
|
|
182
|
+
readOnly
|
|
183
|
+
value={previewUrl}
|
|
184
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
185
|
+
className="flex-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 font-mono text-xs"
|
|
186
|
+
/>
|
|
187
|
+
<button
|
|
188
|
+
onClick={copyToClipboard}
|
|
189
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700"
|
|
190
|
+
>
|
|
191
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
192
|
+
{copied ? 'Copied' : 'Copy'}
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
{expiresAt && (
|
|
196
|
+
<p className="text-xs text-gray-500">
|
|
197
|
+
Expires {new Date(expiresAt).toLocaleString()}
|
|
198
|
+
</p>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
) : null}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div className="flex items-center justify-end gap-2 border-t border-gray-200 px-4 py-3">
|
|
205
|
+
<button
|
|
206
|
+
onClick={onClose}
|
|
207
|
+
className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
|
|
208
|
+
>
|
|
209
|
+
Close
|
|
210
|
+
</button>
|
|
211
|
+
<button
|
|
212
|
+
onClick={generate}
|
|
213
|
+
disabled={generating}
|
|
214
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
|
215
|
+
>
|
|
216
|
+
{generating ? (
|
|
217
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
218
|
+
) : (
|
|
219
|
+
<Link2 className="h-4 w-4" />
|
|
220
|
+
)}
|
|
221
|
+
{previewUrl ? 'Generate new link' : 'Generate link'}
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { FormSubmissions } from './views/FormSubmissions.js'
|
|
|
21
21
|
export { Redirects } from './views/Redirects.js'
|
|
22
22
|
export { Users } from './views/Users.js'
|
|
23
23
|
export { Settings } from './views/Settings.js'
|
|
24
|
+
export { ApiKeys } from './views/ApiKeys.js'
|
|
24
25
|
export { SEO } from './views/SEO.js'
|
|
25
26
|
export { SetupWizard } from './views/SetupWizard.js'
|
|
26
27
|
export type { SetupWizardProps } from './views/SetupWizard.js'
|
|
@@ -59,6 +60,10 @@ export type { SEOData, SEOPanelProps } from './components/SEOPanel.js'
|
|
|
59
60
|
export { LivePreview } from './components/LivePreview.js'
|
|
60
61
|
export { VersionHistory } from './components/VersionHistory.js'
|
|
61
62
|
export type { VersionHistoryProps } from './components/VersionHistory.js'
|
|
63
|
+
export { SchedulePublishDialog } from './components/SchedulePublishDialog.js'
|
|
64
|
+
export type { SchedulePublishDialogProps } from './components/SchedulePublishDialog.js'
|
|
65
|
+
export { SharePreviewLinkDialog } from './components/SharePreviewLinkDialog.js'
|
|
66
|
+
export type { SharePreviewLinkDialogProps } from './components/SharePreviewLinkDialog.js'
|
|
62
67
|
export { MediaPickerModal } from './components/MediaPickerModal.js'
|
|
63
68
|
export type { MediaPickerModalProps } from './components/MediaPickerModal.js'
|
|
64
69
|
export { ThemeProvider, useTheme } from './components/ThemeProvider.js'
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
Code2,
|
|
23
23
|
LayoutTemplate,
|
|
24
24
|
Library,
|
|
25
|
+
KeyRound,
|
|
25
26
|
} from 'lucide-react'
|
|
26
27
|
import type { LucideIcon } from 'lucide-react'
|
|
27
28
|
|
|
@@ -145,6 +146,7 @@ const defaultNavItems = [
|
|
|
145
146
|
{ path: '/forms', label: 'Forms', icon: ClipboardList },
|
|
146
147
|
{ path: '/seo', label: 'SEO', icon: SearchIcon },
|
|
147
148
|
{ path: '/users', label: 'Users', icon: Users },
|
|
149
|
+
{ path: '/api-keys', label: 'API Keys', icon: KeyRound },
|
|
148
150
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
|
149
151
|
]
|
|
150
152
|
|
|
@@ -299,6 +301,7 @@ function buildNavItems(config: any): NavItem[] {
|
|
|
299
301
|
{ path: '/seo', label: 'SEO', icon: SearchIcon },
|
|
300
302
|
{ path: '/script-tags', label: 'Script Tags', icon: Code2 },
|
|
301
303
|
{ path: '/users', label: 'Users', icon: Users },
|
|
304
|
+
{ path: '/api-keys', label: 'API Keys', icon: KeyRound },
|
|
302
305
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
|
303
306
|
)
|
|
304
307
|
|