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,85 @@
1
+ /** Scheduled publish / unpublish controls inside the settings drawer. */
2
+
3
+ import { fromLocalInput, toLocalInput } from '../../utils/datetime';
4
+
5
+ interface Props {
6
+ publishAt: string | null;
7
+ unpublishAt: string | null;
8
+ onPublishAtChange: (iso: string | null) => void;
9
+ onUnpublishAtChange: (iso: string | null) => void;
10
+ onSave: () => void;
11
+ onClear: () => void;
12
+ saveDisabled: boolean;
13
+ clearDisabled: boolean;
14
+ error: string | null;
15
+ }
16
+
17
+ export function SchedulePanel({
18
+ publishAt,
19
+ unpublishAt,
20
+ onPublishAtChange,
21
+ onUnpublishAtChange,
22
+ onSave,
23
+ onClear,
24
+ saveDisabled,
25
+ clearDisabled,
26
+ error,
27
+ }: Props) {
28
+ return (
29
+ <fieldset className="md:col-span-2 border rounded p-3 bg-white">
30
+ <legend className="px-1 text-sm font-medium text-gray-700">Schedule</legend>
31
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
32
+ <label className="flex flex-col gap-1">
33
+ <span className="text-xs text-gray-700">Publish at</span>
34
+ <input
35
+ type="datetime-local"
36
+ value={toLocalInput(publishAt)}
37
+ onChange={(e) => onPublishAtChange(fromLocalInput(e.target.value))}
38
+ className="border rounded px-2 py-1"
39
+ data-testid="schedule-publish-at"
40
+ />
41
+ <span className="text-xs text-gray-500">
42
+ Draft auto-publishes at this time. Cleared after the flip.
43
+ </span>
44
+ </label>
45
+ <label className="flex flex-col gap-1">
46
+ <span className="text-xs text-gray-700">Unpublish at</span>
47
+ <input
48
+ type="datetime-local"
49
+ value={toLocalInput(unpublishAt)}
50
+ onChange={(e) => onUnpublishAtChange(fromLocalInput(e.target.value))}
51
+ className="border rounded px-2 py-1"
52
+ data-testid="schedule-unpublish-at"
53
+ />
54
+ <span className="text-xs text-gray-500">
55
+ Published page reverts to draft at this time.
56
+ </span>
57
+ </label>
58
+ </div>
59
+ <div className="mt-2 flex items-center gap-2">
60
+ <button
61
+ type="button"
62
+ onClick={onSave}
63
+ disabled={saveDisabled}
64
+ className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
65
+ data-testid="schedule-save"
66
+ >
67
+ Save schedule
68
+ </button>
69
+ <button
70
+ type="button"
71
+ onClick={onClear}
72
+ disabled={clearDisabled}
73
+ className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
74
+ >
75
+ Clear
76
+ </button>
77
+ {error && (
78
+ <span className="text-xs text-red-600" data-testid="schedule-error">
79
+ {error}
80
+ </span>
81
+ )}
82
+ </div>
83
+ </fieldset>
84
+ );
85
+ }
@@ -0,0 +1,119 @@
1
+ /** SEO metadata drawer: description, OG image, canonical, indexing, JSON-LD. */
2
+
3
+ import type { ReactNode } from 'react';
4
+
5
+ interface Props {
6
+ metaDescription: string;
7
+ onMetaDescriptionChange: (value: string) => void;
8
+ ogImage: string;
9
+ onOgImageChange: (value: string) => void;
10
+ canonicalUrl: string;
11
+ onCanonicalUrlChange: (value: string) => void;
12
+ indexInSearch: boolean;
13
+ onIndexInSearchChange: (value: boolean) => void;
14
+ jsonLdText: string;
15
+ jsonLdError: string | null;
16
+ onJsonLdChange: (value: string) => void;
17
+ /** Rendered inside the same grid, spanning both columns. */
18
+ schedule: ReactNode;
19
+ }
20
+
21
+ export function SeoSettingsPanel({
22
+ metaDescription,
23
+ onMetaDescriptionChange,
24
+ ogImage,
25
+ onOgImageChange,
26
+ canonicalUrl,
27
+ onCanonicalUrlChange,
28
+ indexInSearch,
29
+ onIndexInSearchChange,
30
+ jsonLdText,
31
+ jsonLdError,
32
+ onJsonLdChange,
33
+ schedule,
34
+ }: Props) {
35
+ return (
36
+ <div className="border-b bg-gray-50 px-4 py-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
37
+ <label className="flex flex-col gap-1">
38
+ <span className="font-medium text-gray-700">Meta description</span>
39
+ <textarea
40
+ value={metaDescription}
41
+ onChange={(e) => onMetaDescriptionChange(e.target.value)}
42
+ maxLength={500}
43
+ rows={2}
44
+ placeholder="Shown in search results and link previews."
45
+ className="border rounded px-2 py-1"
46
+ />
47
+ <span className="text-xs text-gray-500">{metaDescription.length}/500</span>
48
+ </label>
49
+ <label className="flex flex-col gap-1">
50
+ <span className="font-medium text-gray-700">Open Graph image URL</span>
51
+ <input
52
+ type="text"
53
+ value={ogImage}
54
+ onChange={(e) => onOgImageChange(e.target.value)}
55
+ maxLength={500}
56
+ placeholder="https://… or /media/pagebuilder/…"
57
+ className="border rounded px-2 py-1 font-mono"
58
+ />
59
+ <span className="text-xs text-gray-500">
60
+ Paste a URL from the{' '}
61
+ <a href="/pagebuilder/media" className="text-blue-600 hover:underline">
62
+ media library
63
+ </a>
64
+ .
65
+ </span>
66
+ </label>
67
+ <label className="flex flex-col gap-1">
68
+ <span className="font-medium text-gray-700">Canonical URL</span>
69
+ <input
70
+ type="text"
71
+ value={canonicalUrl}
72
+ onChange={(e) => onCanonicalUrlChange(e.target.value)}
73
+ maxLength={500}
74
+ placeholder="Defaults to this page's own URL."
75
+ className="border rounded px-2 py-1 font-mono"
76
+ />
77
+ <span className="text-xs text-gray-500">
78
+ Override when this page is a duplicate of content hosted elsewhere.
79
+ </span>
80
+ </label>
81
+ <label className="flex items-start gap-2 mt-1">
82
+ <input
83
+ type="checkbox"
84
+ checked={indexInSearch}
85
+ onChange={(e) => onIndexInSearchChange(e.target.checked)}
86
+ className="mt-1"
87
+ />
88
+ <span className="flex flex-col">
89
+ <span className="font-medium text-gray-700">Allow search engines to index this page</span>
90
+ <span className="text-xs text-gray-500">
91
+ Uncheck to emit <code>noindex,nofollow</code> and exclude from sitemap.
92
+ </span>
93
+ </span>
94
+ </label>
95
+ <label className="flex flex-col gap-1 md:col-span-2">
96
+ <span className="font-medium text-gray-700">JSON-LD structured data</span>
97
+ <textarea
98
+ value={jsonLdText}
99
+ onChange={(e) => onJsonLdChange(e.target.value)}
100
+ rows={6}
101
+ placeholder='{"@context":"https://schema.org","@type":"Article",…}'
102
+ className="border rounded px-2 py-1 font-mono text-xs"
103
+ />
104
+ {jsonLdError ? (
105
+ <span className="text-xs text-red-600" data-testid="json-ld-error">
106
+ {jsonLdError}
107
+ </span>
108
+ ) : (
109
+ <span className="text-xs text-gray-500">
110
+ Embedded inside <code>&lt;script type="application/ld+json"&gt;</code>. Leave blank to
111
+ omit.
112
+ </span>
113
+ )}
114
+ </label>
115
+
116
+ {schedule}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,109 @@
1
+ import type { Config } from '@puckeditor/core';
2
+
3
+ import { registeredBlocks, registryVersion } from './blockRegistry';
4
+ import { basePageConfig, getPuckConfig } from './puckConfig';
5
+ import { SiteFooterWidget } from './widgets/site-footer-widget';
6
+ import { SiteHeaderWidget } from './widgets/site-header-widget';
7
+
8
+ let cachedConfig: Config | null = null;
9
+ let cachedVersion = -1;
10
+
11
+ /**
12
+ * The site-layout palette.
13
+ *
14
+ * A small, curated set — see `categories` below, which is what the palette is
15
+ * built from. The page-shaped root is replaced with a transparent pass-through
16
+ * so the host wrapper (`<header>`/`<footer>`) controls layout rather than
17
+ * Puck's default page container.
18
+ *
19
+ * A module's blocks only appear here if it registered with `layout: true` — a
20
+ * news feed belongs on a page, not in the site chrome.
21
+ *
22
+ * Memoised on the registry version, not unconditionally: caching on first call
23
+ * would drop any block registered after the first render. Call it inside a
24
+ * component, never at module scope.
25
+ */
26
+ export function getLayoutPuckConfig(): Config {
27
+ if (cachedConfig && cachedVersion === registryVersion()) return cachedConfig;
28
+
29
+ const pageConfig = getPuckConfig();
30
+ const categories: Record<string, { title?: string; visible?: boolean; components: string[] }> = {
31
+ // Site chrome is registered here and NOT in the page palette: a nav bar
32
+ // dropped into the middle of a page would render a second one under the
33
+ // real header.
34
+ chrome: { title: 'Site chrome', components: ['SiteHeader', 'SiteFooter'] },
35
+ typography: { components: ['Heading', 'Text'] },
36
+ media: { components: ['Image'] },
37
+ actions: { components: ['Button'] },
38
+ // The partner strip under the GCA footer is this widget rather than markup
39
+ // baked into SiteFooter, so an author can reorder or drop it.
40
+ collections: { title: 'Collections', components: ['LogoCloud'] },
41
+ layout: { components: ['Spacer', 'Divider'] },
42
+ // Offered in the site-layout editor before the palette was curated, so a
43
+ // header or footer saved back then may contain one. Kept registered — Puck
44
+ // renders nothing at all for a type its config doesn't know, with no error
45
+ // and no placeholder, so removing them outright would quietly delete
46
+ // sections from live sites — but `visible: false` keeps them out of the
47
+ // palette, so nobody adds a new one.
48
+ _legacy: {
49
+ visible: false,
50
+ components: [
51
+ 'PageHeader',
52
+ 'Hero',
53
+ 'EyebrowSection',
54
+ 'MediaObject',
55
+ 'CallToAction',
56
+ 'FeatureCards',
57
+ 'Faq',
58
+ 'Stats',
59
+ 'ArticleCards',
60
+ 'ContactCards',
61
+ 'ContactForm',
62
+ 'Tags',
63
+ ],
64
+ },
65
+ };
66
+
67
+ // Built from the categories above — an allowlist, not "the page palette minus
68
+ // a couple". The subtractive form silently inherited every block added to the
69
+ // page: when the catalogue went from 20 to 56, the header/footer editor
70
+ // quietly started offering Leaderboard, Timeline and ~45 other page sections
71
+ // under Puck's catch-all "Other" group, because they had no category here.
72
+ // Site chrome is a deliberately small palette; it has to stay one by
73
+ // construction — and anything an author could once place here has to stay
74
+ // renderable, which is what `_legacy` above is for.
75
+ const components: Record<string, unknown> = {};
76
+ for (const name of Object.values(categories).flatMap((c) => c.components)) {
77
+ const block = (basePageConfig.components as Record<string, unknown>)[name];
78
+ if (block) components[name] = block;
79
+ }
80
+
81
+ components.SiteHeader = SiteHeaderWidget;
82
+ components.SiteFooter = SiteFooterWidget;
83
+
84
+ for (const registration of registeredBlocks()) {
85
+ if (!registration.layout) continue;
86
+ Object.assign(components, registration.blocks);
87
+ if (!registration.category) continue;
88
+ const { key, title } = registration.category;
89
+ categories[key] = {
90
+ title,
91
+ components: [...(categories[key]?.components ?? []), ...Object.keys(registration.blocks)],
92
+ };
93
+ }
94
+
95
+ cachedConfig = {
96
+ ...pageConfig,
97
+ root: {
98
+ fields: {},
99
+ defaultProps: {},
100
+ render: ({ children }: { children: React.ReactNode }) => <>{children}</>,
101
+ },
102
+ categories,
103
+ components,
104
+ } as unknown as Config;
105
+ cachedVersion = registryVersion();
106
+ return cachedConfig;
107
+ }
108
+
109
+ export const emptyLayoutData = { content: [], root: { props: {} } };
@@ -0,0 +1,83 @@
1
+ /** Search, content-type, and size filter controls. */
2
+
3
+ import { Input } from '@simple-module-py/ui/components/ui/input';
4
+
5
+ import type { ListFilters } from './types';
6
+
7
+ const CONTENT_TYPE_OPTIONS: { label: string; value: string }[] = [
8
+ { label: 'Any type', value: '' },
9
+ { label: 'JPEG', value: 'image/jpeg' },
10
+ { label: 'PNG', value: 'image/png' },
11
+ { label: 'GIF', value: 'image/gif' },
12
+ { label: 'WebP', value: 'image/webp' },
13
+ ];
14
+
15
+ interface Props {
16
+ filters: ListFilters;
17
+ onChange: (update: (previous: ListFilters) => ListFilters) => void;
18
+ }
19
+
20
+ export function MediaFilters({ filters, onChange }: Props) {
21
+ return (
22
+ <div className="flex flex-wrap gap-3 items-end mb-4">
23
+ <div className="flex-1 min-w-[200px]">
24
+ <label htmlFor="media-filter-search" className="block text-xs text-gray-500 mb-1">
25
+ Search
26
+ </label>
27
+ <Input
28
+ id="media-filter-search"
29
+ type="search"
30
+ value={filters.search}
31
+ onChange={(e) => onChange((f) => ({ ...f, search: e.target.value }))}
32
+ placeholder="Filename contains…"
33
+ className="w-full"
34
+ />
35
+ </div>
36
+ <div>
37
+ <label htmlFor="media-filter-type" className="block text-xs text-gray-500 mb-1">
38
+ Type
39
+ </label>
40
+ <select
41
+ id="media-filter-type"
42
+ value={filters.contentType}
43
+ onChange={(e) => onChange((f) => ({ ...f, contentType: e.target.value }))}
44
+ className="h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs"
45
+ >
46
+ {CONTENT_TYPE_OPTIONS.map((o) => (
47
+ <option key={o.value} value={o.value}>
48
+ {o.label}
49
+ </option>
50
+ ))}
51
+ </select>
52
+ </div>
53
+ <div>
54
+ <label htmlFor="media-filter-min-kb" className="block text-xs text-gray-500 mb-1">
55
+ Min KB
56
+ </label>
57
+ <Input
58
+ id="media-filter-min-kb"
59
+ type="number"
60
+ inputMode="numeric"
61
+ min={0}
62
+ value={filters.minKB}
63
+ onChange={(e) => onChange((f) => ({ ...f, minKB: e.target.value }))}
64
+ className="w-24"
65
+ />
66
+ </div>
67
+ <div>
68
+ <label htmlFor="media-filter-max-kb" className="block text-xs text-gray-500 mb-1">
69
+ Max KB
70
+ </label>
71
+ <Input
72
+ id="media-filter-max-kb"
73
+ type="number"
74
+ inputMode="numeric"
75
+ min={0}
76
+ value={filters.maxKB}
77
+ onChange={(e) => onChange((f) => ({ ...f, maxKB: e.target.value }))}
78
+ className="w-24"
79
+ />
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,97 @@
1
+ /** Folder navigation and the upload-target folder input. */
2
+
3
+ import { Input } from '@simple-module-py/ui/components/ui/input';
4
+
5
+ interface FolderItemProps {
6
+ label: string;
7
+ active: boolean;
8
+ onClick: () => void;
9
+ }
10
+
11
+ function FolderItem({ label, active, onClick }: FolderItemProps) {
12
+ return (
13
+ <li>
14
+ <button
15
+ type="button"
16
+ onClick={onClick}
17
+ className={`w-full text-left px-2 py-1 rounded truncate ${
18
+ active ? 'bg-blue-100 text-blue-900 font-medium' : 'hover:bg-gray-100 text-gray-700'
19
+ }`}
20
+ title={label}
21
+ >
22
+ {label}
23
+ </button>
24
+ </li>
25
+ );
26
+ }
27
+
28
+ interface Props {
29
+ folders: string[];
30
+ folderOptions: string[];
31
+ activeFolder: string | null;
32
+ onSelectFolder: (folder: string | null) => void;
33
+ uploadFolder: string;
34
+ onUploadFolderChange: (value: string) => void;
35
+ }
36
+
37
+ export function MediaFolderSidebar({
38
+ folders,
39
+ folderOptions,
40
+ activeFolder,
41
+ onSelectFolder,
42
+ uploadFolder,
43
+ onUploadFolderChange,
44
+ }: Props) {
45
+ return (
46
+ <aside className="space-y-4">
47
+ <div>
48
+ <h2 className="text-sm font-semibold text-gray-600 mb-2 uppercase tracking-wide">
49
+ Folders
50
+ </h2>
51
+ <ul className="space-y-1 text-sm">
52
+ <FolderItem
53
+ label="All assets"
54
+ active={activeFolder === null}
55
+ onClick={() => onSelectFolder(null)}
56
+ />
57
+ <FolderItem
58
+ label="Unfiled"
59
+ active={activeFolder === ''}
60
+ onClick={() => onSelectFolder('')}
61
+ />
62
+ {folders.map((name) => (
63
+ <FolderItem
64
+ key={name}
65
+ label={name}
66
+ active={activeFolder === name}
67
+ onClick={() => onSelectFolder(name)}
68
+ />
69
+ ))}
70
+ </ul>
71
+ </div>
72
+ <div>
73
+ <label
74
+ htmlFor="media-upload-folder"
75
+ className="block text-sm font-semibold text-gray-600 mb-1"
76
+ >
77
+ Upload to folder
78
+ </label>
79
+ <Input
80
+ id="media-upload-folder"
81
+ type="text"
82
+ value={uploadFolder}
83
+ onChange={(e) => onUploadFolderChange(e.target.value)}
84
+ list="pagebuilder-folder-suggestions"
85
+ placeholder="e.g. marketing/heros"
86
+ className="w-full"
87
+ />
88
+ <datalist id="pagebuilder-folder-suggestions">
89
+ {folderOptions.map((name) => (
90
+ <option key={name} value={name} />
91
+ ))}
92
+ </datalist>
93
+ <p className="text-xs text-gray-500 mt-1">Leave blank to upload into Unfiled.</p>
94
+ </div>
95
+ </aside>
96
+ );
97
+ }
@@ -0,0 +1,76 @@
1
+ /** Thumbnail grid of media assets with copy-URL and delete actions. */
2
+
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+
5
+ import type { MediaAssetRead } from '../../utils/api';
6
+ import { formatBytes } from '../../utils/mediaFormat';
7
+
8
+ interface Props {
9
+ assets: MediaAssetRead[];
10
+ loading: boolean;
11
+ copiedId: number | null;
12
+ onCopy: (asset: MediaAssetRead) => void;
13
+ onDelete: (id: number) => void;
14
+ }
15
+
16
+ export function MediaGrid({ assets, loading, copiedId, onCopy, onDelete }: Props) {
17
+ if (assets.length === 0 && !loading) {
18
+ return (
19
+ <div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
20
+ No assets match the current filter.
21
+ </div>
22
+ );
23
+ }
24
+ return (
25
+ <ul className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
26
+ {assets.map((a) => (
27
+ <li key={a.id} className="overflow-hidden rounded-lg border bg-card shadow-xs">
28
+ <div className="flex aspect-square items-center justify-center overflow-hidden bg-muted">
29
+ {a.content_type.startsWith('image/') ? (
30
+ <img
31
+ src={a.url}
32
+ alt={a.original_filename}
33
+ className="max-h-full max-w-full object-contain"
34
+ />
35
+ ) : (
36
+ <span className="text-xs text-muted-foreground">{a.content_type}</span>
37
+ )}
38
+ </div>
39
+ <div className="p-2 text-xs space-y-1">
40
+ <div className="font-medium truncate" title={a.original_filename}>
41
+ {a.original_filename}
42
+ </div>
43
+ <div className="text-muted-foreground">
44
+ {formatBytes(a.size_bytes)}
45
+ {a.width && a.height ? ` · ${a.width}×${a.height}` : ''}
46
+ </div>
47
+ {a.folder && (
48
+ <div className="truncate text-muted-foreground" title={a.folder}>
49
+ {a.folder}
50
+ </div>
51
+ )}
52
+ <div className="flex gap-2 pt-1 flex-wrap">
53
+ <Button
54
+ variant="link"
55
+ size="sm"
56
+ className="h-auto p-0"
57
+ onClick={() => onCopy(a)}
58
+ title="Useful for the SEO og_image field or external use"
59
+ >
60
+ {copiedId === a.id ? 'Copied!' : 'Copy URL'}
61
+ </Button>
62
+ <Button
63
+ variant="link"
64
+ size="sm"
65
+ className="ml-auto h-auto p-0 text-destructive"
66
+ onClick={() => onDelete(a.id)}
67
+ >
68
+ Delete
69
+ </Button>
70
+ </div>
71
+ </div>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ );
76
+ }
@@ -0,0 +1,37 @@
1
+ /** Media library actions: back-to-pages link and the file picker.
2
+ *
3
+ * Rendered into PageShell's `actions` slot, so the heading lives there
4
+ * rather than here.
5
+ */
6
+
7
+ import { router } from '@inertiajs/react';
8
+ import { Button } from '@simple-module-py/ui/components/ui/button';
9
+ import type { ChangeEvent, RefObject } from 'react';
10
+
11
+ interface Props {
12
+ fileInputRef: RefObject<HTMLInputElement | null>;
13
+ onFilesSelected: (e: ChangeEvent<HTMLInputElement>) => void;
14
+ }
15
+
16
+ export function MediaHeader({ fileInputRef, onFilesSelected }: Props) {
17
+ return (
18
+ <>
19
+ <Button variant="outline" onClick={() => router.visit('/pagebuilder')}>
20
+ ← Pages
21
+ </Button>
22
+ {/* Stays a <label> wrapping the input: that pairing is what makes the
23
+ hidden file input clickable, and a <button> cannot wrap it. */}
24
+ <label className="inline-flex h-9 cursor-pointer items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-xs hover:bg-primary/90">
25
+ Upload
26
+ <input
27
+ ref={fileInputRef}
28
+ type="file"
29
+ accept="image/*"
30
+ multiple
31
+ className="hidden"
32
+ onChange={onFilesSelected}
33
+ />
34
+ </label>
35
+ </>
36
+ );
37
+ }