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,125 @@
|
|
|
1
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildSrcset,
|
|
5
|
+
DEFAULT_IMAGE_SIZES,
|
|
6
|
+
lookupAsset,
|
|
7
|
+
type MediaAssetRead,
|
|
8
|
+
} from '../../utils/api';
|
|
9
|
+
import { MediaPicker } from './MediaPicker';
|
|
10
|
+
|
|
11
|
+
type AltKind = 'meaningful' | 'decorative';
|
|
12
|
+
|
|
13
|
+
interface ImageProps {
|
|
14
|
+
src: string;
|
|
15
|
+
alt: string;
|
|
16
|
+
altKind: AltKind;
|
|
17
|
+
width: number | null;
|
|
18
|
+
height: number | null;
|
|
19
|
+
objectFit: 'cover' | 'contain' | 'fill';
|
|
20
|
+
srcset: string;
|
|
21
|
+
sizes: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveSizes(srcset: string, sizes: string): string | undefined {
|
|
25
|
+
if (sizes) return sizes;
|
|
26
|
+
// Browsers assume 100vw when sizes is omitted but srcset is present;
|
|
27
|
+
// setting it explicitly silences a console warning in dev tools.
|
|
28
|
+
if (srcset) return '100vw';
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ImageBlock: ComponentConfig<ImageProps> = {
|
|
33
|
+
label: 'Image',
|
|
34
|
+
fields: {
|
|
35
|
+
src: {
|
|
36
|
+
type: 'custom',
|
|
37
|
+
render: ({ value, onChange, readOnly }) => (
|
|
38
|
+
<MediaPicker
|
|
39
|
+
value={(value as string) || ''}
|
|
40
|
+
onChange={(v) => onChange(v as ImageProps['src'])}
|
|
41
|
+
readOnly={readOnly}
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
altKind: {
|
|
46
|
+
type: 'radio',
|
|
47
|
+
options: [
|
|
48
|
+
{ label: 'Meaningful (describe the image)', value: 'meaningful' },
|
|
49
|
+
{ label: 'Decorative (hidden from screen readers)', value: 'decorative' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
alt: { type: 'text' },
|
|
53
|
+
width: { type: 'number' },
|
|
54
|
+
height: { type: 'number' },
|
|
55
|
+
objectFit: {
|
|
56
|
+
type: 'select',
|
|
57
|
+
options: [
|
|
58
|
+
{ label: 'Cover', value: 'cover' },
|
|
59
|
+
{ label: 'Contain', value: 'contain' },
|
|
60
|
+
{ label: 'Fill', value: 'fill' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
srcset: { type: 'textarea' },
|
|
64
|
+
sizes: { type: 'text' },
|
|
65
|
+
},
|
|
66
|
+
defaultProps: {
|
|
67
|
+
src: '',
|
|
68
|
+
alt: '',
|
|
69
|
+
altKind: 'meaningful',
|
|
70
|
+
width: null,
|
|
71
|
+
height: null,
|
|
72
|
+
objectFit: 'cover',
|
|
73
|
+
srcset: '',
|
|
74
|
+
sizes: '',
|
|
75
|
+
},
|
|
76
|
+
resolveData: async ({ props }, { changed }) => {
|
|
77
|
+
if (!changed.src || !props.src) return { props };
|
|
78
|
+
let asset: MediaAssetRead | undefined;
|
|
79
|
+
try {
|
|
80
|
+
asset = await lookupAsset(props.src);
|
|
81
|
+
} catch {
|
|
82
|
+
// Network failure mid-edit shouldn't break the editor — leave the
|
|
83
|
+
// src picked and let the author fill metadata manually.
|
|
84
|
+
return { props };
|
|
85
|
+
}
|
|
86
|
+
if (!asset) return { props };
|
|
87
|
+
return {
|
|
88
|
+
props: {
|
|
89
|
+
...props,
|
|
90
|
+
width: asset.width ?? props.width,
|
|
91
|
+
height: asset.height ?? props.height,
|
|
92
|
+
srcset: buildSrcset(asset),
|
|
93
|
+
// Don't clobber values the author has set by hand.
|
|
94
|
+
sizes: props.sizes || DEFAULT_IMAGE_SIZES,
|
|
95
|
+
alt: props.alt || asset.original_filename.replace(/\.[^.]+$/, ''),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
render: ({ src, alt, altKind, width, height, objectFit, srcset, sizes }) => {
|
|
100
|
+
if (!src) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="my-3 p-6 border-2 border-dashed border-gray-300 rounded text-center text-gray-500 text-sm">
|
|
103
|
+
No image selected. Use the inspector to pick one.
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const decorative = altKind === 'decorative';
|
|
108
|
+
return (
|
|
109
|
+
<img
|
|
110
|
+
src={src}
|
|
111
|
+
srcSet={srcset || undefined}
|
|
112
|
+
sizes={resolveSizes(srcset, sizes)}
|
|
113
|
+
alt={decorative ? '' : (alt ?? '')}
|
|
114
|
+
role={decorative ? 'presentation' : undefined}
|
|
115
|
+
aria-hidden={decorative ? true : undefined}
|
|
116
|
+
width={width ?? undefined}
|
|
117
|
+
height={height ?? undefined}
|
|
118
|
+
loading="lazy"
|
|
119
|
+
decoding="async"
|
|
120
|
+
style={{ objectFit, maxWidth: '100%' }}
|
|
121
|
+
className="my-3"
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Puck field that opens a modal grid of uploaded media assets.
|
|
3
|
+
* Selecting an asset emits its URL via `onChange` and stashes the full
|
|
4
|
+
* `MediaAssetRead` in the picked-asset cache so the Image block's
|
|
5
|
+
* `resolveData` can back-fill sibling props without a second fetch.
|
|
6
|
+
* A manually typed URL is also accepted; `resolveData` then falls back
|
|
7
|
+
* to a fresh list lookup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { listMedia, type MediaAssetRead, rememberPickedAsset } from '../../utils/api';
|
|
13
|
+
|
|
14
|
+
interface MediaPickerProps {
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
readOnly?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function MediaPicker({ value, onChange, readOnly }: MediaPickerProps) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="space-y-2">
|
|
25
|
+
<input
|
|
26
|
+
type="text"
|
|
27
|
+
value={value || ''}
|
|
28
|
+
onChange={(e) => onChange(e.target.value)}
|
|
29
|
+
readOnly={readOnly}
|
|
30
|
+
placeholder="Pick from library or paste a URL"
|
|
31
|
+
className="w-full px-2 py-1.5 border rounded text-sm"
|
|
32
|
+
/>
|
|
33
|
+
<div className="flex gap-2">
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
disabled={readOnly}
|
|
37
|
+
onClick={() => setOpen(true)}
|
|
38
|
+
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
|
39
|
+
>
|
|
40
|
+
Browse media…
|
|
41
|
+
</button>
|
|
42
|
+
{value && (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
disabled={readOnly}
|
|
46
|
+
onClick={() => onChange('')}
|
|
47
|
+
className="px-3 py-1.5 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
|
|
48
|
+
>
|
|
49
|
+
Clear
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
{value && (
|
|
54
|
+
<img src={value} alt="" className="max-h-24 rounded border bg-gray-50 object-contain" />
|
|
55
|
+
)}
|
|
56
|
+
{open && (
|
|
57
|
+
<MediaPickerModal
|
|
58
|
+
onPick={(asset) => {
|
|
59
|
+
rememberPickedAsset(asset);
|
|
60
|
+
onChange(asset.url);
|
|
61
|
+
setOpen(false);
|
|
62
|
+
}}
|
|
63
|
+
onClose={() => setOpen(false)}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function MediaPickerModal({
|
|
71
|
+
onPick,
|
|
72
|
+
onClose,
|
|
73
|
+
}: {
|
|
74
|
+
onPick: (asset: MediaAssetRead) => void;
|
|
75
|
+
onClose: () => void;
|
|
76
|
+
}) {
|
|
77
|
+
const [assets, setAssets] = useState<MediaAssetRead[] | null>(null);
|
|
78
|
+
const [error, setError] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
listMedia()
|
|
83
|
+
.then((r) => {
|
|
84
|
+
if (!cancelled) setAssets(r.items);
|
|
85
|
+
})
|
|
86
|
+
.catch((e) => {
|
|
87
|
+
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load media');
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Close on Escape so keyboard-only users aren't trapped.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const onKey = (e: KeyboardEvent) => {
|
|
97
|
+
if (e.key === 'Escape') onClose();
|
|
98
|
+
};
|
|
99
|
+
window.addEventListener('keydown', onKey);
|
|
100
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
101
|
+
}, [onClose]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
// Click-outside is a mouse affordance only. The keyboard path is the
|
|
105
|
+
// Escape handler registered above, plus the labelled Close button inside.
|
|
106
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: Escape and the Close button are the keyboard paths
|
|
107
|
+
<div
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-label="Media library picker"
|
|
111
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
112
|
+
onClick={onClose}
|
|
113
|
+
>
|
|
114
|
+
{/* Not an interactive element — the handler only stops the backdrop's
|
|
115
|
+
click-to-close from firing when the click lands inside the dialog. */}
|
|
116
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: only stops backdrop click-through */}
|
|
117
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: only stops backdrop click-through */}
|
|
118
|
+
<div
|
|
119
|
+
className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col"
|
|
120
|
+
onClick={(e) => e.stopPropagation()}
|
|
121
|
+
>
|
|
122
|
+
<div className="flex items-center justify-between border-b p-4">
|
|
123
|
+
<h2 className="text-lg font-semibold">Pick an image</h2>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={onClose}
|
|
127
|
+
className="text-gray-500 hover:text-gray-800 text-xl leading-none"
|
|
128
|
+
aria-label="Close picker"
|
|
129
|
+
>
|
|
130
|
+
×
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex-1 overflow-auto p-4">
|
|
134
|
+
{error && <div className="text-red-600 text-sm">{error}</div>}
|
|
135
|
+
{!error && assets === null && <div className="text-gray-500 text-sm">Loading…</div>}
|
|
136
|
+
{assets && assets.length === 0 && (
|
|
137
|
+
<div className="text-gray-500 text-sm">
|
|
138
|
+
No uploads yet. Upload images from the Media library page.
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
{assets && assets.length > 0 && (
|
|
142
|
+
<ul className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
143
|
+
{assets.map((a) => (
|
|
144
|
+
<li key={a.id}>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
onClick={() => onPick(a)}
|
|
148
|
+
className="w-full text-left border rounded overflow-hidden hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
149
|
+
>
|
|
150
|
+
<div className="aspect-square bg-gray-100 flex items-center justify-center overflow-hidden">
|
|
151
|
+
{a.content_type.startsWith('image/') ? (
|
|
152
|
+
<img
|
|
153
|
+
src={a.url}
|
|
154
|
+
alt={a.original_filename}
|
|
155
|
+
className="max-h-full max-w-full object-contain"
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<span className="text-xs text-gray-500">{a.content_type}</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
<div className="p-2 text-xs space-y-0.5">
|
|
162
|
+
<div className="font-medium truncate" title={a.original_filename}>
|
|
163
|
+
{a.original_filename}
|
|
164
|
+
</div>
|
|
165
|
+
<div className="text-gray-500">
|
|
166
|
+
{a.width && a.height ? `${a.width}×${a.height}` : '—'}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</button>
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
|
+
|
|
3
|
+
interface SpacerProps {
|
|
4
|
+
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const heights: Record<SpacerProps['size'], number> = {
|
|
8
|
+
xs: 8,
|
|
9
|
+
sm: 16,
|
|
10
|
+
md: 32,
|
|
11
|
+
lg: 64,
|
|
12
|
+
xl: 128,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const SpacerBlock: ComponentConfig<SpacerProps> = {
|
|
16
|
+
label: 'Spacer',
|
|
17
|
+
fields: {
|
|
18
|
+
size: {
|
|
19
|
+
type: 'select',
|
|
20
|
+
options: [
|
|
21
|
+
{ label: 'XS', value: 'xs' },
|
|
22
|
+
{ label: 'S', value: 'sm' },
|
|
23
|
+
{ label: 'M', value: 'md' },
|
|
24
|
+
{ label: 'L', value: 'lg' },
|
|
25
|
+
{ label: 'XL', value: 'xl' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultProps: { size: 'md' },
|
|
30
|
+
render: ({ size }) => <div style={{ height: heights[size] }} aria-hidden />,
|
|
31
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils/widgetUtils';
|
|
4
|
+
import { RichTextBlock } from '../widgets/_internal/rich-text';
|
|
5
|
+
|
|
6
|
+
interface TextProps {
|
|
7
|
+
/**
|
|
8
|
+
* Light markdown, not HTML: `**bold**`, `*italic*`, `` `code` ``,
|
|
9
|
+
* `[label](href)`, `- ` bullets and blank-line paragraphs. See the note on
|
|
10
|
+
* the field below for why this isn't Puck's `richtext`.
|
|
11
|
+
*/
|
|
12
|
+
text: string;
|
|
13
|
+
size: 'sm' | 'base' | 'lg' | 'xl';
|
|
14
|
+
align: 'left' | 'center' | 'right';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sizeClass: Record<TextProps['size'], string> = {
|
|
18
|
+
sm: 'text-sm',
|
|
19
|
+
base: 'text-base',
|
|
20
|
+
lg: 'text-lg',
|
|
21
|
+
xl: 'text-xl',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Spelled out rather than `text-${align}` — Tailwind's scanner cannot see an
|
|
25
|
+
// interpolated class name. See the same note in Heading.
|
|
26
|
+
const alignClass: Record<TextProps['align'], string> = {
|
|
27
|
+
left: 'text-left',
|
|
28
|
+
center: 'text-center',
|
|
29
|
+
right: 'text-right',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const TextBlock: ComponentConfig<TextProps> = {
|
|
33
|
+
label: 'Text',
|
|
34
|
+
fields: {
|
|
35
|
+
/**
|
|
36
|
+
* Upstream uses Puck's `richtext` field here, which stores HTML and hands
|
|
37
|
+
* it to Puck to render. This stays a textarea rendered through
|
|
38
|
+
* `RichTextBlock`, which parses light markdown into React elements.
|
|
39
|
+
*
|
|
40
|
+
* Same expressive range for an author — bold, italic, code, links, bullets,
|
|
41
|
+
* paragraphs — but nothing on a published page is ever built from stored
|
|
42
|
+
* markup, so there is no injection surface to sanitise. That matters more
|
|
43
|
+
* here than upstream: pages reach the public viewer through a review
|
|
44
|
+
* workflow, and the CSP that would otherwise be the backstop is an
|
|
45
|
+
* operator-tunable setting that can be emptied. It also keeps this block
|
|
46
|
+
* consistent with the other 50, which all render copy this way.
|
|
47
|
+
*/
|
|
48
|
+
text: { type: 'textarea', label: 'Text (markdown)' },
|
|
49
|
+
size: {
|
|
50
|
+
type: 'select',
|
|
51
|
+
label: 'Size',
|
|
52
|
+
options: [
|
|
53
|
+
{ label: 'Small', value: 'sm' },
|
|
54
|
+
{ label: 'Base', value: 'base' },
|
|
55
|
+
{ label: 'Large', value: 'lg' },
|
|
56
|
+
{ label: 'Extra large', value: 'xl' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
align: {
|
|
60
|
+
type: 'radio',
|
|
61
|
+
label: 'Alignment',
|
|
62
|
+
options: [
|
|
63
|
+
{ label: 'Left', value: 'left' },
|
|
64
|
+
{ label: 'Center', value: 'center' },
|
|
65
|
+
{ label: 'Right', value: 'right' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
defaultProps: { text: 'Some descriptive text.', size: 'base', align: 'left' },
|
|
70
|
+
// `size` is defaulted here too: every Text already on a page predates the
|
|
71
|
+
// field and arrives undefined, which would index `sizeClass` to `undefined`.
|
|
72
|
+
render: ({ text, size = 'base', align = 'left' }) => (
|
|
73
|
+
<div className="container mx-auto px-4 py-2">
|
|
74
|
+
<RichTextBlock
|
|
75
|
+
text={text}
|
|
76
|
+
className={cn('leading-relaxed', sizeClass[size], alignClass[align])}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** Top toolbar: title/slug inputs, status badges, and workflow actions. */
|
|
2
|
+
|
|
3
|
+
import { router } from '@inertiajs/react';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
6
|
+
|
|
7
|
+
import type { PageDetail } from '../../utils/api';
|
|
8
|
+
import { formatSaveLabel, type SaveState } from '../../utils/editorSnapshot';
|
|
9
|
+
import { slugify } from '../../utils/slugify';
|
|
10
|
+
import { ScheduledBadge } from '../ScheduledBadge';
|
|
11
|
+
import { StatusBadge } from '../StatusBadge';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
pageId: number | null;
|
|
15
|
+
title: string;
|
|
16
|
+
onTitleChange: (value: string) => void;
|
|
17
|
+
effectiveSlug: string;
|
|
18
|
+
onSlugChange: (value: string) => void;
|
|
19
|
+
status: PageDetail['status'];
|
|
20
|
+
publishAt: string | null;
|
|
21
|
+
unpublishAt: string | null;
|
|
22
|
+
saveState: SaveState;
|
|
23
|
+
isDirty: boolean;
|
|
24
|
+
lastSavedAt: Date | null;
|
|
25
|
+
autosaveError: string | null;
|
|
26
|
+
revisionCount: number;
|
|
27
|
+
busy: boolean;
|
|
28
|
+
message: string | null;
|
|
29
|
+
onToggleSettings: () => void;
|
|
30
|
+
onToggleHistory: () => void;
|
|
31
|
+
onSave: () => void;
|
|
32
|
+
onPublish: () => void;
|
|
33
|
+
onUnpublish: () => void;
|
|
34
|
+
onSubmitForReview: () => void;
|
|
35
|
+
onApprove: () => void;
|
|
36
|
+
onReject: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PageEditorToolbar({
|
|
40
|
+
pageId,
|
|
41
|
+
title,
|
|
42
|
+
onTitleChange,
|
|
43
|
+
effectiveSlug,
|
|
44
|
+
onSlugChange,
|
|
45
|
+
status,
|
|
46
|
+
publishAt,
|
|
47
|
+
unpublishAt,
|
|
48
|
+
saveState,
|
|
49
|
+
isDirty,
|
|
50
|
+
lastSavedAt,
|
|
51
|
+
autosaveError,
|
|
52
|
+
revisionCount,
|
|
53
|
+
busy,
|
|
54
|
+
message,
|
|
55
|
+
onToggleSettings,
|
|
56
|
+
onToggleHistory,
|
|
57
|
+
onSave,
|
|
58
|
+
onPublish,
|
|
59
|
+
onUnpublish,
|
|
60
|
+
onSubmitForReview,
|
|
61
|
+
onApprove,
|
|
62
|
+
onReject,
|
|
63
|
+
}: Props) {
|
|
64
|
+
const saveStateClass =
|
|
65
|
+
saveState === 'error'
|
|
66
|
+
? 'text-xs text-destructive'
|
|
67
|
+
: isDirty
|
|
68
|
+
? 'text-xs text-amber-700'
|
|
69
|
+
: 'text-xs text-muted-foreground';
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex flex-wrap items-center gap-3 border-b bg-background px-4 py-2">
|
|
73
|
+
<Button variant="link" size="sm" onClick={() => router.visit('/pagebuilder')}>
|
|
74
|
+
← All pages
|
|
75
|
+
</Button>
|
|
76
|
+
{/* Explicit widths, not min-w: Input's base class list carries `w-full`,
|
|
77
|
+
which would stretch these across the row and wrap the toolbar onto
|
|
78
|
+
three lines. cn()'s tailwind-merge resolves w-64/w-52 over w-full. */}
|
|
79
|
+
<Input
|
|
80
|
+
type="text"
|
|
81
|
+
value={title}
|
|
82
|
+
onChange={(e) => onTitleChange(e.target.value)}
|
|
83
|
+
placeholder="Page title"
|
|
84
|
+
className="h-8 w-64 shrink-0 text-sm font-medium"
|
|
85
|
+
/>
|
|
86
|
+
<Input
|
|
87
|
+
type="text"
|
|
88
|
+
value={effectiveSlug}
|
|
89
|
+
onChange={(e) => onSlugChange(slugify(e.target.value))}
|
|
90
|
+
placeholder="slug"
|
|
91
|
+
className="h-8 w-52 shrink-0 font-mono text-sm"
|
|
92
|
+
/>
|
|
93
|
+
<StatusBadge status={status} />
|
|
94
|
+
<ScheduledBadge status={status} publishAt={publishAt} unpublishAt={unpublishAt} />
|
|
95
|
+
{pageId !== null && (
|
|
96
|
+
<span
|
|
97
|
+
className={saveStateClass}
|
|
98
|
+
data-testid="autosave-status"
|
|
99
|
+
title={autosaveError ?? undefined}
|
|
100
|
+
>
|
|
101
|
+
{formatSaveLabel(saveState, isDirty, lastSavedAt, autosaveError)}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
<div className="ml-auto flex gap-2">
|
|
105
|
+
<Button variant="outline" size="sm" onClick={onToggleSettings}>
|
|
106
|
+
SEO
|
|
107
|
+
</Button>
|
|
108
|
+
<Button variant="outline" size="sm" disabled={pageId === null} onClick={onToggleHistory}>
|
|
109
|
+
History ({revisionCount})
|
|
110
|
+
</Button>
|
|
111
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={onSave}>
|
|
112
|
+
Save draft
|
|
113
|
+
</Button>
|
|
114
|
+
{status === 'published' && (
|
|
115
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={onUnpublish}>
|
|
116
|
+
Unpublish
|
|
117
|
+
</Button>
|
|
118
|
+
)}
|
|
119
|
+
{status === 'draft' && (
|
|
120
|
+
<Button
|
|
121
|
+
variant="outline"
|
|
122
|
+
size="sm"
|
|
123
|
+
className="border-amber-500 text-amber-700 hover:bg-amber-50"
|
|
124
|
+
disabled={busy || pageId === null}
|
|
125
|
+
onClick={onSubmitForReview}
|
|
126
|
+
>
|
|
127
|
+
Submit for review
|
|
128
|
+
</Button>
|
|
129
|
+
)}
|
|
130
|
+
{status === 'submitted_for_review' && (
|
|
131
|
+
<>
|
|
132
|
+
<Button
|
|
133
|
+
variant="outline"
|
|
134
|
+
size="sm"
|
|
135
|
+
className="border-destructive text-destructive hover:bg-destructive/10"
|
|
136
|
+
disabled={busy}
|
|
137
|
+
onClick={onReject}
|
|
138
|
+
>
|
|
139
|
+
Reject
|
|
140
|
+
</Button>
|
|
141
|
+
<Button
|
|
142
|
+
size="sm"
|
|
143
|
+
className="bg-green-600 text-white hover:bg-green-700"
|
|
144
|
+
disabled={busy}
|
|
145
|
+
onClick={onApprove}
|
|
146
|
+
>
|
|
147
|
+
Approve
|
|
148
|
+
</Button>
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
<Button size="sm" disabled={busy} onClick={onPublish}>
|
|
152
|
+
Publish
|
|
153
|
+
</Button>
|
|
154
|
+
{/* Stays an <a>: the e2e selects it with getByRole('link', {name: /^view$/i}). */}
|
|
155
|
+
{status === 'published' && pageId !== null && (
|
|
156
|
+
<a
|
|
157
|
+
href={`/p/${effectiveSlug}`}
|
|
158
|
+
target="_blank"
|
|
159
|
+
rel="noopener noreferrer"
|
|
160
|
+
className="inline-flex h-8 items-center rounded-md border px-3 text-sm hover:bg-accent"
|
|
161
|
+
>
|
|
162
|
+
View
|
|
163
|
+
</a>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
{message && <span className="mt-1 w-full text-sm text-muted-foreground">{message}</span>}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Revision history drawer with per-revision compare and restore. */
|
|
2
|
+
|
|
3
|
+
import type { PageRevisionRead, RevisionDiff, RevisionEvent } from '../../utils/api';
|
|
4
|
+
import { DiffSummary } from '../DiffSummary';
|
|
5
|
+
|
|
6
|
+
const EVENT_LABELS: Record<RevisionEvent, string> = {
|
|
7
|
+
publish: 'Published',
|
|
8
|
+
unpublish: 'Unpublished',
|
|
9
|
+
submit: 'Submitted for review',
|
|
10
|
+
approve: 'Approved',
|
|
11
|
+
reject: 'Rejected',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Revision events whose ``data`` snapshot matches what /p/{slug} served.
|
|
15
|
+
const RESTORABLE_EVENTS: ReadonlySet<RevisionEvent> = new Set(['publish', 'approve']);
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
revisions: PageRevisionRead[];
|
|
19
|
+
activeDiff: RevisionDiff | null;
|
|
20
|
+
diffError: string | null;
|
|
21
|
+
busy: boolean;
|
|
22
|
+
onCompare: (beforeId: number, afterId: number) => void;
|
|
23
|
+
onRestore: (revisionId: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function RevisionHistoryPanel({
|
|
27
|
+
revisions,
|
|
28
|
+
activeDiff,
|
|
29
|
+
diffError,
|
|
30
|
+
busy,
|
|
31
|
+
onCompare,
|
|
32
|
+
onRestore,
|
|
33
|
+
}: Props) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="border-b bg-gray-50 px-4 py-3 text-sm">
|
|
36
|
+
{revisions.length === 0 ? (
|
|
37
|
+
<p className="text-gray-500">No history yet. Publish or submit to record one.</p>
|
|
38
|
+
) : (
|
|
39
|
+
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
|
40
|
+
{revisions.map((r, idx) => {
|
|
41
|
+
// Revisions are sorted newest-first, so the "previous" is
|
|
42
|
+
// the next index. The last item has no predecessor.
|
|
43
|
+
const previous = revisions[idx + 1];
|
|
44
|
+
const isOpen =
|
|
45
|
+
activeDiff !== null &&
|
|
46
|
+
activeDiff.after_id === r.id &&
|
|
47
|
+
previous !== undefined &&
|
|
48
|
+
activeDiff.before_id === previous.id;
|
|
49
|
+
return (
|
|
50
|
+
<li key={r.id} className="flex items-start justify-between gap-3 py-1">
|
|
51
|
+
<div className="flex-1 min-w-0">
|
|
52
|
+
<span className="font-medium">{EVENT_LABELS[r.event] ?? r.event}</span>
|
|
53
|
+
<span className="text-gray-700 ml-2">{r.title}</span>
|
|
54
|
+
<span className="text-gray-500 ml-2">
|
|
55
|
+
{new Date(r.created_at).toLocaleString()}
|
|
56
|
+
</span>
|
|
57
|
+
{r.created_by && <span className="text-gray-500 ml-2">by {r.created_by}</span>}
|
|
58
|
+
{r.note && <div className="text-gray-800 mt-0.5 break-words">Note: {r.note}</div>}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="flex gap-3 shrink-0">
|
|
61
|
+
{previous && (
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => onCompare(previous.id, r.id)}
|
|
65
|
+
className="text-blue-600 hover:underline"
|
|
66
|
+
data-testid={`compare-${r.id}`}
|
|
67
|
+
>
|
|
68
|
+
{isOpen ? 'Hide diff' : 'Compare'}
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
{RESTORABLE_EVENTS.has(r.event) && (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
disabled={busy}
|
|
75
|
+
onClick={() => onRestore(r.id)}
|
|
76
|
+
className="text-blue-600 hover:underline disabled:opacity-50"
|
|
77
|
+
>
|
|
78
|
+
Restore as draft
|
|
79
|
+
</button>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</li>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</ul>
|
|
86
|
+
)}
|
|
87
|
+
{diffError && (
|
|
88
|
+
<p className="mt-2 text-xs text-red-600" data-testid="diff-error">
|
|
89
|
+
{diffError}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
{activeDiff && <DiffSummary diff={activeDiff} />}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|