@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.
@@ -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 { AlertTriangle, Eye, EyeOff, Save, Copy, Loader2, Clock } from 'lucide-react'
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
  }