@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.
@@ -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
+ }