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,156 @@
1
+ /** Props type and grid/heading tables for the media-object widget. */
2
+
3
+ import type { CSSProperties } from 'react';
4
+
5
+ export type MediaObjectWidgetProps = {
6
+ imageUrl: string;
7
+ imageAlt: string;
8
+ imagePosition: 'left' | 'right';
9
+ heading: string;
10
+ body: string;
11
+ linkLabel: string;
12
+ linkHref: string;
13
+ logos?: { src: string; alt: string }[];
14
+ /** "narrow" constrains the heading so a short title wraps to two lines. */
15
+ headingWidth?: 'auto' | 'narrow';
16
+ /**
17
+ * Organic mask (SVG url, supplied by the tenant's content) applied to the
18
+ * side image. When set the image is clipped to the shape and loses its
19
+ * rounded corners. Empty = the plain rounded image (unchanged).
20
+ */
21
+ imageMaskUrl?: string;
22
+ /**
23
+ * How the side image is framed. "rounded" is the default rounded rectangle.
24
+ * "native" renders the file exactly as supplied — no crop, no rounding, no
25
+ * mask — for transparent artwork that is already cut to its final shape.
26
+ * Preferred over `imageMaskUrl`: a supplied cut-out is pixel-accurate, where
27
+ * a mask re-derives the silhouette and has to be kept in sync by hand.
28
+ */
29
+ imageShape?: 'rounded' | 'native';
30
+ /** Chip overlaid top-left on the image (e.g. a location tag). */
31
+ imageTag?: string;
32
+ /** CSS color for the image tag chip (text stays surface-contrast). */
33
+ imageTagColor?: string;
34
+ /**
35
+ * Organic mask for the image tag. Set it and the tag renders as the Figma's
36
+ * tilted blob overhanging the photo's top-left instead of a rounded pill.
37
+ */
38
+ imageTagMaskUrl?: string;
39
+ /**
40
+ * Transparent artwork for the image tag, used INSTEAD of `imageTagMaskUrl` +
41
+ * `imageTagColor`: the file carries the tag's shape and colour, and only the
42
+ * upright label is drawn over it.
43
+ */
44
+ imageTagImageUrl?: string;
45
+ /** Small pill rendered above the heading (e.g. a date/time or a count). */
46
+ datePill?: string;
47
+ /** CSS color for the date pill (text stays surface-contrast). */
48
+ datePillColor?: string;
49
+ surface: 'default' | 'dark' | 'muted';
50
+ /**
51
+ * Optional CSS color for the band background (e.g. a tenant's program
52
+ * color). When set, the band renders like the dark surface (white text)
53
+ * but on this color instead of `--secondary`.
54
+ */
55
+ surfaceColor?: string;
56
+ /** Small label in the first grid column (grid/banner layouts only). */
57
+ eyebrow?: string;
58
+ /**
59
+ * Structured points rendered under `body` as a marker list — the Figma's
60
+ * "Warum mitmachen?" (title + explanation per point) and "Netzwerk" (bare
61
+ * names). Kept separate from `body`, which is one text block and can only
62
+ * flatten such a list into indistinguishable lines.
63
+ */
64
+ bullets?: { title: string; body?: string }[];
65
+ /**
66
+ * Optional CSS max-width for the body paragraph alone (the Figma caps the
67
+ * event-card copy at 478px inside a wider text column). Empty = unconstrained.
68
+ */
69
+ bodyMaxWidth?: string;
70
+ /**
71
+ * Trailing credit line under the body, set semibold italic — the Figma's
72
+ * "In Zusammenarbeit mit …" on each event card.
73
+ */
74
+ footnote?: string;
75
+ /** Point-title size: `md` = 20px, `lg` = 24px (both from the Figma). */
76
+ bulletSize?: 'md' | 'lg';
77
+ /**
78
+ * Render `linkLabel` as the tenant's outlined CTA button rather than an
79
+ * inline text link — the Figma draws the "Projekt-Flyer herunterladen"
80
+ * action on the green band as a bordered pill, not a link.
81
+ */
82
+ linkVariant?: 'link' | 'button';
83
+ /** Show the design's small square marker before each point. */
84
+ bulletMarker?: 'square' | 'none';
85
+ /**
86
+ * "half" is the legacy 50/50 flex split. "grid" aligns image and text to the
87
+ * 12-column page grid (Mowing Figma: image spans 6 columns, text sits at the
88
+ * 2nd column for image-right / ends at the 12th for image-left). "banner"
89
+ * drops the side image: eyebrow left, text at the 4th column, with the image
90
+ * (e.g. a partner-logo strip) rendered underneath the text.
91
+ */
92
+ layout?: 'half' | 'grid' | 'banner';
93
+ /** Text column width on the page grid: xs ≈ 315px, sm ≈ 432px, md ≈ 546px. */
94
+ textWidth?: 'auto' | 'xs' | 'sm' | 'md';
95
+ };
96
+
97
+ // Display tokens for the heading — weight/tracking/font come from the per-tenant
98
+ // page-builder theme (see styles.css `:root` for Recodo defaults and `.gca-root`
99
+ // for the GCA overrides). Color is overridden to white in the dark surface. The
100
+ // font-size reads `--pb-heading-lg` but falls back to the prior size, so tenants
101
+ // that don't set the token (GCA, Recodo) render unchanged.
102
+
103
+ export function headingStyle(
104
+ inverse: boolean,
105
+ layout: MediaObjectWidgetProps['layout'] = 'half',
106
+ ): CSSProperties {
107
+ return {
108
+ fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
109
+ letterSpacing: 'var(--pb-display-tracking)',
110
+ fontFamily: 'var(--pb-display-font)',
111
+ // The banner reads the section-heading size (`--pb-heading-md`, Mowing →
112
+ // 40px) so its title matches the other eyebrow sections on the page.
113
+ fontSize:
114
+ layout === 'banner'
115
+ ? 'var(--pb-heading-md, clamp(1.875rem, 1.3rem + 1.4vw, 2.5rem))'
116
+ : 'var(--pb-heading-lg, clamp(1.875rem, 1.3rem + 1.8vw, 2.25rem))',
117
+ color: inverse ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--pb-heading-color)',
118
+ };
119
+ }
120
+
121
+ // 12-col grid placement (gap 24px): the text column spans 3/4/5 columns and the
122
+ // image spans the design's 6 columns for image-right, or fills the columns left
123
+ // of the text for image-left — both text placements end flush with the Figma
124
+ // grid (image-right text starts at col 2; image-left text ends before col 12).
125
+ export const GRID_TEXT: Record<'xs' | 'sm' | 'md', { right: string; left: string; max: string }> = {
126
+ xs: {
127
+ right: 'lg:col-start-2 lg:col-span-3',
128
+ left: 'lg:col-start-9 lg:col-span-3',
129
+ max: 'max-w-[315px]',
130
+ },
131
+ sm: {
132
+ right: 'lg:col-start-2 lg:col-span-4',
133
+ left: 'lg:col-start-8 lg:col-span-4',
134
+ max: 'max-w-[432px]',
135
+ },
136
+ md: {
137
+ right: 'lg:col-start-2 lg:col-span-5',
138
+ left: 'lg:col-start-7 lg:col-span-5',
139
+ max: 'max-w-[546px]',
140
+ },
141
+ };
142
+
143
+ export const GRID_IMAGE: Record<'xs' | 'sm' | 'md', { right: string; left: string }> = {
144
+ xs: {
145
+ right: 'lg:col-start-7 lg:col-span-6',
146
+ left: 'lg:col-start-1 lg:col-span-7',
147
+ },
148
+ sm: {
149
+ right: 'lg:col-start-7 lg:col-span-6',
150
+ left: 'lg:col-start-1 lg:col-span-6',
151
+ },
152
+ md: {
153
+ right: 'lg:col-start-7 lg:col-span-6',
154
+ left: 'lg:col-start-1 lg:col-span-5',
155
+ },
156
+ };
@@ -0,0 +1,284 @@
1
+ /** MediaObject render. The props type and layout tables live in
2
+ * media-object-layout.ts so this file stays under the 300-line cap. */
3
+
4
+ /** MediaObjectWidget types, helpers and render — split from media-object-widget.tsx,
5
+ * which keeps the field definitions, so both stay under the 300-line cap. */
6
+
7
+ import type { CSSProperties } from 'react';
8
+ import { cn } from '../../utils/widgetUtils';
9
+ import { AccentText } from './_internal/accent-text';
10
+ import { LinkArrow } from './_internal/link-arrow';
11
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
12
+ import { CTAButton, eyebrowTextStyle, maskStyle } from './_shared';
13
+
14
+ import { GRID_IMAGE, GRID_TEXT, type MediaObjectWidgetProps } from './media-object-layout';
15
+ import { MediaObjectText } from './media-object-text';
16
+
17
+ export type { MediaObjectWidgetProps } from './media-object-layout';
18
+
19
+ export const MediaObjectWidgetRender = ({
20
+ imageUrl,
21
+ imageAlt,
22
+ imageMaskUrl = '',
23
+ imageShape = 'rounded',
24
+ imageTag = '',
25
+ imageTagColor = '',
26
+ imageTagMaskUrl = '',
27
+ imageTagImageUrl = '',
28
+ imagePosition,
29
+ eyebrow = '',
30
+ datePill = '',
31
+ datePillColor = '',
32
+ bodyMaxWidth = '',
33
+ footnote = '',
34
+ bullets = [],
35
+ bulletSize = 'md',
36
+ bulletMarker = 'square',
37
+ linkVariant = 'link',
38
+ heading,
39
+ body,
40
+ linkLabel,
41
+ linkHref,
42
+ logos,
43
+ headingWidth = 'auto',
44
+ layout = 'half',
45
+ textWidth = 'auto',
46
+ surface,
47
+ surfaceColor = '',
48
+ }: MediaObjectWidgetProps) => {
49
+ const isDark = surface === 'dark' || !!surfaceColor;
50
+ const grid = layout === 'grid';
51
+ // "auto" keeps the legacy unconstrained text; the grid layout needs a
52
+ // concrete column span, so it falls back to the 4-column (~432px) text.
53
+ const widthKey = textWidth === 'auto' ? 'sm' : textWidth;
54
+ const textMax = textWidth === 'auto' ? '' : GRID_TEXT[widthKey].max;
55
+
56
+ const textBlock = (
57
+ <MediaObjectText
58
+ {...({
59
+ eyebrow,
60
+ datePill,
61
+ datePillColor,
62
+ heading,
63
+ body,
64
+ bodyMaxWidth,
65
+ bullets,
66
+ bulletSize,
67
+ bulletMarker,
68
+ footnote,
69
+ linkLabel,
70
+ linkHref,
71
+ linkVariant,
72
+ logos,
73
+ headingWidth,
74
+ layout,
75
+ } as MediaObjectWidgetProps)}
76
+ isDark={isDark}
77
+ grid={grid}
78
+ textMax={textMax}
79
+ />
80
+ );
81
+
82
+ // Supplied cut-out wins over a mask: the artwork already has the shape, so
83
+ // it renders untouched. A mask is the fallback for square source images.
84
+ const native = imageShape === 'native';
85
+ const masked = !native && Boolean(imageMaskUrl);
86
+ const imageEl = imageUrl ? (
87
+ <img
88
+ src={imageUrl}
89
+ alt={imageAlt || heading}
90
+ loading="lazy"
91
+ className={cn('w-full h-auto', !masked && !native && 'rounded-lg')}
92
+ style={masked ? maskStyle(imageMaskUrl) : undefined}
93
+ />
94
+ ) : (
95
+ <div
96
+ className={cn(
97
+ 'w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300',
98
+ !masked && !native && 'rounded-lg',
99
+ )}
100
+ style={masked ? maskStyle(imageMaskUrl) : undefined}
101
+ />
102
+ );
103
+ const image = imageTag ? (
104
+ <div className="relative">
105
+ {imageEl}
106
+ {imageTagImageUrl ? (
107
+ // Supplied tag artwork: the file carries the shape AND the colour,
108
+ // so only the upright label is drawn over it. No colour behind it
109
+ // for the same reason as the timeline blob — the artwork is a
110
+ // transparent cut-out, and a backing colour would square off the
111
+ // tilted tag shape.
112
+ <span className="pointer-events-none absolute -top-4 -left-3 block w-[34%] max-w-[188px]">
113
+ <img
114
+ src={imageTagImageUrl}
115
+ alt=""
116
+ aria-hidden="true"
117
+ loading="lazy"
118
+ className="block h-auto w-full"
119
+ />
120
+ <span
121
+ className="absolute inset-0 flex items-center justify-center px-2 text-center text-[20px] font-bold leading-tight"
122
+ style={{ color: 'var(--pb-surface-contrast, #ffffff)' }}
123
+ >
124
+ {renderRichText(imageTag)}
125
+ </span>
126
+ </span>
127
+ ) : imageTagMaskUrl ? (
128
+ // Figma tag: a tilted blob straddling the photo's top-left corner
129
+ // with an upright label over it (same two-layer construction the
130
+ // timeline marker uses — the art rotates, the text does not).
131
+ <span
132
+ className="pointer-events-none absolute -top-4 -left-3 block w-[34%] max-w-[188px]"
133
+ style={{ aspectRatio: '188 / 161' }}
134
+ >
135
+ <span
136
+ aria-hidden="true"
137
+ className="absolute inset-0"
138
+ style={{
139
+ backgroundColor: imageTagColor || 'var(--pb-accent, #16a34a)',
140
+ transform: 'rotate(-18.2deg)',
141
+ ...maskStyle(imageTagMaskUrl),
142
+ }}
143
+ />
144
+ <span
145
+ className="absolute inset-0 flex items-center justify-center px-2 text-center text-[20px] font-bold leading-tight"
146
+ style={{ color: 'var(--pb-surface-contrast, #ffffff)' }}
147
+ >
148
+ {renderRichText(imageTag)}
149
+ </span>
150
+ </span>
151
+ ) : (
152
+ <span
153
+ className="absolute left-4 top-6 inline-flex items-center rounded-full px-4 py-1.5 text-sm font-bold"
154
+ style={{
155
+ backgroundColor: imageTagColor || 'var(--pb-accent, #16a34a)',
156
+ color: 'var(--pb-surface-contrast, #ffffff)',
157
+ }}
158
+ >
159
+ {renderRichText(imageTag)}
160
+ </span>
161
+ )}
162
+ </div>
163
+ ) : (
164
+ imageEl
165
+ );
166
+
167
+ // Banner: eyebrow in the first grid column, text at the 4th column
168
+ // (Figma "Project Partners": 652px text, logo strip underneath).
169
+ if (layout === 'banner') {
170
+ return (
171
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
172
+ <div
173
+ className={cn(
174
+ 'rounded-[12px] px-8 py-12 lg:grid lg:grid-cols-12 lg:gap-6 lg:px-0 lg:py-20',
175
+ isDark && !surfaceColor
176
+ ? 'bg-[var(--secondary,#1a353e)]'
177
+ : surface === 'muted' && !surfaceColor
178
+ ? 'bg-[var(--pb-surface-muted,#f3f3f4)]'
179
+ : '',
180
+ )}
181
+ style={surfaceColor ? { backgroundColor: surfaceColor } : undefined}
182
+ >
183
+ {eyebrow && (
184
+ <p
185
+ className="mb-6 lg:col-span-3 lg:mb-0 lg:pl-12"
186
+ style={{
187
+ ...eyebrowTextStyle('1.125rem', '400'),
188
+ color: isDark ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--pb-heading-color)',
189
+ }}
190
+ >
191
+ {renderRichText(eyebrow)}
192
+ </p>
193
+ )}
194
+ <div className="max-w-[652px] space-y-4 lg:col-start-4 lg:col-span-7">
195
+ {textBlock}
196
+ {imageUrl && (
197
+ <img
198
+ src={imageUrl}
199
+ alt={imageAlt || heading}
200
+ loading="lazy"
201
+ className="mt-8 h-[68px] w-auto max-w-full object-contain object-left"
202
+ />
203
+ )}
204
+ </div>
205
+ </div>
206
+ </section>
207
+ );
208
+ }
209
+
210
+ const layoutBlock = grid ? (
211
+ // 12-col page grid: text and image cells sit on the same columns as the
212
+ // Figma layout, so section text lines up across the whole page. Both
213
+ // cells pin to row 1: the image cell renders first in the DOM, so for
214
+ // image-right (image at col 7, text at col 2) grid auto-placement would
215
+ // otherwise wrap the text cell to a second row instead of beside it.
216
+ <div className="flex flex-col gap-8 lg:grid lg:grid-cols-12 lg:items-center lg:gap-6">
217
+ <div
218
+ className={cn(
219
+ 'w-full lg:row-start-1',
220
+ imagePosition === 'right' ? GRID_IMAGE[widthKey].right : GRID_IMAGE[widthKey].left,
221
+ )}
222
+ >
223
+ {image}
224
+ </div>
225
+ <div
226
+ className={cn(
227
+ 'space-y-4 lg:row-start-1',
228
+ textMax,
229
+ imagePosition === 'right' ? GRID_TEXT[widthKey].right : GRID_TEXT[widthKey].left,
230
+ )}
231
+ >
232
+ {textBlock}
233
+ </div>
234
+ </div>
235
+ ) : (
236
+ <div
237
+ className={cn(
238
+ 'flex flex-col md:flex-row gap-8 lg:gap-12 items-center',
239
+ imagePosition === 'right' ? 'md:flex-row-reverse' : '',
240
+ )}
241
+ >
242
+ <div className="md:w-1/2">{image}</div>
243
+ <div className={cn('md:w-1/2 space-y-4', textMax)}>{textBlock}</div>
244
+ </div>
245
+ );
246
+
247
+ if (isDark) {
248
+ return (
249
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
250
+ <div
251
+ className={cn(
252
+ 'rounded-[12px]',
253
+ !surfaceColor && 'bg-[var(--secondary,#1a353e)]',
254
+ grid ? 'px-8 py-12 lg:px-0' : 'p-8 lg:p-12',
255
+ )}
256
+ style={surfaceColor ? { backgroundColor: surfaceColor } : undefined}
257
+ >
258
+ {layoutBlock}
259
+ </div>
260
+ </section>
261
+ );
262
+ }
263
+
264
+ if (surface === 'muted') {
265
+ return (
266
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
267
+ <div
268
+ className={cn(
269
+ 'rounded-[12px] bg-[var(--pb-surface-muted,#f3f3f4)]',
270
+ grid ? 'px-8 py-12 lg:px-0' : 'p-8 sm:p-10 lg:p-14',
271
+ )}
272
+ >
273
+ {layoutBlock}
274
+ </div>
275
+ </section>
276
+ );
277
+ }
278
+
279
+ return (
280
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
281
+ {layoutBlock}
282
+ </section>
283
+ );
284
+ };
@@ -0,0 +1,202 @@
1
+ /** The text column of the media-object widget — eyebrow, heading, body,
2
+ * bullets, footnote and link. Split out so the render file stays under
3
+ * the 300-line cap. */
4
+
5
+ import type { CSSProperties } from 'react';
6
+
7
+ import { cn } from '../../utils/widgetUtils';
8
+ import { AccentText } from './_internal/accent-text';
9
+ import { LinkArrow } from './_internal/link-arrow';
10
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
11
+ import { CTAButton, eyebrowTextStyle, maskStyle } from './_shared';
12
+ import { GRID_TEXT, headingStyle, type MediaObjectWidgetProps } from './media-object-layout';
13
+
14
+ type TextBlockProps = MediaObjectWidgetProps & {
15
+ isDark: boolean;
16
+ grid: boolean;
17
+ textMax: string;
18
+ };
19
+
20
+ export function MediaObjectText({
21
+ eyebrow = '',
22
+ datePill = '',
23
+ datePillColor = '',
24
+ heading,
25
+ body,
26
+ bodyMaxWidth = '',
27
+ bullets = [],
28
+ bulletSize = 'md',
29
+ bulletMarker = 'square',
30
+ footnote = '',
31
+ linkLabel,
32
+ linkHref,
33
+ linkVariant = 'link',
34
+ logos,
35
+ headingWidth = 'auto',
36
+ layout = 'half',
37
+ isDark,
38
+ grid,
39
+ textMax,
40
+ }: TextBlockProps) {
41
+ return (
42
+ <>
43
+ {/* The banner layout renders the eyebrow in its own grid column, so
44
+ only the other layouts carry it inline above the heading (the
45
+ BioGarden "VIELFÄLTIGE GÄRTEN" kicker). */}
46
+ {eyebrow && layout !== 'banner' && (
47
+ <p
48
+ style={{
49
+ ...eyebrowTextStyle(),
50
+ color: isDark
51
+ ? 'var(--pb-surface-contrast, #ffffff)'
52
+ : 'var(--pb-heading-color, #161728)',
53
+ }}
54
+ >
55
+ {renderRichText(eyebrow)}
56
+ </p>
57
+ )}
58
+ {datePill && (
59
+ <span
60
+ className="inline-flex w-fit items-center rounded-full px-3 py-1 text-sm font-bold"
61
+ style={{
62
+ backgroundColor: datePillColor || 'var(--pb-accent, #16a34a)',
63
+ color: 'var(--pb-surface-contrast, #ffffff)',
64
+ }}
65
+ >
66
+ {renderRichText(datePill)}
67
+ </span>
68
+ )}
69
+ {heading && (
70
+ <h3
71
+ className={cn('leading-tight', headingWidth === 'narrow' && 'max-w-[400px]')}
72
+ style={headingStyle(isDark, layout)}
73
+ >
74
+ <AccentText text={heading} />
75
+ </h3>
76
+ )}
77
+ {body && (
78
+ <RichTextBlock
79
+ text={body}
80
+ className={cn(
81
+ 'whitespace-pre-line text-lg leading-[var(--pb-body-leading,1.625)]',
82
+ isDark && 'opacity-80',
83
+ )}
84
+ style={{
85
+ color: isDark ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--pb-body-color)',
86
+ maxWidth: bodyMaxWidth || undefined,
87
+ }}
88
+ />
89
+ )}
90
+ {footnote && (
91
+ <p
92
+ className={cn(
93
+ 'font-semibold italic leading-[var(--pb-body-leading,1.625)]',
94
+ isDark && 'opacity-80',
95
+ )}
96
+ style={{
97
+ color: isDark ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--pb-body-color)',
98
+ }}
99
+ >
100
+ {renderRichText(footnote)}
101
+ </p>
102
+ )}
103
+ {bullets.filter((b) => b.title || b.body).length > 0 && (
104
+ // The design's point list: a small square marker, a Medium point
105
+ // title, and an optional explanation line beneath it.
106
+ <ul className="flex list-none flex-col gap-6 pt-2">
107
+ {bullets
108
+ .filter((b) => b.title || b.body)
109
+ .map((point, i) => (
110
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
111
+ <li key={i} className="flex gap-3">
112
+ {bulletMarker === 'square' && (
113
+ <span
114
+ aria-hidden="true"
115
+ className="mt-[0.55em] size-[7px] shrink-0 bg-current"
116
+ style={{
117
+ color: isDark
118
+ ? 'var(--pb-surface-contrast, #ffffff)'
119
+ : 'var(--pb-heading-color, #161728)',
120
+ }}
121
+ />
122
+ )}
123
+ <div className="flex flex-col gap-2">
124
+ {point.title && (
125
+ <p
126
+ className={cn(
127
+ 'font-medium leading-tight',
128
+ bulletSize === 'lg' ? 'text-[24px]' : 'text-[20px]',
129
+ )}
130
+ style={{
131
+ color: isDark
132
+ ? 'var(--pb-surface-contrast, #ffffff)'
133
+ : 'var(--pb-heading-color, #161728)',
134
+ }}
135
+ >
136
+ {renderRichText(point.title)}
137
+ </p>
138
+ )}
139
+ {point.body && (
140
+ <RichTextBlock
141
+ text={point.body}
142
+ className="whitespace-pre-line leading-[var(--pb-body-leading,1.625)]"
143
+ style={{
144
+ color: isDark
145
+ ? 'var(--pb-surface-contrast, #ffffff)'
146
+ : 'var(--pb-body-color)',
147
+ opacity: isDark ? 0.8 : 1,
148
+ }}
149
+ />
150
+ )}
151
+ </div>
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ {linkLabel && linkVariant === 'button' && (
157
+ <div className="pt-2">
158
+ <CTAButton
159
+ label={linkLabel}
160
+ href={linkHref || '#'}
161
+ variant={isDark ? 'inverse-outline' : 'secondary'}
162
+ />
163
+ </div>
164
+ )}
165
+ {linkLabel &&
166
+ linkVariant === 'link' &&
167
+ (isDark ? (
168
+ <a
169
+ href={linkHref || '#'}
170
+ className="inline-flex items-center gap-1 font-medium hover:opacity-80"
171
+ style={{ color: 'var(--pb-surface-contrast, #ffffff)' }}
172
+ >
173
+ {renderRichText(linkLabel, { allowLinks: false })}
174
+ <LinkArrow className="size-5" />
175
+ </a>
176
+ ) : (
177
+ <a
178
+ href={linkHref || '#'}
179
+ className="inline-flex items-center gap-1 font-medium text-[color:var(--pb-link-color,var(--pb-accent,#2f7d32))] hover:text-[color:var(--pb-link-hover-color,var(--pb-link-color,var(--pb-accent,#2f7d32)))] hover:[text-decoration-line:var(--pb-link-hover-decoration,underline)]"
180
+ >
181
+ {renderRichText(linkLabel, { allowLinks: false })}
182
+ <LinkArrow className="size-5" />
183
+ </a>
184
+ ))}
185
+ {logos && logos.filter((l) => l.src).length > 0 && (
186
+ <div className="flex flex-wrap items-center gap-6 pt-2">
187
+ {logos
188
+ .filter((l) => l.src)
189
+ .map((logo, i) => (
190
+ <img
191
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
192
+ key={i}
193
+ src={logo.src}
194
+ alt={logo.alt}
195
+ className="h-12 w-auto object-contain"
196
+ />
197
+ ))}
198
+ </div>
199
+ )}
200
+ </>
201
+ );
202
+ }