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