@kyro-cms/admin 0.12.6 → 0.12.8

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.
Files changed (77) hide show
  1. package/dist/index.cjs +32 -110
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.cts +5 -5
  4. package/dist/index.d.ts +5 -5
  5. package/dist/index.js +32 -110
  6. package/package.json +11 -5
  7. package/src/components/AuthBridge.tsx +2 -2
  8. package/src/components/AutoForm.tsx +49 -57
  9. package/src/components/BrandingHub.tsx +1 -1
  10. package/src/components/DashboardMetrics.tsx +519 -290
  11. package/src/components/DetailView.tsx +5 -3
  12. package/src/components/FieldRenderer.tsx +59 -47
  13. package/src/components/ListView.tsx +25 -10
  14. package/src/components/MediaGallery.tsx +1 -1
  15. package/src/components/PluginsManager.tsx +39 -21
  16. package/src/components/Sidebar.astro +7 -14
  17. package/src/components/UserMenu.tsx +10 -9
  18. package/src/components/autoform/AutoFormApiView.tsx +1 -1
  19. package/src/components/autoform/AutoFormHeader.tsx +1 -1
  20. package/src/components/autoform/ErrorBoundary.tsx +1 -1
  21. package/src/components/blocks/AccordionBlock.tsx +8 -4
  22. package/src/components/blocks/ArrayBlock.tsx +4 -3
  23. package/src/components/blocks/BlockEditModal.tsx +4 -3
  24. package/src/components/blocks/CardBlock.tsx +10 -2
  25. package/src/components/blocks/ChildBlocksTree.tsx +19 -18
  26. package/src/components/blocks/CodeBlock.tsx +7 -2
  27. package/src/components/blocks/FileBlock.tsx +6 -2
  28. package/src/components/blocks/GenericBlock.tsx +1 -1
  29. package/src/components/blocks/HeadingBlock.tsx +6 -2
  30. package/src/components/blocks/HeadingSubheadingBlock.tsx +7 -2
  31. package/src/components/blocks/HeroBlock.tsx +12 -3
  32. package/src/components/blocks/ImageBlock.tsx +8 -2
  33. package/src/components/blocks/ListBlock.tsx +6 -2
  34. package/src/components/blocks/ParagraphBlock.tsx +2 -2
  35. package/src/components/blocks/RelationshipBlock.tsx +10 -2
  36. package/src/components/blocks/VideoBlock.tsx +7 -2
  37. package/src/components/fields/AccordionField.tsx +1 -1
  38. package/src/components/fields/ArrayLayout.tsx +55 -58
  39. package/src/components/fields/BlocksField.tsx +1 -1
  40. package/src/components/fields/ChildrenField.tsx +3 -2
  41. package/src/components/fields/CodeField.tsx +3 -2
  42. package/src/components/fields/ColumnsField.tsx +3 -2
  43. package/src/components/fields/DateField.tsx +2 -2
  44. package/src/components/fields/FieldLayout.tsx +3 -3
  45. package/src/components/fields/GroupLayout.tsx +2 -2
  46. package/src/components/fields/IconField.tsx +1 -1
  47. package/src/components/fields/MarkdownField.tsx +2 -2
  48. package/src/components/fields/NumberField.tsx +4 -4
  49. package/src/components/fields/RelationshipField.tsx +4 -1
  50. package/src/components/fields/RichTextField.tsx +200 -5
  51. package/src/components/fields/extensions/blockComponents.tsx +1 -1
  52. package/src/components/fields/extensions/blocksStore.ts +15 -12
  53. package/src/components/ui/Button.tsx +5 -2
  54. package/src/components/ui/Pagination.tsx +1 -1
  55. package/src/components/users/UserDetail.tsx +7 -7
  56. package/src/components/users/UserForm.tsx +3 -2
  57. package/src/components/users/UsersList.tsx +5 -4
  58. package/src/env.d.ts +4 -0
  59. package/src/fields/index.ts +1 -1
  60. package/src/hooks/useAutoFormState.ts +7 -6
  61. package/src/hooks/useQueue.ts +3 -2
  62. package/src/index.ts +0 -1
  63. package/src/integration.ts +50 -12
  64. package/src/kyro-cms.d.ts +5 -0
  65. package/src/lib/api.ts +1 -0
  66. package/src/lib/autoform-store.ts +4 -3
  67. package/src/lib/config.ts +6 -19
  68. package/src/lib/core-types.ts +78 -0
  69. package/src/lib/normalize-upload-fields.ts +17 -4
  70. package/src/pages/[collection]/index.astro +1 -1
  71. package/src/pages/users/index.astro +1 -1
  72. package/src/plugins/seo-admin.tsx +155 -0
  73. package/src/styles/main.css +2 -0
  74. package/src/vite-env.d.ts +10 -1
  75. package/src/components/fix_imports.cjs +0 -23
  76. package/src/components/fix_imports2.cjs +0 -19
  77. package/src/components/replace_svgs.cjs +0 -63
@@ -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: '![alt text](https://image.pollinations.ai/prompt/URL_ENCODED_KEYWORDS)' (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: '![alt text](https://image.pollinations.ai/prompt/URL_ENCODED_KEYWORDS)'.
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: '![alt text](https://image.pollinations.ai/prompt/URL_ENCODED_KEYWORDS)'`;
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: true,
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="overflow-y-auto min-h-[160px] max-h-[400px]">
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="overflow-y-auto flex-1 h-[calc(100vh-140px)] bg-[var(--kyro-bg)]">
708
- <EditorContent editor={editor} />
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: Record<string, unknown>; index: number }>> = {
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
- if (updatedBlock.data?.columnData && Array.isArray(updatedBlock.data.columnData)) {
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: updatedBlock.data.columnData.map((col: Record<string, unknown>) => ({
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).substr(2, 9),
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
- if (b.data?.columnData) {
216
- for (const col of b.data.columnData) {
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
- if (block.data?.columnData && Array.isArray(block.data.columnData)) {
274
- const updatedColumnData = block.data.columnData.map((col: Record<string, unknown>) => {
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: Record<string, unknown>, i: number) => col !== block.data.columnData[i])) {
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
  }
@@ -1,8 +1,11 @@
1
1
  import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
2
 
3
+ export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
4
+ export type ButtonSize = 'sm' | 'md' | 'lg';
5
+
3
6
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
- variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
5
- size?: 'sm' | 'md' | 'lg';
7
+ variant?: ButtonVariant;
8
+ size?: ButtonSize;
6
9
  loading?: boolean;
7
10
  children: ReactNode;
8
11
  }
@@ -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}
@@ -1,9 +1,9 @@
1
- import React, { useState, useEffect } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { apiGet } from "../../lib/api";
3
3
  import { useUIStore, toast } from "../../lib/stores";
4
- import { X } from "../ui/icons";
5
4
  import { UploadField } from "../fields/UploadField";
6
5
  import { useTranslation } from "react-i18next";
6
+ import { navigate } from 'astro:transitions/client';
7
7
 
8
8
  interface User {
9
9
  id: string;
@@ -43,8 +43,8 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
43
43
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
44
44
  const [avatarMedia, setAvatarMedia] = useState<any>(user.avatar ? { id: user.avatar } : null);
45
45
  const [saving, setSaving] = useState(false);
46
- const { confirm, alert } = useUIStore();
47
- const [deleting, setDeleting] = useState(false);
46
+ const { confirm } = useUIStore();
47
+ const [, setDeleting] = useState(false);
48
48
  const [locking, setLocking] = useState(false);
49
49
  const [isLocked, setIsLocked] = useState(user.locked || false);
50
50
 
@@ -90,7 +90,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
90
90
  }
91
91
 
92
92
  if (Object.keys(body).length === 0) {
93
- window.location.href = adminPath + "/users";
93
+ navigate(adminPath + "/users");
94
94
  return;
95
95
  }
96
96
 
@@ -105,7 +105,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
105
105
 
106
106
  if (res.ok) {
107
107
  toast.success("User updated");
108
- window.location.href = adminPath + "/users";
108
+ navigate(adminPath + "/users");
109
109
  } else {
110
110
  toast.error(data.error || "Failed to save user");
111
111
  }
@@ -162,7 +162,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
162
162
  });
163
163
  if (res.ok) {
164
164
  toast.success("User deleted");
165
- window.location.href = adminPath + "/users";
165
+ navigate(adminPath + "/users");
166
166
  } else {
167
167
  toast.error("Failed to delete user");
168
168
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useState } from "react";
2
2
  import { useTranslation } from "react-i18next";
3
+ import { navigate } from 'astro:transitions/client';
3
4
 
4
5
  interface UserFormProps {
5
6
  mode: "create" | "edit";
@@ -38,7 +39,7 @@ export function UserForm({ mode, apiPath, adminPath, user }: UserFormProps) {
38
39
  type: "success" | "error";
39
40
  } | null>(null);
40
41
 
41
- const handleSubmit = async (e: React.FormEvent) => {
42
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
42
43
  e.preventDefault();
43
44
  setLoading(true);
44
45
  setMessage(null);
@@ -71,7 +72,7 @@ export function UserForm({ mode, apiPath, adminPath, user }: UserFormProps) {
71
72
  type: "success",
72
73
  });
73
74
  setTimeout(() => {
74
- window.location.href = `${adminPath}/users`;
75
+ navigate(`${adminPath}/users`);
75
76
  }, 1000);
76
77
  } else {
77
78
  setMessage({
@@ -1,6 +1,7 @@
1
1
  import { Plus, Lock, CheckCircle2, Edit2, Trash2, XCircle, X } from "../ui/icons";
2
- import React, { useState } from "react";
2
+ import { useState } from "react";
3
3
  import { useUIStore } from "../../lib/stores";
4
+ import { navigate } from 'astro:transitions/client';
4
5
 
5
6
  interface User {
6
7
  id: string;
@@ -39,8 +40,8 @@ export function UsersList({
39
40
  }: UsersListProps) {
40
41
  const [users, setUsers] = useState<User[]>(initialUsers);
41
42
  const [totalUsers, setTotalUsers] = useState(initialTotal);
42
- const { confirm, alert } = useUIStore();
43
- const [deleting, setDeleting] = useState(false);
43
+ const { confirm } = useUIStore();
44
+ const [, setDeleting] = useState(false);
44
45
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
45
46
 
46
47
  const handleDeleteClick = (user: User) => {
@@ -168,7 +169,7 @@ export function UsersList({
168
169
  key={user.id}
169
170
  className="group hover:bg-gray-50/50 transition-colors cursor-pointer"
170
171
  onClick={() =>
171
- (window.location.href = `${adminPath}/users/${user.id}`)
172
+ navigate(`${adminPath}/users/${user.id}`)
172
173
  }
173
174
  >
174
175
  <td className="px-8 py-5">
package/src/env.d.ts CHANGED
@@ -2,6 +2,10 @@ declare const __KYRO_API_PATH__: string;
2
2
  declare const __KYRO_ADMIN_PATH__: string;
3
3
  declare const __KYRO_ADMIN_AUTH_DISABLED__: boolean;
4
4
 
5
+ declare module 'astro:transitions/client' {
6
+ export function navigate(href: string, options?: any): void;
7
+ }
8
+
5
9
  declare namespace App {
6
10
  interface Locals {
7
11
  user?: {
@@ -31,4 +31,4 @@ export type {
31
31
  GroupField,
32
32
  } from "@kyro-cms/core";
33
33
 
34
- export { ALL_FIELD_TYPES } from "@kyro-cms/core";
34
+ export { ALL_FIELD_TYPES } from "@kyro-cms/core/client";
@@ -52,7 +52,7 @@ export function useAutoFormState({
52
52
  resetForm,
53
53
  } = store;
54
54
 
55
- const versionsEnabled = !!config.versions;
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.fields, setField]);
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.fields as FieldConfig[];
465
- const slugField = fields.find(
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.fields, setField]);
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.
@@ -14,7 +14,6 @@ type QueueTask = (fn: QueuedFunction, options?: QueueTaskOptions) => void;
14
14
  * Only the last task in the queue is ever processed; all prior pending tasks are discarded.
15
15
  * This prevents race conditions where multiple autosave fetches could run in parallel.
16
16
  *
17
- * Inspired by Payload's useQueues hook.
18
17
  *
19
18
  * @returns {queueTask} A function used to queue a task for execution.
20
19
  */
@@ -40,7 +39,9 @@ export function useQueue(): { queueTask: QueueTask } {
40
39
  isProcessing.current = true;
41
40
 
42
41
  try {
43
- await latestTask();
42
+ if (latestTask) {
43
+ await latestTask();
44
+ }
44
45
  } catch (err) {
45
46
  console.error("Error in queued function:", err);
46
47
  } finally {
package/src/index.ts CHANGED
@@ -109,7 +109,6 @@ export {
109
109
  } from "./fields/index";
110
110
 
111
111
  // Astro Integration
112
- export { kyroAdmin } from "./integration";
113
112
  export type { KyroAdminOptions } from "./integration";
114
113
 
115
114
  // Paths (for users who need direct access)