@kyro-cms/admin 0.11.3 → 0.11.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -167,6 +167,7 @@ export function AutoForm({
167
167
  const scheduleRef = useRef<HTMLDivElement>(null);
168
168
  const [showSchedulePicker, setShowSchedulePicker] = useState(false);
169
169
  const [localSaveStatus, setLocalSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
170
+ const [isDuplicating, setIsDuplicating] = useState(false);
170
171
  const [now, setNow] = useState(Date.now());
171
172
  const disabled = propDisabled;
172
173
  const [clientLoading, setClientLoading] = useState(false);
@@ -401,37 +402,54 @@ export function AutoForm({
401
402
  };
402
403
 
403
404
  const handleDuplicate = () => {
405
+ if (!formData.id) {
406
+ toast.error("Please save the document before duplicating.");
407
+ return;
408
+ }
409
+
410
+ const performDuplicate = async () => {
411
+ try {
412
+ setIsDuplicating(true);
413
+ const response = await fetchWithAuth(`/api/${collectionSlug}/${formData.id}/duplicate`, {
414
+ method: "POST",
415
+ });
416
+
417
+ if (response.ok) {
418
+ const result = await response.json();
419
+ onActionSuccess?.("Document duplicated successfully");
420
+ if (result.data?.id) {
421
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}/${result.data.id}`;
422
+ } else {
423
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
424
+ }
425
+ } else {
426
+ const err = await response.json();
427
+ toast.error(err.error || "Failed to duplicate");
428
+ }
429
+ } catch (e) {
430
+ toast.error("Failed to duplicate document");
431
+ } finally {
432
+ setIsDuplicating(false);
433
+ }
434
+ };
435
+
436
+ if (hasUnsavedChanges) {
437
+ confirm({
438
+ title: "Unsaved Changes",
439
+ message: "You have unsaved changes. Please save the document before duplicating.",
440
+ onConfirm: async () => {
441
+ await handleSaveDraft();
442
+ await performDuplicate();
443
+ },
444
+ });
445
+ return;
446
+ }
447
+
404
448
  confirm({
405
449
  title: "Duplicate Document",
406
450
  message: "Are you sure you want to duplicate this document?",
407
451
  onConfirm: async () => {
408
- try {
409
- const { id, createdAt, updatedAt, ...duplicateData } = formData;
410
- const normalizedData = normalizeUploadFields(duplicateData) as Record<string, unknown>;
411
- const response = await fetchWithAuth(`/api/${collectionSlug}`, {
412
- method: "POST",
413
- headers: { "Content-Type": "application/json" },
414
- body: JSON.stringify({
415
- ...normalizedData,
416
- title: `${duplicateData.title || duplicateData.name || "Copy"} (Copy)`,
417
- slug: `${duplicateData.slug || "copy"}-${Date.now()}`,
418
- status: "draft",
419
- }),
420
- });
421
-
422
- if (response.ok) {
423
- const result = await response.json();
424
- onActionSuccess?.("Document duplicated successfully");
425
- if (result.data?.id) {
426
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${result.data.id}`;
427
- }
428
- } else {
429
- const error = await response.json();
430
- toast.error(error.error || "Failed to duplicate document");
431
- }
432
- } catch (err) {
433
- toast.error("Failed to duplicate document");
434
- }
452
+ await performDuplicate();
435
453
  },
436
454
  });
437
455
  };
@@ -532,6 +550,7 @@ export function AutoForm({
532
550
  toast.error("Failed to save document");
533
551
  setTimeout(() => setLocalSaveStatus("idle"), 3000);
534
552
  } finally {
553
+ window.dispatchEvent(new CustomEvent("kyro:global-save-end"));
535
554
  autoSaveSkipRef.current = false;
536
555
  }
537
556
  };
@@ -933,6 +952,10 @@ export function AutoForm({
933
952
  onChange={onFieldChange}
934
953
  error={error}
935
954
  disabled={effectiveDisabled}
955
+ formData={formData}
956
+ siblingData={currentData}
957
+ collectionSlug={collectionSlug}
958
+ globalSlug={globalSlug}
936
959
  />
937
960
  );
938
961
  }
@@ -983,6 +1006,7 @@ export function AutoForm({
983
1006
  documentStatus={documentStatus || "draft"}
984
1007
  hasUnpublishedChanges={hasUnpublishedChanges}
985
1008
  localSaveStatus={localSaveStatus}
1009
+ isDuplicating={isDuplicating}
986
1010
  handleCreateNew={handleCreateNew}
987
1011
  handleDuplicate={handleDuplicate}
988
1012
  handleUnpublish={handleUnpublish}
@@ -57,6 +57,7 @@ export function CreateView({
57
57
  action={{
58
58
  label: saving ? "Creating..." : `Create ${collection.singularLabel || label}`,
59
59
  onClick: () => handleSubmit(),
60
+ disabled: saving,
60
61
  }}
61
62
 
62
63
  />
@@ -0,0 +1,280 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { apiGet } from "../lib/api";
3
+
4
+ interface MetricsData {
5
+ totalDocuments: number;
6
+ totalMedia: number;
7
+ totalUsers: number;
8
+ totalWebhooks: number;
9
+ totalApiKeys: number;
10
+ totalStoredRecords: number;
11
+ collectionCounts: Record<string, number>;
12
+ collections: number;
13
+
14
+ timestamp: string;
15
+ }
16
+
17
+ function formatNumber(n: number): string {
18
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
19
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
20
+ return n.toLocaleString();
21
+ }
22
+
23
+ function SkeletonCard() {
24
+ return (
25
+ <div className="p-5 bg-[var(--kyro-surface-accent)] rounded-xl border border-[var(--kyro-border)] animate-pulse">
26
+ <div className="w-10 h-10 rounded-lg bg-[var(--kyro-bg-secondary)] mb-3" />
27
+ <div className="h-7 w-16 bg-[var(--kyro-bg-secondary)] rounded mb-2" />
28
+ <div className="h-4 w-24 bg-[var(--kyro-bg-secondary)] rounded" />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function MetricCard({
34
+ icon,
35
+ iconBg,
36
+ iconColor,
37
+ value,
38
+ label,
39
+ subtext,
40
+ }: {
41
+ icon: React.ReactNode;
42
+ iconBg: string;
43
+ iconColor: string;
44
+ value: string | number;
45
+ label: string;
46
+ subtext?: string;
47
+ }) {
48
+ return (
49
+ <div className="kyro-metric-card group p-5 bg-[var(--kyro-surface-accent)] rounded-lg border border-transparent hover:border-[var(--kyro-border)] transition-all hover:shadow-xs">
50
+ <div
51
+ className="w-10 h-10 rounded-lg flex items-center justify-center mb-3"
52
+ style={{ background: iconBg, color: iconColor }}
53
+ >
54
+ {icon}
55
+ </div>
56
+ <h4 className="text-2xl font-bold text-[var(--kyro-text-primary)] tracking-tight mb-0.5">
57
+ {typeof value === "number" ? formatNumber(value) : value}
58
+ </h4>
59
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)]">
60
+ {label}
61
+ </p>
62
+ {subtext && (
63
+ <p className="text-[10px] text-[var(--kyro-text-muted)] mt-1">
64
+ {subtext}
65
+ </p>
66
+ )}
67
+ </div>
68
+ );
69
+ }
70
+
71
+
72
+
73
+ export function DashboardMetrics() {
74
+ const [data, setData] = useState<MetricsData | null>(null);
75
+ const [loading, setLoading] = useState(true);
76
+ const [error, setError] = useState<string | null>(null);
77
+
78
+ useEffect(() => {
79
+ apiGet<MetricsData>("/api/metrics", { autoToast: false })
80
+ .then((res) => {
81
+ setData(res);
82
+ setError(null);
83
+ })
84
+ .catch((err) => {
85
+ console.error("Failed to load metrics:", err);
86
+ setError("Unable to load metrics");
87
+ })
88
+ .finally(() => setLoading(false));
89
+ }, []);
90
+
91
+ if (error) {
92
+ return (
93
+ <div className="surface-tile overflow-hidden">
94
+ <div className="p-6 border-b border-[var(--kyro-border)]">
95
+ <h2 className="text-xl font-bold tracking-tight text-[var(--kyro-text-primary)]">
96
+ CMS Metrics
97
+ </h2>
98
+ </div>
99
+ <div className="p-6">
100
+ <p className="text-sm text-[var(--kyro-text-muted)]">{error}</p>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className="surface-tile overflow-hidden">
108
+ <div className="p-6 border-b border-[var(--kyro-border)]">
109
+ <div className="flex items-center justify-between">
110
+ <div>
111
+ <h2 className="text-xl font-bold tracking-tight text-[var(--kyro-text-primary)] flex items-center gap-2">
112
+ <svg
113
+ className="w-5 h-5 text-[var(--kyro-primary)]"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ viewBox="0 0 24 24"
117
+ >
118
+ <path
119
+ strokeLinecap="round"
120
+ strokeLinejoin="round"
121
+ strokeWidth="2"
122
+ d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
123
+ />
124
+ </svg>
125
+ CMS Metrics
126
+ </h2>
127
+ <p className="text-xs text-[var(--kyro-text-secondary)] font-medium mt-1">
128
+ Real-time content, performance, and system health overview
129
+ </p>
130
+ </div>
131
+ {data && (
132
+ <span className="text-[10px] text-[var(--kyro-text-muted)] font-mono">
133
+ {new Date(data.timestamp).toLocaleTimeString()}
134
+ </span>
135
+ )}
136
+ </div>
137
+ </div>
138
+
139
+ {/* Primary Metrics */}
140
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 p-6">
141
+ {loading ? (
142
+ <>
143
+ <SkeletonCard />
144
+ <SkeletonCard />
145
+ <SkeletonCard />
146
+ <SkeletonCard />
147
+ <SkeletonCard />
148
+ </>
149
+ ) : data ? (
150
+ <>
151
+ <MetricCard
152
+ icon={
153
+ <svg
154
+ className="w-5 h-5"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ viewBox="0 0 24 24"
158
+ >
159
+ <path
160
+ strokeLinecap="round"
161
+ strokeLinejoin="round"
162
+ strokeWidth="2"
163
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
164
+ />
165
+ </svg>
166
+ }
167
+ iconBg="rgba(99, 102, 241, 0.1)"
168
+ iconColor="#6366f1"
169
+ value={data.totalDocuments}
170
+ label="Documents"
171
+ subtext={`Across ${data.collections} collection${data.collections !== 1 ? "s" : ""}`}
172
+ />
173
+
174
+ {data.totalMedia !== undefined && (
175
+ <MetricCard
176
+ icon={
177
+ <svg
178
+ className="w-5 h-5"
179
+ fill="none"
180
+ stroke="currentColor"
181
+ viewBox="0 0 24 24"
182
+ >
183
+ <path
184
+ strokeLinecap="round"
185
+ strokeLinejoin="round"
186
+ strokeWidth="2"
187
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
188
+ />
189
+ </svg>
190
+ }
191
+ iconBg="rgba(168, 85, 247, 0.1)"
192
+ iconColor="#a855f7"
193
+ value={data.totalMedia}
194
+ label="Media Files"
195
+ subtext="Images, videos & docs"
196
+ />
197
+ )}
198
+
199
+ {data.totalUsers !== undefined && (
200
+ <MetricCard
201
+ icon={
202
+ <svg
203
+ className="w-5 h-5"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ viewBox="0 0 24 24"
207
+ >
208
+ <path
209
+ strokeLinecap="round"
210
+ strokeLinejoin="round"
211
+ strokeWidth="2"
212
+ d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
213
+ />
214
+ </svg>
215
+ }
216
+ iconBg="rgba(14, 165, 233, 0.1)"
217
+ iconColor="#0ea5e9"
218
+ value={data.totalUsers}
219
+ label="Team Members"
220
+ subtext="Active user accounts"
221
+ />
222
+ )}
223
+
224
+ {data.totalWebhooks !== undefined && (
225
+ <MetricCard
226
+ icon={
227
+ <svg
228
+ className="w-5 h-5"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ viewBox="0 0 24 24"
232
+ >
233
+ <path
234
+ strokeLinecap="round"
235
+ strokeLinejoin="round"
236
+ strokeWidth="2"
237
+ d="M13 10V3L4 14h7v7l9-11h-7z"
238
+ />
239
+ </svg>
240
+ }
241
+ iconBg="rgba(34, 197, 94, 0.1)"
242
+ iconColor="#22c55e"
243
+ value={data.totalWebhooks}
244
+ label="Webhooks"
245
+ subtext="Active integrations"
246
+ />
247
+ )}
248
+
249
+ {data.totalApiKeys !== undefined && (
250
+ <MetricCard
251
+ icon={
252
+ <svg
253
+ className="w-5 h-5"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ viewBox="0 0 24 24"
257
+ >
258
+ <path
259
+ strokeLinecap="round"
260
+ strokeLinejoin="round"
261
+ strokeWidth="2"
262
+ d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
263
+ />
264
+ </svg>
265
+ }
266
+ iconBg="rgba(245, 158, 11, 0.1)"
267
+ iconColor="#f59e0b"
268
+ value={data.totalApiKeys}
269
+ label="API Keys"
270
+ subtext="Developer access tokens"
271
+ />
272
+ )}
273
+ </>
274
+ ) : null}
275
+ </div>
276
+
277
+
278
+ </div>
279
+ );
280
+ }
@@ -45,8 +45,9 @@ export function DetailView({
45
45
  const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
46
46
  const [loading, setLoading] = useState(true);
47
47
  const [saving, setSaving] = useState(false);
48
+ const [isDuplicating, setIsDuplicating] = useState(false);
49
+ const [isDeleting, setIsDeleting] = useState(false);
48
50
  const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
49
- const [deleting, setDeleting] = useState(false);
50
51
  const [status, setStatus] = useState<DocumentStatus>("draft");
51
52
  const [createdAt, setCreatedAt] = useState<string | null>(null);
52
53
  const [updatedAt, setUpdatedAt] = useState<string | null>(null);
@@ -213,15 +214,18 @@ export function DetailView({
213
214
 
214
215
  const handleDuplicate = async () => {
215
216
  try {
216
- setSaving(true);
217
- await apiPost(`/api/${slug}/${documentId}/duplicate`, undefined, { autoToast: false });
217
+ setIsDuplicating(true);
218
+ const response = await apiPost(`/api/${slug}/${documentId}/duplicate`, undefined, { autoToast: false }) as { data?: { id?: string } };
218
219
  toast.success("Document duplicated");
219
- } catch (err: unknown) {
220
- const message = err instanceof Error ? err.message : "Failed to duplicate document";
221
- onError(message);
222
- toast.error(message);
220
+ if (response?.data?.id) {
221
+ window.location.href = `${adminPath}/${slug}/${response.data.id}`;
222
+ } else {
223
+ window.location.href = `${adminPath}/${slug}`;
224
+ }
225
+ } catch (e) {
226
+ toast.error((e as Error).message || "Failed to duplicate document");
223
227
  } finally {
224
- setSaving(false);
228
+ setIsDuplicating(false);
225
229
  }
226
230
  };
227
231
 
@@ -232,15 +236,11 @@ export function DetailView({
232
236
  variant: "danger",
233
237
  onConfirm: async () => {
234
238
  try {
235
- setDeleting(true);
236
- await apiDelete(`/api/${slug}/${documentId}`, { autoToast: false });
237
- onDelete?.();
238
- toast.error("Document deleted");
239
- } catch (err: unknown) {
240
- const message = err instanceof Error ? err.message : "Failed to delete document";
241
- toast.error(message);
242
- } finally {
243
- setDeleting(false);
239
+ setIsDeleting(true);
240
+ await apiDelete(`/api/${slug}/${documentId}`);
241
+ window.location.href = `${adminPath}/${slug}`;
242
+ } catch (e) {
243
+ setIsDeleting(false);
244
244
  }
245
245
  }
246
246
  });
@@ -350,9 +350,10 @@ export function DetailView({
350
350
  <button
351
351
  type="button"
352
352
  onClick={handleDeleteTrigger}
353
- className="kyro-btn kyro-btn-lg kyro-btn-ghost text-[var(--kyro-error)]"
353
+ disabled={isDeleting || saving}
354
+ className="kyro-btn kyro-btn-sm text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger)]/10 w-full justify-start mt-2"
354
355
  >
355
- Delete
356
+ {isDeleting ? "Deleting..." : "Delete Document"}
356
357
  </button>
357
358
  )}
358
359
  <SplitButton
@@ -436,9 +437,10 @@ export function DetailView({
436
437
  <button
437
438
  type="button"
438
439
  onClick={handleDuplicate}
440
+ disabled={isDuplicating || saving}
439
441
  className="kyro-btn kyro-btn-sm kyro-btn-ghost w-full justify-start"
440
442
  >
441
- Duplicate Document
443
+ {isDuplicating ? "Duplicating..." : "Duplicate Document"}
442
444
  </button>
443
445
  <button
444
446
  type="button"
@@ -25,6 +25,10 @@ interface FieldRendererProps {
25
25
  onChange: (value: any) => void;
26
26
  error?: string;
27
27
  disabled?: boolean;
28
+ formData?: Record<string, unknown>;
29
+ siblingData?: Record<string, unknown>;
30
+ collectionSlug?: string;
31
+ globalSlug?: string;
28
32
  }
29
33
 
30
34
  export const FieldRenderer: React.FC<FieldRendererProps> = ({
@@ -33,6 +37,10 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
33
37
  onChange,
34
38
  error,
35
39
  disabled,
40
+ formData,
41
+ siblingData,
42
+ collectionSlug,
43
+ globalSlug,
36
44
  }) => {
37
45
  if (field.hidden === true || field.admin?.hidden === true) return null;
38
46
 
@@ -109,9 +117,13 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
109
117
  <SelectField
110
118
  field={field as any}
111
119
  value={value}
112
- onChange={onChange}
113
- disabled={disabled}
120
+ onChange={onChangeKeystroke}
114
121
  error={error}
122
+ disabled={disabled}
123
+ formData={formData}
124
+ siblingData={siblingData}
125
+ collectionSlug={collectionSlug}
126
+ globalSlug={globalSlug}
115
127
  />
116
128
  );
117
129
  case "date":
@@ -244,6 +256,8 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
244
256
  }}
245
257
  disabled={disabled}
246
258
  error={error}
259
+ collectionSlug={collectionSlug}
260
+ globalSlug={globalSlug}
247
261
  />
248
262
  );
249
263
  }}
@@ -638,8 +638,8 @@ export function ListView({
638
638
  {displayFields.map((field) => {
639
639
  const rawValue = extractFieldValue(doc, field);
640
640
  const cellValue =
641
- field.type === "select" && rawValue
642
- ? field.options?.find((o: any) => o.value === rawValue)
641
+ field.type === "select" && rawValue && Array.isArray(field.options)
642
+ ? field.options.find((o: any) => o.value === rawValue)
643
643
  ?.label || rawValue
644
644
  : formatCellValue(rawValue, field.type);
645
645
  return (