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,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
|
+
)
|