@kyro-cms/admin 0.12.5 → 0.12.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 +32 -110
- package/dist/index.css +1 -1
- package/dist/index.d.cts +1 -3
- package/dist/index.d.ts +1 -3
- package/dist/index.js +32 -110
- package/package.json +7 -2
- package/src/components/AutoForm.tsx +35 -45
- package/src/components/DashboardMetrics.tsx +519 -290
- package/src/components/FieldRenderer.tsx +59 -47
- package/src/components/ListView.tsx +10 -5
- package/src/components/PluginsManager.tsx +37 -20
- package/src/components/Sidebar.astro +7 -14
- package/src/components/blocks/AccordionBlock.tsx +8 -4
- package/src/components/blocks/ArrayBlock.tsx +4 -3
- package/src/components/blocks/BlockEditModal.tsx +4 -3
- package/src/components/blocks/CardBlock.tsx +10 -2
- package/src/components/blocks/ChildBlocksTree.tsx +19 -18
- package/src/components/blocks/CodeBlock.tsx +7 -2
- package/src/components/blocks/FileBlock.tsx +6 -2
- package/src/components/blocks/GenericBlock.tsx +1 -1
- package/src/components/blocks/HeadingBlock.tsx +6 -2
- package/src/components/blocks/HeadingSubheadingBlock.tsx +7 -2
- package/src/components/blocks/HeroBlock.tsx +12 -3
- package/src/components/blocks/ImageBlock.tsx +8 -2
- package/src/components/blocks/ListBlock.tsx +6 -2
- package/src/components/blocks/ParagraphBlock.tsx +2 -2
- package/src/components/blocks/RelationshipBlock.tsx +10 -2
- package/src/components/blocks/VideoBlock.tsx +7 -2
- package/src/components/fields/AccordionField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +55 -58
- package/src/components/fields/BlocksField.tsx +1 -1
- package/src/components/fields/ChildrenField.tsx +3 -2
- package/src/components/fields/CodeField.tsx +3 -2
- package/src/components/fields/ColumnsField.tsx +3 -2
- package/src/components/fields/DateField.tsx +2 -2
- package/src/components/fields/FieldLayout.tsx +3 -3
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/MarkdownField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -4
- package/src/components/fields/RelationshipField.tsx +4 -1
- package/src/components/fields/RichTextField.tsx +200 -5
- package/src/components/fields/extensions/blockComponents.tsx +1 -1
- package/src/components/fields/extensions/blocksStore.ts +15 -12
- package/src/components/ui/Pagination.tsx +1 -1
- package/src/fields/index.ts +1 -1
- package/src/hooks/useAutoFormState.ts +7 -6
- package/src/index.ts +0 -1
- package/src/integration.ts +64 -26
- package/src/lib/api.ts +1 -0
- package/src/lib/config.ts +6 -19
- package/src/lib/core-types.ts +78 -0
- package/src/plugins/seo-admin.tsx +155 -0
- package/src/styles/main.css +2 -0
- package/src/vite-env.d.ts +10 -1
|
@@ -3,6 +3,7 @@ import { githubLight } from "@uiw/codemirror-theme-github";
|
|
|
3
3
|
import { aura } from "@uiw/codemirror-theme-aura";
|
|
4
4
|
import type { CodeField as CodeFieldType } from "@kyro-cms/core/client";
|
|
5
5
|
import { useTheme } from "../ThemeProvider";
|
|
6
|
+
import type { Extension } from "@codemirror/state";
|
|
6
7
|
|
|
7
8
|
interface CodeFieldProps {
|
|
8
9
|
field: CodeFieldType;
|
|
@@ -74,7 +75,7 @@ export const CodeField: React.FC<CodeFieldProps> = ({
|
|
|
74
75
|
}) => {
|
|
75
76
|
const [isMounted, setIsMounted] = useState(false);
|
|
76
77
|
const [isDark, setIsDark] = useState(false);
|
|
77
|
-
const [extensions, setExtensions] = useState<
|
|
78
|
+
const [extensions, setExtensions] = useState<Extension[]>([]);
|
|
78
79
|
const [loading, setLoading] = useState(false);
|
|
79
80
|
const [copied, setCopied] = useState(false);
|
|
80
81
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
|
@@ -104,7 +105,7 @@ export const CodeField: React.FC<CodeFieldProps> = ({
|
|
|
104
105
|
const loader =
|
|
105
106
|
languageExtensions[language] || languageExtensions.javascript;
|
|
106
107
|
const ext = await loader();
|
|
107
|
-
setExtensions([ext]);
|
|
108
|
+
setExtensions([ext as Extension]);
|
|
108
109
|
} catch (err) {
|
|
109
110
|
console.error("Failed to load language extension:", err);
|
|
110
111
|
setExtensions([]);
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { ChildBlocksTree } from "../blocks/ChildBlocksTree";
|
|
3
|
+
import type { BlockData } from "@kyro-cms/core/client";
|
|
3
4
|
|
|
4
5
|
interface ColumnData {
|
|
5
6
|
id: number;
|
|
6
|
-
children:
|
|
7
|
+
children: BlockData[];
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
interface ColumnsFieldProps {
|
|
10
11
|
columns?: number;
|
|
11
12
|
columnData?: ColumnData[];
|
|
12
13
|
onColumnsChange: (columns: number) => void;
|
|
13
|
-
onUpdateColumnChildren: (columnIndex: number, newChildren:
|
|
14
|
+
onUpdateColumnChildren: (columnIndex: number, newChildren: BlockData[]) => void;
|
|
14
15
|
compact?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -32,8 +32,8 @@ export default function DateField({
|
|
|
32
32
|
value={value == null ? "" : value}
|
|
33
33
|
onChange={(e) => onChange?.(e.target.value)}
|
|
34
34
|
disabled={disabled || isReadOnly}
|
|
35
|
-
min={field.minDate}
|
|
36
|
-
max={field.maxDate}
|
|
35
|
+
min={field.minDate as string | undefined}
|
|
36
|
+
max={field.maxDate as string | undefined}
|
|
37
37
|
required={field.required}
|
|
38
38
|
className={`kyro-form-input ${
|
|
39
39
|
disabled || isReadOnly ? "opacity-50 cursor-not-allowed" : ""
|
|
@@ -31,11 +31,11 @@ export default function FieldLayout({
|
|
|
31
31
|
{children}
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
|
-
{(field.admin?.description || error) && (
|
|
34
|
+
{((field.admin?.description as string | undefined) || error) && (
|
|
35
35
|
<div className="flex flex-col gap-1.5 px-1">
|
|
36
|
-
{field.admin?.description && !error && (
|
|
36
|
+
{((field.admin?.description as string | undefined) && !error) && (
|
|
37
37
|
<p className="text-[11px] leading-relaxed text-[var(--kyro-text-muted)] font-medium opacity-60 italic">
|
|
38
|
-
{field.admin
|
|
38
|
+
{field.admin?.description as string}
|
|
39
39
|
</p>
|
|
40
40
|
)}
|
|
41
41
|
{error && (
|
|
@@ -8,7 +8,7 @@ interface GroupLayoutProps {
|
|
|
8
8
|
renderField: (
|
|
9
9
|
field: Field,
|
|
10
10
|
parentData: Record<string, unknown>,
|
|
11
|
-
onChange: (value: unknown) => void,
|
|
11
|
+
onChange: (value: Record<string, unknown>) => void,
|
|
12
12
|
) => React.ReactNode;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -29,7 +29,7 @@ export function GroupLayout({
|
|
|
29
29
|
|
|
30
30
|
</div>
|
|
31
31
|
<div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
|
|
32
|
-
{(field as Field & { fields?: Field[] }).fields
|
|
32
|
+
{(field as Field & { fields?: Field[] }).fields?.map((f: Field) =>
|
|
33
33
|
renderField(f, groupData, onChange),
|
|
34
34
|
)}
|
|
35
35
|
</div>
|
|
@@ -237,8 +237,8 @@ export const MarkdownField: React.FC<MarkdownFieldProps> = ({
|
|
|
237
237
|
</div>
|
|
238
238
|
)}
|
|
239
239
|
|
|
240
|
-
{field.admin?.description && !error && (
|
|
241
|
-
<p className="kyro-form-help">{field.admin
|
|
240
|
+
{((field.admin?.description as string | undefined) && !error) && (
|
|
241
|
+
<p className="kyro-form-help">{field.admin?.description as string}</p>
|
|
242
242
|
)}
|
|
243
243
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
244
244
|
</div>
|
|
@@ -31,11 +31,11 @@ export default function NumberField({
|
|
|
31
31
|
id={field.name}
|
|
32
32
|
value={value ?? ""}
|
|
33
33
|
onChange={(e) => onChange?.(parseFloat(e.target.value) || 0)}
|
|
34
|
-
placeholder={field.admin?.placeholder}
|
|
34
|
+
placeholder={field.admin?.placeholder as string | undefined}
|
|
35
35
|
disabled={disabled || isReadOnly}
|
|
36
|
-
min={field.min}
|
|
37
|
-
max={field.max}
|
|
38
|
-
step={field.step || (field.integer ? 1 : "any")}
|
|
36
|
+
min={field.min as number | undefined}
|
|
37
|
+
max={field.max as number | undefined}
|
|
38
|
+
step={(field.step as number | string | undefined) || (field.integer ? 1 : "any")}
|
|
39
39
|
required={field.required}
|
|
40
40
|
className={`kyro-form-input ${
|
|
41
41
|
disabled || isReadOnly ? "opacity-50 cursor-not-allowed" : ""
|
|
@@ -235,7 +235,10 @@ export function RelationshipField({
|
|
|
235
235
|
|
|
236
236
|
const getValueId = (val: unknown): string => {
|
|
237
237
|
if (typeof val === "object" && val !== null) {
|
|
238
|
-
|
|
238
|
+
const inner = (val as { value?: unknown }).value ?? (val as { id?: unknown }).id;
|
|
239
|
+
// Recursively unwrap in case of doubly-nested {relationTo, value: {relationTo, value: id}}
|
|
240
|
+
if (inner !== undefined && inner !== null) return getValueId(inner);
|
|
241
|
+
return "";
|
|
239
242
|
}
|
|
240
243
|
return String(val);
|
|
241
244
|
};
|
|
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from "react";
|
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useEditor, EditorContent } from "@tiptap/react";
|
|
4
4
|
import StarterKit from "@tiptap/starter-kit";
|
|
5
|
+
import { marked } from "marked";
|
|
5
6
|
import Link from "@tiptap/extension-link";
|
|
6
7
|
import Image from "@tiptap/extension-image";
|
|
7
8
|
import TextAlign from "@tiptap/extension-text-align";
|
|
@@ -14,7 +15,10 @@ import Color from "@tiptap/extension-color";
|
|
|
14
15
|
import type { Field } from "@kyro-cms/core/client";
|
|
15
16
|
import FieldLayout from "./FieldLayout";
|
|
16
17
|
import { SlidePanel } from "../ui/SlidePanel";
|
|
18
|
+
import { PromptModal } from "../ui/PromptModal";
|
|
17
19
|
import { MediaGallery } from "../MediaGallery";
|
|
20
|
+
import { useAutoFormStore } from "../../lib/autoform-store";
|
|
21
|
+
import { projectConfig } from "virtual:kyro-plugins";
|
|
18
22
|
import {
|
|
19
23
|
Bold,
|
|
20
24
|
Italic,
|
|
@@ -44,8 +48,10 @@ import {
|
|
|
44
48
|
Maximize2,
|
|
45
49
|
Minimize2,
|
|
46
50
|
ChevronDown,
|
|
51
|
+
Sparkles,
|
|
47
52
|
} from "lucide-react";
|
|
48
53
|
import { useTranslation } from "react-i18next";
|
|
54
|
+
import { useToast } from "@kyro-cms/admin";
|
|
49
55
|
|
|
50
56
|
interface RichTextFieldProps {
|
|
51
57
|
field: Field;
|
|
@@ -73,15 +79,28 @@ const MenuBar = ({
|
|
|
73
79
|
isExpanded,
|
|
74
80
|
setIsExpanded,
|
|
75
81
|
onOpenMediaPicker,
|
|
82
|
+
isAiLoading,
|
|
83
|
+
setIsAiLoading,
|
|
76
84
|
}: {
|
|
77
85
|
editor: any;
|
|
78
86
|
isExpanded: boolean;
|
|
79
87
|
setIsExpanded: (expanded: boolean) => void;
|
|
80
88
|
onOpenMediaPicker: () => void;
|
|
89
|
+
isAiLoading: boolean;
|
|
90
|
+
setIsAiLoading: (loading: boolean) => void;
|
|
81
91
|
}) => {
|
|
82
92
|
const { t } = useTranslation();
|
|
83
93
|
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
|
94
|
+
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false);
|
|
84
95
|
const menuBarRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
const { addToast } = useToast();
|
|
97
|
+
|
|
98
|
+
const formData = useAutoFormStore((state) => state.formData);
|
|
99
|
+
const setField = useAutoFormStore((state) => state.setField);
|
|
100
|
+
|
|
101
|
+
const isAiAssistantEnabled = projectConfig?.plugins?.some?.(
|
|
102
|
+
(p: any) => p.name === "ai-assistant"
|
|
103
|
+
) ?? false;
|
|
85
104
|
|
|
86
105
|
useEffect(() => {
|
|
87
106
|
function handleClickOutside(event: MouseEvent) {
|
|
@@ -126,6 +145,103 @@ const MenuBar = ({
|
|
|
126
145
|
setActiveDropdown(null);
|
|
127
146
|
};
|
|
128
147
|
|
|
148
|
+
const handleAiAction = async (action: string, customPrompt?: string) => {
|
|
149
|
+
setActiveDropdown(null);
|
|
150
|
+
|
|
151
|
+
const titleValue = (document.querySelector('input#title, input[name="title"]') as HTMLInputElement)?.value;
|
|
152
|
+
const title = formData?.title || titleValue || "Untitled Document";
|
|
153
|
+
|
|
154
|
+
let collectionName = "document";
|
|
155
|
+
try {
|
|
156
|
+
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
|
157
|
+
if (pathParts[0] === "admin" && pathParts.length >= 2) {
|
|
158
|
+
collectionName = pathParts[1];
|
|
159
|
+
if (collectionName.endsWith('s')) collectionName = collectionName.slice(0, -1);
|
|
160
|
+
}
|
|
161
|
+
} catch(e) {}
|
|
162
|
+
|
|
163
|
+
const context = editor.state.doc.textBetween(
|
|
164
|
+
Math.max(0, editor.state.selection.from - 500),
|
|
165
|
+
Math.min(editor.state.doc.content.size, editor.state.selection.to + 500),
|
|
166
|
+
' '
|
|
167
|
+
);
|
|
168
|
+
const selectedText = editor.state.doc.textBetween(
|
|
169
|
+
editor.state.selection.from,
|
|
170
|
+
editor.state.selection.to,
|
|
171
|
+
' '
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
let prompt = "";
|
|
175
|
+
if (action === "Generate") {
|
|
176
|
+
prompt = `Write a comprehensive, engaging, and professional ${collectionName} titled "${title}".
|
|
177
|
+
The content MUST be full-featured and highly formatted. You MUST include:
|
|
178
|
+
1. Proper Markdown formatting (headings, paragraphs, bold/italic text, blockquotes)
|
|
179
|
+
2. Relevant and context-specific images. You MUST use EXACT Markdown image syntax like this: '' (replace URL_ENCODED_KEYWORDS with descriptive keywords for the image)
|
|
180
|
+
3. Hyperlinks where necessary to provide additional context or references
|
|
181
|
+
4. Well-structured sections with bullet points or numbered lists where appropriate.`;
|
|
182
|
+
} else if (action === "Summarize") {
|
|
183
|
+
prompt = `Summarize the following content from the ${collectionName} titled "${title}" into a concise paragraph. Use clear Markdown formatting if necessary (like bolding key terms): ${selectedText || context}`;
|
|
184
|
+
} else if (action === "Expand") {
|
|
185
|
+
prompt = `Expand on the following content from the ${collectionName} titled "${title}". Add more details, context, and depth.
|
|
186
|
+
Please format the output using rich Markdown (headings, paragraphs, lists).
|
|
187
|
+
If visual context would be helpful, include relevant images using EXACT Markdown image syntax: ''.
|
|
188
|
+
Here is the content to expand: ${selectedText || context}`;
|
|
189
|
+
} else if (action === "Prompt") {
|
|
190
|
+
if (customPrompt) {
|
|
191
|
+
prompt = `${customPrompt}\n\nPlease format your response using rich Markdown. If your response includes images, use EXACT Markdown image syntax: ''`;
|
|
192
|
+
} else {
|
|
193
|
+
setIsPromptModalOpen(true);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setIsAiLoading(true);
|
|
199
|
+
try {
|
|
200
|
+
const apiPath = (window as any).__KYRO_API_PATH__ || '/api';
|
|
201
|
+
const res = await fetch(`${apiPath}/kyro/ai/completion`, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'Content-Type': 'application/json' },
|
|
204
|
+
credentials: 'include',
|
|
205
|
+
body: JSON.stringify({ prompt, context: selectedText || context })
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) throw new Error("Failed to get AI response");
|
|
208
|
+
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
const generatedText = data.text;
|
|
211
|
+
|
|
212
|
+
if (generatedText) {
|
|
213
|
+
console.log("[AI Client] Raw Markdown Response:", generatedText);
|
|
214
|
+
const htmlContent = await marked.parse(generatedText);
|
|
215
|
+
console.log("[AI Client] Parsed HTML Response:", htmlContent);
|
|
216
|
+
if (selectedText && action !== "Generate") {
|
|
217
|
+
editor.chain().focus().insertContent(htmlContent).run();
|
|
218
|
+
} else {
|
|
219
|
+
editor.chain().focus().insertContent(htmlContent).run();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Automatically generate excerpt and push to excerpt field if it exists
|
|
223
|
+
const excerptInput = document.querySelector('input[name="excerpt"], textarea[name="excerpt"]');
|
|
224
|
+
if (excerptInput || 'excerpt' in formData) {
|
|
225
|
+
const cleanText = generatedText.replace(/<[^>]*>?/gm, '').replace(/[#*]/g, '');
|
|
226
|
+
const excerptMatch = cleanText.match(/^.*?[.!?](?:\s|$)/);
|
|
227
|
+
let excerpt = excerptMatch ? excerptMatch[0].trim() : cleanText.substring(0, 150) + "...";
|
|
228
|
+
if (excerpt.length > 200) {
|
|
229
|
+
excerpt = excerpt.substring(0, 150) + "...";
|
|
230
|
+
}
|
|
231
|
+
setField('excerpt', excerpt);
|
|
232
|
+
addToast('success', "Content generated and excerpt updated.");
|
|
233
|
+
} else {
|
|
234
|
+
addToast('success', "Content generated.");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error("AI Assistant Error:", err);
|
|
239
|
+
addToast('error', "AI Assistant failed to generate text.");
|
|
240
|
+
} finally {
|
|
241
|
+
setIsAiLoading(false);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
129
245
|
const toggleDropdown = (name: string) => {
|
|
130
246
|
setActiveDropdown(activeDropdown === name ? null : name);
|
|
131
247
|
};
|
|
@@ -505,6 +621,59 @@ const MenuBar = ({
|
|
|
505
621
|
|
|
506
622
|
{/* Group 8: Workspace Controls */}
|
|
507
623
|
<div className="flex items-center gap-0.5 p-0.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-md shadow-xs ml-auto">
|
|
624
|
+
{isAiAssistantEnabled && (
|
|
625
|
+
<div className="relative flex items-center justify-center">
|
|
626
|
+
<ToolbarButton
|
|
627
|
+
onClick={() => toggleDropdown("ai")}
|
|
628
|
+
title={t("tooltips.aiAssistant", { defaultValue: "AI Assistant" })}
|
|
629
|
+
isActive={activeDropdown === "ai"}
|
|
630
|
+
disabled={isAiLoading}
|
|
631
|
+
>
|
|
632
|
+
{isAiLoading ? (
|
|
633
|
+
<Sparkles size={14} className="animate-pulse text-[var(--kyro-primary)]" />
|
|
634
|
+
) : activeDropdown === "ai" ? (
|
|
635
|
+
<Sparkles size={14} className="text-[var(--kyro-primary)]" />
|
|
636
|
+
) : (
|
|
637
|
+
<>
|
|
638
|
+
<img src="/logo.svg" alt="Kyro AI" className="w-3.5 h-3.5 object-contain opacity-80 group-hover:opacity-100 block dark:hidden" />
|
|
639
|
+
<img src="/logo-white.svg" alt="Kyro AI" className="w-3.5 h-3.5 object-contain opacity-80 group-hover:opacity-100 hidden dark:block" />
|
|
640
|
+
</>
|
|
641
|
+
)}
|
|
642
|
+
</ToolbarButton>
|
|
643
|
+
{activeDropdown === "ai" && (
|
|
644
|
+
<div className="absolute top-full right-0 mt-1.5 p-1 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 min-w-32 flex flex-col gap-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
onClick={() => handleAiAction("Generate")}
|
|
648
|
+
className="px-2.5 py-1.5 text-xs text-left rounded-md hover:bg-[var(--kyro-bg-hover)] cursor-pointer text-[var(--kyro-text)] transition-colors flex items-center gap-2"
|
|
649
|
+
>
|
|
650
|
+
<Sparkles size={12} className="text-[var(--kyro-primary)]" /> Generate
|
|
651
|
+
</button>
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
onClick={() => handleAiAction("Summarize")}
|
|
655
|
+
className="px-2.5 py-1.5 text-xs text-left rounded-md hover:bg-[var(--kyro-bg-hover)] cursor-pointer text-[var(--kyro-text)] transition-colors flex items-center gap-2"
|
|
656
|
+
>
|
|
657
|
+
<AlignLeft size={12} /> Summarize
|
|
658
|
+
</button>
|
|
659
|
+
<button
|
|
660
|
+
type="button"
|
|
661
|
+
onClick={() => handleAiAction("Expand")}
|
|
662
|
+
className="px-2.5 py-1.5 text-xs text-left rounded-md hover:bg-[var(--kyro-bg-hover)] cursor-pointer text-[var(--kyro-text)] transition-colors flex items-center gap-2"
|
|
663
|
+
>
|
|
664
|
+
<Maximize2 size={12} /> Expand
|
|
665
|
+
</button>
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
onClick={() => handleAiAction("Prompt")}
|
|
669
|
+
className="px-2.5 py-1.5 text-xs text-left rounded-md hover:bg-[var(--kyro-bg-hover)] cursor-pointer text-[var(--kyro-text)] transition-colors flex items-center gap-2 border-t border-[var(--kyro-border)] mt-0.5"
|
|
670
|
+
>
|
|
671
|
+
<Terminal size={12} /> Prompt
|
|
672
|
+
</button>
|
|
673
|
+
</div>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
)}
|
|
508
677
|
<ToolbarButton
|
|
509
678
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
510
679
|
title={isExpanded ? "Collapse Workspace" : "Enlarge Workspace"}
|
|
@@ -512,6 +681,18 @@ const MenuBar = ({
|
|
|
512
681
|
{isExpanded ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
|
513
682
|
</ToolbarButton>
|
|
514
683
|
</div>
|
|
684
|
+
|
|
685
|
+
<PromptModal
|
|
686
|
+
open={isPromptModalOpen}
|
|
687
|
+
onClose={() => setIsPromptModalOpen(false)}
|
|
688
|
+
onSubmit={(prompt) => {
|
|
689
|
+
setIsPromptModalOpen(false);
|
|
690
|
+
handleAiAction("Prompt", prompt);
|
|
691
|
+
}}
|
|
692
|
+
title="AI Assistant Prompt"
|
|
693
|
+
placeholder="What do you want the AI to do?"
|
|
694
|
+
defaultValue={editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ') ? "Rewrite this text to be more engaging." : "Write a paragraph about..."}
|
|
695
|
+
/>
|
|
515
696
|
</div>
|
|
516
697
|
);
|
|
517
698
|
};
|
|
@@ -566,6 +747,7 @@ function RichTextEditor({
|
|
|
566
747
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
567
748
|
const [panelWidth, setPanelWidth] = useState(0);
|
|
568
749
|
const [isMediaPickerOpen, setIsMediaPickerOpen] = useState(false);
|
|
750
|
+
const [isAiLoading, setIsAiLoading] = useState(false);
|
|
569
751
|
|
|
570
752
|
useEffect(() => {
|
|
571
753
|
if (!isExpanded) {
|
|
@@ -607,7 +789,7 @@ function RichTextEditor({
|
|
|
607
789
|
const editor = useEditor({
|
|
608
790
|
extensions: [
|
|
609
791
|
StarterKit.configure({
|
|
610
|
-
codeBlock:
|
|
792
|
+
codeBlock: {},
|
|
611
793
|
link: false,
|
|
612
794
|
underline: false,
|
|
613
795
|
}),
|
|
@@ -676,8 +858,15 @@ function RichTextEditor({
|
|
|
676
858
|
isExpanded={isExpanded}
|
|
677
859
|
setIsExpanded={setIsExpanded}
|
|
678
860
|
onOpenMediaPicker={() => setIsMediaPickerOpen(true)}
|
|
861
|
+
isAiLoading={isAiLoading}
|
|
862
|
+
setIsAiLoading={setIsAiLoading}
|
|
679
863
|
/>
|
|
680
|
-
<div className=
|
|
864
|
+
<div className={`overflow-y-auto min-h-[160px] max-h-[400px] transition-all duration-300 ${isAiLoading ? 'opacity-40 pointer-events-none' : ''} relative`}>
|
|
865
|
+
{isAiLoading && (
|
|
866
|
+
<div className="absolute inset-0 flex items-center justify-center z-10 font-medium text-sm text-[var(--kyro-primary)]">
|
|
867
|
+
<Sparkles size={16} className="animate-pulse mr-2" /> Generating content...
|
|
868
|
+
</div>
|
|
869
|
+
)}
|
|
681
870
|
<EditorContent editor={editor} />
|
|
682
871
|
</div>
|
|
683
872
|
</div>
|
|
@@ -703,15 +892,21 @@ function RichTextEditor({
|
|
|
703
892
|
isExpanded={isExpanded}
|
|
704
893
|
setIsExpanded={setIsExpanded}
|
|
705
894
|
onOpenMediaPicker={() => setIsMediaPickerOpen(true)}
|
|
895
|
+
isAiLoading={isAiLoading}
|
|
896
|
+
setIsAiLoading={setIsAiLoading}
|
|
706
897
|
/>
|
|
707
|
-
<div className=
|
|
708
|
-
|
|
898
|
+
<div className={`flex-1 overflow-y-auto transition-all duration-300 ${isAiLoading ? 'opacity-40 pointer-events-none' : ''} relative`}>
|
|
899
|
+
{isAiLoading && (
|
|
900
|
+
<div className="absolute inset-0 flex items-center justify-center z-10 font-medium text-lg text-[var(--kyro-primary)]">
|
|
901
|
+
<Sparkles size={24} className="animate-pulse mr-3" /> Generating content...
|
|
902
|
+
</div>
|
|
903
|
+
)}
|
|
904
|
+
<EditorContent editor={editor} className="h-full" />
|
|
709
905
|
</div>
|
|
710
906
|
</div>,
|
|
711
907
|
document.body
|
|
712
908
|
)}
|
|
713
909
|
|
|
714
|
-
{/* Injected tasklist, and highlight custom styles for preview & editor */}
|
|
715
910
|
<style>{`
|
|
716
911
|
.kyro-richtext ul[data-type="taskList"] {
|
|
717
912
|
list-style: none !important;
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "../../ui/icons";
|
|
40
40
|
|
|
41
41
|
// Block component registry
|
|
42
|
-
export const BLOCK_COMPONENTS: Record<string, React.ComponentType<{ block:
|
|
42
|
+
export const BLOCK_COMPONENTS: Record<string, React.ComponentType<{ block: any; index: number }>> = {
|
|
43
43
|
heading: HeadingBlock,
|
|
44
44
|
paragraph: ParagraphBlock,
|
|
45
45
|
image: ImageBlock,
|
|
@@ -125,10 +125,11 @@ export function ensureIds(blocks: BlockData[]): BlockData[] {
|
|
|
125
125
|
updatedBlock.children = ensureIds(updatedBlock.children);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
const blockData = updatedBlock.data as Record<string, any> | undefined;
|
|
129
|
+
if (blockData?.columnData && Array.isArray(blockData.columnData)) {
|
|
129
130
|
updatedBlock.data = {
|
|
130
131
|
...updatedBlock.data,
|
|
131
|
-
columnData:
|
|
132
|
+
columnData: blockData.columnData.map((col: any) => ({
|
|
132
133
|
...col,
|
|
133
134
|
children: col.children ? ensureIds(col.children) : col.children,
|
|
134
135
|
})),
|
|
@@ -144,12 +145,12 @@ export function createNewBlock(type: string): BlockData {
|
|
|
144
145
|
const defaultData = getDefaultData(type);
|
|
145
146
|
const { options, children, ...data } = defaultData;
|
|
146
147
|
return {
|
|
147
|
-
id: Math.random().toString(36).
|
|
148
|
+
id: Math.random().toString(36).substring(2, 11),
|
|
148
149
|
type,
|
|
149
150
|
name: "",
|
|
150
|
-
data,
|
|
151
|
-
options,
|
|
152
|
-
children,
|
|
151
|
+
data: data as Record<string, unknown>,
|
|
152
|
+
options: options as Record<string, unknown> | undefined,
|
|
153
|
+
children: children as BlockData[] | undefined,
|
|
153
154
|
order: Date.now(),
|
|
154
155
|
};
|
|
155
156
|
}
|
|
@@ -189,7 +190,7 @@ function getDefaultData(type: string): Record<string, unknown> {
|
|
|
189
190
|
gallery: { images: [] },
|
|
190
191
|
tabs: { tabs: [{ label: "Tab 1", content: "" }] },
|
|
191
192
|
};
|
|
192
|
-
return defaults[type] || {}
|
|
193
|
+
return (defaults[type] || {}) as Record<string, unknown>;
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
// React hooks that read from context
|
|
@@ -212,8 +213,9 @@ export function useBlockById(id: string): BlockData | undefined {
|
|
|
212
213
|
const found = findRecursive(b.children);
|
|
213
214
|
if (found) return found;
|
|
214
215
|
}
|
|
215
|
-
|
|
216
|
-
|
|
216
|
+
const bData = b.data as Record<string, any> | undefined;
|
|
217
|
+
if (bData?.columnData && Array.isArray(bData.columnData)) {
|
|
218
|
+
for (const col of bData.columnData) {
|
|
217
219
|
if (col && col.children && col.children.length > 0) {
|
|
218
220
|
const found = findRecursive(col.children);
|
|
219
221
|
if (found) return found;
|
|
@@ -270,8 +272,9 @@ export function traverseBlocks(
|
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
// Handle columnData
|
|
273
|
-
|
|
274
|
-
|
|
275
|
+
const blockData = block.data as Record<string, any> | undefined;
|
|
276
|
+
if (blockData?.columnData && Array.isArray(blockData.columnData)) {
|
|
277
|
+
const updatedColumnData = blockData.columnData.map((col: any) => {
|
|
275
278
|
if (col.children && col.children.length > 0) {
|
|
276
279
|
const updatedColChildren = traverseBlocks(col.children, action);
|
|
277
280
|
if (updatedColChildren !== col.children) {
|
|
@@ -281,7 +284,7 @@ export function traverseBlocks(
|
|
|
281
284
|
return col;
|
|
282
285
|
});
|
|
283
286
|
|
|
284
|
-
if (updatedColumnData.some((col:
|
|
287
|
+
if (updatedColumnData.some((col: any, i: number) => col !== blockData.columnData[i])) {
|
|
285
288
|
updatedBlock.data = { ...updatedBlock.data, columnData: updatedColumnData };
|
|
286
289
|
blockChanged = true;
|
|
287
290
|
}
|
|
@@ -13,7 +13,7 @@ export function Pagination({ page, totalPages, totalDocs, limit, onPageChange, o
|
|
|
13
13
|
if (totalPages <= 1) return null;
|
|
14
14
|
|
|
15
15
|
return (
|
|
16
|
-
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-[var(--kyro-border)]">
|
|
16
|
+
<div className="surface-tile flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-[var(--kyro-border)]">
|
|
17
17
|
{totalDocs !== undefined && limit ? (
|
|
18
18
|
<span className="text-xs text-[var(--kyro-text-secondary)] font-medium">
|
|
19
19
|
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, totalDocs)} of {totalDocs}
|
package/src/fields/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export function useAutoFormState({
|
|
|
52
52
|
resetForm,
|
|
53
53
|
} = store;
|
|
54
54
|
|
|
55
|
-
const versionsEnabled = !!config
|
|
55
|
+
const versionsEnabled = !!config?.versions;
|
|
56
56
|
|
|
57
57
|
// Guard: clear stale formData from a previous page context.
|
|
58
58
|
// For collections, if the loaded document's id doesn't match the expected
|
|
@@ -438,6 +438,7 @@ const persistBrowserDraft = useCallback(
|
|
|
438
438
|
|
|
439
439
|
// Auto-generate metaTitle
|
|
440
440
|
useEffect(() => {
|
|
441
|
+
if (!config?.fields) return;
|
|
441
442
|
const fields = config.fields as Record<string, unknown>[];
|
|
442
443
|
const metaTitleField = findFieldDeep(fields, "metaTitle");
|
|
443
444
|
if (!metaTitleField) return;
|
|
@@ -448,7 +449,7 @@ const persistBrowserDraft = useCallback(
|
|
|
448
449
|
if (titleStr && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
|
|
449
450
|
setField("metaTitle", titleStr);
|
|
450
451
|
}
|
|
451
|
-
}, [formData, config
|
|
452
|
+
}, [formData, config?.fields, setField]);
|
|
452
453
|
|
|
453
454
|
interface FieldConfig {
|
|
454
455
|
name?: string;
|
|
@@ -461,8 +462,8 @@ const persistBrowserDraft = useCallback(
|
|
|
461
462
|
|
|
462
463
|
// Auto-generate slug
|
|
463
464
|
useEffect(() => {
|
|
464
|
-
const fields = config
|
|
465
|
-
const slugField = fields
|
|
465
|
+
const fields = config?.fields as FieldConfig[];
|
|
466
|
+
const slugField = fields?.find(
|
|
466
467
|
(f: FieldConfig) => f.name === "slug" && f.admin?.autoGenerate,
|
|
467
468
|
);
|
|
468
469
|
if (!slugField?.admin?.autoGenerate) return;
|
|
@@ -470,13 +471,13 @@ const persistBrowserDraft = useCallback(
|
|
|
470
471
|
|
|
471
472
|
const sourceValue = resolveFieldValue(fields, formData, sourceField);
|
|
472
473
|
|
|
473
|
-
if (isSlugLocked && sourceValue) {
|
|
474
|
+
if (isSlugLocked && typeof sourceValue === "string") {
|
|
474
475
|
const newSlug = slugifyText(sourceValue);
|
|
475
476
|
if (newSlug !== formData.slug) {
|
|
476
477
|
setField("slug", newSlug);
|
|
477
478
|
}
|
|
478
479
|
}
|
|
479
|
-
}, [formData, isSlugLocked, config
|
|
480
|
+
}, [formData, isSlugLocked, config?.fields, setField]);
|
|
480
481
|
|
|
481
482
|
// Auto-save effect — only starts timers on keystroke-originated changes.
|
|
482
483
|
// Local save fires after 1.5s of inactivity, server save after 8s.
|