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,294 @@
1
+ /** Timeline render — both the default "rows" list and the "blobs" variant.
2
+ * Field definitions live in timeline-widget.tsx. */
3
+
4
+ import type { CSSProperties } from 'react';
5
+ import { cn } from '../../utils/widgetUtils';
6
+ import { AccentText } from './_internal/accent-text';
7
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
8
+ import { eyebrowTextStyle, maskStyle } from './_shared';
9
+
10
+ import { MARKER_BLOB, type TimelineItem, type TimelineWidgetProps } from './timeline-layout';
11
+
12
+ export function TimelineWidgetRender({
13
+ eyebrow,
14
+ title,
15
+ surface = 'default',
16
+ variant = 'rows',
17
+ blobMaskUrl = '',
18
+ markerMaskUrl = '',
19
+ markerImageUrl = '',
20
+ markerColor = '',
21
+ items,
22
+ }: TimelineWidgetProps) {
23
+ // Blobs: a row of organic coloured shapes (BioGarden "Projekt-Fahrplan").
24
+ // The blob content (marker pill, title, body) is centred and inverse; an
25
+ // empty mask falls back to a plain ellipse so the layout still reads.
26
+ if (variant === 'blobs') {
27
+ const markerMaskCss = markerMaskUrl ? maskStyle(markerMaskUrl) : undefined;
28
+ // Per-entry shape, falling back to the section-wide mask. Built once
29
+ // per distinct url rather than per item so mapped entries sharing a
30
+ // shape share one style object.
31
+ const maskCache = new Map<string, ReturnType<typeof maskStyle>>();
32
+ const maskFor = (it: TimelineItem) => {
33
+ const url = it.shapeMaskUrl || blobMaskUrl;
34
+ if (!url) return undefined;
35
+ let css = maskCache.get(url);
36
+ if (!css) {
37
+ css = maskStyle(url);
38
+ maskCache.set(url, css);
39
+ }
40
+ return css;
41
+ };
42
+ return (
43
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
44
+ {/* Eyebrow in the left margin, title starting at a fixed page-grid line —
45
+ `--pb-timeline-cols` (BioGarden Figma: the title begins at the grid's
46
+ 3rd edge, 228px into the 1344px content box). The fallback reproduces
47
+ the previous inline eyebrow-then-title row. */}
48
+ <div className="mb-10 flex flex-col gap-2 sm:grid sm:grid-cols-[var(--pb-timeline-cols,max-content_1fr)] sm:items-baseline sm:gap-x-[var(--pb-timeline-gap,2rem)]">
49
+ {eyebrow && (
50
+ <p
51
+ style={{
52
+ ...eyebrowTextStyle(),
53
+ // This call site's previous literal was `uppercase
54
+ // tracking-wide` UNCONDITIONALLY, unlike the other
55
+ // eyebrows. `eyebrowTextStyle`'s shared fallbacks are
56
+ // `none`/`normal`, so without restating them here a
57
+ // tenant that hasn't opted into uppercase eyebrows would
58
+ // silently get sentence case where it never could before.
59
+ textTransform:
60
+ 'var(--pb-eyebrow-transform, uppercase)' as CSSProperties['textTransform'],
61
+ letterSpacing: 'var(--pb-eyebrow-tracking, 0.025em)',
62
+ color: 'var(--pb-heading-color,#161728)',
63
+ }}
64
+ >
65
+ {renderRichText(eyebrow)}
66
+ </p>
67
+ )}
68
+ {title && (
69
+ <h2
70
+ className={cn('leading-tight', !eyebrow && 'sm:col-span-2')}
71
+ style={{
72
+ color: 'var(--pb-heading-color)',
73
+ fontWeight: 'var(--pb-display-weight)',
74
+ fontFamily: 'var(--pb-display-font)',
75
+ fontSize: 'var(--pb-heading-md, clamp(1.875rem, 1.3rem + 1.4vw, 2.5rem))',
76
+ }}
77
+ >
78
+ <AccentText text={title} />
79
+ </h2>
80
+ )}
81
+ </div>
82
+ {/* A uniform gap (no negative margin) keeps the blobs at the Figma's
83
+ ~415x343 with clear space between them; overlapping them pushed
84
+ each shape ~13% oversize and made neighbours collide. */}
85
+ <div className="flex flex-wrap justify-center gap-6 lg:flex-nowrap lg:justify-between">
86
+ {items.map((it, idx) => (
87
+ // The marker tag is a SIBLING of the masked blob, never a child:
88
+ // the blob's mask clips its own descendants, and the tag
89
+ // deliberately straddles the blob's edge.
90
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
91
+ <div key={idx} className="relative flex min-w-[360px] flex-1">
92
+ <div
93
+ className={cn(
94
+ // Text is left-aligned and vertically centred, inset far
95
+ // enough that the mask's curved edges never cross it (the
96
+ // design indents ~22% from the blob's left, ~14% right).
97
+ 'flex w-full flex-col items-start justify-center py-16 pr-[14%] pl-[22%] text-left',
98
+ !maskFor(it) && !it.shapeImageUrl && 'rounded-[50%]',
99
+ )}
100
+ style={
101
+ it.shapeImageUrl
102
+ ? {
103
+ // Supplied artwork carries both the shape and its
104
+ // fill; the copy simply lays out on top of it.
105
+ // Deliberately NO backgroundColor here: the artwork is
106
+ // a transparent cut-out, so a colour behind it would
107
+ // show through everywhere the PNG is transparent and
108
+ // the blob would render as a solid rectangle — which
109
+ // defeats the whole point of supplying the shape.
110
+ // The trade-off is that a 404 on the artwork paints
111
+ // nothing; the seed uploads these assets, so a missing
112
+ // one is a broken seed rather than a per-entry typo.
113
+ backgroundImage: `url(${it.shapeImageUrl})`,
114
+ backgroundSize: '100% 100%',
115
+ backgroundRepeat: 'no-repeat',
116
+ aspectRatio: '439 / 360',
117
+ color: 'var(--pb-surface-contrast, #ffffff)',
118
+ }
119
+ : {
120
+ // Fallback keeps the blob (and its forced contrast
121
+ // text) visible when an editor leaves the color blank.
122
+ backgroundColor: it.color || 'var(--pb-accent, #16a34a)',
123
+ aspectRatio: '439 / 360',
124
+ color: 'var(--pb-surface-contrast, #ffffff)',
125
+ ...(maskFor(it) ?? {}),
126
+ }
127
+ }
128
+ >
129
+ {/* The inline pill is the fallback for BOTH overlay forms —
130
+ without the `markerImageUrl` guard, the documented
131
+ "artwork replaces mask + colour" configuration (artwork
132
+ set, mask empty) renders the year twice. */}
133
+ {it.marker && !markerMaskCss && !markerImageUrl && (
134
+ <span
135
+ className="mb-3 inline-flex items-center rounded-full bg-white px-4 py-1 text-sm font-bold"
136
+ style={{ color: 'var(--pb-heading-color,#161728)' }}
137
+ >
138
+ {renderRichText(it.marker)}
139
+ </span>
140
+ )}
141
+ {it.title && <p className="text-xl font-bold">{renderRichText(it.title)}</p>}
142
+ {it.body && (
143
+ <RichTextBlock text={it.body} className="mt-2 text-sm leading-relaxed" />
144
+ )}
145
+ {it.meta && (
146
+ <p className="mt-3 text-sm font-bold italic leading-relaxed">
147
+ {renderRichText(it.meta)}
148
+ </p>
149
+ )}
150
+ </div>
151
+ {it.marker && markerImageUrl && (
152
+ <span
153
+ className="pointer-events-none absolute"
154
+ style={{
155
+ left: MARKER_BLOB.left,
156
+ top: MARKER_BLOB.top,
157
+ width: MARKER_BLOB.width,
158
+ aspectRatio: MARKER_BLOB.aspect,
159
+ transform: 'translate(-50%, -50%)',
160
+ }}
161
+ >
162
+ {/* Artwork is drawn already tilted, so no rotation here. */}
163
+ <img
164
+ src={markerImageUrl}
165
+ alt=""
166
+ aria-hidden="true"
167
+ loading="lazy"
168
+ className="absolute inset-0 size-full"
169
+ />
170
+ <span
171
+ className="absolute inset-0 flex items-center justify-center px-2 text-center text-[18px] font-bold leading-tight"
172
+ style={{
173
+ color: 'var(--pb-timeline-marker-color, #ffffff)',
174
+ }}
175
+ >
176
+ {renderRichText(it.marker)}
177
+ </span>
178
+ </span>
179
+ )}
180
+ {it.marker && !markerImageUrl && markerMaskCss && (
181
+ <span
182
+ className="pointer-events-none absolute"
183
+ style={{
184
+ left: MARKER_BLOB.left,
185
+ top: MARKER_BLOB.top,
186
+ width: MARKER_BLOB.width,
187
+ aspectRatio: MARKER_BLOB.aspect,
188
+ transform: 'translate(-50%, -50%)',
189
+ }}
190
+ >
191
+ {/* Tilted art layer… */}
192
+ <span
193
+ aria-hidden="true"
194
+ className="absolute inset-0"
195
+ style={{
196
+ backgroundColor: markerColor || 'var(--pb-accent, #16a34a)',
197
+ transform: `rotate(${MARKER_BLOB.rotation})`,
198
+ ...markerMaskCss,
199
+ }}
200
+ />
201
+ {/* …and an upright label over it, as in the design. */}
202
+ <span
203
+ className="absolute inset-0 flex items-center justify-center px-2 text-center text-[18px] font-bold leading-tight"
204
+ style={{
205
+ color: 'var(--pb-timeline-marker-color, #ffffff)',
206
+ }}
207
+ >
208
+ {renderRichText(it.marker)}
209
+ </span>
210
+ </span>
211
+ )}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ </section>
216
+ );
217
+ }
218
+ return (
219
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
220
+ <div
221
+ className={cn(
222
+ 'grid gap-y-6 md:grid-cols-[var(--pb-split-cols,1fr_2fr)] md:gap-x-[var(--pb-split-gap,2rem)]',
223
+ surface === 'muted' &&
224
+ 'rounded-[12px] bg-[var(--pb-surface-muted,#f3f3f4)] px-8 py-12 lg:px-0 lg:py-20',
225
+ )}
226
+ >
227
+ {eyebrow && (
228
+ <p
229
+ className={cn(surface === 'muted' && 'lg:pl-12')}
230
+ style={{
231
+ // Eyebrow size/weight/case/tracking are theme-driven; the
232
+ // fallbacks keep today's look for tenants that don't set them.
233
+ ...eyebrowTextStyle('1.125rem', '400'),
234
+ color: 'var(--pb-heading-color,#161728)',
235
+ }}
236
+ >
237
+ {renderRichText(eyebrow)}
238
+ </p>
239
+ )}
240
+ <div
241
+ className={cn('max-w-[var(--pb-split-content-max,48rem)]', !eyebrow && 'md:col-span-2')}
242
+ >
243
+ {title && (
244
+ <h2
245
+ className="leading-tight"
246
+ style={{
247
+ color: 'var(--pb-heading-color)',
248
+ fontWeight: 'var(--pb-display-weight)',
249
+ fontFamily: 'var(--pb-display-font)',
250
+ fontSize: 'var(--pb-heading-md, clamp(1.875rem, 1.3rem + 1.4vw, 2.5rem))',
251
+ }}
252
+ >
253
+ {renderRichText(title)}
254
+ </h2>
255
+ )}
256
+ <div className="mt-8">
257
+ {items.map((it, idx) => (
258
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
259
+ <div key={idx} className="border-b border-[var(--border,#dcdcdf)] py-6 first:pt-0">
260
+ {it.marker && (
261
+ <p
262
+ className="text-[1.75rem] font-medium leading-tight"
263
+ style={{ color: 'var(--pb-heading-color,#161728)' }}
264
+ >
265
+ {renderRichText(it.marker)}
266
+ </p>
267
+ )}
268
+ {it.title && (
269
+ <p
270
+ className="mt-2 text-xl font-medium"
271
+ style={{ color: 'var(--pb-heading-color,#161728)' }}
272
+ >
273
+ {renderRichText(it.title)}
274
+ </p>
275
+ )}
276
+ {it.body && (
277
+ <RichTextBlock
278
+ text={it.body}
279
+ className="mt-3 text-lg leading-[var(--pb-body-leading,1.625)] text-[var(--pb-body-color,#686873)]"
280
+ />
281
+ )}
282
+ {it.meta && (
283
+ <p className="mt-2 text-lg font-bold italic leading-[var(--pb-body-leading,1.625)] text-[var(--pb-body-color,#686873)]">
284
+ {renderRichText(it.meta)}
285
+ </p>
286
+ )}
287
+ </div>
288
+ ))}
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </section>
293
+ );
294
+ }
@@ -0,0 +1,106 @@
1
+ /** TimelineWidget: field definitions and config assembly.
2
+ * Types live in timeline-layout.ts, render in timeline-render.tsx. */
3
+
4
+ import type { ComponentConfig } from '@puckeditor/core';
5
+
6
+ import type { TimelineWidgetProps } from './timeline-layout';
7
+ import { TimelineWidgetRender } from './timeline-render';
8
+
9
+ export type * from './timeline-layout';
10
+
11
+ export const TimelineWidget: ComponentConfig<TimelineWidgetProps> = {
12
+ label: 'Timeline',
13
+ fields: {
14
+ eyebrow: { type: 'text', label: 'Eyebrow' },
15
+ title: { type: 'text', label: 'Title' },
16
+ surface: {
17
+ type: 'select',
18
+ label: 'Surface',
19
+ options: [
20
+ { label: 'Default', value: 'default' },
21
+ { label: 'Muted (soft card)', value: 'muted' },
22
+ ],
23
+ },
24
+ variant: {
25
+ type: 'select',
26
+ label: 'Variant',
27
+ options: [
28
+ { label: 'Rows (list)', value: 'rows' },
29
+ { label: 'Blobs (organic shapes)', value: 'blobs' },
30
+ ],
31
+ },
32
+ blobMaskUrl: {
33
+ type: 'text',
34
+ label: 'Blob mask URL (organic shape — blobs variant)',
35
+ },
36
+ markerMaskUrl: {
37
+ type: 'text',
38
+ label: 'Marker mask URL (organic tag shape — blobs variant)',
39
+ },
40
+ markerImageUrl: {
41
+ type: 'text',
42
+ label: 'Marker artwork (transparent PNG — replaces mask + colour)',
43
+ },
44
+ markerColor: {
45
+ type: 'text',
46
+ label: 'Marker color (CSS — blobs variant)',
47
+ },
48
+ items: {
49
+ type: 'array',
50
+ label: 'Entries',
51
+ arrayFields: {
52
+ marker: { type: 'text', label: 'Marker (e.g. year)' },
53
+ title: { type: 'text', label: 'Title' },
54
+ body: { type: 'textarea', label: 'Body' },
55
+ meta: { type: 'text', label: 'Trailing line (e.g. date)' },
56
+ color: { type: 'text', label: 'Blob color (CSS — blobs variant)' },
57
+ shapeMaskUrl: {
58
+ type: 'text',
59
+ label: 'Blob shape mask URL (overrides the section mask)',
60
+ },
61
+ shapeImageUrl: {
62
+ type: 'text',
63
+ label: 'Blob artwork (transparent PNG — replaces mask + colour)',
64
+ },
65
+ },
66
+ defaultItemProps: {
67
+ marker: '2026',
68
+ title: 'Milestone',
69
+ body: '',
70
+ meta: '',
71
+ color: '',
72
+ shapeMaskUrl: '',
73
+ shapeImageUrl: '',
74
+ },
75
+ min: 1,
76
+ max: 12,
77
+ },
78
+ },
79
+ defaultProps: {
80
+ eyebrow: '',
81
+ title: 'Incentives & Recognition',
82
+ surface: 'default',
83
+ variant: 'rows',
84
+ blobMaskUrl: '',
85
+ markerMaskUrl: '',
86
+ markerImageUrl: '',
87
+ markerColor: '',
88
+ items: [
89
+ {
90
+ marker: '2026',
91
+ title: 'Scientific Co-Authorship',
92
+ body: 'In the current year (2026) at the end of the project, the top data contributors will be invited to co-write and become co-authors in a data descriptor paper that will be submitted to a scientific journal.',
93
+ },
94
+ {
95
+ marker: '2027',
96
+ title: 'Additional Incentives',
97
+ body: 'In the next year (2027) the pilot project will include additionally some basic incentives for participants providing large quantities of high-quality quests.',
98
+ },
99
+ ],
100
+ },
101
+ // Column geometry is tenant-tunable via `--pb-split-cols`/`--pb-split-gap`
102
+ // (Mowing aligns the content to the 4th page-grid column) and the content is
103
+ // capped by `--pb-split-content-max` (Mowing → 774px per the Figma); the
104
+ // fallbacks keep tenants without the tokens on the prior layout.
105
+ render: TimelineWidgetRender,
106
+ };
@@ -0,0 +1,40 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { renderRichText } from './_internal/rich-text';
3
+
4
+ export type UnderConstructionWidgetProps = {
5
+ message: string;
6
+ };
7
+
8
+ export const UnderConstructionWidget: ComponentConfig<UnderConstructionWidgetProps> = {
9
+ label: 'Under Construction',
10
+ fields: {
11
+ message: { type: 'text', label: 'Message' },
12
+ },
13
+ defaultProps: {
14
+ message: 'This section is under construction.',
15
+ },
16
+ render: ({ message }) => (
17
+ <div className="container mx-auto px-4">
18
+ <div className="max-w-xl mx-auto py-12 px-6 text-center border-2 border-dashed border-yellow-400 rounded-xl bg-yellow-50 dark:bg-yellow-950/30">
19
+ <svg
20
+ className="size-10 mx-auto mb-3 text-yellow-600 dark:text-yellow-400"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ strokeWidth="2"
24
+ viewBox="0 0 24 24"
25
+ aria-hidden="true"
26
+ >
27
+ <title>Under construction</title>
28
+ <path
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
32
+ />
33
+ </svg>
34
+ <p className="text-yellow-900 dark:text-yellow-100 font-medium">
35
+ {renderRichText(message)}
36
+ </p>
37
+ </div>
38
+ </div>
39
+ ),
40
+ };
@@ -0,0 +1,103 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { createCheckboxField } from '../../fields';
3
+ import { EmptyPlaceholder } from './_shared';
4
+
5
+ export type VideoWidgetProps = {
6
+ src: string;
7
+ autoplay: boolean;
8
+ controls: boolean;
9
+ loop: boolean;
10
+ muted: boolean;
11
+ width: 'full' | 'container' | 'narrow';
12
+ };
13
+
14
+ function isEmbeddable(url: string): {
15
+ type: 'youtube' | 'vimeo' | 'file';
16
+ src: string;
17
+ } {
18
+ try {
19
+ const u = new URL(url);
20
+ if (u.hostname.includes('youtube.com') || u.hostname.includes('youtu.be')) {
21
+ const id = u.hostname.includes('youtu.be') ? u.pathname.slice(1) : u.searchParams.get('v');
22
+ return { type: 'youtube', src: `https://www.youtube.com/embed/${id}` };
23
+ }
24
+ if (u.hostname.includes('vimeo.com')) {
25
+ return {
26
+ type: 'vimeo',
27
+ src: `https://player.vimeo.com/video/${u.pathname.replace(/\//, '')}`,
28
+ };
29
+ }
30
+ return { type: 'file', src: url };
31
+ } catch {
32
+ return { type: 'file', src: url };
33
+ }
34
+ }
35
+
36
+ export const VideoWidget: ComponentConfig<VideoWidgetProps> = {
37
+ label: 'Video',
38
+ fields: {
39
+ src: { type: 'text', label: 'Video URL (YouTube, Vimeo, or file)' },
40
+ autoplay: createCheckboxField('Autoplay'),
41
+ controls: createCheckboxField('Show controls'),
42
+ loop: createCheckboxField('Loop'),
43
+ muted: createCheckboxField('Muted'),
44
+ width: {
45
+ type: 'select',
46
+ label: 'Width',
47
+ options: [
48
+ { label: 'Full', value: 'full' },
49
+ { label: 'Container', value: 'container' },
50
+ { label: 'Narrow (centered)', value: 'narrow' },
51
+ ],
52
+ },
53
+ },
54
+ defaultProps: {
55
+ src: '',
56
+ autoplay: false,
57
+ controls: true,
58
+ loop: false,
59
+ muted: false,
60
+ width: 'full',
61
+ },
62
+ render: ({ src, autoplay, controls, loop, muted, width = 'full' }) => {
63
+ if (!src) {
64
+ return <EmptyPlaceholder label="Video (no source)" />;
65
+ }
66
+ const wrap =
67
+ width === 'narrow' ? 'mx-auto max-w-3xl' : width === 'container' ? 'mx-auto max-w-5xl' : '';
68
+ const { type, src: embedSrc } = isEmbeddable(src);
69
+ if (type === 'file') {
70
+ return (
71
+ <div className="container mx-auto py-6">
72
+ <div className={wrap}>
73
+ <video
74
+ className="w-full rounded-lg"
75
+ src={embedSrc}
76
+ autoPlay={autoplay}
77
+ controls={controls}
78
+ loop={loop}
79
+ muted={muted}
80
+ >
81
+ <track kind="captions" />
82
+ </video>
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+ return (
88
+ <div className="container mx-auto py-6">
89
+ <div className={wrap}>
90
+ <div className="relative w-full" style={{ paddingBottom: '56.25%' }}>
91
+ <iframe
92
+ className="absolute inset-0 w-full h-full rounded-lg"
93
+ src={embedSrc}
94
+ title="Embedded video"
95
+ allow="autoplay; encrypted-media; picture-in-picture"
96
+ allowFullScreen
97
+ />
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ },
103
+ };
@@ -0,0 +1,38 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
3
+
4
+ export type WelcomeWidgetProps = {
5
+ greeting: string;
6
+ body: string;
7
+ signature: string;
8
+ };
9
+
10
+ export const WelcomeWidget: ComponentConfig<WelcomeWidgetProps> = {
11
+ label: 'Welcome',
12
+ fields: {
13
+ greeting: { type: 'text', label: 'Greeting' },
14
+ body: { type: 'textarea', label: 'Body' },
15
+ signature: { type: 'text', label: 'Signature' },
16
+ },
17
+ defaultProps: {
18
+ greeting: 'Welcome!',
19
+ body: "We're glad to have you here.",
20
+ signature: 'The team',
21
+ },
22
+ render: ({ greeting, body, signature }) => (
23
+ <div className="container mx-auto max-w-2xl py-12 px-4 text-center">
24
+ <h2 className="text-3xl sm:text-4xl lg:text-5xl font-light mb-4">
25
+ {renderRichText(greeting)}
26
+ </h2>
27
+ <RichTextBlock
28
+ text={body}
29
+ className="text-lg text-gray-700 dark:text-gray-300 leading-relaxed"
30
+ />
31
+ {signature && (
32
+ <p className="mt-6 italic text-gray-500 dark:text-gray-400">
33
+ — {renderRichText(signature)}
34
+ </p>
35
+ )}
36
+ </div>
37
+ ),
38
+ };
@@ -0,0 +1,57 @@
1
+ """PageBuilder contracts — the public interface for other modules.
2
+
3
+ Everything outside this package is internal and may change without a
4
+ major version bump.
5
+ """
6
+
7
+ from pagebuilder.contracts.schemas import (
8
+ BlockChange,
9
+ LayoutDetail,
10
+ LayoutRead,
11
+ LayoutRevisionDetail,
12
+ LayoutRevisionListResponse,
13
+ LayoutRevisionRead,
14
+ LayoutUpdate,
15
+ MediaAssetListResponse,
16
+ MediaAssetRead,
17
+ MediaAssetVariant,
18
+ MetadataChange,
19
+ PageCreate,
20
+ PageDetail,
21
+ PageListResponse,
22
+ PageNoteRequest,
23
+ PageRead,
24
+ PageRejectRequest,
25
+ PageRevisionDetail,
26
+ PageRevisionListResponse,
27
+ PageRevisionRead,
28
+ PageScheduleRequest,
29
+ PageUpdate,
30
+ RevisionDiffResponse,
31
+ )
32
+
33
+ __all__ = [
34
+ "BlockChange",
35
+ "LayoutDetail",
36
+ "LayoutRead",
37
+ "LayoutRevisionDetail",
38
+ "LayoutRevisionListResponse",
39
+ "LayoutRevisionRead",
40
+ "LayoutUpdate",
41
+ "MediaAssetListResponse",
42
+ "MediaAssetRead",
43
+ "MediaAssetVariant",
44
+ "MetadataChange",
45
+ "PageCreate",
46
+ "PageDetail",
47
+ "PageListResponse",
48
+ "PageNoteRequest",
49
+ "PageRead",
50
+ "PageRejectRequest",
51
+ "PageRevisionDetail",
52
+ "PageRevisionListResponse",
53
+ "PageRevisionRead",
54
+ "PageScheduleRequest",
55
+ "PageUpdate",
56
+ "RevisionDiffResponse",
57
+ ]
@@ -0,0 +1,25 @@
1
+ """Domain events other modules can subscribe to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from simple_module_core.events import Event
8
+
9
+
10
+ @dataclass
11
+ class PageDeleted(Event):
12
+ """A page and its revisions were removed.
13
+
14
+ Modules that key their own rows to a page must handle this. Nothing else
15
+ can: the framework gives each module its own ``MetaData``, so a
16
+ cross-module foreign key cannot be declared and there is no
17
+ ``ON DELETE CASCADE`` to fall back on.
18
+
19
+ It matters more than "a row points at nothing" suggests — SQLite reuses a
20
+ deleted row's id, so a stale reference does not stay dangling, it silently
21
+ re-attaches to whatever page is created next.
22
+ """
23
+
24
+ page_id: int
25
+ slug: str