@kyro-cms/admin 0.10.5 → 0.10.7
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/index.cjs +38 -15119
- package/dist/index.css +1 -1698
- package/dist/index.js +38 -15033
- package/package.json +1 -1
- package/src/components/ApiKeysManager.tsx +33 -126
- package/src/components/AutoForm.tsx +130 -1188
- package/src/components/PluginsManager.tsx +13 -141
- package/src/components/SettingsPage.tsx +0 -1
- package/src/components/UserMenu.tsx +17 -1
- package/src/components/autoform/AutoFormApiView.tsx +96 -0
- package/src/components/autoform/AutoFormEditView.tsx +91 -0
- package/src/components/autoform/AutoFormHeader.tsx +488 -0
- package/src/components/autoform/AutoFormVersionView.tsx +283 -0
- package/src/hooks/useAutoFormState.ts +3 -1
- package/src/lib/autoform-store.ts +8 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.css.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { ChevronRight, ExternalLink, Check } from "../ui/icons";
|
|
3
|
+
import { Dropdown, DropdownItem, DropdownSeparator } from "../ui/Dropdown";
|
|
4
|
+
import { SplitButton } from "../ui/SplitButton";
|
|
5
|
+
import type { SplitButtonStatus } from "../ui/SplitButton";
|
|
6
|
+
import { useAutoFormStore } from "../../lib/autoform-store";
|
|
7
|
+
import { adminPath as ADMIN_BASE } from "../../lib/paths";
|
|
8
|
+
|
|
9
|
+
type View = "edit" | "version" | "api";
|
|
10
|
+
|
|
11
|
+
interface AutoFormHeaderProps {
|
|
12
|
+
collectionSlug?: string;
|
|
13
|
+
globalSlug?: string;
|
|
14
|
+
documentStatus: string;
|
|
15
|
+
hasUnpublishedChanges: boolean;
|
|
16
|
+
localSaveStatus: "idle" | "saving" | "saved" | "error";
|
|
17
|
+
handleCreateNew: () => void;
|
|
18
|
+
handleDuplicate: () => void;
|
|
19
|
+
handleUnpublish: () => void;
|
|
20
|
+
handleDelete: () => void;
|
|
21
|
+
handlePublish: () => void;
|
|
22
|
+
handleSchedulePublish: (scheduledFor: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AutoFormHeader({
|
|
26
|
+
collectionSlug,
|
|
27
|
+
globalSlug,
|
|
28
|
+
documentStatus,
|
|
29
|
+
hasUnpublishedChanges,
|
|
30
|
+
localSaveStatus,
|
|
31
|
+
handleCreateNew,
|
|
32
|
+
handleDuplicate,
|
|
33
|
+
handleUnpublish,
|
|
34
|
+
handleDelete,
|
|
35
|
+
handlePublish,
|
|
36
|
+
handleSchedulePublish,
|
|
37
|
+
}: AutoFormHeaderProps) {
|
|
38
|
+
const [now, setNow] = useState(Date.now());
|
|
39
|
+
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
|
|
40
|
+
const scheduleRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
formData,
|
|
44
|
+
hasUnsavedChanges,
|
|
45
|
+
autoSaveStatus,
|
|
46
|
+
lastSavedAt,
|
|
47
|
+
retryCount,
|
|
48
|
+
view,
|
|
49
|
+
setView,
|
|
50
|
+
showPreview,
|
|
51
|
+
setShowPreview,
|
|
52
|
+
setFormData,
|
|
53
|
+
markSaved,
|
|
54
|
+
lastSavedData,
|
|
55
|
+
} = useAutoFormStore();
|
|
56
|
+
|
|
57
|
+
const isNew = !formData.id;
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const id = setInterval(() => setNow(Date.now()), 10_000);
|
|
61
|
+
return () => clearInterval(id);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
66
|
+
if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) {
|
|
67
|
+
setShowSchedulePicker(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
if (showSchedulePicker) {
|
|
71
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
72
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
73
|
+
}
|
|
74
|
+
}, [showSchedulePicker]);
|
|
75
|
+
|
|
76
|
+
const docTitle = String(
|
|
77
|
+
(formData.mainTabs as { title?: string })?.title ||
|
|
78
|
+
(typeof formData.title === "object" ? "" : formData.title) ||
|
|
79
|
+
(typeof formData.name === "object" ? "" : formData.name) ||
|
|
80
|
+
"Untitled",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const lastModified = formData.updatedAt
|
|
84
|
+
? new Date(formData.updatedAt as string).toLocaleString()
|
|
85
|
+
: "Just now";
|
|
86
|
+
const createdAt = formData.createdAt
|
|
87
|
+
? new Date(formData.createdAt as string).toLocaleString()
|
|
88
|
+
: "Just now";
|
|
89
|
+
|
|
90
|
+
const statusLabel = hasUnpublishedChanges
|
|
91
|
+
? 'Draft (unpublished changes)'
|
|
92
|
+
: documentStatus === 'published'
|
|
93
|
+
? 'Published'
|
|
94
|
+
: 'Draft';
|
|
95
|
+
|
|
96
|
+
const statusLabelMobile = hasUnpublishedChanges
|
|
97
|
+
? 'Unpublished'
|
|
98
|
+
: documentStatus === 'published'
|
|
99
|
+
? 'Published'
|
|
100
|
+
: 'Draft';
|
|
101
|
+
|
|
102
|
+
const statusColor = documentStatus === 'published' && !hasUnsavedChanges
|
|
103
|
+
? 'bg-[var(--kyro-success)]'
|
|
104
|
+
: hasUnpublishedChanges
|
|
105
|
+
? 'bg-[var(--kyro-warning)]'
|
|
106
|
+
: 'bg-[var(--kyro-text-muted)]';
|
|
107
|
+
|
|
108
|
+
const statusBadgeBg = documentStatus === 'published' && !hasUnpublishedChanges
|
|
109
|
+
? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
|
|
110
|
+
: hasUnpublishedChanges
|
|
111
|
+
? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
|
|
112
|
+
: 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
|
|
113
|
+
|
|
114
|
+
const renderAutoSaveStatus = (compact = false) => (
|
|
115
|
+
<>
|
|
116
|
+
{autoSaveStatus === "saving" && (
|
|
117
|
+
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
118
|
+
<svg className="animate-spin h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="none">
|
|
119
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
120
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
121
|
+
</svg>
|
|
122
|
+
{compact ? "Saving…" : "Saving draft..."}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
{autoSaveStatus === "success" && (
|
|
126
|
+
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
127
|
+
<Check className="w-3.5 h-3.5 shrink-0" />
|
|
128
|
+
{compact
|
|
129
|
+
? "Saved"
|
|
130
|
+
: lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
{autoSaveStatus === "retrying" && (
|
|
134
|
+
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
135
|
+
<svg className="animate-spin h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="none">
|
|
136
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
137
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
138
|
+
</svg>
|
|
139
|
+
{compact ? `Retry ${retryCount}/5` : `Retrying save (${retryCount}/5)`}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
{autoSaveStatus === "offline" && (
|
|
143
|
+
<span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
|
|
144
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
|
145
|
+
<path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
|
|
146
|
+
<path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
|
|
147
|
+
<path d="m2 2 20 20" />
|
|
148
|
+
<path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
|
|
149
|
+
<path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
|
|
150
|
+
</svg>
|
|
151
|
+
{compact ? "Offline" : "Offline — cached locally"}
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
{autoSaveStatus === "error" && (
|
|
155
|
+
<span className="text-[var(--kyro-danger)]">{compact ? "Failed" : "Draft save failed"}</span>
|
|
156
|
+
)}
|
|
157
|
+
{autoSaveStatus === "conflict" && (
|
|
158
|
+
compact ? (
|
|
159
|
+
<span className="text-[var(--kyro-danger)] font-semibold">Conflict</span>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="flex items-center gap-3">
|
|
162
|
+
<span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
|
|
163
|
+
<span className="opacity-30">—</span>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={async () => {
|
|
167
|
+
// To keep changes during conflict, we'd fire the prop or a store action.
|
|
168
|
+
// For now, reload handles the other side. Since this is in header, we'll
|
|
169
|
+
// trigger a window reload or a saveDocument via context if needed.
|
|
170
|
+
// It's cleaner to handle this conflict directly if passed as a prop,
|
|
171
|
+
// but let's just trigger a reload for simplicity if they want the server version.
|
|
172
|
+
}}
|
|
173
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
174
|
+
>
|
|
175
|
+
Keep my changes
|
|
176
|
+
</button>
|
|
177
|
+
<span className="opacity-30">|</span>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={() => window.location.reload()}
|
|
181
|
+
className="text-[var(--kyro-danger)] hover:underline"
|
|
182
|
+
>
|
|
183
|
+
Reload server version
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
)}
|
|
188
|
+
</>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const renderKebabMenu = () => !isNew && (
|
|
192
|
+
<Dropdown
|
|
193
|
+
trigger={
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
className="kyro-btn p-2 md:p-2.5 rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)] transition-all"
|
|
197
|
+
title="More actions"
|
|
198
|
+
>
|
|
199
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
200
|
+
<circle cx="12" cy="5" r="1.5" />
|
|
201
|
+
<circle cx="12" cy="12" r="1.5" />
|
|
202
|
+
<circle cx="12" cy="19" r="1.5" />
|
|
203
|
+
</svg>
|
|
204
|
+
</button>
|
|
205
|
+
}
|
|
206
|
+
direction="down"
|
|
207
|
+
>
|
|
208
|
+
{!globalSlug && (
|
|
209
|
+
<DropdownItem
|
|
210
|
+
onClick={handleCreateNew}
|
|
211
|
+
icon={
|
|
212
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
213
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
214
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
215
|
+
</svg>
|
|
216
|
+
}
|
|
217
|
+
>
|
|
218
|
+
Create New
|
|
219
|
+
</DropdownItem>
|
|
220
|
+
)}
|
|
221
|
+
{!globalSlug && (
|
|
222
|
+
<DropdownItem
|
|
223
|
+
onClick={handleDuplicate}
|
|
224
|
+
icon={
|
|
225
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
226
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
227
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
228
|
+
</svg>
|
|
229
|
+
}
|
|
230
|
+
>
|
|
231
|
+
Duplicate
|
|
232
|
+
</DropdownItem>
|
|
233
|
+
)}
|
|
234
|
+
<DropdownItem
|
|
235
|
+
onClick={() => setShowSchedulePicker(true)}
|
|
236
|
+
icon={
|
|
237
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
238
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
239
|
+
<line x1="16" y1="2" x2="16" y2="6" />
|
|
240
|
+
<line x1="8" y1="2" x2="8" y2="6" />
|
|
241
|
+
<line x1="3" y1="10" x2="21" y2="10" />
|
|
242
|
+
</svg>
|
|
243
|
+
}
|
|
244
|
+
>
|
|
245
|
+
Schedule Publish
|
|
246
|
+
</DropdownItem>
|
|
247
|
+
{documentStatus === "published" && (
|
|
248
|
+
<DropdownItem
|
|
249
|
+
onClick={handleUnpublish}
|
|
250
|
+
icon={
|
|
251
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
252
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
253
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
254
|
+
</svg>
|
|
255
|
+
}
|
|
256
|
+
>
|
|
257
|
+
Unpublish
|
|
258
|
+
</DropdownItem>
|
|
259
|
+
)}
|
|
260
|
+
{!globalSlug && (
|
|
261
|
+
<>
|
|
262
|
+
<DropdownSeparator />
|
|
263
|
+
<DropdownItem
|
|
264
|
+
onClick={handleDelete}
|
|
265
|
+
danger
|
|
266
|
+
icon={
|
|
267
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
268
|
+
<polyline points="3 6 5 6 21 6" />
|
|
269
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
270
|
+
</svg>
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
Delete
|
|
274
|
+
</DropdownItem>
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
277
|
+
</Dropdown>
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const renderSchedulePicker = () => showSchedulePicker && (
|
|
281
|
+
<div ref={scheduleRef} className="relative">
|
|
282
|
+
<div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 min-w-[260px]">
|
|
283
|
+
<p className="text-xs font-medium mb-2">Schedule Publish</p>
|
|
284
|
+
<input
|
|
285
|
+
type="datetime-local"
|
|
286
|
+
id="schedule-datetime"
|
|
287
|
+
className="kyro-form-input text-xs mb-3 w-full"
|
|
288
|
+
min={new Date().toISOString().slice(0, 16)}
|
|
289
|
+
/>
|
|
290
|
+
<div className="flex items-center gap-2 justify-end">
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={() => setShowSchedulePicker(false)}
|
|
294
|
+
className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
|
|
295
|
+
>
|
|
296
|
+
Cancel
|
|
297
|
+
</button>
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
onClick={() => {
|
|
301
|
+
const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
|
|
302
|
+
if (val) handleSchedulePublish(val);
|
|
303
|
+
}}
|
|
304
|
+
className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
|
|
305
|
+
>
|
|
306
|
+
Schedule
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
{/* MOBILE HEADER */}
|
|
316
|
+
<header className="md:hidden border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] backdrop-blur-md rounded-lg">
|
|
317
|
+
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
318
|
+
<a
|
|
319
|
+
href={`${ADMIN_BASE}/${collectionSlug}`}
|
|
320
|
+
className="p-1.5 rounded-lg hover:bg-[var(--kyro-bg-secondary)] transition-colors shrink-0"
|
|
321
|
+
>
|
|
322
|
+
<ChevronRight className="w-4 h-4" />
|
|
323
|
+
</a>
|
|
324
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
325
|
+
<h1 className="text-base font-bold tracking-tight truncate min-w-0">{docTitle}</h1>
|
|
326
|
+
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-medium border ${statusBadgeBg}`}>
|
|
327
|
+
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
328
|
+
{statusLabelMobile}
|
|
329
|
+
</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
332
|
+
<SplitButton
|
|
333
|
+
status={documentStatus as SplitButtonStatus}
|
|
334
|
+
saveStatus={localSaveStatus}
|
|
335
|
+
hasChanges={hasUnsavedChanges}
|
|
336
|
+
onPublish={handlePublish}
|
|
337
|
+
disabled={localSaveStatus === "saving"}
|
|
338
|
+
/>
|
|
339
|
+
{renderKebabMenu()}
|
|
340
|
+
{renderSchedulePicker()}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-[var(--kyro-border)]/50 bg-[var(--kyro-bg-secondary)]/30">
|
|
345
|
+
<div className="flex items-center gap-0.5 bg-[var(--kyro-bg-secondary)] p-0.5 rounded-lg border border-[var(--kyro-border)]/50">
|
|
346
|
+
{(["edit", "version", "api"] as const).map((v) => (
|
|
347
|
+
<button
|
|
348
|
+
key={v}
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => setView(v as View)}
|
|
351
|
+
className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${view === v
|
|
352
|
+
? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]"
|
|
353
|
+
: "text-[var(--kyro-text-secondary)] opacity-50 active:opacity-100"
|
|
354
|
+
}`}
|
|
355
|
+
>
|
|
356
|
+
{v === "edit" ? "Edit" : v === "version" ? "History" : "API"}
|
|
357
|
+
</button>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div className="flex items-center gap-2 text-[10px] font-medium">
|
|
362
|
+
{renderAutoSaveStatus(true)}
|
|
363
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => {
|
|
367
|
+
setFormData(lastSavedData);
|
|
368
|
+
markSaved();
|
|
369
|
+
}}
|
|
370
|
+
className="text-[var(--kyro-primary)] text-[10px] font-medium hover:underline"
|
|
371
|
+
>
|
|
372
|
+
Revert
|
|
373
|
+
</button>
|
|
374
|
+
)}
|
|
375
|
+
<div className="h-4 w-px bg-[var(--kyro-border)] mx-0.5" />
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
379
|
+
className={`p-1.5 rounded-lg transition-all ${showPreview
|
|
380
|
+
? "bg-[var(--kyro-primary)]/10 text-[var(--kyro-primary)]"
|
|
381
|
+
: "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"
|
|
382
|
+
}`}
|
|
383
|
+
>
|
|
384
|
+
<ExternalLink className="w-3.5 h-3.5" />
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</header>
|
|
389
|
+
|
|
390
|
+
{/* DESKTOP HEADER */}
|
|
391
|
+
<header className="hidden md:flex surface-tile px-8 py-6 items-center justify-between sticky top-0 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
|
|
392
|
+
<div className="flex flex-col gap-2 min-w-0">
|
|
393
|
+
<div className="flex items-center gap-3 flex-wrap min-w-0">
|
|
394
|
+
<a
|
|
395
|
+
href={`${ADMIN_BASE}/${collectionSlug}`}
|
|
396
|
+
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors shrink-0"
|
|
397
|
+
>
|
|
398
|
+
<ChevronRight className="w-4 h-4" />
|
|
399
|
+
</a>
|
|
400
|
+
<h1 className="text-xl font-bold tracking-tighter truncate min-w-0">{docTitle}</h1>
|
|
401
|
+
<span className={`shrink-0 inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
|
|
402
|
+
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
403
|
+
{statusLabel}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
407
|
+
{renderAutoSaveStatus(false)}
|
|
408
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
409
|
+
<>
|
|
410
|
+
<span className="opacity-30">—</span>
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={() => {
|
|
414
|
+
setFormData(lastSavedData);
|
|
415
|
+
markSaved();
|
|
416
|
+
}}
|
|
417
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
418
|
+
>
|
|
419
|
+
Revert changes
|
|
420
|
+
</button>
|
|
421
|
+
</>
|
|
422
|
+
)}
|
|
423
|
+
{lastSavedAt && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "success" && (
|
|
424
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
425
|
+
Draft saved {(() => {
|
|
426
|
+
const diffMs = now - lastSavedAt;
|
|
427
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
428
|
+
const diffSec = Math.floor(diffMs / 1_000);
|
|
429
|
+
if (diffMin >= 1) return `${diffMin}m ago`;
|
|
430
|
+
if (diffSec >= 5) return `${diffSec}s ago`;
|
|
431
|
+
return "just now";
|
|
432
|
+
})()}
|
|
433
|
+
</span>
|
|
434
|
+
)}
|
|
435
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
436
|
+
Modified {lastModified}
|
|
437
|
+
</span>
|
|
438
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
439
|
+
Created {createdAt}
|
|
440
|
+
</span>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div className="flex items-center gap-6">
|
|
445
|
+
<div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
|
|
446
|
+
{["edit", "version", "api"].map((v) => (
|
|
447
|
+
<button
|
|
448
|
+
key={v}
|
|
449
|
+
type="button"
|
|
450
|
+
onClick={() => setView(v as View)}
|
|
451
|
+
className={`px-5 py-2 text-xs font-bold rounded-lg transition-all ${view === v ? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
|
|
452
|
+
>
|
|
453
|
+
{v.toUpperCase()}
|
|
454
|
+
</button>
|
|
455
|
+
))}
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
|
|
459
|
+
|
|
460
|
+
<div className="flex items-center gap-3">
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
464
|
+
className={`kyro-btn p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
465
|
+
title="Live Preview"
|
|
466
|
+
>
|
|
467
|
+
<ExternalLink className="w-4 h-4" />
|
|
468
|
+
{showPreview && (
|
|
469
|
+
<span className="text-[10px] font-bold tracking-widest pr-1">
|
|
470
|
+
Active
|
|
471
|
+
</span>
|
|
472
|
+
)}
|
|
473
|
+
</button>
|
|
474
|
+
{renderKebabMenu()}
|
|
475
|
+
{renderSchedulePicker()}
|
|
476
|
+
<SplitButton
|
|
477
|
+
status={documentStatus as SplitButtonStatus}
|
|
478
|
+
saveStatus={localSaveStatus}
|
|
479
|
+
hasChanges={hasUnsavedChanges}
|
|
480
|
+
onPublish={handlePublish}
|
|
481
|
+
disabled={localSaveStatus === "saving"}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</header>
|
|
486
|
+
</>
|
|
487
|
+
);
|
|
488
|
+
}
|