simple-module-pagebuilder 0.0.1__py3-none-any.whl

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 (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,134 @@
1
+ /** Dirty tracking, debounced autosave, and the unsaved-changes guard. */
2
+
3
+ import type { Data } from '@puckeditor/core';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+
6
+ import { savePage } from '../utils/api';
7
+ import {
8
+ AUTOSAVE_DEBOUNCE_MS,
9
+ type EditorSnapshot,
10
+ type SaveState,
11
+ snapshotKey,
12
+ } from '../utils/editorSnapshot';
13
+
14
+ interface Params {
15
+ pageId: number | null;
16
+ busy: boolean;
17
+ data: Data;
18
+ snapshotPayload: EditorSnapshot;
19
+ initialSnapshot: EditorSnapshot;
20
+ writePayload: () => Record<string, unknown>;
21
+ }
22
+
23
+ export interface Autosave {
24
+ isDirty: boolean;
25
+ saveState: SaveState;
26
+ lastSavedAt: Date | null;
27
+ autosaveError: string | null;
28
+ markSaved: (payload: EditorSnapshot) => void;
29
+ }
30
+
31
+ export function useAutosave({
32
+ pageId,
33
+ busy,
34
+ data,
35
+ snapshotPayload,
36
+ initialSnapshot,
37
+ writePayload,
38
+ }: Params): Autosave {
39
+ // Memoize the stringify — Puck ``data`` can be hundreds of KB on a
40
+ // populated page, and the component re-renders on every keystroke.
41
+ // Depending on the `snapshotPayload` object itself would defeat the memo —
42
+ // the caller rebuilds it every render. The fields listed are its full contents.
43
+ // biome-ignore lint/correctness/useExhaustiveDependencies: listed fields are the object's full contents
44
+ const currentSnapshot = useMemo(
45
+ () => snapshotKey(snapshotPayload),
46
+ [
47
+ snapshotPayload.title,
48
+ snapshotPayload.slug,
49
+ snapshotPayload.metaDescription,
50
+ snapshotPayload.ogImage,
51
+ snapshotPayload.canonicalUrl,
52
+ snapshotPayload.indexInSearch,
53
+ snapshotPayload.jsonLdText,
54
+ snapshotPayload.data,
55
+ ],
56
+ );
57
+
58
+ // Intentionally mount-only. Inertia remounts the editor on a different page
59
+ // id, which is exactly when a fresh baseline is wanted; recomputing on prop
60
+ // identity would reset the dirty state mid-edit.
61
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only baseline is deliberate
62
+ const baseline = useMemo(() => snapshotKey(initialSnapshot), []);
63
+
64
+ const [savedSnapshot, setSavedSnapshot] = useState<string>(baseline);
65
+ const [saveState, setSaveState] = useState<SaveState>('idle');
66
+ const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
67
+ const [autosaveError, setAutosaveError] = useState<string | null>(null);
68
+
69
+ const isDirty = currentSnapshot !== savedSnapshot;
70
+
71
+ const markSaved = (payload: EditorSnapshot) => {
72
+ setSavedSnapshot(snapshotKey(payload));
73
+ setSaveState('idle');
74
+ setLastSavedAt(new Date());
75
+ setAutosaveError(null);
76
+ };
77
+
78
+ // Debounced autosave. If the user keeps editing while a save is in
79
+ // flight, ``saveState`` flips back to ``'idle'`` in the finally block,
80
+ // which re-runs this effect; ``isDirty`` is still true relative to the
81
+ // just-stored snapshot, so a fresh debounce window arms automatically
82
+ // — the trailing edit isn't lost.
83
+ //
84
+ // New (unsaved) pages opt out: the first save creates the row and
85
+ // redirects, which can't sensibly happen on a background timer.
86
+ const autosaveInFlight = useRef(false);
87
+ // `data` and `writePayload` are read inside the timeout, not at effect setup.
88
+ // Adding them would re-arm the debounce on every keystroke and the save would
89
+ // never fire; `currentSnapshot` already changes whenever they do.
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deps are read in the timeout, not at setup
91
+ useEffect(() => {
92
+ if (pageId === null) return;
93
+ if (!isDirty) return;
94
+ if (busy) return;
95
+ if (autosaveInFlight.current) return;
96
+ const handle = window.setTimeout(async () => {
97
+ autosaveInFlight.current = true;
98
+ const snapshotAtSave = currentSnapshot;
99
+ const payload = {
100
+ ...writePayload(),
101
+ draft_data: data as unknown as Record<string, unknown>,
102
+ };
103
+ setSaveState('saving');
104
+ try {
105
+ await savePage(pageId, payload);
106
+ setSavedSnapshot(snapshotAtSave);
107
+ setLastSavedAt(new Date());
108
+ setAutosaveError(null);
109
+ setSaveState('idle');
110
+ } catch (e) {
111
+ setAutosaveError(e instanceof Error ? e.message : 'Save failed');
112
+ setSaveState('error');
113
+ } finally {
114
+ autosaveInFlight.current = false;
115
+ }
116
+ }, AUTOSAVE_DEBOUNCE_MS);
117
+ return () => window.clearTimeout(handle);
118
+ }, [pageId, isDirty, busy, currentSnapshot, saveState]);
119
+
120
+ // beforeunload: only attached while dirty, so the browser confirm
121
+ // doesn't fire on a clean exit.
122
+ useEffect(() => {
123
+ if (!isDirty) return;
124
+ const handler = (event: BeforeUnloadEvent) => {
125
+ event.preventDefault();
126
+ // Required by the spec; most browsers ignore the actual string.
127
+ event.returnValue = '';
128
+ };
129
+ window.addEventListener('beforeunload', handler);
130
+ return () => window.removeEventListener('beforeunload', handler);
131
+ }, [isDirty]);
132
+
133
+ return { isDirty, saveState, lastSavedAt, autosaveError, markSaved };
134
+ }
@@ -0,0 +1,193 @@
1
+ /** Field state for the page editor, plus the derived write payload. */
2
+
3
+ import type { Data } from '@puckeditor/core';
4
+ import { useState } from 'react';
5
+
6
+ import { emptyData } from '../components/puckConfig';
7
+ import type { PageDetail } from '../utils/api';
8
+ import type { EditorSnapshot } from '../utils/editorSnapshot';
9
+ import { slugify } from '../utils/slugify';
10
+
11
+ export interface EditorForm {
12
+ pageId: number | null;
13
+ setPageId: (id: number | null) => void;
14
+ title: string;
15
+ setTitle: (v: string) => void;
16
+ slug: string;
17
+ setSlug: (v: string) => void;
18
+ slugTouched: boolean;
19
+ setSlugTouched: (v: boolean) => void;
20
+ effectiveSlug: string;
21
+ metaDescription: string;
22
+ setMetaDescription: (v: string) => void;
23
+ ogImage: string;
24
+ setOgImage: (v: string) => void;
25
+ canonicalUrl: string;
26
+ setCanonicalUrl: (v: string) => void;
27
+ indexInSearch: boolean;
28
+ setIndexInSearch: (v: boolean) => void;
29
+ jsonLdText: string;
30
+ jsonLdError: string | null;
31
+ handleJsonLdChange: (v: string) => void;
32
+ status: PageDetail['status'];
33
+ setStatus: (v: PageDetail['status']) => void;
34
+ publishAt: string | null;
35
+ setPublishAt: (v: string | null) => void;
36
+ unpublishAt: string | null;
37
+ setUnpublishAt: (v: string | null) => void;
38
+ data: Data;
39
+ setData: (v: Data) => void;
40
+ writePayload: () => Record<string, unknown>;
41
+ snapshotPayload: EditorSnapshot;
42
+ applyRestored: (restored: PageDetail) => EditorSnapshot;
43
+ }
44
+
45
+ export function useEditorForm(page: PageDetail | null): EditorForm {
46
+ const [pageId, setPageId] = useState<number | null>(page?.id ?? null);
47
+ const [title, setTitle] = useState(page?.title ?? 'Untitled page');
48
+ const [slug, setSlug] = useState(page?.slug ?? '');
49
+ const [slugTouched, setSlugTouched] = useState(Boolean(page?.slug));
50
+ const [metaDescription, setMetaDescription] = useState(page?.meta_description ?? '');
51
+ const [ogImage, setOgImage] = useState(page?.og_image ?? '');
52
+ const [canonicalUrl, setCanonicalUrl] = useState(page?.canonical_url ?? '');
53
+ const [indexInSearch, setIndexInSearch] = useState<boolean>(page?.index_in_search ?? true);
54
+ const [jsonLdText, setJsonLdText] = useState<string>(
55
+ page?.json_ld ? JSON.stringify(page.json_ld, null, 2) : '',
56
+ );
57
+ const [jsonLdError, setJsonLdError] = useState<string | null>(null);
58
+ const [status, setStatus] = useState<PageDetail['status']>(page?.status ?? 'draft');
59
+ // ISO 8601 strings (with offset). The native ``datetime-local`` input
60
+ // emits *naive* values like ``"2026-05-19T09:30"`` so we adapt at the
61
+ // boundary in toLocalInput/fromLocalInput rather than carrying two
62
+ // representations through component state.
63
+ const [publishAt, setPublishAt] = useState<string | null>(page?.publish_at ?? null);
64
+ const [unpublishAt, setUnpublishAt] = useState<string | null>(page?.unpublish_at ?? null);
65
+ const [data, setData] = useState<Data>(
66
+ (page?.draft_data as unknown as Data) || (emptyData as unknown as Data),
67
+ );
68
+
69
+ const effectiveSlug = slugTouched ? slug : slugify(title);
70
+
71
+ const handleJsonLdChange = (value: string) => {
72
+ setJsonLdText(value);
73
+ const trimmed = value.trim();
74
+ if (!trimmed) {
75
+ setJsonLdError(null);
76
+ return;
77
+ }
78
+ try {
79
+ const parsed = JSON.parse(trimmed);
80
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
81
+ setJsonLdError('JSON-LD must be a JSON object.');
82
+ } else {
83
+ setJsonLdError(null);
84
+ }
85
+ } catch (err) {
86
+ setJsonLdError(err instanceof Error ? err.message : 'Invalid JSON.');
87
+ }
88
+ };
89
+
90
+ // ``json_ld`` is omitted from the payload when the editor textarea is
91
+ // unparseable so a malformed draft doesn't silently overwrite a good
92
+ // saved value. The ``jsonLdError`` state surfaces the parse error inline.
93
+ const parseJsonLd = (): Record<string, unknown> | null | undefined => {
94
+ const trimmed = jsonLdText.trim();
95
+ if (!trimmed) return null;
96
+ try {
97
+ const parsed = JSON.parse(trimmed);
98
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
99
+ return undefined;
100
+ }
101
+ return parsed as Record<string, unknown>;
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ };
106
+
107
+ const writePayload = () => {
108
+ const parsedLd = parseJsonLd();
109
+ return {
110
+ title,
111
+ slug: effectiveSlug || slugify(title) || 'untitled',
112
+ meta_description: metaDescription.trim() ? metaDescription.trim() : null,
113
+ og_image: ogImage.trim() ? ogImage.trim() : null,
114
+ canonical_url: canonicalUrl.trim() ? canonicalUrl.trim() : null,
115
+ index_in_search: indexInSearch,
116
+ // Only include json_ld in the payload when we have a valid parse;
117
+ // ``undefined`` (parse error) is dropped so the server retains the
118
+ // previously saved value.
119
+ ...(parsedLd === undefined ? {} : { json_ld: parsedLd }),
120
+ };
121
+ };
122
+
123
+ const snapshotPayload: EditorSnapshot = {
124
+ title,
125
+ slug: effectiveSlug,
126
+ metaDescription,
127
+ ogImage,
128
+ canonicalUrl,
129
+ indexInSearch,
130
+ jsonLdText,
131
+ data,
132
+ };
133
+
134
+ /** Push a restored revision into the form; returns its snapshot. */
135
+ const applyRestored = (restored: PageDetail): EditorSnapshot => {
136
+ const restoredData = restored.draft_data as unknown as Data;
137
+ const restoredCanonical = restored.canonical_url ?? '';
138
+ const restoredIndex = restored.index_in_search ?? true;
139
+ const restoredJsonLd = restored.json_ld ? JSON.stringify(restored.json_ld, null, 2) : '';
140
+ setTitle(restored.title);
141
+ setMetaDescription(restored.meta_description ?? '');
142
+ setOgImage(restored.og_image ?? '');
143
+ setCanonicalUrl(restoredCanonical);
144
+ setIndexInSearch(restoredIndex);
145
+ setJsonLdText(restoredJsonLd);
146
+ setJsonLdError(null);
147
+ setData(restoredData);
148
+ return {
149
+ title: restored.title,
150
+ slug: restored.slug,
151
+ metaDescription: restored.meta_description ?? '',
152
+ ogImage: restored.og_image ?? '',
153
+ canonicalUrl: restoredCanonical,
154
+ indexInSearch: restoredIndex,
155
+ jsonLdText: restoredJsonLd,
156
+ data: restoredData,
157
+ };
158
+ };
159
+
160
+ return {
161
+ pageId,
162
+ setPageId,
163
+ title,
164
+ setTitle,
165
+ slug,
166
+ setSlug,
167
+ slugTouched,
168
+ setSlugTouched,
169
+ effectiveSlug,
170
+ metaDescription,
171
+ setMetaDescription,
172
+ ogImage,
173
+ setOgImage,
174
+ canonicalUrl,
175
+ setCanonicalUrl,
176
+ indexInSearch,
177
+ setIndexInSearch,
178
+ jsonLdText,
179
+ jsonLdError,
180
+ handleJsonLdChange,
181
+ status,
182
+ setStatus,
183
+ publishAt,
184
+ setPublishAt,
185
+ unpublishAt,
186
+ setUnpublishAt,
187
+ data,
188
+ setData,
189
+ writePayload,
190
+ snapshotPayload,
191
+ applyRestored,
192
+ };
193
+ }
@@ -0,0 +1,96 @@
1
+ /** Upload queue orchestration for the media library. */
2
+
3
+ import { router } from '@inertiajs/react';
4
+ import { useCallback, useState } from 'react';
5
+
6
+ import type { UploadItem } from '../components/media/types';
7
+ import { type MediaAssetRead, uploadMedia } from '../utils/api';
8
+ import { uploadKey } from '../utils/mediaFormat';
9
+
10
+ interface Params {
11
+ /** Folder new uploads are filed into; blank means Unfiled. */
12
+ uploadFolder: string;
13
+ /** Called once per successful upload so the page can splice it into view. */
14
+ onUploaded: (asset: MediaAssetRead) => void;
15
+ setMessage: (message: string | null) => void;
16
+ }
17
+
18
+ export interface MediaUploads {
19
+ uploads: UploadItem[];
20
+ startUploads: (files: File[]) => void;
21
+ dismissUpload: (id: string) => void;
22
+ }
23
+
24
+ export function useMediaUploads({ uploadFolder, onUploaded, setMessage }: Params): MediaUploads {
25
+ const [uploads, setUploads] = useState<UploadItem[]>([]);
26
+
27
+ const startUploads = useCallback(
28
+ (files: File[]) => {
29
+ if (files.length === 0) return;
30
+ const folderForBatch = uploadFolder.trim();
31
+ const items: UploadItem[] = files.map((file) => ({
32
+ id: uploadKey(file),
33
+ file,
34
+ status: 'pending',
35
+ loaded: 0,
36
+ total: file.size,
37
+ error: null,
38
+ }));
39
+ setUploads((prev) => [...prev, ...items]);
40
+ setMessage(null);
41
+
42
+ // Upload sequentially to avoid swamping the server with a 50-file
43
+ // drop; the per-file progress bar is what makes this feel fast.
44
+ void (async () => {
45
+ let succeeded = 0;
46
+ for (const item of items) {
47
+ setUploads((prev) =>
48
+ prev.map((u) => (u.id === item.id ? { ...u, status: 'uploading' } : u)),
49
+ );
50
+ try {
51
+ const asset = await uploadMedia(item.file, {
52
+ folder: folderForBatch || null,
53
+ onProgress: (loaded, total) => {
54
+ setUploads((prev) =>
55
+ prev.map((u) =>
56
+ u.id === item.id ? { ...u, loaded, total: total || u.total } : u,
57
+ ),
58
+ );
59
+ },
60
+ });
61
+ setUploads((prev) =>
62
+ prev.map((u) => (u.id === item.id ? { ...u, status: 'done', loaded: u.total } : u)),
63
+ );
64
+ onUploaded(asset);
65
+ succeeded += 1;
66
+ } catch (e) {
67
+ setUploads((prev) =>
68
+ prev.map((u) =>
69
+ u.id === item.id
70
+ ? {
71
+ ...u,
72
+ status: 'error',
73
+ error: e instanceof Error ? e.message : 'Upload failed',
74
+ }
75
+ : u,
76
+ ),
77
+ );
78
+ }
79
+ }
80
+ if (succeeded > 0) {
81
+ setMessage(`Uploaded ${succeeded} file${succeeded === 1 ? '' : 's'}.`);
82
+ // Best-effort: also re-sync via Inertia so the SSR props
83
+ // reflect the new state on next reload.
84
+ router.reload({ only: ['initial'] });
85
+ }
86
+ })();
87
+ },
88
+ [uploadFolder, onUploaded, setMessage],
89
+ );
90
+
91
+ const dismissUpload = (id: string) => {
92
+ setUploads((prev) => prev.filter((u) => u.id !== id));
93
+ };
94
+
95
+ return { uploads, startUploads, dismissUpload };
96
+ }
@@ -0,0 +1,59 @@
1
+ /** Revision compare and restore actions for the page editor. */
2
+
3
+ import { useState } from 'react';
4
+
5
+ import { diffRevisions, type RevisionDiff, restoreRevision } from '../utils/api';
6
+ import type { EditorSnapshot } from '../utils/editorSnapshot';
7
+ import type { EditorForm } from './useEditorForm';
8
+
9
+ interface Params {
10
+ form: EditorForm;
11
+ setBusy: (v: boolean) => void;
12
+ setMessage: (v: string | null) => void;
13
+ markSaved: (payload: EditorSnapshot) => void;
14
+ }
15
+
16
+ export interface PageRevisions {
17
+ activeDiff: RevisionDiff | null;
18
+ diffError: string | null;
19
+ handleCompare: (beforeId: number, afterId: number) => Promise<void>;
20
+ handleRestore: (revisionId: number) => Promise<void>;
21
+ }
22
+
23
+ export function usePageRevisions({ form, setBusy, setMessage, markSaved }: Params): PageRevisions {
24
+ const [activeDiff, setActiveDiff] = useState<RevisionDiff | null>(null);
25
+ const [diffError, setDiffError] = useState<string | null>(null);
26
+
27
+ const handleCompare = async (beforeId: number, afterId: number) => {
28
+ if (form.pageId === null) return;
29
+ if (activeDiff?.after_id === afterId && activeDiff?.before_id === beforeId) {
30
+ setActiveDiff(null);
31
+ return;
32
+ }
33
+ setDiffError(null);
34
+ try {
35
+ const diff = await diffRevisions(form.pageId, beforeId, afterId);
36
+ setActiveDiff(diff);
37
+ } catch (e) {
38
+ setDiffError(e instanceof Error ? e.message : 'Could not load diff');
39
+ }
40
+ };
41
+
42
+ const handleRestore = async (revisionId: number) => {
43
+ if (form.pageId === null) return;
44
+ if (!confirm('Replace the current draft with this revision?')) return;
45
+ setBusy(true);
46
+ setMessage(null);
47
+ try {
48
+ const restored = await restoreRevision(form.pageId, revisionId);
49
+ markSaved(form.applyRestored(restored));
50
+ setMessage('Revision restored to draft.');
51
+ } catch (e) {
52
+ setMessage(e instanceof Error ? e.message : 'Restore failed');
53
+ } finally {
54
+ setBusy(false);
55
+ }
56
+ };
57
+
58
+ return { activeDiff, diffError, handleCompare, handleRestore };
59
+ }
@@ -0,0 +1,72 @@
1
+ /** Scheduled publish / unpublish actions for the page editor. */
2
+
3
+ import { useState } from 'react';
4
+
5
+ import { schedulePage } from '../utils/api';
6
+ import { fromLocalInput } from '../utils/datetime';
7
+ import type { EditorForm } from './useEditorForm';
8
+
9
+ interface Params {
10
+ form: EditorForm;
11
+ setBusy: (v: boolean) => void;
12
+ setMessage: (v: string | null) => void;
13
+ }
14
+
15
+ export interface PageSchedule {
16
+ scheduleError: string | null;
17
+ handleSaveSchedule: () => Promise<void>;
18
+ handleClearSchedule: () => Promise<void>;
19
+ }
20
+
21
+ export function usePageSchedule({ form, setBusy, setMessage }: Params): PageSchedule {
22
+ const [scheduleError, setScheduleError] = useState<string | null>(null);
23
+
24
+ const handleSaveSchedule = async () => {
25
+ if (form.pageId === null) {
26
+ setScheduleError('Save a draft first.');
27
+ return;
28
+ }
29
+ setScheduleError(null);
30
+ const publish = fromLocalInput(form.publishAt);
31
+ const unpublish = fromLocalInput(form.unpublishAt);
32
+ if (publish && unpublish && publish >= unpublish) {
33
+ setScheduleError('Unpublish time must be after publish time.');
34
+ return;
35
+ }
36
+ setBusy(true);
37
+ try {
38
+ const updated = await schedulePage(form.pageId, {
39
+ publish_at: publish,
40
+ unpublish_at: unpublish,
41
+ });
42
+ form.setPublishAt(updated.publish_at);
43
+ form.setUnpublishAt(updated.unpublish_at);
44
+ setMessage('Schedule saved.');
45
+ } catch (e) {
46
+ setScheduleError(e instanceof Error ? e.message : 'Could not save schedule');
47
+ } finally {
48
+ setBusy(false);
49
+ }
50
+ };
51
+
52
+ const handleClearSchedule = async () => {
53
+ if (form.pageId === null) return;
54
+ setBusy(true);
55
+ setScheduleError(null);
56
+ try {
57
+ const updated = await schedulePage(form.pageId, {
58
+ publish_at: null,
59
+ unpublish_at: null,
60
+ });
61
+ form.setPublishAt(updated.publish_at);
62
+ form.setUnpublishAt(updated.unpublish_at);
63
+ setMessage('Schedule cleared.');
64
+ } catch (e) {
65
+ setScheduleError(e instanceof Error ? e.message : 'Could not clear schedule');
66
+ } finally {
67
+ setBusy(false);
68
+ }
69
+ };
70
+
71
+ return { scheduleError, handleSaveSchedule, handleClearSchedule };
72
+ }