@kyro-cms/admin 0.3.2 → 0.3.4

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 (242) hide show
  1. package/dist/EditorClient-XEUOVAAC.js +466 -0
  2. package/dist/EditorClient-XEUOVAAC.js.map +1 -0
  3. package/dist/EditorClient-YLCGVDXY.cjs +468 -0
  4. package/dist/EditorClient-YLCGVDXY.cjs.map +1 -0
  5. package/dist/chunk-7KPIUCGT.js +384 -0
  6. package/dist/chunk-7KPIUCGT.js.map +1 -0
  7. package/dist/chunk-GOACG6R7.cjs +473 -0
  8. package/dist/chunk-GOACG6R7.cjs.map +1 -0
  9. package/dist/index.cjs +14861 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.css +1661 -0
  12. package/dist/index.css.map +1 -0
  13. package/dist/index.d.ts +563 -0
  14. package/dist/index.js +14784 -0
  15. package/dist/index.js.map +1 -0
  16. package/package.json +19 -19
  17. package/src/components/ActionBar.tsx +7 -43
  18. package/src/components/Admin.tsx +138 -277
  19. package/src/components/ApiKeysManager.tsx +428 -419
  20. package/src/components/AuditLogsPage.tsx +35 -39
  21. package/src/components/AuthBridge.tsx +51 -0
  22. package/src/components/AutoForm.tsx +495 -1230
  23. package/src/components/BrandingHub.tsx +18 -19
  24. package/src/components/BulkActionsBar.tsx +1 -1
  25. package/src/components/CreateView.tsx +22 -36
  26. package/src/components/Dashboard.tsx +60 -84
  27. package/src/components/DetailView.tsx +113 -91
  28. package/src/components/DeveloperCenter.tsx +200 -198
  29. package/src/components/FieldRenderer.tsx +206 -0
  30. package/src/components/GraphQLPlayground.tsx +340 -480
  31. package/src/components/ListView.tsx +828 -254
  32. package/src/components/LoginPage.tsx +3 -4
  33. package/src/components/MarketplaceManager.tsx +254 -0
  34. package/src/components/MediaGallery.tsx +856 -1192
  35. package/src/components/PluginsManager.tsx +277 -0
  36. package/src/components/RestPlayground.tsx +398 -560
  37. package/src/components/SessionsManager.tsx +211 -0
  38. package/src/components/Sidebar.astro +179 -151
  39. package/src/components/ThemeProvider.tsx +7 -161
  40. package/src/components/UserManagement.tsx +162 -146
  41. package/src/components/UserMenu.tsx +110 -0
  42. package/src/components/WebhookManager.tsx +305 -367
  43. package/src/components/blocks/AccordionBlock.tsx +4 -4
  44. package/src/components/blocks/ArrayBlock.tsx +3 -3
  45. package/src/components/blocks/BlockEditModal.tsx +8 -8
  46. package/src/components/blocks/BlockWrapper.tsx +61 -0
  47. package/src/components/blocks/ButtonBlock.tsx +4 -4
  48. package/src/components/blocks/ChildBlocksTree.tsx +23 -25
  49. package/src/components/blocks/CodeBlock.tsx +15 -15
  50. package/src/components/blocks/ColumnsBlock.tsx +6 -44
  51. package/src/components/blocks/DividerBlock.tsx +3 -3
  52. package/src/components/blocks/FileBlock.tsx +4 -4
  53. package/src/components/blocks/HeadingBlock.tsx +6 -38
  54. package/src/components/blocks/HeroBlock.tsx +4 -4
  55. package/src/components/blocks/ImageBlock.tsx +4 -4
  56. package/src/components/blocks/LinkBlock.tsx +4 -4
  57. package/src/components/blocks/ListBlock.tsx +3 -3
  58. package/src/components/blocks/ParagraphBlock.tsx +12 -42
  59. package/src/components/blocks/RelationshipBlock.tsx +4 -4
  60. package/src/components/blocks/RichTextBlock.tsx +4 -4
  61. package/src/components/blocks/VStackBlock.tsx +5 -37
  62. package/src/components/blocks/VideoBlock.tsx +4 -4
  63. package/src/components/blocks/types.ts +11 -0
  64. package/src/components/fields/AccordionField.tsx +1 -1
  65. package/src/components/fields/ArrayField.tsx +2 -2
  66. package/src/components/fields/ArrayLayout.tsx +93 -0
  67. package/src/components/fields/BlocksField.tsx +122 -111
  68. package/src/components/fields/ButtonField.tsx +1 -1
  69. package/src/components/fields/CheckboxField.tsx +14 -15
  70. package/src/components/fields/ChildrenField.tsx +2 -2
  71. package/src/components/fields/CodeField.tsx +3 -3
  72. package/src/components/fields/ColumnsField.tsx +2 -2
  73. package/src/components/fields/DateField.tsx +13 -26
  74. package/src/components/fields/EditorClient.tsx +26 -28
  75. package/src/components/fields/FieldLayout.tsx +52 -0
  76. package/src/components/fields/GroupLayout.tsx +35 -0
  77. package/src/components/fields/JSONField.tsx +7 -7
  78. package/src/components/fields/LinkField.tsx +1 -1
  79. package/src/components/fields/MarkdownField.tsx +1 -1
  80. package/src/components/fields/NumberField.tsx +13 -26
  81. package/src/components/fields/PortableTextField.tsx +4 -4
  82. package/src/components/fields/PortableTextRenderer.tsx +1 -1
  83. package/src/components/fields/RelationshipBlockField.tsx +31 -23
  84. package/src/components/fields/RelationshipField.tsx +14 -14
  85. package/src/components/fields/SelectField.tsx +17 -26
  86. package/src/components/fields/TabsLayout.tsx +69 -0
  87. package/src/components/fields/TextField.tsx +85 -38
  88. package/src/components/fields/UploadField.tsx +71 -41
  89. package/src/components/fields/VideoField.tsx +1 -1
  90. package/src/components/fields/extensions/blockComponents.tsx +2 -2
  91. package/src/components/fields/extensions/blocksStore.ts +207 -193
  92. package/src/components/fields/types.ts +22 -0
  93. package/src/components/layout/Layout.tsx +1 -1
  94. package/src/components/ui/ActionMenu.tsx +63 -0
  95. package/src/components/ui/Badge.tsx +59 -5
  96. package/src/components/ui/BlockDrawer.tsx +4 -5
  97. package/src/components/ui/CommandPalette.tsx +58 -36
  98. package/src/components/ui/CommandPaletteWrapper.tsx +18 -17
  99. package/src/components/ui/Dropdown.tsx +18 -16
  100. package/src/components/ui/EmptyState.tsx +25 -0
  101. package/src/components/ui/GlobalModal.tsx +49 -0
  102. package/src/components/ui/IconButton.tsx +44 -0
  103. package/src/components/ui/Modal.tsx +19 -20
  104. package/src/components/ui/PageHeader.tsx +158 -0
  105. package/src/components/ui/Pagination.tsx +61 -0
  106. package/src/components/ui/PromptModal.tsx +1 -1
  107. package/src/components/ui/SearchInput.tsx +57 -0
  108. package/src/components/ui/SeoPreview.tsx +31 -0
  109. package/src/components/ui/SessionModal.tsx +0 -0
  110. package/src/components/ui/SlidePanel.tsx +2 -0
  111. package/src/components/ui/Toast.tsx +65 -122
  112. package/src/components/ui/Toaster.tsx +18 -0
  113. package/src/components/ui/icons.tsx +112 -0
  114. package/src/components/users/UserDetail.tsx +290 -0
  115. package/src/components/users/UserForm.tsx +242 -0
  116. package/src/components/users/UsersList.tsx +338 -0
  117. package/src/env.d.ts +13 -13
  118. package/src/fields/index.ts +2 -1
  119. package/src/global.d.ts +7 -0
  120. package/src/hooks/data.ts +2 -9
  121. package/src/hooks/useAsyncData.ts +36 -0
  122. package/src/hooks/useAutoFormState.ts +527 -0
  123. package/src/hooks/useSelection.ts +49 -0
  124. package/src/hooks/useSession.ts +0 -0
  125. package/src/index.ts +11 -1
  126. package/src/integration.ts +86 -11
  127. package/src/kyro-cms.d.ts +209 -0
  128. package/src/layouts/AdminLayout.astro +128 -11
  129. package/src/layouts/AuthLayout.astro +21 -5
  130. package/src/lib/api.ts +175 -55
  131. package/src/lib/autoform-store.ts +435 -0
  132. package/src/lib/config.ts +82 -34
  133. package/src/lib/createRegistry.ts +29 -0
  134. package/src/lib/default-kyro-config.ts +4 -0
  135. package/src/lib/globals.ts +50 -0
  136. package/src/lib/media-utils.ts +18 -0
  137. package/src/lib/object-utils.ts +77 -0
  138. package/src/lib/paths.ts +61 -0
  139. package/src/lib/stores/index.ts +370 -0
  140. package/src/lib/types.ts +43 -0
  141. package/src/lib/useResourceManager.ts +105 -0
  142. package/src/pages/403.astro +67 -0
  143. package/src/pages/[collection]/[id].astro +14 -180
  144. package/src/pages/[collection]/index.astro +11 -6
  145. package/src/pages/api-explorer.astro +173 -0
  146. package/src/pages/audit/index.astro +2 -0
  147. package/src/pages/auth/login.astro +122 -0
  148. package/src/pages/auth/register.astro +167 -0
  149. package/src/pages/graphql-explorer.astro +59 -0
  150. package/src/pages/{admin/graphql.astro → graphql.astro} +51 -17
  151. package/src/pages/index.astro +577 -0
  152. package/src/pages/index_ALT.astro +3 -0
  153. package/src/pages/keys.astro +11 -0
  154. package/src/pages/marketplace.astro +11 -0
  155. package/src/pages/media.astro +3 -0
  156. package/src/pages/plugins.astro +8 -0
  157. package/src/pages/preview/[collection]/[id].astro +188 -123
  158. package/src/pages/rest-playground.astro +62 -0
  159. package/src/pages/roles/index.astro +183 -76
  160. package/src/pages/sessions.astro +8 -0
  161. package/src/pages/settings/[slug].astro +92 -114
  162. package/src/pages/settings/index.astro +5 -3
  163. package/src/pages/users/[id].astro +25 -154
  164. package/src/pages/users/index.astro +19 -130
  165. package/src/pages/users/new.astro +9 -86
  166. package/src/pages/webhooks.astro +11 -0
  167. package/src/routes.ts +80 -0
  168. package/src/styles/main.css +119 -79
  169. package/src/theme/tokens.ts +1 -0
  170. package/src/vite-env.d.ts +14 -0
  171. package/src/collections/auth/index.ts +0 -155
  172. package/src/collections/portfolio/index.ts +0 -343
  173. package/src/components/ApiExplorer.tsx +0 -325
  174. package/src/components/EnhancedListView.tsx +0 -889
  175. package/src/components/GraphQLExplorer.tsx +0 -675
  176. package/src/components/Icons.tsx +0 -23
  177. package/src/components/StatusBadge.tsx +0 -76
  178. package/src/lib/MediaService.ts +0 -541
  179. package/src/lib/auth/sqlite-adapter.ts +0 -319
  180. package/src/lib/dataStore.ts +0 -226
  181. package/src/lib/db/adapter.ts +0 -54
  182. package/src/lib/db/drizzle-mysql-adapter.ts +0 -194
  183. package/src/lib/db/drizzle-mysql-auth-adapter.ts +0 -327
  184. package/src/lib/db/drizzle-postgres-adapter.ts +0 -202
  185. package/src/lib/db/drizzle-postgres-auth-adapter.ts +0 -304
  186. package/src/lib/db/drizzle-sqlite-adapter.ts +0 -227
  187. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +0 -548
  188. package/src/lib/db/index.ts +0 -449
  189. package/src/lib/db/mongodb-adapter.ts +0 -207
  190. package/src/lib/db/mongodb-auth-adapter.ts +0 -305
  191. package/src/lib/db/schema/mysql-auth.ts +0 -113
  192. package/src/lib/db/schema/mysql-content.ts +0 -20
  193. package/src/lib/db/schema/postgres-auth.ts +0 -116
  194. package/src/lib/db/schema/postgres-content.ts +0 -35
  195. package/src/lib/db/schema/postgres-media.ts +0 -52
  196. package/src/lib/db/schema/postgres-settings.ts +0 -11
  197. package/src/lib/db/schema/sqlite-auth.ts +0 -112
  198. package/src/lib/db/schema/sqlite-content.ts +0 -20
  199. package/src/lib/db/version-adapter.ts +0 -248
  200. package/src/lib/graphql/index.ts +0 -1
  201. package/src/lib/graphql/schema.ts +0 -443
  202. package/src/lib/rate-limit.ts +0 -267
  203. package/src/lib/storage.ts +0 -374
  204. package/src/lib/store.ts +0 -85
  205. package/src/middleware.ts +0 -177
  206. package/src/pages/admin/api-explorer.astro +0 -98
  207. package/src/pages/admin/graphql-explorer.astro +0 -40
  208. package/src/pages/admin/index.astro +0 -286
  209. package/src/pages/admin/keys.astro +0 -8
  210. package/src/pages/admin/rest-playground.astro +0 -44
  211. package/src/pages/admin/webhooks.astro +0 -8
  212. package/src/pages/api/[collection]/[id]/publish.ts +0 -52
  213. package/src/pages/api/[collection]/[id]/unpublish.ts +0 -42
  214. package/src/pages/api/[collection]/[id]/versions.ts +0 -66
  215. package/src/pages/api/[collection]/[id].ts +0 -213
  216. package/src/pages/api/[collection]/index.ts +0 -209
  217. package/src/pages/api/auth/[id].ts +0 -121
  218. package/src/pages/api/auth/audit-logs.ts +0 -57
  219. package/src/pages/api/auth/login.ts +0 -211
  220. package/src/pages/api/auth/logout.ts +0 -66
  221. package/src/pages/api/auth/me.ts +0 -36
  222. package/src/pages/api/auth/refresh.ts +0 -119
  223. package/src/pages/api/auth/register.ts +0 -188
  224. package/src/pages/api/auth/users.ts +0 -97
  225. package/src/pages/api/collections.ts +0 -59
  226. package/src/pages/api/globals/[slug].ts +0 -42
  227. package/src/pages/api/graphql.ts +0 -90
  228. package/src/pages/api/health.ts +0 -426
  229. package/src/pages/api/keys/[id].ts +0 -26
  230. package/src/pages/api/keys/index.ts +0 -75
  231. package/src/pages/api/media/[id].ts +0 -309
  232. package/src/pages/api/media/folders.ts +0 -609
  233. package/src/pages/api/media/index.ts +0 -146
  234. package/src/pages/api/media/resize.ts +0 -267
  235. package/src/pages/api/search.ts +0 -82
  236. package/src/pages/api/slug-availability.ts +0 -70
  237. package/src/pages/api/storage-config.ts +0 -20
  238. package/src/pages/api/storage-status.ts +0 -206
  239. package/src/pages/api/upload.ts +0 -334
  240. package/src/pages/api/webhooks/index.ts +0 -71
  241. package/src/pages/login.astro +0 -82
  242. package/src/pages/register.astro +0 -102
@@ -0,0 +1,527 @@
1
+ import { useEffect, useRef, useCallback } from "react";
2
+ import { useAutoFormStore } from "../lib/autoform-store";
3
+ import { slugifyText } from "../lib/slugify";
4
+ import { resolveUrl, fetchWithAuth } from "../lib/api";
5
+ import { useUIStore } from "../lib/stores";
6
+
7
+ interface UseAutoFormStateProps {
8
+ config: Record<string, unknown>;
9
+ initialData: Record<string, unknown>;
10
+ collectionSlug?: string;
11
+ globalSlug?: string;
12
+ onChange?: (data: Record<string, unknown>) => void;
13
+ onActionSuccess?: (msg: string) => void;
14
+ onActionError?: (msg: string) => void;
15
+ }
16
+
17
+ export function useAutoFormState({
18
+ config,
19
+ initialData,
20
+ collectionSlug,
21
+ globalSlug,
22
+ onChange,
23
+ onActionSuccess,
24
+ onActionError,
25
+ }: UseAutoFormStateProps) {
26
+ const store = useAutoFormStore();
27
+ const { confirm, alert } = useUIStore();
28
+ const {
29
+ formData,
30
+ setFormData,
31
+ setField,
32
+ lastSavedData,
33
+ setLastSavedData,
34
+ setHasUnsavedChanges,
35
+ isSlugLocked,
36
+ loadDocument,
37
+ setIsAutoSaving,
38
+ setAutoSaveStatus,
39
+ setVersions,
40
+ setLoadingVersions,
41
+ sidebarCollapsed,
42
+ setSidebarCollapsed,
43
+ getDraftCache,
44
+ setDraftCache,
45
+ clearDraftCache,
46
+ } = store;
47
+
48
+ const versionsEnabled = !!config.versions;
49
+
50
+ const localSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
51
+ const serverSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
52
+ const lastAutoSaveTimeRef = useRef<number>(0);
53
+ const autoSaveSkipRef = useRef<boolean>(false);
54
+ const restorePromptedRef = useRef<string | null>(null);
55
+
56
+ const getDocumentKey = useCallback(
57
+ (id?: string) => {
58
+ if (globalSlug) return `global:${globalSlug}`;
59
+ if (collectionSlug && id) return `${collectionSlug}:${id}`;
60
+ return null;
61
+ },
62
+ [collectionSlug, globalSlug],
63
+ );
64
+
65
+ const persistBrowserDraft = useCallback(
66
+ (
67
+ documentKey: string,
68
+ data: Record<string, unknown>,
69
+ options?: { lastSyncedAt?: string | null },
70
+ ) => {
71
+ setDraftCache(documentKey, {
72
+ data,
73
+ baseUpdatedAt: lastSavedData.updatedAt ?? null,
74
+ draftUpdatedAt: new Date().toISOString(),
75
+ lastSyncedAt: options?.lastSyncedAt ?? null,
76
+ });
77
+ },
78
+ [lastSavedData.updatedAt, setDraftCache],
79
+ );
80
+
81
+ const clearDraftArtifacts = useCallback(async () => {
82
+ const state = useAutoFormStore.getState();
83
+ const documentKey = getDocumentKey(state.formData.id);
84
+ if (documentKey) {
85
+ clearDraftCache(documentKey);
86
+ }
87
+
88
+ const draftUrl = globalSlug
89
+ ? resolveUrl(`/api/globals/${globalSlug}/draft`)
90
+ : collectionSlug && state.formData.id
91
+ ? resolveUrl(`/api/${collectionSlug}/${state.formData.id}/draft`)
92
+ : null;
93
+
94
+ if (draftUrl && versionsEnabled) {
95
+ try {
96
+ await fetchWithAuth(draftUrl, {
97
+ method: "DELETE",
98
+ });
99
+ } catch (err) {
100
+ console.error("Failed to clear draft snapshot:", err);
101
+ }
102
+ }
103
+ }, [clearDraftCache, collectionSlug, globalSlug, getDocumentKey]);
104
+
105
+ const fetchVersions = useCallback(async () => {
106
+ const url = globalSlug
107
+ ? resolveUrl(`/api/globals/${globalSlug}/versions`)
108
+ : collectionSlug && formData.id
109
+ ? resolveUrl(`/api/${collectionSlug}/${formData.id}/versions`)
110
+ : null;
111
+
112
+ if (!url) return;
113
+ setLoadingVersions(true);
114
+ try {
115
+ const resp = await fetchWithAuth(url);
116
+ const data = await resp.json();
117
+ setVersions(data.docs || []);
118
+ } catch (e) {
119
+ console.error("Failed to fetch versions:", e);
120
+ } finally {
121
+ setLoadingVersions(false);
122
+ }
123
+ }, [formData.id, collectionSlug, globalSlug, setLoadingVersions, setVersions]);
124
+
125
+ const performLocalAutoSave = useCallback(() => {
126
+ const state = useAutoFormStore.getState();
127
+ const latestFormData = state.formData;
128
+ const currentLastSaved = state.lastSavedData;
129
+ if (autoSaveSkipRef.current || !collectionSlug || !latestFormData.id) return;
130
+ if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
131
+ const documentKey = getDocumentKey(latestFormData.id);
132
+ if (documentKey) {
133
+ persistBrowserDraft(documentKey, latestFormData);
134
+ }
135
+ }, [collectionSlug, getDocumentKey, persistBrowserDraft]);
136
+
137
+ const performServerAutoSave = useCallback(async (options?: { keepalive?: boolean }) => {
138
+ const state = useAutoFormStore.getState();
139
+ const latestFormData = state.formData;
140
+ const currentLastSaved = state.lastSavedData;
141
+
142
+ if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
143
+ if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
144
+ if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
145
+
146
+ const documentKey = getDocumentKey(latestFormData.id);
147
+ if (documentKey) {
148
+ persistBrowserDraft(documentKey, latestFormData);
149
+ }
150
+
151
+ setIsAutoSaving(true);
152
+ setAutoSaveStatus("saving");
153
+
154
+ try {
155
+ const draftUpdatedAt = new Date().toISOString();
156
+ const draftUrl = globalSlug
157
+ ? resolveUrl(`/api/globals/${globalSlug}/draft`)
158
+ : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}/draft`);
159
+
160
+ const response = await fetchWithAuth(
161
+ draftUrl,
162
+ {
163
+ method: "PUT",
164
+ headers: { "Content-Type": "application/json" },
165
+ keepalive: options?.keepalive,
166
+ body: JSON.stringify({
167
+ data: latestFormData,
168
+ baseUpdatedAt: currentLastSaved.updatedAt ?? null,
169
+ draftUpdatedAt,
170
+ }),
171
+ },
172
+ );
173
+
174
+ if (response.ok) {
175
+ const result = await response.json();
176
+ lastAutoSaveTimeRef.current = Date.now();
177
+ if (documentKey) {
178
+ setDraftCache(documentKey, {
179
+ data: latestFormData,
180
+ baseUpdatedAt: currentLastSaved.updatedAt ?? null,
181
+ draftUpdatedAt: result.data?.draftUpdatedAt || draftUpdatedAt,
182
+ lastSyncedAt: result.data?.updatedAt || new Date().toISOString(),
183
+ });
184
+ }
185
+ setAutoSaveStatus("success");
186
+ setTimeout(() => setAutoSaveStatus("idle"), 2000);
187
+ } else {
188
+ const error = await response.json().catch(() => ({}));
189
+ console.error("Draft auto-save failed:", error);
190
+ setAutoSaveStatus("error");
191
+ setTimeout(() => setAutoSaveStatus("idle"), 5000);
192
+ }
193
+ } catch (err) {
194
+ console.error("Auto-save failed:", err);
195
+ setAutoSaveStatus("error");
196
+ setTimeout(() => setAutoSaveStatus("idle"), 5000);
197
+ } finally {
198
+ setIsAutoSaving(false);
199
+ }
200
+ }, [
201
+ collectionSlug,
202
+ getDocumentKey,
203
+ persistBrowserDraft,
204
+ setAutoSaveStatus,
205
+ setDraftCache,
206
+ setIsAutoSaving,
207
+ ]);
208
+
209
+ const saveDocument = useCallback(
210
+ async (dataOverride?: Record<string, unknown>) => {
211
+ const state = useAutoFormStore.getState();
212
+ const payload = dataOverride || state.formData;
213
+
214
+ const url = globalSlug
215
+ ? resolveUrl(`/api/globals/${globalSlug}`)
216
+ : resolveUrl(`/api/${collectionSlug}/${payload.id}`);
217
+
218
+ const response = await fetchWithAuth(
219
+ url,
220
+ {
221
+ method: "PATCH",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({
224
+ ...payload,
225
+ baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
226
+ }),
227
+ },
228
+ );
229
+
230
+ if (response.status === 409) {
231
+ setAutoSaveStatus("conflict");
232
+ }
233
+
234
+ return response;
235
+ },
236
+ [collectionSlug, globalSlug, setAutoSaveStatus],
237
+ );
238
+
239
+ const publishDocument = useCallback(async () => {
240
+ const state = useAutoFormStore.getState();
241
+ const url = globalSlug
242
+ ? resolveUrl(`/api/globals/${globalSlug}/publish`)
243
+ : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/publish`);
244
+
245
+ const response = await fetchWithAuth(
246
+ url,
247
+ {
248
+ method: "POST",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify({
251
+ baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
252
+ }),
253
+ },
254
+ );
255
+
256
+ if (response.status === 409) {
257
+ setAutoSaveStatus("conflict");
258
+ }
259
+
260
+ return response;
261
+ }, [collectionSlug, globalSlug, setAutoSaveStatus]);
262
+
263
+ const unpublishDocument = useCallback(async () => {
264
+ const state = useAutoFormStore.getState();
265
+ const url = globalSlug
266
+ ? resolveUrl(`/api/globals/${globalSlug}/unpublish`)
267
+ : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/unpublish`);
268
+
269
+ const response = await fetchWithAuth(
270
+ url,
271
+ {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ },
275
+ );
276
+ return response;
277
+ }, [collectionSlug, globalSlug]);
278
+
279
+ // Track sidebar toggle
280
+ useEffect(() => {
281
+ const handleToggle = () => {
282
+ setSidebarCollapsed(!sidebarCollapsed);
283
+ };
284
+ window.addEventListener("toggle-sidebar", handleToggle);
285
+ return () => window.removeEventListener("toggle-sidebar", handleToggle);
286
+ }, [sidebarCollapsed, setSidebarCollapsed]);
287
+
288
+ // Track unsaved changes
289
+ useEffect(() => {
290
+ const isDifferent = JSON.stringify(formData) !== JSON.stringify(lastSavedData);
291
+ setHasUnsavedChanges(isDifferent);
292
+ }, [formData, lastSavedData, setHasUnsavedChanges]);
293
+
294
+ // Initial data load
295
+ const lastLoadedSlugRef = useRef<string | null>(null);
296
+ const initialDataLoadedRef = useRef(false);
297
+ useEffect(() => {
298
+ const currentSlug = globalSlug || initialData?.id;
299
+ if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug) return;
300
+
301
+ setFormData(initialData || {});
302
+ loadDocument(initialData || {}, initialData || {});
303
+ initialDataLoadedRef.current = true;
304
+ lastLoadedSlugRef.current = currentSlug;
305
+ }, [formData.id, globalSlug, initialData, loadDocument, setFormData]);
306
+
307
+ useEffect(() => {
308
+ if (!collectionSlug || !initialData?.id) return;
309
+
310
+ const documentKey = getDocumentKey(initialData.id);
311
+ if (!documentKey) return;
312
+ if (restorePromptedRef.current === documentKey) return;
313
+
314
+ let cancelled = false;
315
+
316
+ const maybeRestoreDraft = async () => {
317
+ if (!versionsEnabled) return;
318
+ const browserDraft = getDraftCache(documentKey);
319
+ let serverDraft: Record<string, unknown> | null = null;
320
+
321
+ try {
322
+ const response = await fetchWithAuth(
323
+ resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
324
+ );
325
+ if (response.ok) {
326
+ const result: { data?: Record<string, unknown> } = await response.json();
327
+ serverDraft = result.data || null;
328
+ }
329
+ } catch (err) {
330
+ console.error("Failed to fetch server draft:", err);
331
+ }
332
+
333
+ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
334
+ data: Record<string, unknown>;
335
+ draftUpdatedAt: string;
336
+ }>;
337
+
338
+ const candidate = drafts.sort(
339
+ (a, b) =>
340
+ new Date(b.draftUpdatedAt).getTime() -
341
+ new Date(a.draftUpdatedAt).getTime(),
342
+ )[0];
343
+
344
+ if (!candidate) return;
345
+ if (JSON.stringify(candidate.data) === JSON.stringify(initialData)) {
346
+ return;
347
+ }
348
+
349
+ restorePromptedRef.current = documentKey;
350
+
351
+ confirm({
352
+ title: "Restore draft?",
353
+ message:
354
+ "A newer autosaved draft was found for this document. Restore it or discard it and continue with the saved version.",
355
+ confirmLabel: "Restore draft",
356
+ cancelLabel: "Discard draft",
357
+ onConfirm: async () => {
358
+ if (cancelled) return;
359
+ setFormData(candidate.data);
360
+ onActionSuccess?.("Recovered autosaved draft");
361
+ },
362
+ onCancel: async () => {
363
+ clearDraftCache(documentKey);
364
+ try {
365
+ await fetchWithAuth(
366
+ resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
367
+ { method: "DELETE" },
368
+ );
369
+ } catch (err) {
370
+ console.error("Failed to discard server draft:", err);
371
+ }
372
+ },
373
+ });
374
+ };
375
+
376
+ maybeRestoreDraft();
377
+
378
+ return () => {
379
+ cancelled = true;
380
+ };
381
+ }, [
382
+ clearDraftCache,
383
+ collectionSlug,
384
+ confirm,
385
+ getDocumentKey,
386
+ getDraftCache,
387
+ initialData,
388
+ onActionSuccess,
389
+ setFormData,
390
+ ]);
391
+
392
+ // Auto-generate metaTitle
393
+ useEffect(() => {
394
+ const metaTitleField = config.fields.find(
395
+ (f: Record<string, unknown>) => f.name === "metaTitle" && f.admin?.autoGenerate === "title",
396
+ );
397
+ if (!metaTitleField) return;
398
+
399
+ let titleValue = "";
400
+ for (const field of config.fields) {
401
+ if (field.type === "tabs" && "tabs" in field && field.name) {
402
+ const tabData = formData[field.name as string];
403
+ if (tabData && typeof tabData === "object" && tabData.title) {
404
+ titleValue = tabData.title;
405
+ break;
406
+ }
407
+ }
408
+ }
409
+
410
+ if (titleValue && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
411
+ setField("metaTitle", titleValue);
412
+ }
413
+ }, [formData, config.fields, setField]);
414
+
415
+ // Auto-generate slug
416
+ useEffect(() => {
417
+ const slugField = config.fields.find(
418
+ (f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
419
+ );
420
+ if (!slugField?.admin?.autoGenerate) return;
421
+ const sourceField: string = slugField.admin.autoGenerate;
422
+
423
+ let sourceValue = formData[sourceField];
424
+ if (!sourceValue) {
425
+ for (const field of config.fields) {
426
+ if (field.type === "tabs" && "tabs" in field && field.name) {
427
+ const tabData = formData[field.name as string];
428
+ if (tabData && typeof tabData === "object" && tabData[sourceField]) {
429
+ sourceValue = tabData[sourceField];
430
+ break;
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ if (isSlugLocked && sourceValue) {
437
+ const newSlug = slugifyText(sourceValue);
438
+ if (newSlug !== formData.slug) {
439
+ setField("slug", newSlug);
440
+ }
441
+ }
442
+ }, [formData, isSlugLocked, config.fields, setField]);
443
+
444
+ // Auto-save effect (Split-timer: 1.5s local, 10s server)
445
+ useEffect(() => {
446
+ if (sidebarCollapsed) return;
447
+ if (!globalSlug && (!collectionSlug || !formData.id)) return;
448
+
449
+ if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
450
+ if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
451
+
452
+ localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
453
+ serverSaveTimerRef.current = setTimeout(performServerAutoSave, 10000);
454
+
455
+ return () => {
456
+ if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
457
+ if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
458
+ };
459
+ }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performServerAutoSave]);
460
+
461
+ useEffect(() => {
462
+ if (!globalSlug && (!collectionSlug || !formData.id)) return;
463
+
464
+ const flushDraft = () => {
465
+ if (autoSaveSkipRef.current) return;
466
+ void performServerAutoSave({ keepalive: true });
467
+ };
468
+
469
+ const handleVisibilityChange = () => {
470
+ if (document.hidden) {
471
+ flushDraft();
472
+ }
473
+ };
474
+
475
+ window.addEventListener("blur", flushDraft);
476
+ window.addEventListener("pagehide", flushDraft);
477
+ document.addEventListener("visibilitychange", handleVisibilityChange);
478
+
479
+ return () => {
480
+ window.removeEventListener("blur", flushDraft);
481
+ window.removeEventListener("pagehide", flushDraft);
482
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
483
+ };
484
+ }, [collectionSlug, globalSlug, formData.id, performServerAutoSave]);
485
+
486
+ // Astro sync
487
+ useEffect(() => {
488
+ const hiddenInput = document.getElementById("form-data") as HTMLInputElement;
489
+ if (hiddenInput) {
490
+ hiddenInput.value = JSON.stringify(formData);
491
+ }
492
+ onChange?.(formData);
493
+ }, [formData, onChange]);
494
+
495
+ // Fetch versions on load
496
+ useEffect(() => {
497
+ if (globalSlug || formData.id) fetchVersions();
498
+ }, [formData.id, globalSlug, fetchVersions]);
499
+
500
+ // Derived status values the UI can use for badges and button state
501
+ const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
502
+ if (!versionsEnabled) return 'published';
503
+ if (!formData.id && !globalSlug) return 'draft';
504
+ // If it has a pending draft version, effectively it's in a draft state for the editor
505
+ if (formData._has_draft) return 'draft';
506
+ return formData._status || 'published';
507
+ })();
508
+
509
+ const hasUnpublishedChanges =
510
+ (!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData._has_draft);
511
+
512
+ return {
513
+ ...store,
514
+ fetchVersions,
515
+ performAutoSave: performServerAutoSave,
516
+ saveDocument,
517
+ publishDocument,
518
+ unpublishDocument,
519
+ clearDraftArtifacts,
520
+ autoSaveSkipRef,
521
+ lastAutoSaveTimeRef,
522
+ alert,
523
+ documentStatus,
524
+ hasUnpublishedChanges,
525
+ versionsEnabled,
526
+ };
527
+ }
@@ -0,0 +1,49 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ interface SelectionState {
4
+ selectedIds: Set<string>;
5
+ selectOne: (id: string) => void;
6
+ selectAll: (ids: string[]) => void;
7
+ clearSelection: () => void;
8
+ isSelected: (id: string) => boolean;
9
+ selectedCount: number;
10
+ }
11
+
12
+ export function useSelection(): SelectionState {
13
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
14
+
15
+ const selectOne = useCallback((id: string) => {
16
+ setSelectedIds(prev => {
17
+ const next = new Set(prev);
18
+ if (next.has(id)) next.delete(id);
19
+ else next.add(id);
20
+ return next;
21
+ });
22
+ }, []);
23
+
24
+ const selectAll = useCallback((ids: string[]) => {
25
+ setSelectedIds(prev => {
26
+ if (prev.size === ids.length && ids.every(id => prev.has(id))) {
27
+ return new Set();
28
+ }
29
+ return new Set(ids);
30
+ });
31
+ }, []);
32
+
33
+ const clearSelection = useCallback(() => {
34
+ setSelectedIds(new Set());
35
+ }, []);
36
+
37
+ const isSelected = useCallback((id: string) => {
38
+ return selectedIds.has(id);
39
+ }, [selectedIds]);
40
+
41
+ return {
42
+ selectedIds,
43
+ selectOne,
44
+ selectAll,
45
+ clearSelection,
46
+ isSelected,
47
+ selectedCount: selectedIds.size,
48
+ };
49
+ }
File without changes
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@ export {
11
11
  type SaveStatus,
12
12
  } from "./components/ActionBar";
13
13
  export { BulkActionsBar } from "./components/BulkActionsBar";
14
- export { StatusBadge, CountBadge } from "./components/StatusBadge";
14
+ export { Badge, CountBadge } from "./components/ui/Badge";
15
+ export { PageHeader } from "./components/ui/PageHeader";
15
16
  export { VersionHistoryPanel } from "./components/VersionHistoryPanel";
16
17
  export {
17
18
  ThemeProvider,
@@ -110,3 +111,12 @@ export {
110
111
  // Astro Integration
111
112
  export { kyroAdmin } from "./integration";
112
113
  export type { KyroAdminOptions } from "./integration";
114
+
115
+ // Paths (for users who need direct access)
116
+ export {
117
+ adminPath,
118
+ apiPath,
119
+ resolveApi,
120
+ resolveAdmin,
121
+ paths,
122
+ } from "./lib/paths";