@actuate-media/cms-admin 0.9.0 → 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/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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +7 -5
- package/dist/views/ApiKeys.js.map +1 -1
- 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/components/SchedulePublishDialog.tsx +241 -0
- package/src/components/SharePreviewLinkDialog.tsx +227 -0
- package/src/index.ts +4 -0
- package/src/views/DocumentEdit.tsx +81 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Calendar, Clock, Loader2, X } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { cmsApi } from '../lib/api.js'
|
|
7
|
+
|
|
8
|
+
export interface SchedulePublishDialogProps {
|
|
9
|
+
collectionSlug: string
|
|
10
|
+
documentId: string
|
|
11
|
+
/** Existing scheduled publish time, if any. */
|
|
12
|
+
scheduledAt?: string | null
|
|
13
|
+
/** Existing scheduled unpublish time, if any. */
|
|
14
|
+
scheduledUnpublishAt?: string | null
|
|
15
|
+
open: boolean
|
|
16
|
+
onClose: () => void
|
|
17
|
+
/** Called with the updated schedule fields after a successful save. */
|
|
18
|
+
onScheduled?: (next: {
|
|
19
|
+
status: string
|
|
20
|
+
scheduledAt: string | null
|
|
21
|
+
scheduledUnpublishAt: string | null
|
|
22
|
+
}) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert a Date or ISO string to the `YYYY-MM-DDTHH:mm` shape that the
|
|
27
|
+
* `<input type="datetime-local">` element expects, in the user's local
|
|
28
|
+
* timezone (the input is timezone-naive but we present + parse it as local).
|
|
29
|
+
*/
|
|
30
|
+
function toLocalDateTimeInput(value: string | Date | null | undefined): string {
|
|
31
|
+
if (!value) return ''
|
|
32
|
+
const d = typeof value === 'string' ? new Date(value) : value
|
|
33
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
34
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
35
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Round "now + 1 hour" to the next quarter-hour for a friendly default. */
|
|
39
|
+
function defaultScheduleTime(): string {
|
|
40
|
+
const d = new Date(Date.now() + 60 * 60 * 1000)
|
|
41
|
+
d.setMinutes(Math.ceil(d.getMinutes() / 15) * 15, 0, 0)
|
|
42
|
+
return toLocalDateTimeInput(d)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SchedulePublishDialog({
|
|
46
|
+
collectionSlug,
|
|
47
|
+
documentId,
|
|
48
|
+
scheduledAt,
|
|
49
|
+
scheduledUnpublishAt,
|
|
50
|
+
open,
|
|
51
|
+
onClose,
|
|
52
|
+
onScheduled,
|
|
53
|
+
}: SchedulePublishDialogProps) {
|
|
54
|
+
const [publishAt, setPublishAt] = useState('')
|
|
55
|
+
const [unpublishAt, setUnpublishAt] = useState('')
|
|
56
|
+
const [includeUnpublish, setIncludeUnpublish] = useState(false)
|
|
57
|
+
const [saving, setSaving] = useState(false)
|
|
58
|
+
const [cancelling, setCancelling] = useState(false)
|
|
59
|
+
|
|
60
|
+
const hasExistingSchedule = Boolean(scheduledAt || scheduledUnpublishAt)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!open) return
|
|
64
|
+
setPublishAt(scheduledAt ? toLocalDateTimeInput(scheduledAt) : defaultScheduleTime())
|
|
65
|
+
setUnpublishAt(scheduledUnpublishAt ? toLocalDateTimeInput(scheduledUnpublishAt) : '')
|
|
66
|
+
setIncludeUnpublish(Boolean(scheduledUnpublishAt))
|
|
67
|
+
}, [open, scheduledAt, scheduledUnpublishAt])
|
|
68
|
+
|
|
69
|
+
async function handleSave() {
|
|
70
|
+
if (!publishAt && !(includeUnpublish && unpublishAt)) {
|
|
71
|
+
toast.error('Pick a publish or unpublish time')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// datetime-local is parsed by the browser as local time; new Date() on
|
|
76
|
+
// that string respects the user's timezone and serialises to a UTC
|
|
77
|
+
// ISO string the server can compare against `Date.now()` cleanly.
|
|
78
|
+
const publishDate = publishAt ? new Date(publishAt) : null
|
|
79
|
+
const unpublishDate = includeUnpublish && unpublishAt ? new Date(unpublishAt) : null
|
|
80
|
+
|
|
81
|
+
if (publishDate && publishDate.getTime() <= Date.now()) {
|
|
82
|
+
toast.error('Publish time must be in the future')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (unpublishDate && unpublishDate.getTime() <= Date.now()) {
|
|
86
|
+
toast.error('Unpublish time must be in the future')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (publishDate && unpublishDate && unpublishDate <= publishDate) {
|
|
90
|
+
toast.error('Unpublish time must be after publish time')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setSaving(true)
|
|
95
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}/schedule`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
scheduledAt: publishDate ? publishDate.toISOString() : null,
|
|
99
|
+
scheduledUnpublishAt: unpublishDate ? unpublishDate.toISOString() : null,
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
setSaving(false)
|
|
103
|
+
if (res.error) {
|
|
104
|
+
toast.error(res.error)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
toast.success(publishDate ? 'Publish scheduled' : 'Unpublish scheduled')
|
|
108
|
+
onScheduled?.({
|
|
109
|
+
status: res.data?.status ?? 'SCHEDULED',
|
|
110
|
+
scheduledAt: res.data?.scheduledAt ?? (publishDate ? publishDate.toISOString() : null),
|
|
111
|
+
scheduledUnpublishAt:
|
|
112
|
+
res.data?.scheduledUnpublishAt ?? (unpublishDate ? unpublishDate.toISOString() : null),
|
|
113
|
+
})
|
|
114
|
+
onClose()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function handleCancelSchedule() {
|
|
118
|
+
setCancelling(true)
|
|
119
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}/schedule`, {
|
|
120
|
+
method: 'DELETE',
|
|
121
|
+
})
|
|
122
|
+
setCancelling(false)
|
|
123
|
+
if (res.error) {
|
|
124
|
+
toast.error(res.error)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
toast.success('Schedule cancelled')
|
|
128
|
+
onScheduled?.({
|
|
129
|
+
status: res.data?.status ?? 'DRAFT',
|
|
130
|
+
scheduledAt: null,
|
|
131
|
+
scheduledUnpublishAt: null,
|
|
132
|
+
})
|
|
133
|
+
onClose()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!open) return null
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
140
|
+
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
|
141
|
+
<div className="relative w-full max-w-md rounded-lg bg-white shadow-xl">
|
|
142
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
143
|
+
<div className="flex items-center gap-2">
|
|
144
|
+
<Calendar className="h-5 w-5 text-gray-600" />
|
|
145
|
+
<h2 className="text-lg font-semibold text-gray-900">Schedule publishing</h2>
|
|
146
|
+
</div>
|
|
147
|
+
<button
|
|
148
|
+
onClick={onClose}
|
|
149
|
+
className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
|
|
150
|
+
>
|
|
151
|
+
<X className="h-5 w-5 text-gray-500" />
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className="space-y-4 px-4 py-4">
|
|
156
|
+
<div className="space-y-1.5">
|
|
157
|
+
<label className="text-sm font-medium text-gray-700">Publish at</label>
|
|
158
|
+
<div className="relative">
|
|
159
|
+
<Clock className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
|
160
|
+
<input
|
|
161
|
+
type="datetime-local"
|
|
162
|
+
value={publishAt}
|
|
163
|
+
onChange={(e) => setPublishAt(e.target.value)}
|
|
164
|
+
min={toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
|
|
165
|
+
className="w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
<p className="text-xs text-gray-500">
|
|
169
|
+
The document will move from DRAFT to PUBLISHED at this time (your local timezone).
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
checked={includeUnpublish}
|
|
177
|
+
onChange={(e) => setIncludeUnpublish(e.target.checked)}
|
|
178
|
+
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
179
|
+
/>
|
|
180
|
+
Also schedule an unpublish
|
|
181
|
+
</label>
|
|
182
|
+
|
|
183
|
+
{includeUnpublish && (
|
|
184
|
+
<div className="space-y-1.5">
|
|
185
|
+
<label className="text-sm font-medium text-gray-700">Unpublish at</label>
|
|
186
|
+
<div className="relative">
|
|
187
|
+
<Clock className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
|
188
|
+
<input
|
|
189
|
+
type="datetime-local"
|
|
190
|
+
value={unpublishAt}
|
|
191
|
+
onChange={(e) => setUnpublishAt(e.target.value)}
|
|
192
|
+
min={publishAt || toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
|
|
193
|
+
className="w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="flex items-center justify-between gap-2 border-t border-gray-200 px-4 py-3">
|
|
201
|
+
{hasExistingSchedule ? (
|
|
202
|
+
<button
|
|
203
|
+
onClick={handleCancelSchedule}
|
|
204
|
+
disabled={cancelling || saving}
|
|
205
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
|
|
206
|
+
>
|
|
207
|
+
{cancelling ? (
|
|
208
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
209
|
+
) : (
|
|
210
|
+
<X className="h-4 w-4" />
|
|
211
|
+
)}
|
|
212
|
+
Cancel schedule
|
|
213
|
+
</button>
|
|
214
|
+
) : (
|
|
215
|
+
<span />
|
|
216
|
+
)}
|
|
217
|
+
<div className="flex items-center gap-2">
|
|
218
|
+
<button
|
|
219
|
+
onClick={onClose}
|
|
220
|
+
className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
|
|
221
|
+
>
|
|
222
|
+
Close
|
|
223
|
+
</button>
|
|
224
|
+
<button
|
|
225
|
+
onClick={handleSave}
|
|
226
|
+
disabled={saving || cancelling}
|
|
227
|
+
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"
|
|
228
|
+
>
|
|
229
|
+
{saving ? (
|
|
230
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
231
|
+
) : (
|
|
232
|
+
<Calendar className="h-4 w-4" />
|
|
233
|
+
)}
|
|
234
|
+
{hasExistingSchedule ? 'Reschedule' : 'Schedule'}
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -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
|
@@ -60,6 +60,10 @@ export type { SEOData, SEOPanelProps } from './components/SEOPanel.js'
|
|
|
60
60
|
export { LivePreview } from './components/LivePreview.js'
|
|
61
61
|
export { VersionHistory } from './components/VersionHistory.js'
|
|
62
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'
|
|
63
67
|
export { MediaPickerModal } from './components/MediaPickerModal.js'
|
|
64
68
|
export type { MediaPickerModalProps } from './components/MediaPickerModal.js'
|
|
65
69
|
export { ThemeProvider, useTheme } from './components/ThemeProvider.js'
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
Calendar,
|
|
7
|
+
Eye,
|
|
8
|
+
EyeOff,
|
|
9
|
+
Save,
|
|
10
|
+
Copy,
|
|
11
|
+
Loader2,
|
|
12
|
+
Clock,
|
|
13
|
+
Share2,
|
|
14
|
+
XCircle,
|
|
15
|
+
} from 'lucide-react'
|
|
5
16
|
import { toast } from 'sonner'
|
|
6
17
|
import { FieldRenderer } from '../fields/FieldRenderer.js'
|
|
7
18
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
@@ -12,6 +23,8 @@ import { VersionHistory } from '../components/VersionHistory.js'
|
|
|
12
23
|
import { PresenceIndicator } from '../components/PresenceIndicator.js'
|
|
13
24
|
import { SEOPanel } from '../components/SEOPanel.js'
|
|
14
25
|
import type { SEOData } from '../components/SEOPanel.js'
|
|
26
|
+
import { SchedulePublishDialog } from '../components/SchedulePublishDialog.js'
|
|
27
|
+
import { SharePreviewLinkDialog } from '../components/SharePreviewLinkDialog.js'
|
|
15
28
|
import { cmsApi } from '../lib/api.js'
|
|
16
29
|
|
|
17
30
|
export interface DocumentEditProps {
|
|
@@ -40,7 +53,11 @@ export function DocumentEdit({
|
|
|
40
53
|
const [loading, setLoading] = useState(!isNew)
|
|
41
54
|
const [showPreview, setShowPreview] = useState(false)
|
|
42
55
|
const [showVersions, setShowVersions] = useState(false)
|
|
56
|
+
const [showSchedule, setShowSchedule] = useState(false)
|
|
57
|
+
const [showShareLink, setShowShareLink] = useState(false)
|
|
43
58
|
const [docStatus, setDocStatus] = useState<string>('DRAFT')
|
|
59
|
+
const [scheduledAt, setScheduledAt] = useState<string | null>(null)
|
|
60
|
+
const [scheduledUnpublishAt, setScheduledUnpublishAt] = useState<string | null>(null)
|
|
44
61
|
const hasLoadedRef = useRef(false)
|
|
45
62
|
|
|
46
63
|
const collections = config?.collections
|
|
@@ -125,6 +142,8 @@ export function DocumentEdit({
|
|
|
125
142
|
setValues(merged)
|
|
126
143
|
setInitialValues(merged)
|
|
127
144
|
setDocStatus(doc.status ?? 'DRAFT')
|
|
145
|
+
setScheduledAt(doc.scheduledAt ?? null)
|
|
146
|
+
setScheduledUnpublishAt(doc.scheduledUnpublishAt ?? null)
|
|
128
147
|
|
|
129
148
|
const loadedSeo: SEOData = {}
|
|
130
149
|
for (const key of SEO_FIELDS) {
|
|
@@ -309,6 +328,14 @@ export function DocumentEdit({
|
|
|
309
328
|
</button>
|
|
310
329
|
{!isNew && (
|
|
311
330
|
<>
|
|
331
|
+
<button
|
|
332
|
+
onClick={() => setShowShareLink(true)}
|
|
333
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
334
|
+
title="Share a signed preview link for the current draft"
|
|
335
|
+
>
|
|
336
|
+
<Share2 className="h-4 w-4" />
|
|
337
|
+
Share preview
|
|
338
|
+
</button>
|
|
312
339
|
<button
|
|
313
340
|
onClick={() => setShowVersions(true)}
|
|
314
341
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
@@ -379,6 +406,31 @@ export function DocumentEdit({
|
|
|
379
406
|
Unpublish
|
|
380
407
|
</Button>
|
|
381
408
|
)}
|
|
409
|
+
{!isNew && (
|
|
410
|
+
<button
|
|
411
|
+
onClick={() => setShowSchedule(true)}
|
|
412
|
+
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--border)] bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
413
|
+
>
|
|
414
|
+
<Calendar className="h-4 w-4" />
|
|
415
|
+
{scheduledAt || scheduledUnpublishAt ? 'Reschedule' : 'Schedule'}
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
{!isNew && (scheduledAt || scheduledUnpublishAt) && (
|
|
419
|
+
<div className="space-y-1 rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-900">
|
|
420
|
+
{scheduledAt && (
|
|
421
|
+
<div className="flex items-center gap-1.5">
|
|
422
|
+
<Calendar className="h-3.5 w-3.5" />
|
|
423
|
+
Publishes {new Date(scheduledAt).toLocaleString()}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
{scheduledUnpublishAt && (
|
|
427
|
+
<div className="flex items-center gap-1.5">
|
|
428
|
+
<XCircle className="h-3.5 w-3.5" />
|
|
429
|
+
Unpublishes {new Date(scheduledUnpublishAt).toLocaleString()}
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
382
434
|
</div>
|
|
383
435
|
</div>
|
|
384
436
|
|
|
@@ -495,6 +547,34 @@ export function DocumentEdit({
|
|
|
495
547
|
}}
|
|
496
548
|
/>
|
|
497
549
|
)}
|
|
550
|
+
|
|
551
|
+
{!isNew && documentId && (
|
|
552
|
+
<SchedulePublishDialog
|
|
553
|
+
collectionSlug={collectionSlug}
|
|
554
|
+
documentId={documentId}
|
|
555
|
+
scheduledAt={scheduledAt}
|
|
556
|
+
scheduledUnpublishAt={scheduledUnpublishAt}
|
|
557
|
+
open={showSchedule}
|
|
558
|
+
onClose={() => setShowSchedule(false)}
|
|
559
|
+
onScheduled={(next) => {
|
|
560
|
+
setDocStatus(next.status)
|
|
561
|
+
setScheduledAt(next.scheduledAt)
|
|
562
|
+
setScheduledUnpublishAt(next.scheduledUnpublishAt)
|
|
563
|
+
}}
|
|
564
|
+
/>
|
|
565
|
+
)}
|
|
566
|
+
|
|
567
|
+
{!isNew && documentId && (
|
|
568
|
+
<SharePreviewLinkDialog
|
|
569
|
+
collectionSlug={collectionSlug}
|
|
570
|
+
documentId={documentId}
|
|
571
|
+
documentSlug={docSlug}
|
|
572
|
+
siteUrl={config?.siteUrl}
|
|
573
|
+
urlPrefix={collection?.urlPrefix ?? collectionSlug}
|
|
574
|
+
open={showShareLink}
|
|
575
|
+
onClose={() => setShowShareLink(false)}
|
|
576
|
+
/>
|
|
577
|
+
)}
|
|
498
578
|
</div>
|
|
499
579
|
)
|
|
500
580
|
}
|