@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/dist/index.cjs +27 -24
- package/dist/index.css +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +27 -24
- package/package.json +1 -1
- package/src/components/AutoForm.tsx +51 -27
- package/src/components/CreateView.tsx +1 -0
- package/src/components/DashboardMetrics.tsx +280 -0
- package/src/components/DetailView.tsx +22 -20
- package/src/components/FieldRenderer.tsx +16 -2
- package/src/components/ListView.tsx +2 -2
- package/src/components/MediaGallery.tsx +80 -123
- package/src/components/SessionsManager.tsx +5 -0
- package/src/components/Sidebar.astro +36 -17
- package/src/components/autoform/AutoFormHeader.tsx +5 -2
- package/src/components/fields/ArrayLayout.tsx +2 -2
- package/src/components/fields/SelectField.tsx +65 -1
- package/src/components/fields/TabsLayout.tsx +1 -1
- package/src/components/fields/UploadField.tsx +40 -69
- package/src/components/ui/CommandPaletteWrapper.tsx +2 -3
- package/src/components/ui/ImageFocalEditor.tsx +299 -0
- package/src/components/ui/Modal.tsx +1 -1
- package/src/components/ui/PageHeader.tsx +5 -2
- package/src/integration.ts +39 -4
- package/src/layouts/AdminLayout.astro +1 -1
- package/src/lib/globals.ts +53 -9
- package/src/lib/normalize-upload-fields.ts +1 -1
- package/src/pages/index.astro +34 -113
- package/src/pages/media.astro +1 -1
- package/src/pages/settings/[slug].astro +19 -1
- package/src/styles/main.css +50 -38
|
@@ -347,46 +347,6 @@ export function UploadField({
|
|
|
347
347
|
);
|
|
348
348
|
};
|
|
349
349
|
|
|
350
|
-
if (value) {
|
|
351
|
-
return (
|
|
352
|
-
<div className="space-y-2">
|
|
353
|
-
{isMultiple ? (
|
|
354
|
-
<div className="grid grid-cols-2 gap-2">
|
|
355
|
-
{currentValue.map((img: any, i: number) =>
|
|
356
|
-
renderImagePreview(img, i),
|
|
357
|
-
)}
|
|
358
|
-
{canAddMore && (
|
|
359
|
-
<button
|
|
360
|
-
type="button"
|
|
361
|
-
onClick={() => {
|
|
362
|
-
setSelectedItems([]);
|
|
363
|
-
setShowPicker(true);
|
|
364
|
-
}}
|
|
365
|
-
disabled={disabled}
|
|
366
|
-
className="flex items-center justify-center h-12 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-secondary)] hover:border-[var(--kyro-border-active)] cursor-pointer transition-colors"
|
|
367
|
-
>
|
|
368
|
-
+ Add {fieldLabel}
|
|
369
|
-
</button>
|
|
370
|
-
)}
|
|
371
|
-
</div>
|
|
372
|
-
) : (
|
|
373
|
-
renderImagePreview(value)
|
|
374
|
-
)}
|
|
375
|
-
<input
|
|
376
|
-
ref={inputRef}
|
|
377
|
-
type="file"
|
|
378
|
-
accept="image/*"
|
|
379
|
-
onChange={(e) => {
|
|
380
|
-
const file = e.target.files?.[0];
|
|
381
|
-
if (file) uploadFile(file);
|
|
382
|
-
}}
|
|
383
|
-
disabled={disabled}
|
|
384
|
-
className="hidden"
|
|
385
|
-
/>
|
|
386
|
-
</div>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
350
|
return (
|
|
391
351
|
<div className="space-y-2 relative">
|
|
392
352
|
<input
|
|
@@ -400,35 +360,46 @@ export function UploadField({
|
|
|
400
360
|
disabled={disabled}
|
|
401
361
|
className="hidden"
|
|
402
362
|
/>
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
>
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
363
|
+
|
|
364
|
+
{currentValue.length > 0 && (
|
|
365
|
+
<div className={isMultiple ? "grid grid-cols-2 gap-2" : "space-y-2"}>
|
|
366
|
+
{currentValue.map((img: any, i: number) =>
|
|
367
|
+
renderImagePreview(img, i),
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
|
|
372
|
+
{(!currentValue.length || canAddMore) && (
|
|
373
|
+
<div className="flex gap-2 flex-wrap">
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
onClick={() => inputRef.current?.click()}
|
|
377
|
+
disabled={disabled}
|
|
378
|
+
className="px-3 py-1.5 text-xs font-semibold rounded border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
|
|
379
|
+
>
|
|
380
|
+
+ Upload {fieldLabel}
|
|
381
|
+
</button>
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={() => {
|
|
385
|
+
setSelectedItems([]);
|
|
386
|
+
setShowPicker(true);
|
|
387
|
+
}}
|
|
388
|
+
disabled={disabled}
|
|
389
|
+
className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
|
|
390
|
+
>
|
|
391
|
+
Library
|
|
392
|
+
</button>
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
onClick={() => setShowUrlInput(!showUrlInput)}
|
|
396
|
+
disabled={disabled}
|
|
397
|
+
className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
|
|
398
|
+
>
|
|
399
|
+
URL
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
432
403
|
|
|
433
404
|
{showUrlInput && (
|
|
434
405
|
<div className="flex gap-2 items-center">
|
|
@@ -33,9 +33,8 @@ export function CommandPaletteWrapper({ collections, globals }: Props) {
|
|
|
33
33
|
const handleClose = () => setIsOpen(false);
|
|
34
34
|
|
|
35
35
|
const handleLogoutConfirm = () => {
|
|
36
|
-
|
|
37
|
-
(
|
|
38
|
-
window.location.href = "/login";
|
|
36
|
+
document.getElementById("logout-btn")?.click();
|
|
37
|
+
setShowLogoutConfirm(false);
|
|
39
38
|
};
|
|
40
39
|
|
|
41
40
|
const handleNavigate = (view: string, collection?: string, id?: string) => {
|
|
@@ -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-[
|
|
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
|
-
|
|
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}
|
package/src/integration.ts
CHANGED
|
@@ -79,14 +79,17 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
79
79
|
import { parentPort } from 'worker_threads';
|
|
80
80
|
import('${pathToFileURL(tmpFile).href}').then(mod => {
|
|
81
81
|
const cfg = mod.default || mod;
|
|
82
|
-
const serialize = (obj) => {
|
|
82
|
+
const serialize = (obj, key) => {
|
|
83
83
|
if (obj === null || obj === undefined) return obj;
|
|
84
|
-
if (typeof obj === 'function')
|
|
85
|
-
|
|
84
|
+
if (typeof obj === 'function') {
|
|
85
|
+
if (key === 'options') return "__KYRO_DYNAMIC_OPTIONS__";
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(obj)) return obj.map((v) => serialize(v));
|
|
86
89
|
if (typeof obj === 'object') {
|
|
87
90
|
const result = {};
|
|
88
91
|
for (const [k, v] of Object.entries(obj)) {
|
|
89
|
-
const sv = serialize(v);
|
|
92
|
+
const sv = serialize(v, k);
|
|
90
93
|
if (sv !== undefined) result[k] = sv;
|
|
91
94
|
}
|
|
92
95
|
return result;
|
|
@@ -116,6 +119,38 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
116
119
|
if (configResult.error) {
|
|
117
120
|
throw new Error(configResult.error);
|
|
118
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
|
+
|
|
119
154
|
fs.writeFileSync(configFile, JSON.stringify(configResult, null, 2), "utf8");
|
|
120
155
|
logger.info("Project config loaded for admin");
|
|
121
156
|
} catch (e: any) {
|
package/src/lib/globals.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
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)
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|
|
@@ -25,7 +25,7 @@ export function normalizeUploadFields(value: unknown): unknown {
|
|
|
25
25
|
const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
|
|
26
26
|
const hasMediaField = "url" in obj && ("filename" in obj || "mimeType" in obj);
|
|
27
27
|
|
|
28
|
-
if (hasId && hasMediaField && keys.length <=
|
|
28
|
+
if (hasId && hasMediaField && keys.length <= 25) {
|
|
29
29
|
return obj.id;
|
|
30
30
|
}
|
|
31
31
|
|