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,78 @@
1
+ """Site-wide layout and its revision history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from simple_module_db import get_db
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from pagebuilder.contracts.schemas import (
10
+ LayoutDetail,
11
+ LayoutRevisionDetail,
12
+ LayoutRevisionListResponse,
13
+ LayoutRevisionRead,
14
+ LayoutUpdate,
15
+ )
16
+ from pagebuilder.endpoints.api._deps import require_edit
17
+ from pagebuilder.layout_service import LayoutService
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.get("/layout", response_model=LayoutDetail)
23
+ async def get_layout(db: AsyncSession = Depends(get_db)) -> LayoutDetail:
24
+ """Return the site-wide layout. Created lazily on first read."""
25
+ layout = await LayoutService(db).get()
26
+ return LayoutDetail.model_validate(layout)
27
+
28
+
29
+ @router.put(
30
+ "/layout",
31
+ response_model=LayoutDetail,
32
+ dependencies=[require_edit],
33
+ )
34
+ async def update_layout(
35
+ body: LayoutUpdate,
36
+ db: AsyncSession = Depends(get_db),
37
+ ) -> LayoutDetail:
38
+ layout = await LayoutService(db).update(
39
+ header_data=body.header_data,
40
+ footer_data=body.footer_data,
41
+ note=body.note,
42
+ )
43
+ return LayoutDetail.model_validate(layout)
44
+
45
+
46
+ @router.get("/layout/revisions", response_model=LayoutRevisionListResponse)
47
+ async def list_layout_revisions(
48
+ db: AsyncSession = Depends(get_db),
49
+ ) -> LayoutRevisionListResponse:
50
+ revisions = await LayoutService(db).list_revisions()
51
+ return LayoutRevisionListResponse(
52
+ items=[LayoutRevisionRead.model_validate(r) for r in revisions]
53
+ )
54
+
55
+
56
+ @router.get(
57
+ "/layout/revisions/{revision_id}",
58
+ response_model=LayoutRevisionDetail,
59
+ )
60
+ async def get_layout_revision(
61
+ revision_id: int,
62
+ db: AsyncSession = Depends(get_db),
63
+ ) -> LayoutRevisionDetail:
64
+ revision = await LayoutService(db).get_revision(revision_id)
65
+ return LayoutRevisionDetail.model_validate(revision)
66
+
67
+
68
+ @router.post(
69
+ "/layout/revisions/{revision_id}/restore",
70
+ response_model=LayoutDetail,
71
+ dependencies=[require_edit],
72
+ )
73
+ async def restore_layout_revision(
74
+ revision_id: int,
75
+ db: AsyncSession = Depends(get_db),
76
+ ) -> LayoutDetail:
77
+ layout = await LayoutService(db).restore(revision_id)
78
+ return LayoutDetail.model_validate(layout)
@@ -0,0 +1,85 @@
1
+ """Page CRUD and listing endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, Request, status
6
+ from simple_module_db import get_db
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from pagebuilder.contracts.schemas import (
10
+ PageCreate,
11
+ PageDetail,
12
+ PageListResponse,
13
+ PageRead,
14
+ PageUpdate,
15
+ )
16
+ from pagebuilder.endpoints.api._deps import require_approve, require_edit
17
+ from pagebuilder.service import PagesService
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.get("/pages", response_model=PageListResponse)
23
+ async def list_pages(db: AsyncSession = Depends(get_db)) -> PageListResponse:
24
+ pages = await PagesService(db).list_pages()
25
+ return PageListResponse(items=[PageRead.model_validate(p) for p in pages])
26
+
27
+
28
+ # Declared before "/pages/{page_id}" so the literal path wins the match.
29
+ # Keep these two adjacent — separating them across modules would make the
30
+ # ordering depend on router include order instead of being visible here.
31
+ @router.get(
32
+ "/pages/pending",
33
+ response_model=PageListResponse,
34
+ dependencies=[require_approve],
35
+ )
36
+ async def list_pending(db: AsyncSession = Depends(get_db)) -> PageListResponse:
37
+ """Approver queue — pages currently in ``submitted_for_review``."""
38
+ pages = await PagesService(db).list_pending()
39
+ return PageListResponse(items=[PageRead.model_validate(p) for p in pages])
40
+
41
+
42
+ @router.post(
43
+ "/pages",
44
+ response_model=PageDetail,
45
+ status_code=status.HTTP_201_CREATED,
46
+ dependencies=[require_edit],
47
+ )
48
+ async def create_page(data: PageCreate, db: AsyncSession = Depends(get_db)) -> PageDetail:
49
+ page = await PagesService(db).create(data)
50
+ return PageDetail.model_validate(page)
51
+
52
+
53
+ @router.get("/pages/{page_id}", response_model=PageDetail)
54
+ async def get_page(page_id: int, db: AsyncSession = Depends(get_db)) -> PageDetail:
55
+ page = await PagesService(db).get_page(page_id)
56
+ return PageDetail.model_validate(page)
57
+
58
+
59
+ @router.put(
60
+ "/pages/{page_id}",
61
+ response_model=PageDetail,
62
+ dependencies=[require_edit],
63
+ )
64
+ async def update_page(
65
+ page_id: int,
66
+ data: PageUpdate,
67
+ db: AsyncSession = Depends(get_db),
68
+ ) -> PageDetail:
69
+ page = await PagesService(db).update(page_id, data)
70
+ return PageDetail.model_validate(page)
71
+
72
+
73
+ @router.delete(
74
+ "/pages/{page_id}",
75
+ status_code=status.HTTP_204_NO_CONTENT,
76
+ dependencies=[require_edit],
77
+ )
78
+ async def delete_page(
79
+ page_id: int, request: Request, db: AsyncSession = Depends(get_db)
80
+ ) -> None:
81
+ # The bus is passed only here: deleting a page is the one operation other
82
+ # modules must hear about, because nothing at the database level can tell
83
+ # them (no cross-module foreign keys, so no cascade).
84
+ bus = getattr(request.app.state.sm, "event_bus", None)
85
+ await PagesService(db, event_bus=bus).delete(page_id)
@@ -0,0 +1,79 @@
1
+ """Page revision history, diffing, and restore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from simple_module_db import get_db
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from pagebuilder.contracts.schemas import (
10
+ PageDetail,
11
+ PageRevisionDetail,
12
+ PageRevisionListResponse,
13
+ PageRevisionRead,
14
+ RevisionDiffResponse,
15
+ )
16
+ from pagebuilder.endpoints.api._deps import require_edit
17
+ from pagebuilder.service import PagesService
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.get("/pages/{page_id}/revisions", response_model=PageRevisionListResponse)
23
+ async def list_revisions(
24
+ page_id: int,
25
+ db: AsyncSession = Depends(get_db),
26
+ ) -> PageRevisionListResponse:
27
+ service = PagesService(db)
28
+ await service.get_page(page_id)
29
+ revisions = await service.list_revisions(page_id)
30
+ return PageRevisionListResponse(items=[PageRevisionRead.model_validate(r) for r in revisions])
31
+
32
+
33
+ @router.get(
34
+ "/pages/{page_id}/revisions/{revision_id}",
35
+ response_model=PageRevisionDetail,
36
+ )
37
+ async def get_revision(
38
+ page_id: int,
39
+ revision_id: int,
40
+ db: AsyncSession = Depends(get_db),
41
+ ) -> PageRevisionDetail:
42
+ revision = await PagesService(db).get_revision(page_id, revision_id)
43
+ return PageRevisionDetail.model_validate(revision)
44
+
45
+
46
+ @router.get(
47
+ "/pages/{page_id}/revisions/{before_id}/diff/{after_id}",
48
+ response_model=RevisionDiffResponse,
49
+ response_model_exclude_none=True,
50
+ )
51
+ async def diff_revisions(
52
+ page_id: int,
53
+ before_id: int,
54
+ after_id: int,
55
+ db: AsyncSession = Depends(get_db),
56
+ ) -> RevisionDiffResponse:
57
+ """Block-level diff between two revisions.
58
+
59
+ The URL spells the direction explicitly (``before`` vs ``after``)
60
+ rather than relying on numeric ordering — restoring an older
61
+ revision can produce a higher id with older content, so id sort
62
+ isn't a reliable proxy for chronology.
63
+ """
64
+ result = await PagesService(db).diff_revisions(page_id, before_id, after_id)
65
+ return RevisionDiffResponse.model_validate(result)
66
+
67
+
68
+ @router.post(
69
+ "/pages/{page_id}/revisions/{revision_id}/restore",
70
+ response_model=PageDetail,
71
+ dependencies=[require_edit],
72
+ )
73
+ async def restore_revision(
74
+ page_id: int,
75
+ revision_id: int,
76
+ db: AsyncSession = Depends(get_db),
77
+ ) -> PageDetail:
78
+ page = await PagesService(db).restore_revision(page_id, revision_id)
79
+ return PageDetail.model_validate(page)
@@ -0,0 +1,92 @@
1
+ """Media upload, listing, and deletion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, Form, Query, UploadFile, status
6
+
7
+ from pagebuilder.contracts.schemas import MediaAssetListResponse, MediaAssetRead
8
+ from pagebuilder.deps import get_media_service
9
+ from pagebuilder.endpoints.api._deps import require_edit
10
+ from pagebuilder.media_service import MediaService
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get("/uploads", response_model=MediaAssetListResponse)
16
+ async def list_uploads(
17
+ media: MediaService = Depends(get_media_service),
18
+ search: str | None = Query(default=None, max_length=200),
19
+ content_type: str | None = Query(default=None, max_length=120),
20
+ folder: str | None = Query(default=None, max_length=300),
21
+ min_size_bytes: int | None = Query(default=None, ge=0),
22
+ max_size_bytes: int | None = Query(default=None, ge=0),
23
+ cursor: int | None = Query(default=None, ge=1),
24
+ offset: int | None = Query(
25
+ default=None,
26
+ ge=0,
27
+ description=(
28
+ 'Offset paging, for callers that jump to an arbitrary page (the '
29
+ 'image-picker gallery). Takes precedence over `cursor` and makes '
30
+ 'the response carry `total`.'
31
+ ),
32
+ ),
33
+ limit: int = Query(default=60, ge=1, le=200),
34
+ ) -> MediaAssetListResponse:
35
+ # ``folder`` absent → no folder filter. ``folder=""`` (or whitespace)
36
+ # → the "Unfiled" sidebar bucket. Any other value → that exact path.
37
+ folder_filter: str | None = None
38
+ unfiled_only = False
39
+ if folder is not None:
40
+ trimmed = folder.strip().strip("/")
41
+ if trimmed:
42
+ folder_filter = trimmed
43
+ else:
44
+ unfiled_only = True
45
+ filters = {
46
+ "search": search,
47
+ "content_type": content_type,
48
+ "folder": folder_filter,
49
+ "unfiled_only": unfiled_only,
50
+ "min_size_bytes": min_size_bytes,
51
+ "max_size_bytes": max_size_bytes,
52
+ }
53
+ assets, next_cursor = await media.list_assets(
54
+ cursor=cursor, offset=offset, limit=limit, **filters
55
+ )
56
+ folders = await media.list_folders()
57
+ # The COUNT is only worth running for offset callers; cursor paging
58
+ # detects the end of the list from the row count it already fetched.
59
+ total = await media.count_assets(**filters) if offset is not None else None
60
+ return MediaAssetListResponse(
61
+ items=[media.to_read(a) for a in assets],
62
+ next_cursor=next_cursor,
63
+ folders=folders,
64
+ total=total,
65
+ )
66
+
67
+
68
+ @router.post(
69
+ "/uploads",
70
+ response_model=MediaAssetRead,
71
+ status_code=status.HTTP_201_CREATED,
72
+ dependencies=[require_edit],
73
+ )
74
+ async def create_upload(
75
+ file: UploadFile,
76
+ folder: str | None = Form(default=None),
77
+ media: MediaService = Depends(get_media_service),
78
+ ) -> MediaAssetRead:
79
+ asset = await media.upload(file, folder=folder)
80
+ return media.to_read(asset)
81
+
82
+
83
+ @router.delete(
84
+ "/uploads/{asset_id}",
85
+ status_code=status.HTTP_204_NO_CONTENT,
86
+ dependencies=[require_edit],
87
+ )
88
+ async def delete_upload(
89
+ asset_id: int,
90
+ media: MediaService = Depends(get_media_service),
91
+ ) -> None:
92
+ await media.delete(asset_id)
@@ -0,0 +1,124 @@
1
+ """Status-transition endpoints: publish, unpublish, submit, approve, reject, schedule."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from simple_module_db import get_db
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from pagebuilder.contracts.schemas import (
10
+ PageNoteRequest,
11
+ PageRead,
12
+ PageRejectRequest,
13
+ PageScheduleRequest,
14
+ )
15
+ from pagebuilder.endpoints.api._deps import require_approve, require_edit, require_publish
16
+ from pagebuilder.service import _UNSET, PagesService
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def _note(body: PageNoteRequest | None) -> str | None:
22
+ return body.note if body else None
23
+
24
+
25
+ @router.post(
26
+ "/pages/{page_id}/publish",
27
+ response_model=PageRead,
28
+ dependencies=[require_publish],
29
+ )
30
+ async def publish_page(
31
+ page_id: int,
32
+ db: AsyncSession = Depends(get_db),
33
+ body: PageNoteRequest | None = None,
34
+ ) -> PageRead:
35
+ """Publish the current draft. The optional ``note`` is captured on
36
+ the revision row so the history panel can label the publish."""
37
+ page = await PagesService(db).publish(page_id, note=_note(body))
38
+ return PageRead.model_validate(page)
39
+
40
+
41
+ @router.post(
42
+ "/pages/{page_id}/unpublish",
43
+ response_model=PageRead,
44
+ dependencies=[require_publish],
45
+ )
46
+ async def unpublish_page(
47
+ page_id: int,
48
+ db: AsyncSession = Depends(get_db),
49
+ body: PageNoteRequest | None = None,
50
+ ) -> PageRead:
51
+ page = await PagesService(db).unpublish(page_id, note=_note(body))
52
+ return PageRead.model_validate(page)
53
+
54
+
55
+ @router.post(
56
+ "/pages/{page_id}/submit",
57
+ response_model=PageRead,
58
+ dependencies=[require_edit],
59
+ )
60
+ async def submit_page(
61
+ page_id: int,
62
+ db: AsyncSession = Depends(get_db),
63
+ body: PageNoteRequest | None = None,
64
+ ) -> PageRead:
65
+ """Editor action: hand a draft off to the approver queue."""
66
+ page = await PagesService(db).submit_for_review(page_id, note=_note(body))
67
+ return PageRead.model_validate(page)
68
+
69
+
70
+ @router.post(
71
+ "/pages/{page_id}/approve",
72
+ response_model=PageRead,
73
+ dependencies=[require_approve],
74
+ )
75
+ async def approve_page(
76
+ page_id: int,
77
+ db: AsyncSession = Depends(get_db),
78
+ body: PageNoteRequest | None = None,
79
+ ) -> PageRead:
80
+ """Approver action: take a submission live (also publishes)."""
81
+ page = await PagesService(db).approve(page_id, note=_note(body))
82
+ return PageRead.model_validate(page)
83
+
84
+
85
+ @router.post(
86
+ "/pages/{page_id}/schedule",
87
+ response_model=PageRead,
88
+ dependencies=[require_publish],
89
+ )
90
+ async def schedule_page(
91
+ page_id: int,
92
+ body: PageScheduleRequest,
93
+ db: AsyncSession = Depends(get_db),
94
+ ) -> PageRead:
95
+ """Set or clear scheduled flip timestamps.
96
+
97
+ Each field uses ``model_fields_set`` to distinguish "field absent"
98
+ (leave the existing value alone) from ``"field": null`` (clear). So
99
+ ``POST {"publish_at": "..."}`` only touches ``publish_at`` and leaves
100
+ ``unpublish_at`` as-is.
101
+ """
102
+ publish_at = body.publish_at if "publish_at" in body.model_fields_set else _UNSET
103
+ unpublish_at = body.unpublish_at if "unpublish_at" in body.model_fields_set else _UNSET
104
+ page = await PagesService(db).schedule(
105
+ page_id,
106
+ publish_at=publish_at,
107
+ unpublish_at=unpublish_at,
108
+ )
109
+ return PageRead.model_validate(page)
110
+
111
+
112
+ @router.post(
113
+ "/pages/{page_id}/reject",
114
+ response_model=PageRead,
115
+ dependencies=[require_approve],
116
+ )
117
+ async def reject_page(
118
+ page_id: int,
119
+ body: PageRejectRequest,
120
+ db: AsyncSession = Depends(get_db),
121
+ ) -> PageRead:
122
+ """Approver action: send a submission back to draft with feedback."""
123
+ page = await PagesService(db).reject(page_id, body.note)
124
+ return PageRead.model_validate(page)
@@ -0,0 +1,95 @@
1
+ """Public ``/sitemap.xml`` and ``/robots.txt`` routes (issue #24).
2
+
3
+ Mounted directly on the host app from ``on_startup`` (alongside
4
+ ``/p/{slug}``) so the URLs sit at the site root, not under the admin
5
+ prefix. Both routes are short-cached so a busy crawler doesn't hammer
6
+ the page-list query, and both honour per-page ``index_in_search`` so a
7
+ ``noindex`` page is also kept out of discovery.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from xml.sax.saxutils import escape as xml_escape
13
+
14
+ from fastapi import APIRouter, Depends, Request, Response
15
+ from simple_module_db import get_db
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from pagebuilder.deps import get_settings
19
+ from pagebuilder.service import PagesService
20
+ from pagebuilder.settings import PagebuilderSettings
21
+
22
+ seo_router = APIRouter()
23
+
24
+
25
+ def _public_origin(request: Request, settings: PagebuilderSettings) -> str:
26
+ """Same fallback as the public viewer: explicit setting wins, else inferred."""
27
+ if settings.public_base_url:
28
+ return settings.public_base_url.rstrip("/")
29
+ return f"{request.url.scheme}://{request.url.netloc}".rstrip("/")
30
+
31
+
32
+ def _sitemap_url_for(origin: str, prefix: str, slug: str) -> str:
33
+ return f"{origin}{prefix.rstrip('/')}/{slug}"
34
+
35
+
36
+ @seo_router.get("/sitemap.xml", response_class=Response)
37
+ async def sitemap(
38
+ request: Request,
39
+ db: AsyncSession = Depends(get_db),
40
+ settings: PagebuilderSettings = Depends(get_settings),
41
+ ) -> Response:
42
+ if not settings.sitemap_enabled:
43
+ return Response(status_code=404)
44
+
45
+ pages = await PagesService(db).list_indexable_published()
46
+ origin = _public_origin(request, settings)
47
+ prefix = settings.public_route_prefix
48
+
49
+ entries: list[str] = []
50
+ for page in pages:
51
+ loc = xml_escape(_sitemap_url_for(origin, prefix, page.slug))
52
+ lastmod = page.updated_at.isoformat() if page.updated_at else None
53
+ if lastmod is not None:
54
+ entries.append(
55
+ f" <url><loc>{loc}</loc><lastmod>{xml_escape(lastmod)}</lastmod></url>"
56
+ )
57
+ else:
58
+ entries.append(f" <url><loc>{loc}</loc></url>")
59
+
60
+ body = (
61
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
62
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
63
+ + "\n".join(entries)
64
+ + ("\n" if entries else "")
65
+ + "</urlset>\n"
66
+ )
67
+ return Response(
68
+ content=body,
69
+ media_type="application/xml",
70
+ headers={"Cache-Control": f"public, max-age={settings.seo_cache_max_age}"},
71
+ )
72
+
73
+
74
+ @seo_router.get("/robots.txt", response_class=Response)
75
+ async def robots(
76
+ request: Request,
77
+ settings: PagebuilderSettings = Depends(get_settings),
78
+ ) -> Response:
79
+ if not settings.robots_enabled:
80
+ return Response(status_code=404)
81
+
82
+ if settings.robots_body is not None:
83
+ body = settings.robots_body
84
+ else:
85
+ lines = ["User-agent: *", "Allow: /"]
86
+ if settings.sitemap_enabled:
87
+ origin = _public_origin(request, settings)
88
+ lines.append(f"Sitemap: {origin}/sitemap.xml")
89
+ body = "\n".join(lines) + "\n"
90
+
91
+ return Response(
92
+ content=body,
93
+ media_type="text/plain",
94
+ headers={"Cache-Control": f"public, max-age={settings.seo_cache_max_age}"},
95
+ )