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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- 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
|