@kyro-cms/admin 0.9.1 → 0.9.2

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 (37) hide show
  1. package/dist/index.cjs +1196 -1727
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +4 -3
  4. package/dist/index.d.ts +4 -3
  5. package/dist/index.js +891 -1422
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/ActionBar.tsx +25 -174
  9. package/src/components/Admin.tsx +1 -3
  10. package/src/components/AuditLogsPage.tsx +2 -13
  11. package/src/components/AutoForm.tsx +160 -265
  12. package/src/components/DetailView.tsx +38 -66
  13. package/src/components/FieldRenderer.tsx +1 -1
  14. package/src/components/ListView.tsx +26 -198
  15. package/src/components/MediaGallery.tsx +117 -175
  16. package/src/components/RestPlayground.tsx +54 -47
  17. package/src/components/fields/BlocksField.tsx +8 -10
  18. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  19. package/src/components/fields/RelationshipField.tsx +2 -3
  20. package/src/components/fix_imports.cjs +23 -0
  21. package/src/components/fix_imports2.cjs +19 -0
  22. package/src/components/replace_svgs.cjs +63 -0
  23. package/src/components/ui/Dropdown.tsx +7 -2
  24. package/src/components/ui/Modal.tsx +24 -27
  25. package/src/components/ui/PromptModal.tsx +2 -10
  26. package/src/components/ui/SlidePanel.tsx +2 -10
  27. package/src/components/ui/SplitButton.tsx +107 -0
  28. package/src/components/ui/Toaster.tsx +0 -1
  29. package/src/components/ui/icons.tsx +1 -0
  30. package/src/components/users/UsersList.tsx +8 -85
  31. package/src/hooks/useAutoFormState.ts +89 -161
  32. package/src/hooks/useQueue.ts +60 -0
  33. package/src/layouts/AdminLayout.astro +22 -2
  34. package/src/layouts/AuthLayout.astro +66 -18
  35. package/src/lib/autoform-store.ts +6 -2
  36. package/src/lib/globals.ts +5 -3
  37. package/src/pages/auth/register.astro +5 -2
@@ -6,6 +6,7 @@ import { resolveUrl, fetchWithAuth } from "../lib/api";
6
6
  import { normalizeUploadFields } from "../lib/normalize-upload-fields";
7
7
  import { useUIStore } from "../lib/stores";
8
8
  import { resolveFieldValue } from "../lib/resolve-field-value";
9
+ import { useQueue } from "./useQueue";
9
10
 
10
11
  interface UseAutoFormStateProps {
11
12
  config: Record<string, unknown>;
@@ -83,6 +84,9 @@ export function useAutoFormState({
83
84
  const lastAutoSaveTimeRef = useRef<number>(0);
84
85
  const autoSaveSkipRef = useRef<boolean>(false);
85
86
  const restorePromptedRef = useRef<string | null>(null);
87
+ const previousFormDataRef = useRef<string>("");
88
+ const astroSyncDataRef = useRef<string>("");
89
+ const { queueTask } = useQueue();
86
90
 
87
91
  const getDocumentKey = useCallback(
88
92
  (id?: string) => {
@@ -109,30 +113,6 @@ const persistBrowserDraft = useCallback(
109
113
  [lastSavedData.updatedAt, setDraftCache],
110
114
  );
111
115
 
112
- const clearDraftArtifacts = useCallback(async () => {
113
- const state = useAutoFormStore.getState();
114
- const documentKey = getDocumentKey(state.formData.id);
115
- if (documentKey) {
116
- clearDraftCache(documentKey);
117
- }
118
-
119
- const draftUrl = globalSlug
120
- ? resolveUrl(`/api/globals/${globalSlug}/draft`)
121
- : collectionSlug && state.formData.id
122
- ? resolveUrl(`/api/${collectionSlug}/${state.formData.id}/draft`)
123
- : null;
124
-
125
- if (draftUrl && versionsEnabled) {
126
- try {
127
- await fetchWithAuth(draftUrl, {
128
- method: "DELETE",
129
- });
130
- } catch (err) {
131
- console.error("Failed to clear draft snapshot:", err);
132
- }
133
- }
134
- }, [clearDraftCache, collectionSlug, globalSlug, getDocumentKey]);
135
-
136
116
  const fetchVersions = useCallback(async () => {
137
117
  const url = globalSlug
138
118
  ? resolveUrl(`/api/globals/${globalSlug}/versions`)
@@ -164,15 +144,15 @@ const persistBrowserDraft = useCallback(
164
144
  }
165
145
  }, [collectionSlug, getDocumentKey, persistBrowserDraft]);
166
146
 
167
- const performServerAutoSave = useCallback(async (options?: { keepalive?: boolean }) => {
147
+ const doAutosaveFetch = useCallback(async (options?: { keepalive?: boolean }) => {
168
148
  const state = useAutoFormStore.getState();
169
149
  const latestFormData = state.formData;
170
150
  const currentLastSaved = state.lastSavedData;
171
-
151
+
172
152
  if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
173
153
  if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
174
154
  if (!state.hasDirtyFields()) return;
175
-
155
+
176
156
  const documentKey = getDocumentKey(latestFormData.id);
177
157
  if (documentKey) {
178
158
  persistBrowserDraft(documentKey, latestFormData);
@@ -185,72 +165,67 @@ const persistBrowserDraft = useCallback(
185
165
 
186
166
  setIsAutoSaving(true);
187
167
  setAutoSaveStatus("saving");
168
+ state.setBackgroundProcessing(true);
188
169
 
189
170
  try {
190
- const draftUpdatedAt = new Date().toISOString();
191
- const draftUrl = globalSlug
192
- ? resolveUrl(`/api/globals/${globalSlug}/draft`)
193
- : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}/draft`);
194
-
195
- const delta = state.getDirtyData();
196
- const idempotencyKey = `${documentKey || "global"}:${Date.now()}`;
197
-
198
- const response = await fetchWithAuth(
199
- draftUrl,
200
- {
201
- method: "PUT",
202
- headers: {
203
- "Content-Type": "application/json",
204
- "X-Idempotency-Key": idempotencyKey
205
- },
206
- keepalive: options?.keepalive,
207
- body: JSON.stringify({
208
- delta: normalizeUploadFields(delta),
209
- baseUpdatedAt: currentLastSaved.updatedAt ?? null,
210
- draftUpdatedAt,
211
- }),
171
+ const url = globalSlug
172
+ ? resolveUrl(`/api/globals/${globalSlug}?autosave=true`)
173
+ : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}?autosave=true`);
174
+
175
+ const response = await fetchWithAuth(url, {
176
+ method: "PATCH",
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ "X-Draft": "true",
212
180
  },
213
- );
181
+ keepalive: options?.keepalive,
182
+ body: JSON.stringify({
183
+ ...normalizeUploadFields(latestFormData),
184
+ baseUpdatedAt: currentLastSaved.updatedAt ?? null,
185
+ }),
186
+ });
214
187
 
215
188
  if (response.ok) {
216
- const result = await response.json();
217
189
  lastAutoSaveTimeRef.current = Date.now();
218
190
  state.setRetryCount(0);
219
191
  state.setLastSavedAt(Date.now());
220
192
  if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
221
-
193
+
222
194
  if (documentKey) {
223
195
  setDraftCache(documentKey, {
224
196
  data: latestFormData,
225
197
  baseUpdatedAt: currentLastSaved.updatedAt ?? null,
226
- draftUpdatedAt: result.data?.draftUpdatedAt || draftUpdatedAt,
227
- lastSyncedAt: result.data?.updatedAt || new Date().toISOString(),
198
+ draftUpdatedAt: new Date().toISOString(),
199
+ lastSyncedAt: (await response.clone().json()).data?.updatedAt || new Date().toISOString(),
228
200
  });
229
201
  }
230
202
  setAutoSaveStatus("success");
231
203
  setTimeout(() => {
232
- if (useAutoFormStore.getState().autoSaveStatus === "success") {
233
- setAutoSaveStatus("idle");
234
- }
204
+ if (useAutoFormStore.getState().autoSaveStatus === "success") {
205
+ setAutoSaveStatus("idle");
206
+ }
235
207
  }, 2000);
208
+ } else if (response.status === 409) {
209
+ setAutoSaveStatus("conflict");
236
210
  } else {
237
211
  throw new Error(`Draft auto-save failed with status ${response.status}`);
238
212
  }
239
213
  } catch (err) {
240
214
  console.error("Auto-save failed:", err);
241
- const state = useAutoFormStore.getState();
242
- const currentRetryCount = state.retryCount;
215
+ const currentState = useAutoFormStore.getState();
216
+ const currentRetryCount = currentState.retryCount;
243
217
  if (currentRetryCount < 5) {
244
- state.setRetryCount(currentRetryCount + 1);
218
+ currentState.setRetryCount(currentRetryCount + 1);
245
219
  setAutoSaveStatus("retrying");
246
220
  const delay = Math.min(1000 * Math.pow(2, currentRetryCount), 60000);
247
221
  if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
248
- retryTimerRef.current = setTimeout(() => performServerAutoSave(options), delay);
222
+ retryTimerRef.current = setTimeout(() => performAutosave(options), delay);
249
223
  } else {
250
224
  setAutoSaveStatus("offline");
251
225
  }
252
226
  } finally {
253
227
  setIsAutoSaving(false);
228
+ useAutoFormStore.getState().setBackgroundProcessing(false);
254
229
  }
255
230
  }, [
256
231
  collectionSlug,
@@ -260,11 +235,25 @@ const persistBrowserDraft = useCallback(
260
235
  setAutoSaveStatus,
261
236
  setDraftCache,
262
237
  setIsAutoSaving,
263
- versionsEnabled
238
+ versionsEnabled,
264
239
  ]);
265
240
 
266
- const saveDocument = useCallback(
267
- async (dataOverride?: Record<string, unknown>) => {
241
+ const performAutosave = useCallback((options?: { keepalive?: boolean }) => {
242
+ queueTask(
243
+ () => doAutosaveFetch(options),
244
+ {
245
+ beforeProcess: () => {
246
+ return true;
247
+ },
248
+ afterProcess: () => {
249
+ // Background processing complete
250
+ },
251
+ },
252
+ );
253
+ }, [doAutosaveFetch, queueTask]);
254
+
255
+ const saveDocument = useCallback(
256
+ async (dataOverride?: Record<string, unknown>, isDraft = true) => {
268
257
  const state = useAutoFormStore.getState();
269
258
  const payload = dataOverride || state.formData;
270
259
 
@@ -276,7 +265,10 @@ const saveDocument = useCallback(
276
265
  url,
277
266
  {
278
267
  method: "PATCH",
279
- headers: { "Content-Type": "application/json" },
268
+ headers: {
269
+ "Content-Type": "application/json",
270
+ "X-Draft": String(isDraft),
271
+ },
280
272
  body: JSON.stringify({
281
273
  ...normalizeUploadFields(payload) as Record<string, unknown>,
282
274
  baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
@@ -293,46 +285,6 @@ const saveDocument = useCallback(
293
285
  [collectionSlug, globalSlug, setAutoSaveStatus],
294
286
  );
295
287
 
296
- const publishDocument = useCallback(async () => {
297
- const state = useAutoFormStore.getState();
298
- const url = globalSlug
299
- ? resolveUrl(`/api/globals/${globalSlug}/publish`)
300
- : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/publish`);
301
-
302
- const response = await fetchWithAuth(
303
- url,
304
- {
305
- method: "POST",
306
- headers: { "Content-Type": "application/json" },
307
- body: JSON.stringify({
308
- baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
309
- }),
310
- },
311
- );
312
-
313
- if (response.status === 409) {
314
- setAutoSaveStatus("conflict");
315
- }
316
-
317
- return response;
318
- }, [collectionSlug, globalSlug, setAutoSaveStatus]);
319
-
320
- const unpublishDocument = useCallback(async () => {
321
- const state = useAutoFormStore.getState();
322
- const url = globalSlug
323
- ? resolveUrl(`/api/globals/${globalSlug}/unpublish`)
324
- : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/unpublish`);
325
-
326
- const response = await fetchWithAuth(
327
- url,
328
- {
329
- method: "POST",
330
- headers: { "Content-Type": "application/json" },
331
- },
332
- );
333
- return response;
334
- }, [collectionSlug, globalSlug]);
335
-
336
288
  // Track sidebar toggle
337
289
  useEffect(() => {
338
290
  const handleToggle = () => {
@@ -346,14 +298,17 @@ const saveDocument = useCallback(
346
298
 
347
299
  // Initial data load
348
300
  const lastLoadedSlugRef = useRef<string | null>(null);
301
+ const lastInitialDataRef = useRef<string>("");
349
302
  const initialDataLoadedRef = useRef(false);
350
303
  useEffect(() => {
351
304
  const currentSlug = globalSlug || initialData?.id;
352
- if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug) return;
305
+ const serialized = JSON.stringify(initialData);
306
+ if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug && lastInitialDataRef.current === serialized) return;
353
307
 
354
308
  loadDocument(initialData || {}, initialData || {});
355
309
  initialDataLoadedRef.current = true;
356
310
  lastLoadedSlugRef.current = currentSlug;
311
+ lastInitialDataRef.current = serialized;
357
312
  }, [collectionSlug, formData.id, globalSlug, initialData, loadDocument]);
358
313
 
359
314
  useEffect(() => {
@@ -368,33 +323,10 @@ const saveDocument = useCallback(
368
323
  const maybeRestoreDraft = async () => {
369
324
  if (!versionsEnabled) return;
370
325
  const browserDraft = getDraftCache(documentKey);
371
- let serverDraft: Record<string, unknown> | null = null;
372
-
373
- try {
374
- const response = await fetchWithAuth(
375
- resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
376
- );
377
- if (response.ok) {
378
- const result: { data?: Record<string, unknown> } = await response.json();
379
- serverDraft = result.data || null;
380
- }
381
- } catch (err) {
382
- console.error("Failed to fetch server draft:", err);
383
- }
384
-
385
- const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
386
- data: Record<string, unknown>;
387
- draftUpdatedAt: string;
388
- }>;
389
326
 
390
- const candidate = drafts.sort(
391
- (a, b) =>
392
- new Date(b.draftUpdatedAt).getTime() -
393
- new Date(a.draftUpdatedAt).getTime(),
394
- )[0];
395
-
396
- if (!candidate) return;
397
- if (JSON.stringify(candidate.data) === JSON.stringify(initialData)) {
327
+ if (!browserDraft) return;
328
+ if (JSON.stringify(browserDraft.data) === JSON.stringify(initialData)) {
329
+ clearDraftCache(documentKey);
398
330
  return;
399
331
  }
400
332
 
@@ -409,20 +341,12 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
409
341
  onConfirm: async () => {
410
342
  if (cancelled) return;
411
343
  const currentFormData = useAutoFormStore.getState().formData;
412
- const mergedData = { ...currentFormData, ...candidate.data };
344
+ const mergedData = { ...currentFormData, ...browserDraft.data };
413
345
  setFormData(mergedData);
414
346
  onActionSuccess?.("Recovered autosaved draft");
415
347
  },
416
348
  onCancel: async () => {
417
349
  clearDraftCache(documentKey);
418
- try {
419
- await fetchWithAuth(
420
- resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
421
- { method: "DELETE" },
422
- );
423
- } catch (err) {
424
- console.error("Failed to discard server draft:", err);
425
- }
426
350
  },
427
351
  });
428
352
  };
@@ -441,6 +365,7 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
441
365
  initialData,
442
366
  onActionSuccess,
443
367
  setFormData,
368
+ versionsEnabled,
444
369
  ]);
445
370
 
446
371
  // Recursively find a field by name inside tabs/group/collapsible
@@ -506,17 +431,22 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
506
431
 
507
432
  // Only schedule/reschedule on keystroke-originated changes
508
433
  if (getLastChangeSource() !== "keystroke") return;
509
-
510
- // Reset source so subsequent non-keystroke changes don't re-schedule
511
434
  setChangeSource("other");
512
435
 
513
- // Clear existing timers and start fresh
514
- if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
515
- if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
436
+ // Compare serialized form data to avoid scheduling for unchanged metadata
437
+ const serialized = JSON.stringify(formData);
438
+ if (serialized === previousFormDataRef.current) return;
516
439
 
440
+ if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
517
441
  localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
518
- serverSaveTimerRef.current = setTimeout(performServerAutoSave, 8000);
519
- }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performServerAutoSave]);
442
+
443
+ // Queue autosave via the queue (debounced at 8s)
444
+ if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
445
+ serverSaveTimerRef.current = setTimeout(() => {
446
+ previousFormDataRef.current = serialized;
447
+ performAutosave();
448
+ }, 8000);
449
+ }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performAutosave]);
520
450
 
521
451
  useEffect(() => {
522
452
  if (!globalSlug && (!collectionSlug || !formData.id)) return;
@@ -524,9 +454,8 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
524
454
  const flushDraft = () => {
525
455
  if (autoSaveSkipRef.current) return;
526
456
  const state = useAutoFormStore.getState();
527
- // Only flush if there are actual unsaved changes
528
457
  if (!state.hasDirtyFields()) return;
529
- void performServerAutoSave({ keepalive: true });
458
+ void performAutosave({ keepalive: true });
530
459
  };
531
460
 
532
461
  const handleVisibilityChange = () => {
@@ -561,13 +490,17 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
561
490
  window.removeEventListener("offline", handleOffline);
562
491
  document.removeEventListener("visibilitychange", handleVisibilityChange);
563
492
  };
564
- }, [collectionSlug, globalSlug, formData.id, performServerAutoSave]);
493
+ }, [collectionSlug, globalSlug, formData.id, performAutosave]);
565
494
 
566
- // Astro sync
495
+ // Astro sync — avoid ping-pong loop with DetailView's setData
567
496
  useEffect(() => {
497
+ const serialized = JSON.stringify(formData);
498
+ if (serialized === astroSyncDataRef.current) return;
499
+ astroSyncDataRef.current = serialized;
500
+
568
501
  const hiddenInput = document.getElementById("form-data") as HTMLInputElement;
569
502
  if (hiddenInput) {
570
- hiddenInput.value = JSON.stringify(formData);
503
+ hiddenInput.value = serialized;
571
504
  }
572
505
  onChange?.(formData);
573
506
  }, [formData, onChange]);
@@ -581,22 +514,17 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
581
514
  const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
582
515
  if (!formData.id && !globalSlug) return 'draft';
583
516
  if (!versionsEnabled) return 'published';
584
- // If it has a pending draft version, effectively it's in a draft state for the editor
585
- if (formData.hasDraft) return 'draft';
586
- return formData.publishStatus || 'published';
517
+ return (formData.status as 'draft' | 'published' | undefined) || 'published';
587
518
  })();
588
519
 
589
520
  const hasUnpublishedChanges =
590
- (!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData.hasDraft);
521
+ (!!formData.id || !!globalSlug) && documentStatus === 'draft';
591
522
 
592
523
  return {
593
524
  ...store,
594
525
  fetchVersions,
595
- performAutoSave: performServerAutoSave,
526
+ performAutoSave: performAutosave,
596
527
  saveDocument,
597
- publishDocument,
598
- unpublishDocument,
599
- clearDraftArtifacts,
600
528
  autoSaveSkipRef,
601
529
  lastAutoSaveTimeRef,
602
530
  documentStatus,
@@ -0,0 +1,60 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ type QueuedFunction = () => Promise<void>;
4
+
5
+ type QueueTaskOptions = {
6
+ afterProcess?: () => void;
7
+ beforeProcess?: () => boolean;
8
+ };
9
+
10
+ type QueueTask = (fn: QueuedFunction, options?: QueueTaskOptions) => void;
11
+
12
+ /**
13
+ * A hook that queues async tasks for sequential execution.
14
+ * Only the last task in the queue is ever processed; all prior pending tasks are discarded.
15
+ * This prevents race conditions where multiple autosave fetches could run in parallel.
16
+ *
17
+ * Inspired by Payload's useQueues hook.
18
+ *
19
+ * @returns {queueTask} A function used to queue a task for execution.
20
+ */
21
+ export function useQueue(): { queueTask: QueueTask } {
22
+ const queue = useRef<QueuedFunction[]>([]);
23
+ const isProcessing = useRef(false);
24
+
25
+ const queueTask = useCallback<QueueTask>((fn, options) => {
26
+ queue.current.push(fn);
27
+
28
+ async function processQueue() {
29
+ if (isProcessing.current) return;
30
+
31
+ if (typeof options?.beforeProcess === "function") {
32
+ const shouldContinue = options.beforeProcess();
33
+ if (shouldContinue === false) return;
34
+ }
35
+
36
+ while (queue.current.length > 0) {
37
+ const latestTask = queue.current.pop();
38
+ queue.current = [];
39
+
40
+ isProcessing.current = true;
41
+
42
+ try {
43
+ await latestTask();
44
+ } catch (err) {
45
+ console.error("Error in queued function:", err);
46
+ } finally {
47
+ isProcessing.current = false;
48
+
49
+ if (typeof options?.afterProcess === "function") {
50
+ options.afterProcess();
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ processQueue();
57
+ }, []);
58
+
59
+ return { queueTask };
60
+ }
@@ -7,7 +7,7 @@ import { adminPath, apiPath } from "../lib/paths";
7
7
  import { AuthBridge } from "../components/AuthBridge";
8
8
  import { GlobalModal } from "../components/ui/GlobalModal";
9
9
  import { Toaster } from "../components/ui/Toaster";
10
- import { getSiteSettings } from "../lib/globals";
10
+ import { getSiteSettings, getGlobal } from "../lib/globals";
11
11
 
12
12
  interface Props {
13
13
  title: string;
@@ -15,8 +15,25 @@ interface Props {
15
15
 
16
16
  const { title } = Astro.props;
17
17
  const siteSettings = await getSiteSettings({ request: Astro.request });
18
+ const seoSettings = await getGlobal("seo-settings", { request: Astro.request });
19
+
18
20
  const siteName = siteSettings?.siteName || "Kyro CMS";
19
21
  const siteFavicon = siteSettings?.siteFavicon;
22
+ const siteDescription = siteSettings?.siteDescription || "";
23
+
24
+ // SEO Logic
25
+ const seoMode = seoSettings?.seoMode || "simple";
26
+ const includeSiteName = seoSettings?.siteNameInTitle ?? true;
27
+ const titleSeparator = seoSettings?.separator || " - ";
28
+ const defaultTitle = seoSettings?.defaultTitle || title;
29
+ const defaultDescription = seoSettings?.defaultDescription || siteDescription;
30
+ const canonicalUrl = seoMode === "advanced" ? seoSettings?.meta?.canonicalUrl : "";
31
+ const robots = seoMode === "advanced" && seoSettings?.meta?.robots ? seoSettings.meta.robots : "noindex, nofollow";
32
+
33
+ let displayTitle = title === "Dashboard" && defaultTitle !== "Dashboard" ? defaultTitle : title;
34
+ if (includeSiteName) {
35
+ displayTitle = `${displayTitle}${titleSeparator}${siteName}`;
36
+ }
20
37
  ---
21
38
 
22
39
  <!doctype html>
@@ -24,7 +41,10 @@ const siteFavicon = siteSettings?.siteFavicon;
24
41
  <head>
25
42
  <meta charset="UTF-8" />
26
43
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
27
- <title>{title} - {siteName}</title>
44
+ <title>{displayTitle}</title>
45
+ {defaultDescription && <meta name="description" content={defaultDescription} />}
46
+ {robots && <meta name="robots" content={robots} />}
47
+ {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
28
48
  <link
29
49
  rel="icon"
30
50
  type={siteFavicon?.mimeType || "image/svg+xml"}
@@ -1,12 +1,37 @@
1
1
  ---
2
2
  import "../styles/main.css";
3
3
  import { adminPath, apiPath } from "../lib/paths";
4
+ import { getSiteSettings, getGlobal } from "../lib/globals";
4
5
 
5
6
  interface Props {
6
7
  title: string;
7
8
  }
8
9
 
9
10
  const { title } = Astro.props;
11
+ const siteSettings = await getSiteSettings({ request: Astro.request });
12
+ const seoSettings = await getGlobal("seo-settings", { request: Astro.request });
13
+
14
+ const siteName = siteSettings?.siteName || "Kyro CMS";
15
+ const siteFavicon = siteSettings?.siteFavicon;
16
+ const siteLogo = siteSettings?.siteLogo;
17
+ const logoWidth = siteSettings?.logo?.width;
18
+ const logoHeight = siteSettings?.logo?.height;
19
+ const logoAlt = siteSettings?.logo?.altText || siteName;
20
+ const siteDescription = siteSettings?.siteDescription || "";
21
+
22
+ // SEO Logic
23
+ const seoMode = seoSettings?.seoMode || "simple";
24
+ const includeSiteName = seoSettings?.siteNameInTitle ?? true;
25
+ const titleSeparator = seoSettings?.separator || " - ";
26
+ const defaultTitle = seoSettings?.defaultTitle || title;
27
+ const defaultDescription = seoSettings?.defaultDescription || siteDescription;
28
+ const canonicalUrl = seoMode === "advanced" ? seoSettings?.meta?.canonicalUrl : "";
29
+ const robots = seoMode === "advanced" && seoSettings?.meta?.robots ? seoSettings.meta.robots : "noindex, nofollow";
30
+
31
+ let displayTitle = title === "Login" && defaultTitle !== "Login" ? defaultTitle : title;
32
+ if (includeSiteName) {
33
+ displayTitle = `${displayTitle}${titleSeparator}${siteName}`;
34
+ }
10
35
  ---
11
36
 
12
37
  <!doctype html>
@@ -14,8 +39,15 @@ const { title } = Astro.props;
14
39
  <head>
15
40
  <meta charset="UTF-8" />
16
41
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
17
- <title>{title} - Kyro CMS</title>
18
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
42
+ <title>{displayTitle}</title>
43
+ {defaultDescription && <meta name="description" content={defaultDescription} />}
44
+ {robots && <meta name="robots" content={robots} />}
45
+ {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
46
+ <link
47
+ rel="icon"
48
+ type={siteFavicon?.mimeType || "image/svg+xml"}
49
+ href={siteFavicon?.url || "/favicon.svg"}
50
+ />
19
51
  <script is:inline define:vars={{ adminPath, apiPath }}>
20
52
  (async () => {
21
53
  try {
@@ -50,22 +82,38 @@ const { title } = Astro.props;
50
82
  <!-- Logo -->
51
83
  <div class="w-full text-center mb-8">
52
84
  <a href="/" class="inline-flex items-center justify-center gap-2">
53
- <svg class="w-7 h-7 text-[var(--kyro-text-primary)]" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
54
- <defs>
55
- <mask id="kyro-cutout-auth">
56
- <rect width="100" height="100" fill="white" />
57
- <path d="M 40 89.72 L 40 40 L 89.72 40 A 45 45 0 0 1 40 89.72 Z" stroke="black" stroke-width="8" fill="none" />
58
- </mask>
59
- </defs>
60
- <g mask="url(#kyro-cutout-auth)" fill="currentColor">
61
- <circle cx="45" cy="45" r="45" />
62
- <rect x="40" y="40" width="60" height="60" />
63
- </g>
64
- </svg>
65
- <span
66
- class="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]"
67
- >Kyro CMS</span
68
- >
85
+ {
86
+ siteLogo ? (
87
+ <img
88
+ src={siteLogo.url}
89
+ alt={logoAlt}
90
+ style={{
91
+ width: logoWidth ? `${logoWidth}px` : "auto",
92
+ height: logoHeight ? `${logoHeight}px` : "40px",
93
+ objectFit: "contain",
94
+ }}
95
+ class="rounded-lg"
96
+ />
97
+ ) : (
98
+ <>
99
+ <svg class="w-7 h-7 text-[var(--kyro-text-primary)]" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
100
+ <defs>
101
+ <mask id="kyro-cutout-auth">
102
+ <rect width="100" height="100" fill="white" />
103
+ <path d="M 40 89.72 L 40 40 L 89.72 40 A 45 45 0 0 1 40 89.72 Z" stroke="black" stroke-width="8" fill="none" />
104
+ </mask>
105
+ </defs>
106
+ <g mask="url(#kyro-cutout-auth)" fill="currentColor">
107
+ <circle cx="45" cy="45" r="45" />
108
+ <rect x="40" y="40" width="60" height="60" />
109
+ </g>
110
+ </svg>
111
+ <span class="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
112
+ {siteName}
113
+ </span>
114
+ </>
115
+ )
116
+ }
69
117
  </a>
70
118
  </div>
71
119
 
@@ -92,8 +92,9 @@ interface AutoFormStore {
92
92
  compareSelected: string[];
93
93
  compareDiffs: VersionDiff[];
94
94
  loadingDiffs: boolean;
95
- isAutoSaving: boolean;
96
- autoSaveStatus: AutoSaveStatus;
95
+ isAutoSaving: boolean;
96
+ autoSaveStatus: AutoSaveStatus;
97
+ backgroundProcessing: boolean;
97
98
 
98
99
  // Auto-save
99
100
  lastAutoSaveTime: number;
@@ -125,6 +126,7 @@ interface AutoFormStore {
125
126
  setLoadingDiffs: (loading: boolean) => void;
126
127
  setIsAutoSaving: (saving: boolean) => void;
127
128
  setAutoSaveStatus: (status: AutoSaveStatus) => void;
129
+ setBackgroundProcessing: (processing: boolean) => void;
128
130
  setSidebarCollapsed: (collapsed: boolean) => void;
129
131
  setLastSavedAt: (time: number | null) => void;
130
132
  setRetryCount: (count: number) => void;
@@ -192,6 +194,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
192
194
  loadingDiffs: false,
193
195
  isAutoSaving: false,
194
196
  autoSaveStatus: "idle" as AutoSaveStatus,
197
+ backgroundProcessing: false,
195
198
 
196
199
  // Auto-save state
197
200
  lastAutoSaveTime: 0,
@@ -293,6 +296,7 @@ setField: (field: string, value: unknown) => {
293
296
  setLoadingDiffs: (loading: boolean) => set({ loadingDiffs: loading }),
294
297
  setIsAutoSaving: (saving: boolean) => set({ isAutoSaving: saving }),
295
298
  setAutoSaveStatus: (status: AutoSaveStatus) => set({ autoSaveStatus: status }),
299
+ setBackgroundProcessing: (processing: boolean) => set({ backgroundProcessing: processing }),
296
300
  setSidebarCollapsed: (collapsed: boolean) =>
297
301
  set({ sidebarCollapsed: collapsed }),
298
302
  setLastSavedAt: (time: number | null) => set({ lastSavedAt: time }),