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,249 @@
1
+ import { router, usePage } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
4
+ import type React from 'react';
5
+ import {
6
+ type ChangeEvent,
7
+ type DragEvent,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+
15
+ import { MediaFilters } from '../components/media/MediaFilters';
16
+ import { MediaFolderSidebar } from '../components/media/MediaFolderSidebar';
17
+ import { MediaGrid } from '../components/media/MediaGrid';
18
+ import { MediaHeader } from '../components/media/MediaHeader';
19
+ import { MediaDropzone, MediaUploadQueue } from '../components/media/MediaUploadQueue';
20
+ import type { ListFilters } from '../components/media/types';
21
+ import { useMediaUploads } from '../hooks/useMediaUploads';
22
+ import {
23
+ deleteMedia,
24
+ listMedia,
25
+ type MediaAssetRead,
26
+ type MediaListResponse,
27
+ uploadMedia,
28
+ } from '../utils/api';
29
+ import { parseKB } from '../utils/mediaFormat';
30
+
31
+ interface Props {
32
+ initial: MediaListResponse;
33
+ }
34
+
35
+ const PAGE_LIMIT = 60;
36
+
37
+ export default function MediaLibrary() {
38
+ const { initial } = usePage<{ props: Props }>().props as unknown as Props;
39
+
40
+ const [assets, setAssets] = useState<MediaAssetRead[]>(initial.items);
41
+ const [nextCursor, setNextCursor] = useState<number | null>(initial.next_cursor);
42
+ const [folders, setFolders] = useState<string[]>(initial.folders);
43
+ const [loading, setLoading] = useState(false);
44
+ const [loadingMore, setLoadingMore] = useState(false);
45
+ const [message, setMessage] = useState<string | null>(null);
46
+ const [copied, setCopied] = useState<number | null>(null);
47
+ const [filters, setFilters] = useState<ListFilters>({
48
+ search: '',
49
+ contentType: '',
50
+ folder: null,
51
+ minKB: '',
52
+ maxKB: '',
53
+ });
54
+ const [uploadFolder, setUploadFolder] = useState<string>('');
55
+ const [dragActive, setDragActive] = useState(false);
56
+
57
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
58
+
59
+ // Debounced search: state holds what the user is typing, `query`
60
+ // holds what we actually send to the server.
61
+ const [query, setQuery] = useState<string>('');
62
+ useEffect(() => {
63
+ const t = window.setTimeout(() => setQuery(filters.search.trim()), 250);
64
+ return () => window.clearTimeout(t);
65
+ }, [filters.search]);
66
+
67
+ const refresh = useCallback(
68
+ async (mode: 'replace' | 'append' = 'replace', cursor?: number | null) => {
69
+ const isAppend = mode === 'append';
70
+ if (isAppend) {
71
+ setLoadingMore(true);
72
+ } else {
73
+ setLoading(true);
74
+ }
75
+ try {
76
+ const response = await listMedia({
77
+ search: query || undefined,
78
+ content_type: filters.contentType || undefined,
79
+ folder: filters.folder === null ? undefined : filters.folder,
80
+ min_size_bytes: parseKB(filters.minKB),
81
+ max_size_bytes: parseKB(filters.maxKB),
82
+ cursor: cursor ?? undefined,
83
+ limit: PAGE_LIMIT,
84
+ });
85
+ setAssets((prev) => (isAppend ? [...prev, ...response.items] : response.items));
86
+ setNextCursor(response.next_cursor);
87
+ setFolders(response.folders);
88
+ } catch (e) {
89
+ setMessage(e instanceof Error ? e.message : 'Failed to load media');
90
+ } finally {
91
+ if (isAppend) setLoadingMore(false);
92
+ else setLoading(false);
93
+ }
94
+ },
95
+ [filters.contentType, filters.folder, filters.maxKB, filters.minKB, query],
96
+ );
97
+
98
+ // Reload from the server whenever a filter or the debounced query
99
+ // changes. We deliberately skip the initial mount (the server already
100
+ // gave us page 1 in props) to avoid a flash of duplicate work.
101
+ const didMountRef = useRef(false);
102
+ useEffect(() => {
103
+ if (!didMountRef.current) {
104
+ didMountRef.current = true;
105
+ return;
106
+ }
107
+ void refresh('replace');
108
+ }, [refresh]);
109
+
110
+ const folderOptions = useMemo(() => {
111
+ const set = new Set(folders);
112
+ if (uploadFolder.trim()) set.add(uploadFolder.trim());
113
+ return Array.from(set).sort();
114
+ }, [folders, uploadFolder]);
115
+
116
+ const handleDelete = async (id: number) => {
117
+ if (!confirm('Delete this asset? Pages using its URL will break.')) return;
118
+ try {
119
+ await deleteMedia(id);
120
+ setAssets((prev) => prev.filter((a) => a.id !== id));
121
+ } catch (e) {
122
+ setMessage(e instanceof Error ? e.message : 'Delete failed');
123
+ }
124
+ };
125
+
126
+ const handleCopy = async (asset: MediaAssetRead) => {
127
+ try {
128
+ await navigator.clipboard.writeText(asset.url);
129
+ setCopied(asset.id);
130
+ setTimeout(() => setCopied((c) => (c === asset.id ? null : c)), 1500);
131
+ } catch {
132
+ setMessage('Copy failed — select the URL manually.');
133
+ }
134
+ };
135
+
136
+ // Splice a freshly uploaded asset into the visible list when it matches the
137
+ // active filter; otherwise it appears when the user switches to that folder.
138
+ const handleUploaded = useCallback(
139
+ (asset: MediaAssetRead) => {
140
+ const matchesActiveFolder =
141
+ filters.folder === null ||
142
+ (filters.folder === '' && !asset.folder) ||
143
+ filters.folder === asset.folder;
144
+ if (matchesActiveFolder) {
145
+ setAssets((prev) => [asset, ...prev]);
146
+ }
147
+ if (asset.folder) {
148
+ setFolders((prev) =>
149
+ prev.includes(asset.folder as string)
150
+ ? prev
151
+ : Array.from(new Set([...prev, asset.folder as string])).sort(),
152
+ );
153
+ }
154
+ },
155
+ [filters.folder],
156
+ );
157
+
158
+ const { uploads, startUploads, dismissUpload } = useMediaUploads({
159
+ uploadFolder,
160
+ onUploaded: handleUploaded,
161
+ setMessage,
162
+ });
163
+
164
+ const onFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
165
+ const list = e.target.files ? Array.from(e.target.files) : [];
166
+ if (fileInputRef.current) fileInputRef.current.value = '';
167
+ startUploads(list);
168
+ };
169
+
170
+ const onDrop = (e: DragEvent<HTMLDivElement>) => {
171
+ e.preventDefault();
172
+ setDragActive(false);
173
+ const files = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
174
+ startUploads(files);
175
+ };
176
+
177
+ const activeFolderLabel = (() => {
178
+ if (filters.folder === null) return 'All assets';
179
+ if (filters.folder === '') return 'Unfiled';
180
+ return filters.folder;
181
+ })();
182
+
183
+ return (
184
+ <PageShell
185
+ title="Media library"
186
+ description="Images available to every page."
187
+ maxWidth="full"
188
+ actions={<MediaHeader fileInputRef={fileInputRef} onFilesSelected={onFileInputChange} />}
189
+ >
190
+ <div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6">
191
+ <MediaFolderSidebar
192
+ folders={folders}
193
+ folderOptions={folderOptions}
194
+ activeFolder={filters.folder}
195
+ onSelectFolder={(folder) => setFilters((f) => ({ ...f, folder }))}
196
+ uploadFolder={uploadFolder}
197
+ onUploadFolderChange={setUploadFolder}
198
+ />
199
+
200
+ <section>
201
+ <MediaFilters filters={filters} onChange={setFilters} />
202
+
203
+ <div className="text-sm text-gray-500 mb-2">
204
+ Browsing <strong>{activeFolderLabel}</strong>
205
+ {loading && <span className="ml-2">Loading…</span>}
206
+ </div>
207
+
208
+ {message && (
209
+ <div className="mb-4 text-sm text-gray-700 bg-yellow-50 border border-yellow-200 px-3 py-2 rounded">
210
+ {message}
211
+ </div>
212
+ )}
213
+
214
+ <MediaDropzone
215
+ uploadFolder={uploadFolder}
216
+ dragActive={dragActive}
217
+ onDragActiveChange={setDragActive}
218
+ onDrop={onDrop}
219
+ />
220
+
221
+ <MediaUploadQueue uploads={uploads} onDismiss={dismissUpload} />
222
+
223
+ <MediaGrid
224
+ assets={assets}
225
+ loading={loading}
226
+ copiedId={copied}
227
+ onCopy={handleCopy}
228
+ onDelete={handleDelete}
229
+ />
230
+
231
+ {nextCursor !== null && (
232
+ <div className="mt-6 flex justify-center">
233
+ <button
234
+ type="button"
235
+ disabled={loadingMore}
236
+ onClick={() => void refresh('append', nextCursor)}
237
+ className="px-4 py-2 rounded border hover:bg-gray-50 text-sm disabled:opacity-50"
238
+ >
239
+ {loadingMore ? 'Loading…' : 'Load more'}
240
+ </button>
241
+ </div>
242
+ )}
243
+ </section>
244
+ </div>
245
+ </PageShell>
246
+ );
247
+ }
248
+
249
+ MediaLibrary.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
@@ -0,0 +1,181 @@
1
+ import { type Data, Puck } from '@puckeditor/core';
2
+ import '@puckeditor/core/puck.css';
3
+ import { usePage } from '@inertiajs/react';
4
+ import { BrandingHead } from '@simple-module-py/ui/components/BrandingHead';
5
+ import { useState } from 'react';
6
+
7
+ import { PageEditorToolbar } from '../components/editor/PageEditorToolbar';
8
+ import { RevisionHistoryPanel } from '../components/editor/RevisionHistoryPanel';
9
+ import { SchedulePanel } from '../components/editor/SchedulePanel';
10
+ import { SeoSettingsPanel } from '../components/editor/SeoSettingsPanel';
11
+ import { migrateContent } from '../components/migrateContent';
12
+ import { editorViewports, emptyData, getPuckConfig } from '../components/puckConfig';
13
+ import { useAutosave } from '../hooks/useAutosave';
14
+ import { useEditorForm } from '../hooks/useEditorForm';
15
+ import { usePageRevisions } from '../hooks/usePageRevisions';
16
+ import { usePageSchedule } from '../hooks/usePageSchedule';
17
+ import { usePageWorkflow } from '../hooks/usePageWorkflow';
18
+ import type { PageDetail, PageRevisionRead } from '../utils/api';
19
+ import type { EditorSnapshot } from '../utils/editorSnapshot';
20
+
21
+ interface Props {
22
+ page: PageDetail | null;
23
+ revisions: PageRevisionRead[];
24
+ }
25
+
26
+ function initialSnapshotFor(page: PageDetail | null): EditorSnapshot {
27
+ return {
28
+ title: page?.title ?? 'Untitled page',
29
+ slug: page?.slug ?? '',
30
+ metaDescription: page?.meta_description ?? '',
31
+ ogImage: page?.og_image ?? '',
32
+ canonicalUrl: page?.canonical_url ?? '',
33
+ indexInSearch: page?.index_in_search ?? true,
34
+ jsonLdText: page?.json_ld ? JSON.stringify(page.json_ld, null, 2) : '',
35
+ // Migrated on the way in rather than on save: a draft the author never
36
+ // touches is never rewritten, so the stored payload only changes shape
37
+ // once they actually edit it.
38
+ data: migrateContent(
39
+ (page?.draft_data as unknown as Data) || (emptyData as unknown as Data),
40
+ getPuckConfig(),
41
+ ),
42
+ };
43
+ }
44
+
45
+ export default function PageEditor() {
46
+ const props = usePage<{ props: Props }>().props as unknown as Props;
47
+ const { page } = props;
48
+ const revisions = props.revisions ?? [];
49
+ // ``rejection_note`` lives on the page prop and refreshes after every
50
+ // partial reload — no separate state slot needed.
51
+ const rejectionNote = page?.rejection_note ?? null;
52
+
53
+ const [busy, setBusy] = useState(false);
54
+ const [message, setMessage] = useState<string | null>(null);
55
+ const [showSettings, setShowSettings] = useState(false);
56
+ const [showHistory, setShowHistory] = useState(false);
57
+
58
+ const form = useEditorForm(page);
59
+
60
+ const { isDirty, saveState, lastSavedAt, autosaveError, markSaved } = useAutosave({
61
+ pageId: form.pageId,
62
+ busy,
63
+ data: form.data,
64
+ snapshotPayload: form.snapshotPayload,
65
+ initialSnapshot: initialSnapshotFor(page),
66
+ writePayload: form.writePayload,
67
+ });
68
+
69
+ const workflow = usePageWorkflow({ form, setBusy, setMessage, markSaved });
70
+ const schedule = usePageSchedule({ form, setBusy, setMessage });
71
+ const revisionActions = usePageRevisions({ form, setBusy, setMessage, markSaved });
72
+
73
+ return (
74
+ <div className="h-screen flex flex-col">
75
+ {/* These editors render their own full-screen shell instead of
76
+ AuthenticatedLayout, so nothing else mounts BrandingHead for them —
77
+ and the preview would show the framework's default action colour
78
+ while the published page shows the configured one. */}
79
+ <BrandingHead />
80
+ <PageEditorToolbar
81
+ pageId={form.pageId}
82
+ title={form.title}
83
+ onTitleChange={form.setTitle}
84
+ effectiveSlug={form.effectiveSlug}
85
+ onSlugChange={(value) => {
86
+ form.setSlug(value);
87
+ form.setSlugTouched(true);
88
+ }}
89
+ status={form.status}
90
+ publishAt={form.publishAt}
91
+ unpublishAt={form.unpublishAt}
92
+ saveState={saveState}
93
+ isDirty={isDirty}
94
+ lastSavedAt={lastSavedAt}
95
+ autosaveError={autosaveError}
96
+ revisionCount={revisions.length}
97
+ busy={busy}
98
+ message={message}
99
+ onToggleSettings={() => setShowSettings((v) => !v)}
100
+ onToggleHistory={() => setShowHistory((v) => !v)}
101
+ onSave={() => workflow.handleSave(form.data)}
102
+ onPublish={workflow.handlePublish}
103
+ onUnpublish={workflow.handleUnpublish}
104
+ onSubmitForReview={workflow.handleSubmitForReview}
105
+ onApprove={workflow.handleApprove}
106
+ onReject={workflow.handleReject}
107
+ />
108
+
109
+ {rejectionNote && form.status === 'draft' && (
110
+ <div className="border-b bg-red-50 px-4 py-3 text-sm text-red-800">
111
+ <strong>Rejected:</strong> {rejectionNote}
112
+ </div>
113
+ )}
114
+
115
+ {showSettings && (
116
+ <SeoSettingsPanel
117
+ metaDescription={form.metaDescription}
118
+ onMetaDescriptionChange={form.setMetaDescription}
119
+ ogImage={form.ogImage}
120
+ onOgImageChange={form.setOgImage}
121
+ canonicalUrl={form.canonicalUrl}
122
+ onCanonicalUrlChange={form.setCanonicalUrl}
123
+ indexInSearch={form.indexInSearch}
124
+ onIndexInSearchChange={form.setIndexInSearch}
125
+ jsonLdText={form.jsonLdText}
126
+ jsonLdError={form.jsonLdError}
127
+ onJsonLdChange={form.handleJsonLdChange}
128
+ schedule={
129
+ <SchedulePanel
130
+ publishAt={form.publishAt}
131
+ unpublishAt={form.unpublishAt}
132
+ onPublishAtChange={form.setPublishAt}
133
+ onUnpublishAtChange={form.setUnpublishAt}
134
+ onSave={schedule.handleSaveSchedule}
135
+ onClear={schedule.handleClearSchedule}
136
+ saveDisabled={busy || form.pageId === null}
137
+ clearDisabled={
138
+ busy ||
139
+ form.pageId === null ||
140
+ (form.publishAt === null && form.unpublishAt === null)
141
+ }
142
+ error={schedule.scheduleError}
143
+ />
144
+ }
145
+ />
146
+ )}
147
+
148
+ {showHistory && (
149
+ <RevisionHistoryPanel
150
+ revisions={revisions}
151
+ activeDiff={revisionActions.activeDiff}
152
+ diffError={revisionActions.diffError}
153
+ busy={busy}
154
+ onCompare={revisionActions.handleCompare}
155
+ onRestore={revisionActions.handleRestore}
156
+ />
157
+ )}
158
+
159
+ <div className="flex-1 min-h-0">
160
+ <Puck
161
+ config={getPuckConfig()}
162
+ data={form.data}
163
+ viewports={editorViewports}
164
+ iframe={{ enabled: true }}
165
+ // Puck's header renders its own primary "Publish" alongside a copy of
166
+ // the page title. Its Publish was wired to a draft save, so the app
167
+ // showed two identical blue Publish buttons where the louder one did
168
+ // the quieter thing. Dropping headerActions leaves the toolbar above
169
+ // as the only publish control; the title, undo/redo and the sidebar
170
+ // toggles stay in Puck's header.
171
+ //
172
+ // Note for anyone auditing selectors: Puck renders that button as a
173
+ // <span>, not a <button>, so it never appeared in the accessibility
174
+ // tree and the e2e suite could not have caught this.
175
+ overrides={{ headerActions: () => <></> }}
176
+ onChange={form.setData}
177
+ />
178
+ </div>
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,129 @@
1
+ import { router, usePage } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@simple-module-py/ui/components/ui/table';
12
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
13
+ import type React from 'react';
14
+ import { useState } from 'react';
15
+
16
+ import { ScheduledBadge } from '../components/ScheduledBadge';
17
+ import { StatusBadge } from '../components/StatusBadge';
18
+ import { deletePage, type PageRead } from '../utils/api';
19
+
20
+ interface Props {
21
+ pages: { items: PageRead[] };
22
+ }
23
+
24
+ export default function PageList() {
25
+ const { pages } = usePage<{ props: Props }>().props as unknown as Props;
26
+ const [busy, setBusy] = useState<number | null>(null);
27
+
28
+ const handleDelete = async (id: number) => {
29
+ if (!confirm('Delete this page?')) return;
30
+ setBusy(id);
31
+ try {
32
+ await deletePage(id);
33
+ router.reload({ only: ['pages'] });
34
+ } finally {
35
+ setBusy(null);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <PageShell
41
+ title="Pages"
42
+ description="Compose, review, and publish site pages."
43
+ actions={
44
+ <>
45
+ <Button variant="outline" onClick={() => router.visit('/pagebuilder/pending')}>
46
+ Pending review
47
+ </Button>
48
+ <Button variant="outline" onClick={() => router.visit('/pagebuilder/layout')}>
49
+ Site layout
50
+ </Button>
51
+ <Button variant="outline" onClick={() => router.visit('/pagebuilder/media')}>
52
+ Media library
53
+ </Button>
54
+ <Button onClick={() => router.visit('/pagebuilder/new')}>New page</Button>
55
+ </>
56
+ }
57
+ >
58
+ {pages.items.length === 0 ? (
59
+ <div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
60
+ No pages yet. Click "New page" to create your first one.
61
+ </div>
62
+ ) : (
63
+ <Table>
64
+ <TableHeader>
65
+ <TableRow>
66
+ <TableHead>Title</TableHead>
67
+ <TableHead>Slug</TableHead>
68
+ <TableHead>Status</TableHead>
69
+ <TableHead>Updated</TableHead>
70
+ <TableHead className="text-right">Actions</TableHead>
71
+ </TableRow>
72
+ </TableHeader>
73
+ <TableBody>
74
+ {pages.items.map((p) => (
75
+ <TableRow key={p.id}>
76
+ <TableCell className="font-medium">{p.title}</TableCell>
77
+ <TableCell className="font-mono text-sm text-muted-foreground">{p.slug}</TableCell>
78
+ <TableCell>
79
+ <span className="flex flex-wrap items-center gap-1.5">
80
+ <StatusBadge status={p.status} />
81
+ <ScheduledBadge
82
+ status={p.status}
83
+ publishAt={p.publish_at}
84
+ unpublishAt={p.unpublish_at}
85
+ />
86
+ </span>
87
+ </TableCell>
88
+ <TableCell className="text-sm text-muted-foreground">
89
+ {p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}
90
+ </TableCell>
91
+ <TableCell className="space-x-1 text-right">
92
+ {/* Stays an <a>: the e2e selects it with getByRole('link'). */}
93
+ {p.status === 'published' && (
94
+ <a
95
+ href={`/p/${p.slug}`}
96
+ target="_blank"
97
+ rel="noopener noreferrer"
98
+ className="text-sm text-primary hover:underline"
99
+ >
100
+ View
101
+ </a>
102
+ )}
103
+ <Button
104
+ variant="link"
105
+ size="sm"
106
+ onClick={() => router.visit(`/pagebuilder/${p.id}/edit`)}
107
+ >
108
+ Edit
109
+ </Button>
110
+ <Button
111
+ variant="link"
112
+ size="sm"
113
+ className="text-destructive"
114
+ disabled={busy === p.id}
115
+ onClick={() => handleDelete(p.id)}
116
+ >
117
+ Delete
118
+ </Button>
119
+ </TableCell>
120
+ </TableRow>
121
+ ))}
122
+ </TableBody>
123
+ </Table>
124
+ )}
125
+ </PageShell>
126
+ );
127
+ }
128
+
129
+ PageList.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
@@ -0,0 +1,128 @@
1
+ import { router, usePage } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@simple-module-py/ui/components/ui/table';
12
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
13
+ import type React from 'react';
14
+ import { useState } from 'react';
15
+
16
+ import { approvePage, type PageRead, promptAndReject } from '../utils/api';
17
+
18
+ interface Props {
19
+ pages: { items: PageRead[] };
20
+ }
21
+
22
+ /**
23
+ * Approver-only queue showing pages currently in
24
+ * ``submitted_for_review``. Approve takes the page live; reject sends
25
+ * it back to draft with a required rationale (surfaced to editors in
26
+ * the editor header + history panel).
27
+ */
28
+ export default function PendingReview() {
29
+ const { pages } = usePage<{ props: Props }>().props as unknown as Props;
30
+ const [busy, setBusy] = useState<number | null>(null);
31
+ const [message, setMessage] = useState<string | null>(null);
32
+
33
+ const handleApprove = async (id: number) => {
34
+ if (!confirm('Approve and publish this page?')) return;
35
+ setBusy(id);
36
+ setMessage(null);
37
+ try {
38
+ await approvePage(id);
39
+ router.reload({ only: ['pages'] });
40
+ } catch (e) {
41
+ setMessage(e instanceof Error ? e.message : 'Approve failed');
42
+ } finally {
43
+ setBusy(null);
44
+ }
45
+ };
46
+
47
+ const handleReject = async (id: number) => {
48
+ setBusy(id);
49
+ setMessage(null);
50
+ const result = await promptAndReject(id);
51
+ setBusy(null);
52
+ if ('skipped' in result) {
53
+ if (result.skipped !== 'cancelled') setMessage(result.skipped);
54
+ return;
55
+ }
56
+ router.reload({ only: ['pages'] });
57
+ };
58
+
59
+ return (
60
+ <PageShell
61
+ title="Pending review"
62
+ description="Pages submitted for approval."
63
+ actions={
64
+ <Button variant="outline" onClick={() => router.visit('/pagebuilder')}>
65
+ ← All pages
66
+ </Button>
67
+ }
68
+ >
69
+ {message && <p className="mb-4 text-sm text-destructive">{message}</p>}
70
+
71
+ {pages.items.length === 0 ? (
72
+ <div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
73
+ No pages are awaiting review.
74
+ </div>
75
+ ) : (
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead>Title</TableHead>
80
+ <TableHead>Slug</TableHead>
81
+ <TableHead>Submitted</TableHead>
82
+ <TableHead className="text-right">Actions</TableHead>
83
+ </TableRow>
84
+ </TableHeader>
85
+ <TableBody>
86
+ {pages.items.map((p) => (
87
+ <TableRow key={p.id}>
88
+ <TableCell className="font-medium">{p.title}</TableCell>
89
+ <TableCell className="font-mono text-sm text-muted-foreground">{p.slug}</TableCell>
90
+ <TableCell className="text-sm text-muted-foreground">
91
+ {p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}
92
+ </TableCell>
93
+ <TableCell className="space-x-1 text-right">
94
+ <Button
95
+ variant="link"
96
+ size="sm"
97
+ onClick={() => router.visit(`/pagebuilder/${p.id}/edit`)}
98
+ >
99
+ Review
100
+ </Button>
101
+ <Button
102
+ variant="link"
103
+ size="sm"
104
+ disabled={busy === p.id}
105
+ onClick={() => handleApprove(p.id)}
106
+ >
107
+ Approve
108
+ </Button>
109
+ <Button
110
+ variant="link"
111
+ size="sm"
112
+ className="text-destructive"
113
+ disabled={busy === p.id}
114
+ onClick={() => handleReject(p.id)}
115
+ >
116
+ Reject
117
+ </Button>
118
+ </TableCell>
119
+ </TableRow>
120
+ ))}
121
+ </TableBody>
122
+ </Table>
123
+ )}
124
+ </PageShell>
125
+ );
126
+ }
127
+
128
+ PendingReview.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;