@kyro-cms/admin 0.11.4 → 0.11.6

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,299 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from "react";
2
+ import ReactCrop, { type Crop } from "react-image-crop";
3
+ import "react-image-crop/dist/ReactCrop.css";
4
+ import { Crop as CropIcon, MousePointerClick, RefreshCcw } from "./icons";
5
+
6
+ interface RectPercent {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ interface ImageFocalEditorProps {
14
+ url: string;
15
+ initialCrop?: RectPercent;
16
+ initialHotspot?: RectPercent;
17
+ onSave: (crop: RectPercent | undefined, hotspot: RectPercent | undefined) => void;
18
+ onCancel: () => void;
19
+ isSaving?: boolean;
20
+ }
21
+
22
+ export function ImageFocalEditor({
23
+ url,
24
+ initialCrop,
25
+ initialHotspot,
26
+ onSave,
27
+ onCancel,
28
+ isSaving = false,
29
+ }: ImageFocalEditorProps) {
30
+ const [mode, setMode] = useState<"crop" | "hotspot">("crop");
31
+
32
+ const [crop, setCrop] = useState<Crop | undefined>(
33
+ initialCrop ? { unit: "%", ...initialCrop } : undefined
34
+ );
35
+
36
+ const [hotspot, setHotspot] = useState<RectPercent>(
37
+ initialHotspot || { x: 40, y: 40, width: 20, height: 20 }
38
+ );
39
+
40
+ const [showHotspot, setShowHotspot] = useState(!!initialHotspot);
41
+ const imgRef = useRef<HTMLImageElement>(null);
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+
44
+ // Dragging logic for Hotspot
45
+ const [isDragging, setIsDragging] = useState(false);
46
+ const [isResizing, setIsResizing] = useState<string | null>(null);
47
+ const dragStartRef = useRef<{ x: number; y: number; hX: number; hY: number; hW: number; hH: number } | null>(null);
48
+
49
+ const handlePointerDown = (e: React.PointerEvent, resizeType?: string) => {
50
+ if (mode !== "hotspot") return;
51
+ e.stopPropagation();
52
+ e.preventDefault();
53
+
54
+ if (resizeType) {
55
+ setIsResizing(resizeType);
56
+ } else {
57
+ setIsDragging(true);
58
+ }
59
+
60
+ dragStartRef.current = {
61
+ x: e.clientX,
62
+ y: e.clientY,
63
+ hX: hotspot.x,
64
+ hY: hotspot.y,
65
+ hW: hotspot.width,
66
+ hH: hotspot.height,
67
+ };
68
+ };
69
+
70
+ const handlePointerMove = useCallback((e: PointerEvent) => {
71
+ if (!dragStartRef.current || !imgRef.current) return;
72
+
73
+ const imgRect = imgRef.current.getBoundingClientRect();
74
+ const dx = ((e.clientX - dragStartRef.current.x) / imgRect.width) * 100;
75
+ const dy = ((e.clientY - dragStartRef.current.y) / imgRect.height) * 100;
76
+
77
+ const start = dragStartRef.current;
78
+
79
+ if (isDragging) {
80
+ let newX = start.hX + dx;
81
+ let newY = start.hY + dy;
82
+
83
+ // Constrain to bounds
84
+ newX = Math.max(0, Math.min(newX, 100 - start.hW));
85
+ newY = Math.max(0, Math.min(newY, 100 - start.hH));
86
+
87
+ setHotspot(prev => ({ ...prev, x: newX, y: newY }));
88
+ } else if (isResizing) {
89
+ let newX = start.hX;
90
+ let newY = start.hY;
91
+ let newW = start.hW;
92
+ let newH = start.hH;
93
+
94
+ if (isResizing.includes("e")) newW = start.hW + dx;
95
+ if (isResizing.includes("w")) { newX = start.hX + dx; newW = start.hW - dx; }
96
+ if (isResizing.includes("s")) newH = start.hH + dy;
97
+ if (isResizing.includes("n")) { newY = start.hY + dy; newH = start.hH - dy; }
98
+
99
+ // Minimum size and constrain bounds
100
+ if (newW < 5) { newW = 5; if (isResizing.includes("w")) newX = start.hX + start.hW - 5; }
101
+ if (newH < 5) { newH = 5; if (isResizing.includes("n")) newY = start.hY + start.hH - 5; }
102
+
103
+ newX = Math.max(0, Math.min(newX, 100 - newW));
104
+ newY = Math.max(0, Math.min(newY, 100 - newH));
105
+ newW = Math.min(newW, 100 - newX);
106
+ newH = Math.min(newH, 100 - newY);
107
+
108
+ setHotspot({ x: newX, y: newY, width: newW, height: newH });
109
+ }
110
+ }, [isDragging, isResizing, hotspot]);
111
+
112
+ const handlePointerUp = useCallback(() => {
113
+ setIsDragging(false);
114
+ setIsResizing(null);
115
+ dragStartRef.current = null;
116
+ }, []);
117
+
118
+ useEffect(() => {
119
+ if (isDragging || isResizing) {
120
+ window.addEventListener("pointermove", handlePointerMove);
121
+ window.addEventListener("pointerup", handlePointerUp);
122
+ }
123
+ return () => {
124
+ window.removeEventListener("pointermove", handlePointerMove);
125
+ window.removeEventListener("pointerup", handlePointerUp);
126
+ };
127
+ }, [isDragging, isResizing, handlePointerMove, handlePointerUp]);
128
+
129
+ const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
130
+ // Optional: init defaults here if needed
131
+ };
132
+
133
+ const handleSave = () => {
134
+ const finalCrop = crop?.width && crop?.height
135
+ ? { x: crop.x, y: crop.y, width: crop.width, height: crop.height }
136
+ : undefined;
137
+
138
+ const finalHotspot = showHotspot ? hotspot : undefined;
139
+
140
+ onSave(finalCrop, finalHotspot);
141
+ };
142
+
143
+ const handleReset = () => {
144
+ setCrop(undefined);
145
+ setShowHotspot(false);
146
+ };
147
+
148
+ return (
149
+ <div className="flex flex-col h-full bg-[var(--kyro-bg)]">
150
+ {/* Toolbar */}
151
+ <div className="flex items-center justify-between p-4 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
152
+ <div className="flex items-center gap-2">
153
+ <button
154
+ onClick={() => setMode("crop")}
155
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-all ${
156
+ mode === "crop"
157
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
158
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
159
+ }`}
160
+ >
161
+ <CropIcon className="w-4 h-4" />
162
+ Crop
163
+ </button>
164
+ <button
165
+ onClick={() => {
166
+ setMode("hotspot");
167
+ if (!showHotspot) setShowHotspot(true);
168
+ }}
169
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-all ${
170
+ mode === "hotspot"
171
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
172
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
173
+ }`}
174
+ >
175
+ <MousePointerClick className="w-4 h-4" />
176
+ Hotspot
177
+ </button>
178
+
179
+ <div className="w-px h-6 bg-[var(--kyro-border)] mx-2"></div>
180
+
181
+ <button
182
+ onClick={handleReset}
183
+ className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
184
+ >
185
+ <RefreshCcw className="w-4 h-4" />
186
+ Reset
187
+ </button>
188
+ </div>
189
+
190
+ <div className="flex items-center gap-3">
191
+ <button
192
+ onClick={onCancel}
193
+ className="px-4 py-2 text-sm font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
194
+ >
195
+ Cancel
196
+ </button>
197
+ <button
198
+ onClick={handleSave}
199
+ disabled={isSaving}
200
+ className="flex items-center justify-center min-w-[120px] px-6 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] text-sm font-bold rounded-lg shadow-lg hover:opacity-90 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
201
+ >
202
+ {isSaving ? (
203
+ <svg className="animate-spin h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
204
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
205
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
206
+ </svg>
207
+ ) : (
208
+ "Save Edits"
209
+ )}
210
+ </button>
211
+ </div>
212
+ </div>
213
+
214
+ {/* Editor Area */}
215
+ <div className="flex-1 w-full flex items-center justify-center p-8 overflow-hidden relative select-none">
216
+ <div className="relative max-w-full max-h-full flex items-center justify-center" ref={containerRef}>
217
+ <ReactCrop
218
+ crop={crop}
219
+ onChange={(c, pc) => setCrop(pc)} // Save percentage crop
220
+ locked={mode === "hotspot"}
221
+ className={mode === "hotspot" ? "opacity-70 transition-opacity" : "transition-opacity"}
222
+ >
223
+ <img
224
+ ref={imgRef}
225
+ src={url}
226
+ alt="Focal Editor"
227
+ className="max-h-[70vh] object-contain pointer-events-none"
228
+ onLoad={onImageLoad}
229
+ />
230
+ </ReactCrop>
231
+
232
+ {/* Hotspot Overlay */}
233
+ {showHotspot && imgRef.current && (
234
+ <div
235
+ className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center"
236
+ >
237
+ {/* Overlay that matches the image dimensions exactly */}
238
+ <div
239
+ className="relative"
240
+ style={{
241
+ width: imgRef.current.width,
242
+ height: imgRef.current.height
243
+ }}
244
+ >
245
+ <div
246
+ className={`absolute border-2 shadow-2xl transition-colors duration-200 ${
247
+ mode === "hotspot"
248
+ ? "border-blue-500 bg-blue-500/20 cursor-move pointer-events-auto shadow-blue-500/50"
249
+ : "border-blue-500/50 bg-blue-500/10 pointer-events-none"
250
+ }`}
251
+ style={{
252
+ left: `${hotspot.x}%`,
253
+ top: `${hotspot.y}%`,
254
+ width: `${hotspot.width}%`,
255
+ height: `${hotspot.height}%`,
256
+ borderRadius: "50%",
257
+ }}
258
+ onPointerDown={(e) => handlePointerDown(e)}
259
+ >
260
+ {/* Center dot */}
261
+ <div className="absolute top-1/2 left-1/2 w-2 h-2 -ml-1 -mt-1 bg-blue-500 rounded-full"></div>
262
+
263
+ {/* Resize handles */}
264
+ {mode === "hotspot" && (
265
+ <>
266
+ <div
267
+ className="absolute top-0 left-1/2 w-4 h-4 -ml-2 -mt-2 bg-blue-500 rounded-full cursor-ns-resize"
268
+ onPointerDown={(e) => handlePointerDown(e, "n")}
269
+ />
270
+ <div
271
+ className="absolute bottom-0 left-1/2 w-4 h-4 -ml-2 -mb-2 bg-blue-500 rounded-full cursor-ns-resize"
272
+ onPointerDown={(e) => handlePointerDown(e, "s")}
273
+ />
274
+ <div
275
+ className="absolute top-1/2 left-0 w-4 h-4 -ml-2 -mt-2 bg-blue-500 rounded-full cursor-ew-resize"
276
+ onPointerDown={(e) => handlePointerDown(e, "w")}
277
+ />
278
+ <div
279
+ className="absolute top-1/2 right-0 w-4 h-4 -mr-2 -mt-2 bg-blue-500 rounded-full cursor-ew-resize"
280
+ onPointerDown={(e) => handlePointerDown(e, "e")}
281
+ />
282
+ </>
283
+ )}
284
+ </div>
285
+ </div>
286
+ </div>
287
+ )}
288
+ </div>
289
+ </div>
290
+
291
+ {/* Help Text */}
292
+ <div className="p-4 text-center bg-[var(--kyro-surface-accent)] border-t border-[var(--kyro-border)] text-sm text-[var(--kyro-text-secondary)]">
293
+ {mode === "crop"
294
+ ? "Drag to define the crop area. This creates a bounding box for the image."
295
+ : "Drag the circle to define the focal hotspot. This area will always remain visible when the image is resized."}
296
+ </div>
297
+ </div>
298
+ );
299
+ }
@@ -59,7 +59,7 @@ export function Modal({
59
59
  const isLightbox = variant === "lightbox";
60
60
 
61
61
  return createPortal(
62
- <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
62
+ <div className="fixed inset-0 z-[999999] flex items-center justify-center p-4">
63
63
  <div
64
64
  className={`absolute inset-0 transition-all duration-500 ${isLightbox ? "bg-black/95 backdrop-blur-none" : "bg-[var(--kyro-black)]/40 backdrop-blur-md"}`}
65
65
  onClick={onClose}
@@ -13,6 +13,7 @@ interface Action {
13
13
  icon?: ComponentType<SVGAttributes<SVGSVGElement>>;
14
14
  variant?: "primary" | "outline" | "ghost";
15
15
  className?: string;
16
+ disabled?: boolean;
16
17
  }
17
18
 
18
19
  interface PageHeaderProps {
@@ -94,13 +95,14 @@ function ActionsSlot({ actions }: { actions: NonNullable<PageHeaderProps["action
94
95
  key={i}
95
96
  type="button"
96
97
  onClick={act.onClick}
98
+ disabled={act.disabled}
97
99
  className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm transition-all shadow-lg shadow-[var(--kyro-primary)]/10 ${
98
100
  act.variant === "outline"
99
101
  ? "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
100
102
  : act.variant === "ghost"
101
103
  ? "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] shadow-none"
102
104
  : "kyro-btn-primary hover:opacity-90"
103
- } ${act.className || ""}`}
105
+ } ${act.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${act.className || ""}`}
104
106
  >
105
107
  {act.icon && <act.icon className="w-4 h-4" />}
106
108
  {act.label}
@@ -117,7 +119,8 @@ function SingleAction({ action }: { action: NonNullable<PageHeaderProps["action"
117
119
  <button
118
120
  type="button"
119
121
  onClick={action.onClick}
120
- className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all shadow-lg shadow-[var(--kyro-primary)]/10 w-full lg:w-auto justify-center ${action.className || ""}`}
122
+ disabled={action.disabled}
123
+ className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all shadow-lg shadow-[var(--kyro-primary)]/10 w-full lg:w-auto justify-center ${action.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${action.className || ""}`}
121
124
  >
122
125
  {action.icon && <action.icon className="w-4 h-4" />}
123
126
  {action.label}
@@ -119,6 +119,38 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
119
119
  if (configResult.error) {
120
120
  throw new Error(configResult.error);
121
121
  }
122
+
123
+ // Mirror the registry's SEO tab injection so the admin schema JSON
124
+ // has the same tabs as the server-side registry at runtime.
125
+ const seoFields = [
126
+ { name: "metaTitle", type: "text", label: "Meta Title", admin: { description: "The title used for search engines (recommended < 60 chars).", autoGenerate: "title" } },
127
+ { name: "metaDescription", type: "textarea", label: "Meta Description", admin: { description: "A brief summary for search engines (recommended < 160 chars).", autoGenerate: "content" } },
128
+ { name: "keywords", type: "text", label: "Keywords", admin: { description: "Comma-separated list of keywords for this page." } },
129
+ { name: "ogImage", type: "upload", label: "OpenGraph Image", relationTo: "media", admin: { description: "The image shown when shared on social media." } },
130
+ { name: "twitter", type: "group", label: "Twitter Card", fields: [
131
+ { name: "title", type: "text", label: "Twitter Title" },
132
+ { name: "description", type: "textarea", label: "Twitter Description" },
133
+ { name: "image", type: "upload", label: "Twitter Image", relationTo: "media" },
134
+ ]},
135
+ { name: "advanced", type: "group", label: "Advanced Search Settings", fields: [
136
+ { name: "noindex", type: "checkbox", label: "Hide from search engines (noindex)", defaultValue: false },
137
+ { name: "nofollow", type: "checkbox", label: "Do not follow links (nofollow)", defaultValue: false },
138
+ { name: "canonicalUrl", type: "text", label: "Canonical URL Override", admin: { description: "Leave empty to use the default canonical URL." } },
139
+ { name: "structuredData", type: "code", label: "JSON-LD Structured Data", admin: { description: "Custom JSON-LD schema for this specific page." } },
140
+ ]},
141
+ ];
142
+ for (const col of (configResult.collections || [])) {
143
+ if (col.seo) {
144
+ const mainTabsField = col.fields?.find((f: any) => f.type === 'tabs' && f.name === 'mainTabs');
145
+ if (mainTabsField?.tabs) {
146
+ // Only add if not already injected (avoid duplicates on hot-reload)
147
+ if (!mainTabsField.tabs.find((t: any) => t.label === 'SEO Settings')) {
148
+ mainTabsField.tabs.push({ label: "SEO Settings", fields: seoFields });
149
+ }
150
+ }
151
+ }
152
+ }
153
+
122
154
  fs.writeFileSync(configFile, JSON.stringify(configResult, null, 2), "utf8");
123
155
  logger.info("Project config loaded for admin");
124
156
  } catch (e: any) {
@@ -198,7 +198,7 @@ if (includeSiteName) {
198
198
  </div>
199
199
  </header>
200
200
 
201
- <div class="flex-1 overflow-y-auto p-4 md:p-0">
201
+ <div class="flex-1 flex flex-col min-h-0 overflow-y-auto p-4 md:p-0">
202
202
  <slot />
203
203
  </div>
204
204
  </main>
@@ -27,9 +27,22 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
27
27
  });
28
28
  if (!doc) return null;
29
29
 
30
- const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
30
+ const mediaFields = [
31
+ { path: ["siteFavicon"] },
32
+ { path: ["favicon"] },
33
+ { path: ["identity", "primaryLogo"] },
34
+ { path: ["identity", "darkLogo"] }
35
+ ];
31
36
  for (const field of mediaFields) {
32
- const val = doc[field];
37
+ let parent = doc;
38
+ let key = field.path[0];
39
+ if (field.path.length > 1) {
40
+ parent = doc[field.path[0]];
41
+ key = field.path[1];
42
+ }
43
+ if (!parent) continue;
44
+
45
+ const val = parent[key];
33
46
  const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
34
47
  if (id) {
35
48
  try {
@@ -37,7 +50,7 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
37
50
  collection: "media",
38
51
  id,
39
52
  });
40
- if (mediaDoc) doc[field] = mediaDoc;
53
+ if (mediaDoc) parent[key] = mediaDoc;
41
54
  } catch { /* media field stays as-is */ }
42
55
  }
43
56
  }
@@ -58,9 +71,22 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
58
71
  const doc = json.data || null;
59
72
  if (!doc) return null;
60
73
  // Resolve media fields via the same API endpoint
61
- const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
74
+ const mediaFields = [
75
+ { path: ["siteFavicon"] },
76
+ { path: ["favicon"] },
77
+ { path: ["identity", "primaryLogo"] },
78
+ { path: ["identity", "darkLogo"] }
79
+ ];
62
80
  for (const field of mediaFields) {
63
- const val = doc[field];
81
+ let parent = doc;
82
+ let key = field.path[0];
83
+ if (field.path.length > 1) {
84
+ parent = doc[field.path[0]];
85
+ key = field.path[1];
86
+ }
87
+ if (!parent) continue;
88
+
89
+ const val = parent[key];
64
90
  const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
65
91
  if (id) {
66
92
  try {
@@ -68,7 +94,7 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
68
94
  headers: { Cookie: cookie },
69
95
  });
70
96
  if (mediaRes.ok) {
71
- doc[field] = await mediaRes.json();
97
+ parent[key] = await mediaRes.json();
72
98
  }
73
99
  } catch { /* media field stays as-is */ }
74
100
  }
@@ -99,9 +125,22 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
99
125
  });
100
126
  if (!doc) return null;
101
127
 
102
- const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
128
+ const mediaFields = [
129
+ { path: ["siteFavicon"] },
130
+ { path: ["favicon"] },
131
+ { path: ["identity", "primaryLogo"] },
132
+ { path: ["identity", "darkLogo"] }
133
+ ];
103
134
  for (const field of mediaFields) {
104
- const val = doc[field];
135
+ let parent = doc;
136
+ let key = field.path[0];
137
+ if (field.path.length > 1) {
138
+ parent = doc[field.path[0]];
139
+ key = field.path[1];
140
+ }
141
+ if (!parent) continue;
142
+
143
+ const val = parent[key];
105
144
  const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
106
145
  if (id) {
107
146
  try {
@@ -109,7 +148,7 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
109
148
  collection: "media",
110
149
  id,
111
150
  });
112
- if (mediaDoc) doc[field] = mediaDoc;
151
+ if (mediaDoc) parent[key] = mediaDoc;
113
152
  } catch { /* media field stays as-is */ }
114
153
  }
115
154
  }
@@ -121,3 +160,8 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
121
160
  export async function getSiteSettings(options?: GlobalOptions) {
122
161
  return await getGlobal("site-settings", options);
123
162
  }
163
+
164
+ /** Convenience helper to get the brand settings. */
165
+ export async function getBrandSettings(options?: GlobalOptions) {
166
+ return await getGlobal("brand-settings", options);
167
+ }