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,125 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+
3
+ import {
4
+ buildSrcset,
5
+ DEFAULT_IMAGE_SIZES,
6
+ lookupAsset,
7
+ type MediaAssetRead,
8
+ } from '../../utils/api';
9
+ import { MediaPicker } from './MediaPicker';
10
+
11
+ type AltKind = 'meaningful' | 'decorative';
12
+
13
+ interface ImageProps {
14
+ src: string;
15
+ alt: string;
16
+ altKind: AltKind;
17
+ width: number | null;
18
+ height: number | null;
19
+ objectFit: 'cover' | 'contain' | 'fill';
20
+ srcset: string;
21
+ sizes: string;
22
+ }
23
+
24
+ function resolveSizes(srcset: string, sizes: string): string | undefined {
25
+ if (sizes) return sizes;
26
+ // Browsers assume 100vw when sizes is omitted but srcset is present;
27
+ // setting it explicitly silences a console warning in dev tools.
28
+ if (srcset) return '100vw';
29
+ return undefined;
30
+ }
31
+
32
+ export const ImageBlock: ComponentConfig<ImageProps> = {
33
+ label: 'Image',
34
+ fields: {
35
+ src: {
36
+ type: 'custom',
37
+ render: ({ value, onChange, readOnly }) => (
38
+ <MediaPicker
39
+ value={(value as string) || ''}
40
+ onChange={(v) => onChange(v as ImageProps['src'])}
41
+ readOnly={readOnly}
42
+ />
43
+ ),
44
+ },
45
+ altKind: {
46
+ type: 'radio',
47
+ options: [
48
+ { label: 'Meaningful (describe the image)', value: 'meaningful' },
49
+ { label: 'Decorative (hidden from screen readers)', value: 'decorative' },
50
+ ],
51
+ },
52
+ alt: { type: 'text' },
53
+ width: { type: 'number' },
54
+ height: { type: 'number' },
55
+ objectFit: {
56
+ type: 'select',
57
+ options: [
58
+ { label: 'Cover', value: 'cover' },
59
+ { label: 'Contain', value: 'contain' },
60
+ { label: 'Fill', value: 'fill' },
61
+ ],
62
+ },
63
+ srcset: { type: 'textarea' },
64
+ sizes: { type: 'text' },
65
+ },
66
+ defaultProps: {
67
+ src: '',
68
+ alt: '',
69
+ altKind: 'meaningful',
70
+ width: null,
71
+ height: null,
72
+ objectFit: 'cover',
73
+ srcset: '',
74
+ sizes: '',
75
+ },
76
+ resolveData: async ({ props }, { changed }) => {
77
+ if (!changed.src || !props.src) return { props };
78
+ let asset: MediaAssetRead | undefined;
79
+ try {
80
+ asset = await lookupAsset(props.src);
81
+ } catch {
82
+ // Network failure mid-edit shouldn't break the editor — leave the
83
+ // src picked and let the author fill metadata manually.
84
+ return { props };
85
+ }
86
+ if (!asset) return { props };
87
+ return {
88
+ props: {
89
+ ...props,
90
+ width: asset.width ?? props.width,
91
+ height: asset.height ?? props.height,
92
+ srcset: buildSrcset(asset),
93
+ // Don't clobber values the author has set by hand.
94
+ sizes: props.sizes || DEFAULT_IMAGE_SIZES,
95
+ alt: props.alt || asset.original_filename.replace(/\.[^.]+$/, ''),
96
+ },
97
+ };
98
+ },
99
+ render: ({ src, alt, altKind, width, height, objectFit, srcset, sizes }) => {
100
+ if (!src) {
101
+ return (
102
+ <div className="my-3 p-6 border-2 border-dashed border-gray-300 rounded text-center text-gray-500 text-sm">
103
+ No image selected. Use the inspector to pick one.
104
+ </div>
105
+ );
106
+ }
107
+ const decorative = altKind === 'decorative';
108
+ return (
109
+ <img
110
+ src={src}
111
+ srcSet={srcset || undefined}
112
+ sizes={resolveSizes(srcset, sizes)}
113
+ alt={decorative ? '' : (alt ?? '')}
114
+ role={decorative ? 'presentation' : undefined}
115
+ aria-hidden={decorative ? true : undefined}
116
+ width={width ?? undefined}
117
+ height={height ?? undefined}
118
+ loading="lazy"
119
+ decoding="async"
120
+ style={{ objectFit, maxWidth: '100%' }}
121
+ className="my-3"
122
+ />
123
+ );
124
+ },
125
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Custom Puck field that opens a modal grid of uploaded media assets.
3
+ * Selecting an asset emits its URL via `onChange` and stashes the full
4
+ * `MediaAssetRead` in the picked-asset cache so the Image block's
5
+ * `resolveData` can back-fill sibling props without a second fetch.
6
+ * A manually typed URL is also accepted; `resolveData` then falls back
7
+ * to a fresh list lookup.
8
+ */
9
+
10
+ import { useEffect, useState } from 'react';
11
+
12
+ import { listMedia, type MediaAssetRead, rememberPickedAsset } from '../../utils/api';
13
+
14
+ interface MediaPickerProps {
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ readOnly?: boolean;
18
+ }
19
+
20
+ export function MediaPicker({ value, onChange, readOnly }: MediaPickerProps) {
21
+ const [open, setOpen] = useState(false);
22
+
23
+ return (
24
+ <div className="space-y-2">
25
+ <input
26
+ type="text"
27
+ value={value || ''}
28
+ onChange={(e) => onChange(e.target.value)}
29
+ readOnly={readOnly}
30
+ placeholder="Pick from library or paste a URL"
31
+ className="w-full px-2 py-1.5 border rounded text-sm"
32
+ />
33
+ <div className="flex gap-2">
34
+ <button
35
+ type="button"
36
+ disabled={readOnly}
37
+ onClick={() => setOpen(true)}
38
+ className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
39
+ >
40
+ Browse media…
41
+ </button>
42
+ {value && (
43
+ <button
44
+ type="button"
45
+ disabled={readOnly}
46
+ onClick={() => onChange('')}
47
+ className="px-3 py-1.5 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
48
+ >
49
+ Clear
50
+ </button>
51
+ )}
52
+ </div>
53
+ {value && (
54
+ <img src={value} alt="" className="max-h-24 rounded border bg-gray-50 object-contain" />
55
+ )}
56
+ {open && (
57
+ <MediaPickerModal
58
+ onPick={(asset) => {
59
+ rememberPickedAsset(asset);
60
+ onChange(asset.url);
61
+ setOpen(false);
62
+ }}
63
+ onClose={() => setOpen(false)}
64
+ />
65
+ )}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function MediaPickerModal({
71
+ onPick,
72
+ onClose,
73
+ }: {
74
+ onPick: (asset: MediaAssetRead) => void;
75
+ onClose: () => void;
76
+ }) {
77
+ const [assets, setAssets] = useState<MediaAssetRead[] | null>(null);
78
+ const [error, setError] = useState<string | null>(null);
79
+
80
+ useEffect(() => {
81
+ let cancelled = false;
82
+ listMedia()
83
+ .then((r) => {
84
+ if (!cancelled) setAssets(r.items);
85
+ })
86
+ .catch((e) => {
87
+ if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load media');
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, []);
93
+
94
+ // Close on Escape so keyboard-only users aren't trapped.
95
+ useEffect(() => {
96
+ const onKey = (e: KeyboardEvent) => {
97
+ if (e.key === 'Escape') onClose();
98
+ };
99
+ window.addEventListener('keydown', onKey);
100
+ return () => window.removeEventListener('keydown', onKey);
101
+ }, [onClose]);
102
+
103
+ return (
104
+ // Click-outside is a mouse affordance only. The keyboard path is the
105
+ // Escape handler registered above, plus the labelled Close button inside.
106
+ // biome-ignore lint/a11y/useKeyWithClickEvents: Escape and the Close button are the keyboard paths
107
+ <div
108
+ role="dialog"
109
+ aria-modal="true"
110
+ aria-label="Media library picker"
111
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
112
+ onClick={onClose}
113
+ >
114
+ {/* Not an interactive element — the handler only stops the backdrop's
115
+ click-to-close from firing when the click lands inside the dialog. */}
116
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: only stops backdrop click-through */}
117
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: only stops backdrop click-through */}
118
+ <div
119
+ className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col"
120
+ onClick={(e) => e.stopPropagation()}
121
+ >
122
+ <div className="flex items-center justify-between border-b p-4">
123
+ <h2 className="text-lg font-semibold">Pick an image</h2>
124
+ <button
125
+ type="button"
126
+ onClick={onClose}
127
+ className="text-gray-500 hover:text-gray-800 text-xl leading-none"
128
+ aria-label="Close picker"
129
+ >
130
+ ×
131
+ </button>
132
+ </div>
133
+ <div className="flex-1 overflow-auto p-4">
134
+ {error && <div className="text-red-600 text-sm">{error}</div>}
135
+ {!error && assets === null && <div className="text-gray-500 text-sm">Loading…</div>}
136
+ {assets && assets.length === 0 && (
137
+ <div className="text-gray-500 text-sm">
138
+ No uploads yet. Upload images from the Media library page.
139
+ </div>
140
+ )}
141
+ {assets && assets.length > 0 && (
142
+ <ul className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
143
+ {assets.map((a) => (
144
+ <li key={a.id}>
145
+ <button
146
+ type="button"
147
+ onClick={() => onPick(a)}
148
+ className="w-full text-left border rounded overflow-hidden hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
149
+ >
150
+ <div className="aspect-square bg-gray-100 flex items-center justify-center overflow-hidden">
151
+ {a.content_type.startsWith('image/') ? (
152
+ <img
153
+ src={a.url}
154
+ alt={a.original_filename}
155
+ className="max-h-full max-w-full object-contain"
156
+ />
157
+ ) : (
158
+ <span className="text-xs text-gray-500">{a.content_type}</span>
159
+ )}
160
+ </div>
161
+ <div className="p-2 text-xs space-y-0.5">
162
+ <div className="font-medium truncate" title={a.original_filename}>
163
+ {a.original_filename}
164
+ </div>
165
+ <div className="text-gray-500">
166
+ {a.width && a.height ? `${a.width}×${a.height}` : '—'}
167
+ </div>
168
+ </div>
169
+ </button>
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ )}
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,31 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+
3
+ interface SpacerProps {
4
+ size: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
5
+ }
6
+
7
+ const heights: Record<SpacerProps['size'], number> = {
8
+ xs: 8,
9
+ sm: 16,
10
+ md: 32,
11
+ lg: 64,
12
+ xl: 128,
13
+ };
14
+
15
+ export const SpacerBlock: ComponentConfig<SpacerProps> = {
16
+ label: 'Spacer',
17
+ fields: {
18
+ size: {
19
+ type: 'select',
20
+ options: [
21
+ { label: 'XS', value: 'xs' },
22
+ { label: 'S', value: 'sm' },
23
+ { label: 'M', value: 'md' },
24
+ { label: 'L', value: 'lg' },
25
+ { label: 'XL', value: 'xl' },
26
+ ],
27
+ },
28
+ },
29
+ defaultProps: { size: 'md' },
30
+ render: ({ size }) => <div style={{ height: heights[size] }} aria-hidden />,
31
+ };
@@ -0,0 +1,80 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+
3
+ import { cn } from '../../utils/widgetUtils';
4
+ import { RichTextBlock } from '../widgets/_internal/rich-text';
5
+
6
+ interface TextProps {
7
+ /**
8
+ * Light markdown, not HTML: `**bold**`, `*italic*`, `` `code` ``,
9
+ * `[label](href)`, `- ` bullets and blank-line paragraphs. See the note on
10
+ * the field below for why this isn't Puck's `richtext`.
11
+ */
12
+ text: string;
13
+ size: 'sm' | 'base' | 'lg' | 'xl';
14
+ align: 'left' | 'center' | 'right';
15
+ }
16
+
17
+ const sizeClass: Record<TextProps['size'], string> = {
18
+ sm: 'text-sm',
19
+ base: 'text-base',
20
+ lg: 'text-lg',
21
+ xl: 'text-xl',
22
+ };
23
+
24
+ // Spelled out rather than `text-${align}` — Tailwind's scanner cannot see an
25
+ // interpolated class name. See the same note in Heading.
26
+ const alignClass: Record<TextProps['align'], string> = {
27
+ left: 'text-left',
28
+ center: 'text-center',
29
+ right: 'text-right',
30
+ };
31
+
32
+ export const TextBlock: ComponentConfig<TextProps> = {
33
+ label: 'Text',
34
+ fields: {
35
+ /**
36
+ * Upstream uses Puck's `richtext` field here, which stores HTML and hands
37
+ * it to Puck to render. This stays a textarea rendered through
38
+ * `RichTextBlock`, which parses light markdown into React elements.
39
+ *
40
+ * Same expressive range for an author — bold, italic, code, links, bullets,
41
+ * paragraphs — but nothing on a published page is ever built from stored
42
+ * markup, so there is no injection surface to sanitise. That matters more
43
+ * here than upstream: pages reach the public viewer through a review
44
+ * workflow, and the CSP that would otherwise be the backstop is an
45
+ * operator-tunable setting that can be emptied. It also keeps this block
46
+ * consistent with the other 50, which all render copy this way.
47
+ */
48
+ text: { type: 'textarea', label: 'Text (markdown)' },
49
+ size: {
50
+ type: 'select',
51
+ label: 'Size',
52
+ options: [
53
+ { label: 'Small', value: 'sm' },
54
+ { label: 'Base', value: 'base' },
55
+ { label: 'Large', value: 'lg' },
56
+ { label: 'Extra large', value: 'xl' },
57
+ ],
58
+ },
59
+ align: {
60
+ type: 'radio',
61
+ label: 'Alignment',
62
+ options: [
63
+ { label: 'Left', value: 'left' },
64
+ { label: 'Center', value: 'center' },
65
+ { label: 'Right', value: 'right' },
66
+ ],
67
+ },
68
+ },
69
+ defaultProps: { text: 'Some descriptive text.', size: 'base', align: 'left' },
70
+ // `size` is defaulted here too: every Text already on a page predates the
71
+ // field and arrives undefined, which would index `sizeClass` to `undefined`.
72
+ render: ({ text, size = 'base', align = 'left' }) => (
73
+ <div className="container mx-auto px-4 py-2">
74
+ <RichTextBlock
75
+ text={text}
76
+ className={cn('leading-relaxed', sizeClass[size], alignClass[align])}
77
+ />
78
+ </div>
79
+ ),
80
+ };
@@ -0,0 +1,169 @@
1
+ /** Top toolbar: title/slug inputs, status badges, and workflow actions. */
2
+
3
+ import { router } from '@inertiajs/react';
4
+ import { Button } from '@simple-module-py/ui/components/ui/button';
5
+ import { Input } from '@simple-module-py/ui/components/ui/input';
6
+
7
+ import type { PageDetail } from '../../utils/api';
8
+ import { formatSaveLabel, type SaveState } from '../../utils/editorSnapshot';
9
+ import { slugify } from '../../utils/slugify';
10
+ import { ScheduledBadge } from '../ScheduledBadge';
11
+ import { StatusBadge } from '../StatusBadge';
12
+
13
+ interface Props {
14
+ pageId: number | null;
15
+ title: string;
16
+ onTitleChange: (value: string) => void;
17
+ effectiveSlug: string;
18
+ onSlugChange: (value: string) => void;
19
+ status: PageDetail['status'];
20
+ publishAt: string | null;
21
+ unpublishAt: string | null;
22
+ saveState: SaveState;
23
+ isDirty: boolean;
24
+ lastSavedAt: Date | null;
25
+ autosaveError: string | null;
26
+ revisionCount: number;
27
+ busy: boolean;
28
+ message: string | null;
29
+ onToggleSettings: () => void;
30
+ onToggleHistory: () => void;
31
+ onSave: () => void;
32
+ onPublish: () => void;
33
+ onUnpublish: () => void;
34
+ onSubmitForReview: () => void;
35
+ onApprove: () => void;
36
+ onReject: () => void;
37
+ }
38
+
39
+ export function PageEditorToolbar({
40
+ pageId,
41
+ title,
42
+ onTitleChange,
43
+ effectiveSlug,
44
+ onSlugChange,
45
+ status,
46
+ publishAt,
47
+ unpublishAt,
48
+ saveState,
49
+ isDirty,
50
+ lastSavedAt,
51
+ autosaveError,
52
+ revisionCount,
53
+ busy,
54
+ message,
55
+ onToggleSettings,
56
+ onToggleHistory,
57
+ onSave,
58
+ onPublish,
59
+ onUnpublish,
60
+ onSubmitForReview,
61
+ onApprove,
62
+ onReject,
63
+ }: Props) {
64
+ const saveStateClass =
65
+ saveState === 'error'
66
+ ? 'text-xs text-destructive'
67
+ : isDirty
68
+ ? 'text-xs text-amber-700'
69
+ : 'text-xs text-muted-foreground';
70
+
71
+ return (
72
+ <div className="flex flex-wrap items-center gap-3 border-b bg-background px-4 py-2">
73
+ <Button variant="link" size="sm" onClick={() => router.visit('/pagebuilder')}>
74
+ ← All pages
75
+ </Button>
76
+ {/* Explicit widths, not min-w: Input's base class list carries `w-full`,
77
+ which would stretch these across the row and wrap the toolbar onto
78
+ three lines. cn()'s tailwind-merge resolves w-64/w-52 over w-full. */}
79
+ <Input
80
+ type="text"
81
+ value={title}
82
+ onChange={(e) => onTitleChange(e.target.value)}
83
+ placeholder="Page title"
84
+ className="h-8 w-64 shrink-0 text-sm font-medium"
85
+ />
86
+ <Input
87
+ type="text"
88
+ value={effectiveSlug}
89
+ onChange={(e) => onSlugChange(slugify(e.target.value))}
90
+ placeholder="slug"
91
+ className="h-8 w-52 shrink-0 font-mono text-sm"
92
+ />
93
+ <StatusBadge status={status} />
94
+ <ScheduledBadge status={status} publishAt={publishAt} unpublishAt={unpublishAt} />
95
+ {pageId !== null && (
96
+ <span
97
+ className={saveStateClass}
98
+ data-testid="autosave-status"
99
+ title={autosaveError ?? undefined}
100
+ >
101
+ {formatSaveLabel(saveState, isDirty, lastSavedAt, autosaveError)}
102
+ </span>
103
+ )}
104
+ <div className="ml-auto flex gap-2">
105
+ <Button variant="outline" size="sm" onClick={onToggleSettings}>
106
+ SEO
107
+ </Button>
108
+ <Button variant="outline" size="sm" disabled={pageId === null} onClick={onToggleHistory}>
109
+ History ({revisionCount})
110
+ </Button>
111
+ <Button variant="outline" size="sm" disabled={busy} onClick={onSave}>
112
+ Save draft
113
+ </Button>
114
+ {status === 'published' && (
115
+ <Button variant="outline" size="sm" disabled={busy} onClick={onUnpublish}>
116
+ Unpublish
117
+ </Button>
118
+ )}
119
+ {status === 'draft' && (
120
+ <Button
121
+ variant="outline"
122
+ size="sm"
123
+ className="border-amber-500 text-amber-700 hover:bg-amber-50"
124
+ disabled={busy || pageId === null}
125
+ onClick={onSubmitForReview}
126
+ >
127
+ Submit for review
128
+ </Button>
129
+ )}
130
+ {status === 'submitted_for_review' && (
131
+ <>
132
+ <Button
133
+ variant="outline"
134
+ size="sm"
135
+ className="border-destructive text-destructive hover:bg-destructive/10"
136
+ disabled={busy}
137
+ onClick={onReject}
138
+ >
139
+ Reject
140
+ </Button>
141
+ <Button
142
+ size="sm"
143
+ className="bg-green-600 text-white hover:bg-green-700"
144
+ disabled={busy}
145
+ onClick={onApprove}
146
+ >
147
+ Approve
148
+ </Button>
149
+ </>
150
+ )}
151
+ <Button size="sm" disabled={busy} onClick={onPublish}>
152
+ Publish
153
+ </Button>
154
+ {/* Stays an <a>: the e2e selects it with getByRole('link', {name: /^view$/i}). */}
155
+ {status === 'published' && pageId !== null && (
156
+ <a
157
+ href={`/p/${effectiveSlug}`}
158
+ target="_blank"
159
+ rel="noopener noreferrer"
160
+ className="inline-flex h-8 items-center rounded-md border px-3 text-sm hover:bg-accent"
161
+ >
162
+ View
163
+ </a>
164
+ )}
165
+ </div>
166
+ {message && <span className="mt-1 w-full text-sm text-muted-foreground">{message}</span>}
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,95 @@
1
+ /** Revision history drawer with per-revision compare and restore. */
2
+
3
+ import type { PageRevisionRead, RevisionDiff, RevisionEvent } from '../../utils/api';
4
+ import { DiffSummary } from '../DiffSummary';
5
+
6
+ const EVENT_LABELS: Record<RevisionEvent, string> = {
7
+ publish: 'Published',
8
+ unpublish: 'Unpublished',
9
+ submit: 'Submitted for review',
10
+ approve: 'Approved',
11
+ reject: 'Rejected',
12
+ };
13
+
14
+ // Revision events whose ``data`` snapshot matches what /p/{slug} served.
15
+ const RESTORABLE_EVENTS: ReadonlySet<RevisionEvent> = new Set(['publish', 'approve']);
16
+
17
+ interface Props {
18
+ revisions: PageRevisionRead[];
19
+ activeDiff: RevisionDiff | null;
20
+ diffError: string | null;
21
+ busy: boolean;
22
+ onCompare: (beforeId: number, afterId: number) => void;
23
+ onRestore: (revisionId: number) => void;
24
+ }
25
+
26
+ export function RevisionHistoryPanel({
27
+ revisions,
28
+ activeDiff,
29
+ diffError,
30
+ busy,
31
+ onCompare,
32
+ onRestore,
33
+ }: Props) {
34
+ return (
35
+ <div className="border-b bg-gray-50 px-4 py-3 text-sm">
36
+ {revisions.length === 0 ? (
37
+ <p className="text-gray-500">No history yet. Publish or submit to record one.</p>
38
+ ) : (
39
+ <ul className="space-y-1 max-h-48 overflow-y-auto">
40
+ {revisions.map((r, idx) => {
41
+ // Revisions are sorted newest-first, so the "previous" is
42
+ // the next index. The last item has no predecessor.
43
+ const previous = revisions[idx + 1];
44
+ const isOpen =
45
+ activeDiff !== null &&
46
+ activeDiff.after_id === r.id &&
47
+ previous !== undefined &&
48
+ activeDiff.before_id === previous.id;
49
+ return (
50
+ <li key={r.id} className="flex items-start justify-between gap-3 py-1">
51
+ <div className="flex-1 min-w-0">
52
+ <span className="font-medium">{EVENT_LABELS[r.event] ?? r.event}</span>
53
+ <span className="text-gray-700 ml-2">{r.title}</span>
54
+ <span className="text-gray-500 ml-2">
55
+ {new Date(r.created_at).toLocaleString()}
56
+ </span>
57
+ {r.created_by && <span className="text-gray-500 ml-2">by {r.created_by}</span>}
58
+ {r.note && <div className="text-gray-800 mt-0.5 break-words">Note: {r.note}</div>}
59
+ </div>
60
+ <div className="flex gap-3 shrink-0">
61
+ {previous && (
62
+ <button
63
+ type="button"
64
+ onClick={() => onCompare(previous.id, r.id)}
65
+ className="text-blue-600 hover:underline"
66
+ data-testid={`compare-${r.id}`}
67
+ >
68
+ {isOpen ? 'Hide diff' : 'Compare'}
69
+ </button>
70
+ )}
71
+ {RESTORABLE_EVENTS.has(r.event) && (
72
+ <button
73
+ type="button"
74
+ disabled={busy}
75
+ onClick={() => onRestore(r.id)}
76
+ className="text-blue-600 hover:underline disabled:opacity-50"
77
+ >
78
+ Restore as draft
79
+ </button>
80
+ )}
81
+ </div>
82
+ </li>
83
+ );
84
+ })}
85
+ </ul>
86
+ )}
87
+ {diffError && (
88
+ <p className="mt-2 text-xs text-red-600" data-testid="diff-error">
89
+ {diffError}
90
+ </p>
91
+ )}
92
+ {activeDiff && <DiffSummary diff={activeDiff} />}
93
+ </div>
94
+ );
95
+ }