@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.
Files changed (35) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/components/SchedulePublishDialog.d.ts +18 -0
  7. package/dist/components/SchedulePublishDialog.d.ts.map +1 -0
  8. package/dist/components/SchedulePublishDialog.js +106 -0
  9. package/dist/components/SchedulePublishDialog.js.map +1 -0
  10. package/dist/components/SharePreviewLinkDialog.d.ts +17 -0
  11. package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -0
  12. package/dist/components/SharePreviewLinkDialog.js +83 -0
  13. package/dist/components/SharePreviewLinkDialog.js.map +1 -0
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/layout/Sidebar.d.ts.map +1 -1
  19. package/dist/layout/Sidebar.js +3 -2
  20. package/dist/layout/Sidebar.js.map +1 -1
  21. package/dist/views/ApiKeys.d.ts +5 -0
  22. package/dist/views/ApiKeys.d.ts.map +1 -0
  23. package/dist/views/ApiKeys.js +154 -0
  24. package/dist/views/ApiKeys.js.map +1 -0
  25. package/dist/views/DocumentEdit.d.ts.map +1 -1
  26. package/dist/views/DocumentEdit.js +15 -3
  27. package/dist/views/DocumentEdit.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/AdminRoot.tsx +5 -0
  30. package/src/components/SchedulePublishDialog.tsx +241 -0
  31. package/src/components/SharePreviewLinkDialog.tsx +227 -0
  32. package/src/index.ts +5 -0
  33. package/src/layout/Sidebar.tsx +3 -0
  34. package/src/views/ApiKeys.tsx +512 -0
  35. 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'
@@ -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