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