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,228 @@
1
+ """Inertia view routes for the pagebuilder module.
2
+
3
+ Admin views are mounted under the module's ``view_prefix`` (``/pagebuilder``).
4
+ The public viewer is mounted at the host root via ``register_routes`` so the
5
+ URL is ``/{public_route_prefix}/{slug}`` (default ``/p/{slug}``) rather than
6
+ namespaced under the admin prefix.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from datetime import datetime
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+ from inertia import InertiaResponse
16
+ from simple_module_db import get_db
17
+ from simple_module_hosting.inertia_deps import InertiaDep
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from pagebuilder.contracts.schemas import (
21
+ LayoutDetail,
22
+ LayoutRevisionListResponse,
23
+ LayoutRevisionRead,
24
+ MediaAssetListResponse,
25
+ PageDetail,
26
+ PageListResponse,
27
+ PageRead,
28
+ PageRevisionListResponse,
29
+ PageRevisionRead,
30
+ )
31
+ from pagebuilder.deps import get_media_service, get_settings
32
+ from pagebuilder.layout_service import LayoutService, public_layout_props
33
+ from pagebuilder.media_service import MediaService
34
+ from pagebuilder.service import PagesService
35
+ from pagebuilder.settings import PagebuilderSettings
36
+
37
+
38
+ def _etag_for(
39
+ page_id: int,
40
+ updated_at: datetime | None,
41
+ layout_updated_at: datetime | None = None,
42
+ ) -> str:
43
+ """Stable, short ETag derived from page identity + last-modified time.
44
+
45
+ ``layout_updated_at`` participates so a site-wide header / footer
46
+ edit invalidates every page's cached chrome — without it, clients
47
+ keep serving stale layout from cache until the page itself changes.
48
+ """
49
+ stamp = updated_at.isoformat() if updated_at is not None else ""
50
+ layout_stamp = (
51
+ layout_updated_at.isoformat() if layout_updated_at is not None else ""
52
+ )
53
+ digest = hashlib.sha1(f"{page_id}:{stamp}:{layout_stamp}".encode()).hexdigest()[:16]
54
+ return f'W/"{digest}"'
55
+
56
+
57
+ def _public_base_url(request: Request, settings: PagebuilderSettings) -> str:
58
+ """Resolve the public origin used to build absolute URLs.
59
+
60
+ Prefer the explicit setting (the deployment knows its public host)
61
+ and only fall back to the inbound request for local dev / scenarios
62
+ where the host header is trustworthy. Always returned without a
63
+ trailing slash so callers can concatenate path segments directly.
64
+ """
65
+ if settings.public_base_url:
66
+ return settings.public_base_url.rstrip("/")
67
+ return f"{request.url.scheme}://{request.url.netloc}".rstrip("/")
68
+
69
+
70
+ def _absolute_page_url(
71
+ request: Request, settings: PagebuilderSettings, slug: str
72
+ ) -> str:
73
+ base = _public_base_url(request, settings)
74
+ prefix = settings.public_route_prefix.rstrip("/")
75
+ return f"{base}{prefix}/{slug}"
76
+
77
+
78
+ router = APIRouter()
79
+ public_router = APIRouter()
80
+
81
+ _PAGE_LIST = "PageBuilder/PageList"
82
+ _PAGE_EDITOR = "PageBuilder/PageEditor"
83
+ _PAGE_PUBLIC = "PageBuilder/PublicPage"
84
+ _PAGE_MEDIA = "PageBuilder/MediaLibrary"
85
+ _PAGE_PENDING = "PageBuilder/PendingReview"
86
+ _PAGE_LAYOUT_EDITOR = "PageBuilder/LayoutEditor"
87
+
88
+
89
+ @router.get("/", response_model=None)
90
+ async def admin_list(
91
+ inertia: InertiaDep,
92
+ db: AsyncSession = Depends(get_db),
93
+ ) -> InertiaResponse:
94
+ pages = await PagesService(db).list_pages()
95
+ payload = PageListResponse(items=[PageRead.model_validate(p) for p in pages])
96
+ return await inertia.render(_PAGE_LIST, {"pages": payload.model_dump(mode="json")})
97
+
98
+
99
+ @router.get("/pending", response_model=None)
100
+ async def admin_pending(
101
+ inertia: InertiaDep,
102
+ db: AsyncSession = Depends(get_db),
103
+ ) -> InertiaResponse:
104
+ """Approver queue view — pages awaiting review."""
105
+ pages = await PagesService(db).list_pending()
106
+ payload = PageListResponse(items=[PageRead.model_validate(p) for p in pages])
107
+ return await inertia.render(
108
+ _PAGE_PENDING, {"pages": payload.model_dump(mode="json")}
109
+ )
110
+
111
+
112
+ @router.get("/new", response_model=None)
113
+ async def admin_new(inertia: InertiaDep) -> InertiaResponse:
114
+ return await inertia.render(_PAGE_EDITOR, {"page": None, "revisions": []})
115
+
116
+
117
+ @router.get("/{page_id}/edit", response_model=None)
118
+ async def admin_edit(
119
+ page_id: int,
120
+ inertia: InertiaDep,
121
+ db: AsyncSession = Depends(get_db),
122
+ ) -> InertiaResponse:
123
+ service = PagesService(db)
124
+ page = await service.get_page(page_id)
125
+ revisions = await service.list_revisions(page_id)
126
+ revisions_payload = PageRevisionListResponse(
127
+ items=[PageRevisionRead.model_validate(r) for r in revisions]
128
+ )
129
+ return await inertia.render(
130
+ _PAGE_EDITOR,
131
+ {
132
+ "page": PageDetail.model_validate(page).model_dump(mode="json"),
133
+ "revisions": revisions_payload.model_dump(mode="json")["items"],
134
+ },
135
+ )
136
+
137
+
138
+ @router.get("/layout", response_model=None)
139
+ async def admin_layout(
140
+ inertia: InertiaDep,
141
+ db: AsyncSession = Depends(get_db),
142
+ ) -> InertiaResponse:
143
+ """Edit the site-wide header + footer."""
144
+ service = LayoutService(db)
145
+ layout = await service.get()
146
+ revisions = await service.list_revisions()
147
+ return await inertia.render(
148
+ _PAGE_LAYOUT_EDITOR,
149
+ {
150
+ "layout": LayoutDetail.model_validate(layout).model_dump(mode="json"),
151
+ "revisions": LayoutRevisionListResponse(
152
+ items=[LayoutRevisionRead.model_validate(r) for r in revisions]
153
+ ).model_dump(mode="json")["items"],
154
+ },
155
+ )
156
+
157
+
158
+ @router.get("/media", response_model=None)
159
+ async def admin_media(
160
+ inertia: InertiaDep,
161
+ media: MediaService = Depends(get_media_service),
162
+ ) -> InertiaResponse:
163
+ # Server-render the first page only; the React side handles further
164
+ # pagination + filtering via the JSON API. Folder list is loaded so
165
+ # the sidebar paints immediately on first navigation.
166
+ assets, next_cursor = await media.list_assets()
167
+ folders = await media.list_folders()
168
+ payload = MediaAssetListResponse(
169
+ items=[media.to_read(a) for a in assets],
170
+ next_cursor=next_cursor,
171
+ folders=folders,
172
+ )
173
+ return await inertia.render(
174
+ _PAGE_MEDIA,
175
+ {
176
+ "initial": payload.model_dump(mode="json"),
177
+ },
178
+ )
179
+
180
+
181
+ @public_router.get("/{slug}", response_model=None)
182
+ async def public_view(
183
+ slug: str,
184
+ request: Request,
185
+ inertia: InertiaDep,
186
+ db: AsyncSession = Depends(get_db),
187
+ settings: PagebuilderSettings = Depends(get_settings),
188
+ ) -> Response:
189
+ page = await PagesService(db).get_by_slug_published(slug)
190
+ if page is None or page.published_data is None:
191
+ raise HTTPException(status_code=404, detail="Page not found")
192
+
193
+ layout = await LayoutService(db).get()
194
+ etag = _etag_for(page.id or 0, page.updated_at, layout.updated_at)
195
+ cache_parts = [f"max-age={settings.public_cache_max_age}"]
196
+ if settings.public_cache_swr > 0:
197
+ cache_parts.append(f"stale-while-revalidate={settings.public_cache_swr}")
198
+ cache_control = "public, " + ", ".join(cache_parts)
199
+
200
+ def apply_headers(response: Response) -> Response:
201
+ response.headers["ETag"] = etag
202
+ response.headers["Cache-Control"] = cache_control
203
+ if settings.public_csp:
204
+ response.headers["Content-Security-Policy"] = settings.public_csp
205
+ return response
206
+
207
+ if request.headers.get("if-none-match") == etag:
208
+ return apply_headers(Response(status_code=304))
209
+
210
+ canonical = page.canonical_url or _absolute_page_url(request, settings, slug)
211
+ return apply_headers(
212
+ await inertia.render(
213
+ _PAGE_PUBLIC,
214
+ {
215
+ "title": page.title,
216
+ "data": page.published_data,
217
+ "meta_description": page.meta_description,
218
+ "og_image": page.og_image,
219
+ "canonical_url": canonical,
220
+ "og_url": canonical,
221
+ "index_in_search": page.index_in_search,
222
+ "json_ld": page.json_ld,
223
+ "site_name": settings.site_name,
224
+ "twitter_handle": settings.twitter_handle,
225
+ **public_layout_props(layout),
226
+ },
227
+ )
228
+ )
@@ -0,0 +1,22 @@
1
+ import type { CustomField } from "@puckeditor/core";
2
+
3
+ export function createCheckboxField(label: string): CustomField<boolean> {
4
+ return {
5
+ type: "custom",
6
+ label,
7
+ render: ({ name, onChange, value, field }) => (
8
+ <div className="flex items-center gap-2 py-2">
9
+ <input
10
+ type="checkbox"
11
+ id={name}
12
+ checked={Boolean(value)}
13
+ onChange={(event) => onChange(event.target.checked)}
14
+ className="size-4 rounded border-gray-300"
15
+ />
16
+ <label htmlFor={name} className="text-sm font-medium leading-none">
17
+ {field.label || label}
18
+ </label>
19
+ </div>
20
+ ),
21
+ };
22
+ }
@@ -0,0 +1,152 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { FileItem, FilePickerAdapter } from "./types";
4
+
5
+ interface FileGalleryProps {
6
+ adapter: FilePickerAdapter;
7
+ open: boolean;
8
+ onClose: () => void;
9
+ /**
10
+ * The picked file. `url` is first so URL-only consumers (Puck widgets, the
11
+ * branding footer logos) keep working unchanged; `item` carries the full
12
+ * record for callers that need the file id instead — see the ImagePicker's
13
+ * `valueMode="id"`.
14
+ */
15
+ onSelect: (url: string, item: FileItem) => void;
16
+ }
17
+
18
+ const PAGE_SIZE = 12;
19
+
20
+ export function FileGallery({
21
+ adapter,
22
+ open,
23
+ onClose,
24
+ onSelect,
25
+ }: FileGalleryProps) {
26
+ const [items, setItems] = useState<FileItem[]>([]);
27
+ const [totalCount, setTotalCount] = useState(0);
28
+ const [loading, setLoading] = useState(false);
29
+ const [page, setPage] = useState(0);
30
+
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ setLoading(true);
34
+ adapter
35
+ .list({ skip: page * PAGE_SIZE, take: PAGE_SIZE })
36
+ .then((result) => {
37
+ setItems(result.items);
38
+ setTotalCount(result.totalCount);
39
+ })
40
+ .catch((err) => {
41
+ // Log instead of letting the rejection go unhandled. Keep whatever
42
+ // items are already displayed — wiping them on a failed page fetch
43
+ // would misread as "your uploads are gone".
44
+ console.error("[FileGallery] Failed to list files:", err);
45
+ })
46
+ .finally(() => setLoading(false));
47
+ }, [adapter, open, page]);
48
+
49
+ useEffect(() => {
50
+ if (!open) return;
51
+ const handleKeyDown = (e: KeyboardEvent) => {
52
+ if (e.key === "Escape") onClose();
53
+ };
54
+ document.addEventListener("keydown", handleKeyDown);
55
+ return () => document.removeEventListener("keydown", handleKeyDown);
56
+ }, [open, onClose]);
57
+
58
+ if (!open) return null;
59
+
60
+ const hasMore = (page + 1) * PAGE_SIZE < totalCount;
61
+ const hasPrev = page > 0;
62
+
63
+ return createPortal(
64
+ <div
65
+ className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
66
+ onClick={(e) => {
67
+ if (e.target === e.currentTarget) onClose();
68
+ }}
69
+ >
70
+ <div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col m-4">
71
+ <div className="flex items-center justify-between px-4 py-3 border-b">
72
+ <h2 className="text-lg font-semibold">Select a file</h2>
73
+ <button
74
+ type="button"
75
+ onClick={onClose}
76
+ className="text-gray-500 hover:text-gray-700 text-xl leading-none"
77
+ >
78
+ &times;
79
+ </button>
80
+ </div>
81
+ <div className="flex-1 overflow-y-auto p-4">
82
+ {loading ? (
83
+ <div className="text-center py-8 text-gray-500">Loading...</div>
84
+ ) : items.length === 0 ? (
85
+ <div className="text-center py-8 text-gray-500">
86
+ No files uploaded yet.
87
+ </div>
88
+ ) : (
89
+ <div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
90
+ {items.map((item) => (
91
+ <button
92
+ key={item.id}
93
+ type="button"
94
+ onClick={() => {
95
+ onSelect(item.url, item);
96
+ onClose();
97
+ }}
98
+ className="group relative aspect-square rounded-md overflow-hidden border hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 outline-none"
99
+ >
100
+ {item.mimeType.startsWith("image/") ? (
101
+ <img
102
+ src={item.url}
103
+ alt=""
104
+ className="w-full h-full object-cover"
105
+ />
106
+ ) : (
107
+ <div className="w-full h-full flex items-center justify-center bg-gray-100 text-xs text-gray-500 p-2 text-center">
108
+ {item.mimeType}
109
+ </div>
110
+ )}
111
+ <div className="absolute inset-x-0 bottom-0 bg-black/60 text-white text-xs px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity truncate">
112
+ {formatFileSize(item.fileSize)}
113
+ </div>
114
+ </button>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ <div className="flex items-center justify-between px-4 py-3 border-t">
120
+ <span className="text-sm text-gray-500">
121
+ {totalCount} file{totalCount !== 1 ? "s" : ""}
122
+ </span>
123
+ <div className="flex gap-2">
124
+ <button
125
+ type="button"
126
+ onClick={() => setPage((p) => p - 1)}
127
+ disabled={!hasPrev}
128
+ className="px-3 py-1 text-sm rounded border disabled:opacity-40 hover:bg-gray-50"
129
+ >
130
+ Prev
131
+ </button>
132
+ <button
133
+ type="button"
134
+ onClick={() => setPage((p) => p + 1)}
135
+ disabled={!hasMore}
136
+ className="px-3 py-1 text-sm rounded border disabled:opacity-40 hover:bg-gray-50"
137
+ >
138
+ Next
139
+ </button>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>,
144
+ document.body,
145
+ );
146
+ }
147
+
148
+ function formatFileSize(bytes: number): string {
149
+ if (bytes < 1024) return `${bytes} B`;
150
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
151
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
152
+ }
@@ -0,0 +1,214 @@
1
+ import type { CustomField } from "@puckeditor/core";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { cn } from "../../utils/widgetUtils";
4
+ import { FileGallery } from "./file-gallery";
5
+ import type { FilePickerAdapter } from "./types";
6
+
7
+ /**
8
+ * An image field backed by a media library.
9
+ *
10
+ * Generic over the prop type because widgets declare image props both as
11
+ * `string` and as `string | undefined` (an optional icon, say), and Puck's
12
+ * `CustomField` is invariant in its value — a single concrete type would fail
13
+ * to assign at one call site or the other.
14
+ */
15
+ export function createImageField<T extends string | undefined = string>(
16
+ adapter: FilePickerAdapter,
17
+ label = "Image",
18
+ ): CustomField<T> {
19
+ return {
20
+ type: "custom",
21
+ label,
22
+ render: ({ value, onChange }) => (
23
+ <ImagePickerFieldRenderer
24
+ value={value ?? ""}
25
+ onChange={(next) => onChange(next as T)}
26
+ adapter={adapter}
27
+ />
28
+ ),
29
+ } as CustomField<T>;
30
+ }
31
+
32
+ interface ImagePickerFieldRendererProps {
33
+ value: string;
34
+ onChange: (value: string) => void;
35
+ adapter: FilePickerAdapter;
36
+ /**
37
+ * What `value`/`onChange` carry.
38
+ *
39
+ * `"url"` (default) — a ready-to-render view URL, which is what Puck stores
40
+ * verbatim in page content. `"id"` — the bare file id, for records that hold
41
+ * a FK to the file (e.g. News.ImageId) and build the URL at render time.
42
+ * Defaulting to `"url"` keeps every existing Puck widget untouched.
43
+ */
44
+ valueMode?: "url" | "id";
45
+ /** Marks the hidden file input + drop zone, so hosts can target them in tests. */
46
+ testId?: string;
47
+ disabled?: boolean;
48
+ }
49
+
50
+ export function ImagePickerFieldRenderer({
51
+ value,
52
+ onChange,
53
+ adapter,
54
+ valueMode = "url",
55
+ testId,
56
+ disabled = false,
57
+ }: ImagePickerFieldRendererProps) {
58
+ const [galleryOpen, setGalleryOpen] = useState(false);
59
+ const [uploading, setUploading] = useState(false);
60
+ const [error, setError] = useState<string | null>(null);
61
+ const [isDragOver, setIsDragOver] = useState(false);
62
+
63
+ // Any external or internal value change resolves a previous failure.
64
+ // biome-ignore lint/correctness/useExhaustiveDependencies(value): the effect intentionally keys on value changes without reading it
65
+ useEffect(() => {
66
+ setError(null);
67
+ }, [value]);
68
+ const fileInputRef = useRef<HTMLInputElement>(null);
69
+
70
+ const uploadFile = useCallback(
71
+ async (file: File) => {
72
+ setError(null);
73
+ setUploading(true);
74
+ try {
75
+ const result = await adapter.upload(file);
76
+ onChange(valueMode === "id" ? result.id : result.url);
77
+ } catch (err) {
78
+ console.error("[ImagePicker] Upload failed:", err);
79
+ setError("Upload failed. Please try again.");
80
+ } finally {
81
+ setUploading(false);
82
+ if (fileInputRef.current) fileInputRef.current.value = "";
83
+ }
84
+ },
85
+ [adapter, onChange, valueMode],
86
+ );
87
+
88
+ // In "id" mode `value` is a bare file id, so the preview has to resolve it
89
+ // through the adapter; in "url" mode it is already renderable.
90
+ const previewSrc = value
91
+ ? valueMode === "id"
92
+ ? adapter.getViewUrl(value)
93
+ : value
94
+ : null;
95
+
96
+ const handleUpload = useCallback(
97
+ (e: React.ChangeEvent<HTMLInputElement>) => {
98
+ const file = e.target.files?.[0];
99
+ if (!file) return;
100
+ void uploadFile(file);
101
+ },
102
+ [uploadFile],
103
+ );
104
+
105
+ const handleDrop = useCallback(
106
+ (e: React.DragEvent<HTMLDivElement>) => {
107
+ e.preventDefault();
108
+ setIsDragOver(false);
109
+ if (uploading) return;
110
+ const file = e.dataTransfer.files?.[0];
111
+ if (!file) return;
112
+ if (!file.type.startsWith("image/")) {
113
+ setError("Only image files can be dropped here.");
114
+ return;
115
+ }
116
+ void uploadFile(file);
117
+ },
118
+ [uploading, uploadFile],
119
+ );
120
+
121
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
122
+ e.preventDefault();
123
+ setIsDragOver(true);
124
+ }, []);
125
+
126
+ const handleDragLeave = useCallback(() => {
127
+ setIsDragOver(false);
128
+ }, []);
129
+
130
+ return (
131
+ <div className="space-y-2">
132
+ <div
133
+ data-testid={testId ? `${testId}-drop-zone` : "image-drop-zone"}
134
+ onDrop={handleDrop}
135
+ onDragOver={handleDragOver}
136
+ onDragLeave={handleDragLeave}
137
+ className={cn(
138
+ "w-full aspect-video rounded-md overflow-hidden",
139
+ isDragOver
140
+ ? "border-2 border-blue-400 bg-blue-50"
141
+ : value
142
+ ? "border bg-gray-50"
143
+ : "border-2 border-dashed border-gray-300",
144
+ )}
145
+ >
146
+ {previewSrc ? (
147
+ <img
148
+ src={previewSrc}
149
+ alt=""
150
+ className="w-full h-full object-contain"
151
+ />
152
+ ) : (
153
+ <div className="w-full h-full flex items-center justify-center text-sm text-gray-400">
154
+ No image selected — or drag an image here
155
+ </div>
156
+ )}
157
+ </div>
158
+ <div className="flex gap-2">
159
+ <button
160
+ type="button"
161
+ onClick={() => fileInputRef.current?.click()}
162
+ disabled={uploading || disabled}
163
+ className="flex-1 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
164
+ >
165
+ {uploading ? "Uploading..." : "Upload"}
166
+ </button>
167
+ <button
168
+ type="button"
169
+ onClick={() => setGalleryOpen(true)}
170
+ disabled={disabled}
171
+ className="flex-1 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
172
+ >
173
+ Browse
174
+ </button>
175
+ {value && (
176
+ <button
177
+ type="button"
178
+ onClick={() => {
179
+ setError(null);
180
+ onChange("");
181
+ }}
182
+ disabled={disabled}
183
+ className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 text-red-600 disabled:opacity-50"
184
+ >
185
+ Clear
186
+ </button>
187
+ )}
188
+ </div>
189
+ {error && (
190
+ <div role="alert" className="text-sm text-red-600">
191
+ {error}
192
+ </div>
193
+ )}
194
+ <input
195
+ ref={fileInputRef}
196
+ type="file"
197
+ accept="image/*"
198
+ onChange={handleUpload}
199
+ disabled={disabled}
200
+ data-testid={testId}
201
+ className="hidden"
202
+ />
203
+ <FileGallery
204
+ adapter={adapter}
205
+ open={galleryOpen}
206
+ onClose={() => setGalleryOpen(false)}
207
+ onSelect={(url, item) => {
208
+ setError(null);
209
+ onChange(valueMode === "id" ? item.id : url);
210
+ }}
211
+ />
212
+ </div>
213
+ );
214
+ }
@@ -0,0 +1,6 @@
1
+ export { FileGallery } from "./file-gallery";
2
+ export {
3
+ createImageField,
4
+ ImagePickerFieldRenderer,
5
+ } from "./image-picker-field";
6
+ export type { FileItem, FilePickerAdapter } from "./types";
@@ -0,0 +1,15 @@
1
+ export interface FileItem {
2
+ id: string;
3
+ mimeType: string;
4
+ fileSize: number;
5
+ url: string;
6
+ }
7
+
8
+ export interface FilePickerAdapter {
9
+ upload(file: File): Promise<FileItem>;
10
+ list(params: {
11
+ skip?: number;
12
+ take?: number;
13
+ }): Promise<{ items: FileItem[]; totalCount: number }>;
14
+ getViewUrl(id: string): string;
15
+ }
@@ -0,0 +1,9 @@
1
+ // Custom Puck fields for this module.
2
+ //
3
+ // The image field is backed by the module's own media library, so authors
4
+ // swap a picture by choosing a different upload rather than typing a path.
5
+ export { createCheckboxField } from './checkbox-field';
6
+ export { FileGallery } from './file-picker/file-gallery';
7
+ export { createImageField, ImagePickerFieldRenderer } from './file-picker/image-picker-field';
8
+ export type { FileItem, FilePickerAdapter } from './file-picker/types';
9
+ export { mediaLibraryAdapter } from './media-adapter';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * FilePickerAdapter backed by this module's own media library.
3
+ *
4
+ * The image fields in every widget go through this, so the pictures on a page
5
+ * are the ones an author uploaded to `/pagebuilder/media` — swapping one is
6
+ * picking a different upload, not editing a path by hand.
7
+ */
8
+
9
+ import { deleteMedia, listMedia, type MediaAssetRead, uploadMedia } from '../utils/api';
10
+ import type { FileItem, FilePickerAdapter } from './file-picker/types';
11
+
12
+ function toFileItem(asset: MediaAssetRead): FileItem {
13
+ return {
14
+ id: String(asset.id),
15
+ mimeType: asset.content_type,
16
+ fileSize: asset.size_bytes,
17
+ url: asset.url,
18
+ };
19
+ }
20
+
21
+ export const mediaLibraryAdapter: FilePickerAdapter = {
22
+ async upload(file: File): Promise<FileItem> {
23
+ return toFileItem(await uploadMedia(file));
24
+ },
25
+
26
+ async list({ skip = 0, take = 24 } = {}) {
27
+ // The gallery jumps to arbitrary pages, so it uses the uploads endpoint's
28
+ // offset mode; that mode is also what makes the response carry `total`.
29
+ const response = await listMedia({ offset: skip, limit: take });
30
+ return {
31
+ items: response.items.map(toFileItem),
32
+ totalCount: response.total ?? response.items.length,
33
+ };
34
+ },
35
+
36
+ /**
37
+ * Only reachable in the picker's "id" mode, which this module never uses:
38
+ * assets carry a ready-to-render URL on the record, so fields store that
39
+ * directly. There is no id → URL endpoint to call here, and inventing a
40
+ * path from the numeric id would silently produce broken images.
41
+ */
42
+ getViewUrl(): string {
43
+ throw new Error(
44
+ 'mediaLibraryAdapter stores URLs, not ids — use the picker in its default "url" mode.',
45
+ );
46
+ },
47
+ };