@kyro-cms/admin 0.1.7 → 0.1.9

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 (71) hide show
  1. package/package.json +7 -2
  2. package/src/components/Admin.tsx +1 -1
  3. package/src/components/AutoForm.tsx +966 -337
  4. package/src/components/CreateView.tsx +1 -1
  5. package/src/components/DetailView.tsx +1 -1
  6. package/src/components/EnhancedListView.tsx +156 -52
  7. package/src/components/ListView.tsx +1 -1
  8. package/src/components/Modal.tsx +65 -8
  9. package/src/components/Sidebar.astro +2 -2
  10. package/src/components/ThemeProvider.tsx +8 -2
  11. package/src/components/blocks/AccordionBlock.tsx +20 -52
  12. package/src/components/blocks/ArrayBlock.tsx +40 -31
  13. package/src/components/blocks/BlockEditModal.tsx +170 -581
  14. package/src/components/blocks/ButtonBlock.tsx +27 -128
  15. package/src/components/blocks/CodeBlock.tsx +88 -40
  16. package/src/components/blocks/ColumnsBlock.tsx +27 -85
  17. package/src/components/blocks/FileBlock.tsx +38 -39
  18. package/src/components/blocks/HeadingBlock.tsx +9 -31
  19. package/src/components/blocks/HeroBlock.tsx +42 -100
  20. package/src/components/blocks/ImageBlock.tsx +6 -7
  21. package/src/components/blocks/LinkBlock.tsx +27 -33
  22. package/src/components/blocks/ListBlock.tsx +47 -26
  23. package/src/components/blocks/RelationshipBlock.tsx +26 -233
  24. package/src/components/blocks/RichTextBlock.tsx +66 -0
  25. package/src/components/blocks/VStackBlock.tsx +23 -37
  26. package/src/components/blocks/VideoBlock.tsx +52 -32
  27. package/src/components/fields/AccordionField.tsx +213 -0
  28. package/src/components/fields/ArrayField.tsx +241 -0
  29. package/src/components/fields/BlocksField.tsx +5 -5
  30. package/src/components/fields/ButtonField.tsx +53 -0
  31. package/src/components/fields/CheckboxField.tsx +7 -3
  32. package/src/components/fields/ChildrenField.tsx +48 -0
  33. package/src/components/fields/CodeField.tsx +154 -94
  34. package/src/components/fields/ColumnsField.tsx +137 -0
  35. package/src/components/fields/DateField.tsx +9 -24
  36. package/src/components/fields/EditorClient.tsx +426 -160
  37. package/src/components/fields/HeadingField.tsx +31 -0
  38. package/src/components/fields/HeroField.tsx +101 -0
  39. package/src/components/fields/JSONField.tsx +7 -27
  40. package/src/components/fields/LinkField.tsx +81 -0
  41. package/src/components/fields/ListField.tsx +74 -0
  42. package/src/components/fields/MarkdownField.tsx +4 -26
  43. package/src/components/fields/NumberField.tsx +9 -27
  44. package/src/components/fields/PortableTextField.tsx +61 -49
  45. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  46. package/src/components/fields/RelationshipField.tsx +59 -13
  47. package/src/components/fields/SelectField.tsx +6 -4
  48. package/src/components/fields/TextField.tsx +9 -24
  49. package/src/components/fields/UploadField.tsx +613 -0
  50. package/src/components/fields/VideoField.tsx +73 -0
  51. package/src/components/fields/extensions/blockComponents.tsx +11 -1
  52. package/src/components/fields/extensions/blocksStore.ts +1 -1
  53. package/src/components/fields/index.ts +12 -1
  54. package/src/components/layout/Layout.tsx +1 -1
  55. package/src/lib/api.ts +163 -0
  56. package/src/lib/config.ts +1 -1
  57. package/src/lib/dataStore.ts +87 -30
  58. package/src/lib/date-utils.ts +69 -0
  59. package/src/lib/db/version-adapter.ts +248 -0
  60. package/src/lib/i18n.tsx +353 -0
  61. package/src/lib/slugify.ts +15 -0
  62. package/src/lib/validation.ts +250 -0
  63. package/src/pages/api/[collection]/[id]/publish.ts +12 -4
  64. package/src/pages/api/[collection]/[id]/versions.ts +39 -9
  65. package/src/pages/api/[collection]/[id].ts +13 -1
  66. package/src/pages/api/[collection]/index.ts +5 -6
  67. package/src/styles/main.css +12 -2
  68. package/src/components/blocks/BlockEditModal.MARKER +0 -12
  69. package/src/components/fields/FileField.tsx +0 -390
  70. package/src/components/fields/HybridContentField.tsx +0 -109
  71. package/src/components/fields/ImageField.tsx +0 -429
@@ -12,6 +12,7 @@ import { FileBlock } from "../../blocks/FileBlock";
12
12
  import { VStackBlock } from "../../blocks/VStackBlock";
13
13
  import { ButtonBlock } from "../../blocks/ButtonBlock";
14
14
  import { AccordionBlock } from "../../blocks/AccordionBlock";
15
+ import { RichTextBlock } from "../../blocks/RichTextBlock";
15
16
 
16
17
  import { HeroBlock } from "../../blocks/HeroBlock";
17
18
  import { ArrayBlock } from "../../blocks/ArrayBlock";
@@ -51,6 +52,7 @@ export const BLOCK_COMPONENTS: Record<string, React.ComponentType<any>> = {
51
52
  vstack: VStackBlock,
52
53
  button: ButtonBlock,
53
54
  accordion: AccordionBlock,
55
+ richtext: RichTextBlock,
54
56
 
55
57
  hero: HeroBlock,
56
58
  array: ArrayBlock,
@@ -72,6 +74,7 @@ export const blockIcons: Record<string, React.ReactNode> = {
72
74
  vstack: <ArrowDown className="w-4 h-4" />,
73
75
  button: <MousePointerClick className="w-4 h-4" />,
74
76
  accordion: <ChevronDown className="w-4 h-4" />,
77
+ richtext: <AlignLeft className="w-4 h-4" />,
75
78
 
76
79
  hero: <Star className="w-4 h-4" />,
77
80
  array: <ListOrdered className="w-4 h-4" />,
@@ -107,6 +110,7 @@ export function getBlockLabel(type: string): string {
107
110
  vstack: "VStack",
108
111
  columns: "Columns",
109
112
  relationship: "Relationship",
113
+ richtext: "Rich Text",
110
114
  };
111
115
  return labelMap[type] || type;
112
116
  }
@@ -143,7 +147,7 @@ export const blockCategories = [
143
147
  type: "heading",
144
148
  label: "Heading",
145
149
  icon: "heading",
146
- description: "H1-H3 heading",
150
+ description: "Heading text",
147
151
  },
148
152
  {
149
153
  type: "paragraph",
@@ -151,6 +155,12 @@ export const blockCategories = [
151
155
  icon: "paragraph",
152
156
  description: "Plain text content",
153
157
  },
158
+ {
159
+ type: "richtext",
160
+ label: "Rich Text",
161
+ icon: "richtext",
162
+ description: "Formatted text with links & styles",
163
+ },
154
164
  {
155
165
  type: "list",
156
166
  label: "List",
@@ -1,5 +1,5 @@
1
1
  import { create } from "zustand";
2
- import type { BlockData } from "@kyro-cms/core";
2
+ import type { BlockData } from "@kyro-cms/core/client";
3
3
 
4
4
  export interface BlocksStore {
5
5
  blocks: BlockData[];
@@ -1,6 +1,5 @@
1
1
  export { default as PortableTextField } from "./PortableTextField";
2
2
  export { PortableTextRenderer } from "./PortableTextRenderer";
3
- export { HybridContentField } from "./HybridContentField";
4
3
  export { CodeField } from "./CodeField";
5
4
  export { JSONField } from "./JSONField";
6
5
  export { MarkdownField } from "./MarkdownField";
@@ -11,3 +10,15 @@ export { default as DateField } from "./DateField";
11
10
  export { default as SelectField } from "./SelectField";
12
11
  export { default as RelationshipField } from "./RelationshipField";
13
12
  export { BlocksField } from "./BlocksField";
13
+ export { AccordionField } from "./AccordionField";
14
+ export { ButtonField } from "./ButtonField";
15
+ export { UploadField } from "./UploadField";
16
+ export { LinkField } from "./LinkField";
17
+ export { HeadingField } from "./HeadingField";
18
+ export { VideoField } from "./VideoField";
19
+ export { ListField } from "./ListField";
20
+ export { HeroField } from "./HeroField";
21
+ export { ArrayField } from "./ArrayField";
22
+ export { ChildrenField } from "./ChildrenField";
23
+ export { ColumnsField } from "./ColumnsField";
24
+ export { RelationshipBlockField } from "./RelationshipBlockField";
@@ -1,4 +1,4 @@
1
- import { type CollectionConfig } from '@kyro-cms/core';
1
+ import { type CollectionConfig } from '@kyro-cms/core/client';
2
2
 
3
3
  interface LayoutProps {
4
4
  children: any;
package/src/lib/api.ts ADDED
@@ -0,0 +1,163 @@
1
+ export interface ApiResponse<T = any> {
2
+ docs?: T[];
3
+ doc?: T;
4
+ totalDocs?: number;
5
+ error?: string;
6
+ }
7
+
8
+ export async function apiGet<T = any>(
9
+ url: string,
10
+ options?: RequestInit,
11
+ ): Promise<T> {
12
+ const response = await fetch(url, {
13
+ ...options,
14
+ credentials: "include",
15
+ });
16
+ if (!response.ok) {
17
+ throw new Error(`API Error: ${response.status}`);
18
+ }
19
+ return response.json();
20
+ }
21
+
22
+ export async function apiPost<T = any>(
23
+ url: string,
24
+ body?: any,
25
+ options?: RequestInit,
26
+ ): Promise<T> {
27
+ const response = await fetch(url, {
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ ...options?.headers,
32
+ },
33
+ body: body ? JSON.stringify(body) : undefined,
34
+ credentials: "include",
35
+ ...options,
36
+ });
37
+ if (!response.ok) {
38
+ throw new Error(`API Error: ${response.status}`);
39
+ }
40
+ return response.json();
41
+ }
42
+
43
+ export async function apiPatch<T = any>(
44
+ url: string,
45
+ body?: any,
46
+ options?: RequestInit,
47
+ ): Promise<T> {
48
+ const response = await fetch(url, {
49
+ method: "PATCH",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ ...options?.headers,
53
+ },
54
+ body: body ? JSON.stringify(body) : undefined,
55
+ credentials: "include",
56
+ ...options,
57
+ });
58
+ if (!response.ok) {
59
+ throw new Error(`API Error: ${response.status}`);
60
+ }
61
+ return response.json();
62
+ }
63
+
64
+ export async function apiPatchNoThrow<T = any>(
65
+ url: string,
66
+ body?: any,
67
+ ): Promise<{ ok: boolean; data?: T; error?: string }> {
68
+ const response = await fetch(url, {
69
+ method: "PATCH",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ credentials: "include",
73
+ });
74
+ if (!response.ok) {
75
+ return { ok: false, error: `Error: ${response.status}` };
76
+ }
77
+ const data = await response.json();
78
+ return { ok: true, data };
79
+ }
80
+
81
+ export async function apiDelete<T = any>(
82
+ url: string,
83
+ options?: RequestInit,
84
+ ): Promise<T> {
85
+ const response = await fetch(url, {
86
+ method: "DELETE",
87
+ credentials: "include",
88
+ ...options,
89
+ });
90
+ if (!response.ok) {
91
+ throw new Error(`API Error: ${response.status}`);
92
+ }
93
+ return response.json();
94
+ }
95
+
96
+ export function buildQueryString(params: Record<string, any>): string {
97
+ const urlParams = new URLSearchParams();
98
+ for (const [key, value] of Object.entries(params)) {
99
+ if (value !== undefined && value !== null && value !== "") {
100
+ urlParams.set(key, String(value));
101
+ }
102
+ }
103
+ return urlParams.toString();
104
+ }
105
+
106
+ export function withCacheBust(url: string): string {
107
+ const separator = url.includes("?") ? "&" : "?";
108
+ return `${url}${separator}t=${Date.now()}`;
109
+ }
110
+
111
+ export function buildSearchQuery(
112
+ search: string,
113
+ fields: string[],
114
+ limit: number = 50,
115
+ ): string {
116
+ if (!search || fields.length === 0) {
117
+ return `limit=${limit}`;
118
+ }
119
+ const searchQuery = fields
120
+ .map((f) => `where[${f}][contains]=${encodeURIComponent(search)}`)
121
+ .join("&");
122
+ return `${searchQuery}&limit=${limit}`;
123
+ }
124
+
125
+ export function buildCollectionUrl(
126
+ collection: string,
127
+ params?: Record<string, any>,
128
+ ): string {
129
+ let url = `/api/${collection}`;
130
+ if (params) {
131
+ const query = buildQueryString(params);
132
+ if (query) url += `?${query}`;
133
+ }
134
+ return withCacheBust(url);
135
+ }
136
+
137
+ export function buildDocumentUrl(
138
+ collection: string,
139
+ id: string,
140
+ params?: Record<string, any>,
141
+ ): string {
142
+ let url = `/api/${collection}/${id}`;
143
+ if (params) {
144
+ const query = buildQueryString(params);
145
+ if (query) url += `?${query}`;
146
+ }
147
+ return url;
148
+ }
149
+
150
+ export async function apiUpload<T = any>(
151
+ url: string,
152
+ body: FormData,
153
+ ): Promise<T> {
154
+ const response = await fetch(url, {
155
+ method: "POST",
156
+ body,
157
+ credentials: "include",
158
+ });
159
+ if (!response.ok) {
160
+ throw new Error(`Upload Error: ${response.status}`);
161
+ }
162
+ return response.json();
163
+ }
package/src/lib/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CollectionConfig, GlobalConfig } from "@kyro-cms/core";
1
+ import type { CollectionConfig, GlobalConfig } from "@kyro-cms/core/client";
2
2
  import { blogCollections } from "../../../src/templates/blog";
3
3
  import { ecommerceCollections } from "../../../src/templates/ecommerce";
4
4
  import { minimalCollections } from "../../../src/templates/minimal";
@@ -1,12 +1,18 @@
1
- import type { CollectionConfig } from "@kyro-cms/core";
1
+ import type { CollectionConfig } from "@kyro-cms/core/client";
2
2
  import { randomUUID } from "crypto";
3
3
  import {
4
4
  initializeDatabase,
5
5
  getDatabaseAdapter,
6
6
  isDatabaseInitialized,
7
7
  } from "./db";
8
+ import type { Version, VersionDiff, DraftPublishConfig } from "@kyro-cms/core";
9
+ import { VersionManager, createVersionManager } from "@kyro-cms/core";
10
+ import { DataStoreVersionAdapter } from "./db/version-adapter.js";
8
11
 
9
12
  class DataStoreWrapper {
13
+ private db: ReturnType<typeof getDatabaseAdapter> | null = null;
14
+ private versionManager: VersionManager | null = null;
15
+
10
16
  private async getStore() {
11
17
  if (!isDatabaseInitialized()) {
12
18
  await initializeDatabase();
@@ -14,10 +20,19 @@ class DataStoreWrapper {
14
20
  return getDatabaseAdapter();
15
21
  }
16
22
 
17
- private readonly VERSIONS_COLLECTION = "_versions";
23
+ private async getVersionManager(): Promise<VersionManager> {
24
+ if (this.versionManager) return this.versionManager;
25
+ const db = await this.getStore();
26
+ const adapter = new DataStoreVersionAdapter(db);
27
+ this.versionManager = createVersionManager(adapter, {
28
+ versioningEnabled: true,
29
+ maxVersionsPerDocument: 50,
30
+ } as DraftPublishConfig);
31
+ return this.versionManager;
32
+ }
18
33
 
19
34
  initialize(collections: Record<string, CollectionConfig>) {
20
- initializeDatabase(collections); // Sync wrapper - adapter handles initialization
35
+ initializeDatabase(collections);
21
36
  }
22
37
 
23
38
  private async getTimestamp(): Promise<string> {
@@ -49,13 +64,13 @@ class DataStoreWrapper {
49
64
  async create<T = any>(slug: string, data: Partial<T>): Promise<T> {
50
65
  const store = await this.getStore();
51
66
  const now = await this.getTimestamp();
52
- const id = data?.id || this.generateId();
67
+ const id = (data as any)?.id || this.generateId();
53
68
  const newDoc = {
54
69
  ...data,
55
70
  id,
56
71
  createdAt: now,
57
72
  updatedAt: now,
58
- };
73
+ } as T;
59
74
  return store.create(slug, newDoc);
60
75
  }
61
76
 
@@ -63,6 +78,10 @@ class DataStoreWrapper {
63
78
  slug: string,
64
79
  id: string,
65
80
  data: Partial<T>,
81
+ options?: {
82
+ versionStatus?: "draft" | "published";
83
+ changeDescription?: string;
84
+ },
66
85
  ): Promise<T | null> {
67
86
  const store = await this.getStore();
68
87
  const existing = await store.findById(slug, id);
@@ -77,48 +96,86 @@ class DataStoreWrapper {
77
96
  };
78
97
 
79
98
  // Save version history before updating
80
- await this.createVersion(slug, id, existing);
99
+ const versionStatus = options?.versionStatus || "draft";
100
+ const changeDescription = options?.changeDescription;
101
+
102
+ await this.createVersion(slug, id, existing, {
103
+ status: versionStatus,
104
+ changeDescription,
105
+ });
81
106
 
82
107
  return store.update(slug, id, updated);
83
108
  }
84
109
 
85
110
  async findVersions(parentCollection: string, parentId: string) {
86
- const store = await this.getStore();
87
- // We use find with a limit and custom filtering logic if supported,
88
- // but for now we'll fetch and filter in memory if the adapter is basic
89
- const result = await store.find(this.VERSIONS_COLLECTION, { limit: 100 });
90
- return result.docs
91
- .filter((v: any) => v.parentCollection === parentCollection && v.parentId === parentId)
92
- .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
93
- }
94
-
95
- async createVersion(parentCollection: string, parentId: string, data: any) {
96
- const store = await this.getStore();
97
- const now = await this.getTimestamp();
98
- const versionDoc = {
99
- id: this.generateId(),
100
- parentId,
111
+ const vm = await this.getVersionManager();
112
+ const versions = await vm.getVersionHistory(
101
113
  parentCollection,
102
- data,
103
- createdAt: now,
104
- version: Date.now() // Simple version numbering
105
- };
114
+ parentId,
115
+ 100,
116
+ );
117
+ return (versions as Version[])
118
+ .map((v) => ({
119
+ id: v.id,
120
+ version: v.version,
121
+ createdAt: v.createdAt,
122
+ status: v.status,
123
+ createdBy: v.createdBy,
124
+ data: v.data,
125
+ changeDescription: v.changeDescription,
126
+ }))
127
+ .sort((a, b) => b.version - a.version);
128
+ }
129
+
130
+ async createVersion(
131
+ parentCollection: string,
132
+ parentId: string,
133
+ data: any,
134
+ options?: {
135
+ status?: string;
136
+ createdBy?: string;
137
+ changeDescription?: string;
138
+ },
139
+ ) {
140
+ const vm = await this.getVersionManager();
106
141
  try {
107
- return await store.create(this.VERSIONS_COLLECTION, versionDoc);
142
+ return await vm.createVersion({
143
+ collection: parentCollection,
144
+ documentId: parentId,
145
+ data,
146
+ status: (options?.status || "draft") as "draft" | "published",
147
+ createdBy: options?.createdBy || "system",
148
+ changeDescription: options?.changeDescription,
149
+ });
108
150
  } catch (e) {
109
151
  console.error("Failed to create version snapshot:", e);
110
152
  return null;
111
153
  }
112
154
  }
113
155
 
114
- async restoreVersion(parentCollection: string, parentId: string, versionId: string) {
156
+ async restoreVersion(
157
+ parentCollection: string,
158
+ parentId: string,
159
+ versionId: string,
160
+ ) {
161
+ const vm = await this.getVersionManager();
115
162
  const store = await this.getStore();
116
- const version = await store.findById(this.VERSIONS_COLLECTION, versionId);
163
+ const version = await store.findById("_versions", versionId);
117
164
  if (!version || version.parentId !== parentId) return null;
118
165
 
119
166
  return this.update(parentCollection, parentId, version.data);
120
167
  }
121
168
 
169
+ async compareVersions(
170
+ collection: string,
171
+ documentId: string,
172
+ versionA: string | number,
173
+ versionB: string | number,
174
+ ): Promise<VersionDiff[]> {
175
+ const vm = await this.getVersionManager();
176
+ return vm.compareTwoVersions(collection, documentId, versionA, versionB);
177
+ }
178
+
122
179
  async delete(slug: string, id: string): Promise<boolean> {
123
180
  const store = await this.getStore();
124
181
  return store.delete(slug, id);
@@ -129,8 +186,8 @@ class DataStoreWrapper {
129
186
  filter: Record<string, any>,
130
187
  ): Promise<any | null> {
131
188
  const store = await this.getStore();
132
- const results = await store.find(slug, { limit: 1, where: filter });
133
- return results.length > 0 ? results[0] : null;
189
+ const results = await store.find(slug, { limit: 1 });
190
+ return results.docs.length > 0 ? results.docs[0] : null;
134
191
  }
135
192
 
136
193
  async findGlobal<T = any>(slug: string): Promise<T> {
@@ -0,0 +1,69 @@
1
+ export type DateStyle = "full" | "date" | "time" | "short";
2
+
3
+ export function formatDate(
4
+ date: string | Date | null | undefined,
5
+ style: DateStyle = "date",
6
+ ): string {
7
+ if (!date) return "—";
8
+ const d = new Date(date);
9
+ if (isNaN(d.getTime())) return "—";
10
+
11
+ switch (style) {
12
+ case "full":
13
+ return d.toLocaleString("en-US", {
14
+ dateStyle: "medium",
15
+ timeStyle: "short",
16
+ });
17
+ case "time":
18
+ return d.toLocaleTimeString("en-US", {
19
+ hour: "numeric",
20
+ minute: "2-digit",
21
+ });
22
+ case "short":
23
+ return d.toLocaleDateString("en-US", {
24
+ month: "short",
25
+ day: "numeric",
26
+ });
27
+ case "date":
28
+ default:
29
+ return d.toLocaleDateString("en-US", {
30
+ month: "short",
31
+ day: "numeric",
32
+ year: "numeric",
33
+ });
34
+ }
35
+ }
36
+
37
+ export function formatRelativeTime(
38
+ date: string | Date | null | undefined,
39
+ ): string {
40
+ if (!date) return "—";
41
+ const d = new Date(date);
42
+ if (isNaN(d.getTime())) return "—";
43
+
44
+ const now = new Date();
45
+ const diffMs = now.getTime() - d.getTime();
46
+ const diffSec = Math.floor(diffMs / 1000);
47
+ const diffMin = Math.floor(diffSec / 60);
48
+ const diffHour = Math.floor(diffMin / 60);
49
+ const diffDay = Math.floor(diffHour / 24);
50
+
51
+ if (diffSec < 60) return "Just now";
52
+ if (diffMin < 60) return `${diffMin}m ago`;
53
+ if (diffHour < 24) return `${diffHour}h ago`;
54
+ if (diffDay < 7) return `${diffDay}d ago`;
55
+ return formatDate(d, "date");
56
+ }
57
+
58
+ export function getTimestamp(): string {
59
+ return `?t=${Date.now()}`;
60
+ }
61
+
62
+ export function generateId(length: number = 16): string {
63
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
64
+ let result = "";
65
+ for (let i = 0; i < length; i++) {
66
+ result += chars[Math.floor(Math.random() * chars.length)];
67
+ }
68
+ return result;
69
+ }