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,249 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import {
|
|
6
|
+
type ChangeEvent,
|
|
7
|
+
type DragEvent,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
|
|
15
|
+
import { MediaFilters } from '../components/media/MediaFilters';
|
|
16
|
+
import { MediaFolderSidebar } from '../components/media/MediaFolderSidebar';
|
|
17
|
+
import { MediaGrid } from '../components/media/MediaGrid';
|
|
18
|
+
import { MediaHeader } from '../components/media/MediaHeader';
|
|
19
|
+
import { MediaDropzone, MediaUploadQueue } from '../components/media/MediaUploadQueue';
|
|
20
|
+
import type { ListFilters } from '../components/media/types';
|
|
21
|
+
import { useMediaUploads } from '../hooks/useMediaUploads';
|
|
22
|
+
import {
|
|
23
|
+
deleteMedia,
|
|
24
|
+
listMedia,
|
|
25
|
+
type MediaAssetRead,
|
|
26
|
+
type MediaListResponse,
|
|
27
|
+
uploadMedia,
|
|
28
|
+
} from '../utils/api';
|
|
29
|
+
import { parseKB } from '../utils/mediaFormat';
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
initial: MediaListResponse;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PAGE_LIMIT = 60;
|
|
36
|
+
|
|
37
|
+
export default function MediaLibrary() {
|
|
38
|
+
const { initial } = usePage<{ props: Props }>().props as unknown as Props;
|
|
39
|
+
|
|
40
|
+
const [assets, setAssets] = useState<MediaAssetRead[]>(initial.items);
|
|
41
|
+
const [nextCursor, setNextCursor] = useState<number | null>(initial.next_cursor);
|
|
42
|
+
const [folders, setFolders] = useState<string[]>(initial.folders);
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
45
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
46
|
+
const [copied, setCopied] = useState<number | null>(null);
|
|
47
|
+
const [filters, setFilters] = useState<ListFilters>({
|
|
48
|
+
search: '',
|
|
49
|
+
contentType: '',
|
|
50
|
+
folder: null,
|
|
51
|
+
minKB: '',
|
|
52
|
+
maxKB: '',
|
|
53
|
+
});
|
|
54
|
+
const [uploadFolder, setUploadFolder] = useState<string>('');
|
|
55
|
+
const [dragActive, setDragActive] = useState(false);
|
|
56
|
+
|
|
57
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
58
|
+
|
|
59
|
+
// Debounced search: state holds what the user is typing, `query`
|
|
60
|
+
// holds what we actually send to the server.
|
|
61
|
+
const [query, setQuery] = useState<string>('');
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const t = window.setTimeout(() => setQuery(filters.search.trim()), 250);
|
|
64
|
+
return () => window.clearTimeout(t);
|
|
65
|
+
}, [filters.search]);
|
|
66
|
+
|
|
67
|
+
const refresh = useCallback(
|
|
68
|
+
async (mode: 'replace' | 'append' = 'replace', cursor?: number | null) => {
|
|
69
|
+
const isAppend = mode === 'append';
|
|
70
|
+
if (isAppend) {
|
|
71
|
+
setLoadingMore(true);
|
|
72
|
+
} else {
|
|
73
|
+
setLoading(true);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const response = await listMedia({
|
|
77
|
+
search: query || undefined,
|
|
78
|
+
content_type: filters.contentType || undefined,
|
|
79
|
+
folder: filters.folder === null ? undefined : filters.folder,
|
|
80
|
+
min_size_bytes: parseKB(filters.minKB),
|
|
81
|
+
max_size_bytes: parseKB(filters.maxKB),
|
|
82
|
+
cursor: cursor ?? undefined,
|
|
83
|
+
limit: PAGE_LIMIT,
|
|
84
|
+
});
|
|
85
|
+
setAssets((prev) => (isAppend ? [...prev, ...response.items] : response.items));
|
|
86
|
+
setNextCursor(response.next_cursor);
|
|
87
|
+
setFolders(response.folders);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
setMessage(e instanceof Error ? e.message : 'Failed to load media');
|
|
90
|
+
} finally {
|
|
91
|
+
if (isAppend) setLoadingMore(false);
|
|
92
|
+
else setLoading(false);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[filters.contentType, filters.folder, filters.maxKB, filters.minKB, query],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Reload from the server whenever a filter or the debounced query
|
|
99
|
+
// changes. We deliberately skip the initial mount (the server already
|
|
100
|
+
// gave us page 1 in props) to avoid a flash of duplicate work.
|
|
101
|
+
const didMountRef = useRef(false);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!didMountRef.current) {
|
|
104
|
+
didMountRef.current = true;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
void refresh('replace');
|
|
108
|
+
}, [refresh]);
|
|
109
|
+
|
|
110
|
+
const folderOptions = useMemo(() => {
|
|
111
|
+
const set = new Set(folders);
|
|
112
|
+
if (uploadFolder.trim()) set.add(uploadFolder.trim());
|
|
113
|
+
return Array.from(set).sort();
|
|
114
|
+
}, [folders, uploadFolder]);
|
|
115
|
+
|
|
116
|
+
const handleDelete = async (id: number) => {
|
|
117
|
+
if (!confirm('Delete this asset? Pages using its URL will break.')) return;
|
|
118
|
+
try {
|
|
119
|
+
await deleteMedia(id);
|
|
120
|
+
setAssets((prev) => prev.filter((a) => a.id !== id));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
setMessage(e instanceof Error ? e.message : 'Delete failed');
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleCopy = async (asset: MediaAssetRead) => {
|
|
127
|
+
try {
|
|
128
|
+
await navigator.clipboard.writeText(asset.url);
|
|
129
|
+
setCopied(asset.id);
|
|
130
|
+
setTimeout(() => setCopied((c) => (c === asset.id ? null : c)), 1500);
|
|
131
|
+
} catch {
|
|
132
|
+
setMessage('Copy failed — select the URL manually.');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Splice a freshly uploaded asset into the visible list when it matches the
|
|
137
|
+
// active filter; otherwise it appears when the user switches to that folder.
|
|
138
|
+
const handleUploaded = useCallback(
|
|
139
|
+
(asset: MediaAssetRead) => {
|
|
140
|
+
const matchesActiveFolder =
|
|
141
|
+
filters.folder === null ||
|
|
142
|
+
(filters.folder === '' && !asset.folder) ||
|
|
143
|
+
filters.folder === asset.folder;
|
|
144
|
+
if (matchesActiveFolder) {
|
|
145
|
+
setAssets((prev) => [asset, ...prev]);
|
|
146
|
+
}
|
|
147
|
+
if (asset.folder) {
|
|
148
|
+
setFolders((prev) =>
|
|
149
|
+
prev.includes(asset.folder as string)
|
|
150
|
+
? prev
|
|
151
|
+
: Array.from(new Set([...prev, asset.folder as string])).sort(),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[filters.folder],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const { uploads, startUploads, dismissUpload } = useMediaUploads({
|
|
159
|
+
uploadFolder,
|
|
160
|
+
onUploaded: handleUploaded,
|
|
161
|
+
setMessage,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const onFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
165
|
+
const list = e.target.files ? Array.from(e.target.files) : [];
|
|
166
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
167
|
+
startUploads(list);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
setDragActive(false);
|
|
173
|
+
const files = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
|
|
174
|
+
startUploads(files);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const activeFolderLabel = (() => {
|
|
178
|
+
if (filters.folder === null) return 'All assets';
|
|
179
|
+
if (filters.folder === '') return 'Unfiled';
|
|
180
|
+
return filters.folder;
|
|
181
|
+
})();
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<PageShell
|
|
185
|
+
title="Media library"
|
|
186
|
+
description="Images available to every page."
|
|
187
|
+
maxWidth="full"
|
|
188
|
+
actions={<MediaHeader fileInputRef={fileInputRef} onFilesSelected={onFileInputChange} />}
|
|
189
|
+
>
|
|
190
|
+
<div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6">
|
|
191
|
+
<MediaFolderSidebar
|
|
192
|
+
folders={folders}
|
|
193
|
+
folderOptions={folderOptions}
|
|
194
|
+
activeFolder={filters.folder}
|
|
195
|
+
onSelectFolder={(folder) => setFilters((f) => ({ ...f, folder }))}
|
|
196
|
+
uploadFolder={uploadFolder}
|
|
197
|
+
onUploadFolderChange={setUploadFolder}
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
<section>
|
|
201
|
+
<MediaFilters filters={filters} onChange={setFilters} />
|
|
202
|
+
|
|
203
|
+
<div className="text-sm text-gray-500 mb-2">
|
|
204
|
+
Browsing <strong>{activeFolderLabel}</strong>
|
|
205
|
+
{loading && <span className="ml-2">Loading…</span>}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{message && (
|
|
209
|
+
<div className="mb-4 text-sm text-gray-700 bg-yellow-50 border border-yellow-200 px-3 py-2 rounded">
|
|
210
|
+
{message}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
<MediaDropzone
|
|
215
|
+
uploadFolder={uploadFolder}
|
|
216
|
+
dragActive={dragActive}
|
|
217
|
+
onDragActiveChange={setDragActive}
|
|
218
|
+
onDrop={onDrop}
|
|
219
|
+
/>
|
|
220
|
+
|
|
221
|
+
<MediaUploadQueue uploads={uploads} onDismiss={dismissUpload} />
|
|
222
|
+
|
|
223
|
+
<MediaGrid
|
|
224
|
+
assets={assets}
|
|
225
|
+
loading={loading}
|
|
226
|
+
copiedId={copied}
|
|
227
|
+
onCopy={handleCopy}
|
|
228
|
+
onDelete={handleDelete}
|
|
229
|
+
/>
|
|
230
|
+
|
|
231
|
+
{nextCursor !== null && (
|
|
232
|
+
<div className="mt-6 flex justify-center">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
disabled={loadingMore}
|
|
236
|
+
onClick={() => void refresh('append', nextCursor)}
|
|
237
|
+
className="px-4 py-2 rounded border hover:bg-gray-50 text-sm disabled:opacity-50"
|
|
238
|
+
>
|
|
239
|
+
{loadingMore ? 'Loading…' : 'Load more'}
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</section>
|
|
244
|
+
</div>
|
|
245
|
+
</PageShell>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
MediaLibrary.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { type Data, Puck } from '@puckeditor/core';
|
|
2
|
+
import '@puckeditor/core/puck.css';
|
|
3
|
+
import { usePage } from '@inertiajs/react';
|
|
4
|
+
import { BrandingHead } from '@simple-module-py/ui/components/BrandingHead';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { PageEditorToolbar } from '../components/editor/PageEditorToolbar';
|
|
8
|
+
import { RevisionHistoryPanel } from '../components/editor/RevisionHistoryPanel';
|
|
9
|
+
import { SchedulePanel } from '../components/editor/SchedulePanel';
|
|
10
|
+
import { SeoSettingsPanel } from '../components/editor/SeoSettingsPanel';
|
|
11
|
+
import { migrateContent } from '../components/migrateContent';
|
|
12
|
+
import { editorViewports, emptyData, getPuckConfig } from '../components/puckConfig';
|
|
13
|
+
import { useAutosave } from '../hooks/useAutosave';
|
|
14
|
+
import { useEditorForm } from '../hooks/useEditorForm';
|
|
15
|
+
import { usePageRevisions } from '../hooks/usePageRevisions';
|
|
16
|
+
import { usePageSchedule } from '../hooks/usePageSchedule';
|
|
17
|
+
import { usePageWorkflow } from '../hooks/usePageWorkflow';
|
|
18
|
+
import type { PageDetail, PageRevisionRead } from '../utils/api';
|
|
19
|
+
import type { EditorSnapshot } from '../utils/editorSnapshot';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
page: PageDetail | null;
|
|
23
|
+
revisions: PageRevisionRead[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function initialSnapshotFor(page: PageDetail | null): EditorSnapshot {
|
|
27
|
+
return {
|
|
28
|
+
title: page?.title ?? 'Untitled page',
|
|
29
|
+
slug: page?.slug ?? '',
|
|
30
|
+
metaDescription: page?.meta_description ?? '',
|
|
31
|
+
ogImage: page?.og_image ?? '',
|
|
32
|
+
canonicalUrl: page?.canonical_url ?? '',
|
|
33
|
+
indexInSearch: page?.index_in_search ?? true,
|
|
34
|
+
jsonLdText: page?.json_ld ? JSON.stringify(page.json_ld, null, 2) : '',
|
|
35
|
+
// Migrated on the way in rather than on save: a draft the author never
|
|
36
|
+
// touches is never rewritten, so the stored payload only changes shape
|
|
37
|
+
// once they actually edit it.
|
|
38
|
+
data: migrateContent(
|
|
39
|
+
(page?.draft_data as unknown as Data) || (emptyData as unknown as Data),
|
|
40
|
+
getPuckConfig(),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function PageEditor() {
|
|
46
|
+
const props = usePage<{ props: Props }>().props as unknown as Props;
|
|
47
|
+
const { page } = props;
|
|
48
|
+
const revisions = props.revisions ?? [];
|
|
49
|
+
// ``rejection_note`` lives on the page prop and refreshes after every
|
|
50
|
+
// partial reload — no separate state slot needed.
|
|
51
|
+
const rejectionNote = page?.rejection_note ?? null;
|
|
52
|
+
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
55
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
56
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
57
|
+
|
|
58
|
+
const form = useEditorForm(page);
|
|
59
|
+
|
|
60
|
+
const { isDirty, saveState, lastSavedAt, autosaveError, markSaved } = useAutosave({
|
|
61
|
+
pageId: form.pageId,
|
|
62
|
+
busy,
|
|
63
|
+
data: form.data,
|
|
64
|
+
snapshotPayload: form.snapshotPayload,
|
|
65
|
+
initialSnapshot: initialSnapshotFor(page),
|
|
66
|
+
writePayload: form.writePayload,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const workflow = usePageWorkflow({ form, setBusy, setMessage, markSaved });
|
|
70
|
+
const schedule = usePageSchedule({ form, setBusy, setMessage });
|
|
71
|
+
const revisionActions = usePageRevisions({ form, setBusy, setMessage, markSaved });
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="h-screen flex flex-col">
|
|
75
|
+
{/* These editors render their own full-screen shell instead of
|
|
76
|
+
AuthenticatedLayout, so nothing else mounts BrandingHead for them —
|
|
77
|
+
and the preview would show the framework's default action colour
|
|
78
|
+
while the published page shows the configured one. */}
|
|
79
|
+
<BrandingHead />
|
|
80
|
+
<PageEditorToolbar
|
|
81
|
+
pageId={form.pageId}
|
|
82
|
+
title={form.title}
|
|
83
|
+
onTitleChange={form.setTitle}
|
|
84
|
+
effectiveSlug={form.effectiveSlug}
|
|
85
|
+
onSlugChange={(value) => {
|
|
86
|
+
form.setSlug(value);
|
|
87
|
+
form.setSlugTouched(true);
|
|
88
|
+
}}
|
|
89
|
+
status={form.status}
|
|
90
|
+
publishAt={form.publishAt}
|
|
91
|
+
unpublishAt={form.unpublishAt}
|
|
92
|
+
saveState={saveState}
|
|
93
|
+
isDirty={isDirty}
|
|
94
|
+
lastSavedAt={lastSavedAt}
|
|
95
|
+
autosaveError={autosaveError}
|
|
96
|
+
revisionCount={revisions.length}
|
|
97
|
+
busy={busy}
|
|
98
|
+
message={message}
|
|
99
|
+
onToggleSettings={() => setShowSettings((v) => !v)}
|
|
100
|
+
onToggleHistory={() => setShowHistory((v) => !v)}
|
|
101
|
+
onSave={() => workflow.handleSave(form.data)}
|
|
102
|
+
onPublish={workflow.handlePublish}
|
|
103
|
+
onUnpublish={workflow.handleUnpublish}
|
|
104
|
+
onSubmitForReview={workflow.handleSubmitForReview}
|
|
105
|
+
onApprove={workflow.handleApprove}
|
|
106
|
+
onReject={workflow.handleReject}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
{rejectionNote && form.status === 'draft' && (
|
|
110
|
+
<div className="border-b bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
111
|
+
<strong>Rejected:</strong> {rejectionNote}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{showSettings && (
|
|
116
|
+
<SeoSettingsPanel
|
|
117
|
+
metaDescription={form.metaDescription}
|
|
118
|
+
onMetaDescriptionChange={form.setMetaDescription}
|
|
119
|
+
ogImage={form.ogImage}
|
|
120
|
+
onOgImageChange={form.setOgImage}
|
|
121
|
+
canonicalUrl={form.canonicalUrl}
|
|
122
|
+
onCanonicalUrlChange={form.setCanonicalUrl}
|
|
123
|
+
indexInSearch={form.indexInSearch}
|
|
124
|
+
onIndexInSearchChange={form.setIndexInSearch}
|
|
125
|
+
jsonLdText={form.jsonLdText}
|
|
126
|
+
jsonLdError={form.jsonLdError}
|
|
127
|
+
onJsonLdChange={form.handleJsonLdChange}
|
|
128
|
+
schedule={
|
|
129
|
+
<SchedulePanel
|
|
130
|
+
publishAt={form.publishAt}
|
|
131
|
+
unpublishAt={form.unpublishAt}
|
|
132
|
+
onPublishAtChange={form.setPublishAt}
|
|
133
|
+
onUnpublishAtChange={form.setUnpublishAt}
|
|
134
|
+
onSave={schedule.handleSaveSchedule}
|
|
135
|
+
onClear={schedule.handleClearSchedule}
|
|
136
|
+
saveDisabled={busy || form.pageId === null}
|
|
137
|
+
clearDisabled={
|
|
138
|
+
busy ||
|
|
139
|
+
form.pageId === null ||
|
|
140
|
+
(form.publishAt === null && form.unpublishAt === null)
|
|
141
|
+
}
|
|
142
|
+
error={schedule.scheduleError}
|
|
143
|
+
/>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{showHistory && (
|
|
149
|
+
<RevisionHistoryPanel
|
|
150
|
+
revisions={revisions}
|
|
151
|
+
activeDiff={revisionActions.activeDiff}
|
|
152
|
+
diffError={revisionActions.diffError}
|
|
153
|
+
busy={busy}
|
|
154
|
+
onCompare={revisionActions.handleCompare}
|
|
155
|
+
onRestore={revisionActions.handleRestore}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<div className="flex-1 min-h-0">
|
|
160
|
+
<Puck
|
|
161
|
+
config={getPuckConfig()}
|
|
162
|
+
data={form.data}
|
|
163
|
+
viewports={editorViewports}
|
|
164
|
+
iframe={{ enabled: true }}
|
|
165
|
+
// Puck's header renders its own primary "Publish" alongside a copy of
|
|
166
|
+
// the page title. Its Publish was wired to a draft save, so the app
|
|
167
|
+
// showed two identical blue Publish buttons where the louder one did
|
|
168
|
+
// the quieter thing. Dropping headerActions leaves the toolbar above
|
|
169
|
+
// as the only publish control; the title, undo/redo and the sidebar
|
|
170
|
+
// toggles stay in Puck's header.
|
|
171
|
+
//
|
|
172
|
+
// Note for anyone auditing selectors: Puck renders that button as a
|
|
173
|
+
// <span>, not a <button>, so it never appeared in the accessibility
|
|
174
|
+
// tree and the e2e suite could not have caught this.
|
|
175
|
+
overrides={{ headerActions: () => <></> }}
|
|
176
|
+
onChange={form.setData}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
12
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
13
|
+
import type React from 'react';
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
|
|
16
|
+
import { ScheduledBadge } from '../components/ScheduledBadge';
|
|
17
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
18
|
+
import { deletePage, type PageRead } from '../utils/api';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
pages: { items: PageRead[] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function PageList() {
|
|
25
|
+
const { pages } = usePage<{ props: Props }>().props as unknown as Props;
|
|
26
|
+
const [busy, setBusy] = useState<number | null>(null);
|
|
27
|
+
|
|
28
|
+
const handleDelete = async (id: number) => {
|
|
29
|
+
if (!confirm('Delete this page?')) return;
|
|
30
|
+
setBusy(id);
|
|
31
|
+
try {
|
|
32
|
+
await deletePage(id);
|
|
33
|
+
router.reload({ only: ['pages'] });
|
|
34
|
+
} finally {
|
|
35
|
+
setBusy(null);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<PageShell
|
|
41
|
+
title="Pages"
|
|
42
|
+
description="Compose, review, and publish site pages."
|
|
43
|
+
actions={
|
|
44
|
+
<>
|
|
45
|
+
<Button variant="outline" onClick={() => router.visit('/pagebuilder/pending')}>
|
|
46
|
+
Pending review
|
|
47
|
+
</Button>
|
|
48
|
+
<Button variant="outline" onClick={() => router.visit('/pagebuilder/layout')}>
|
|
49
|
+
Site layout
|
|
50
|
+
</Button>
|
|
51
|
+
<Button variant="outline" onClick={() => router.visit('/pagebuilder/media')}>
|
|
52
|
+
Media library
|
|
53
|
+
</Button>
|
|
54
|
+
<Button onClick={() => router.visit('/pagebuilder/new')}>New page</Button>
|
|
55
|
+
</>
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
{pages.items.length === 0 ? (
|
|
59
|
+
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
|
60
|
+
No pages yet. Click "New page" to create your first one.
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<Table>
|
|
64
|
+
<TableHeader>
|
|
65
|
+
<TableRow>
|
|
66
|
+
<TableHead>Title</TableHead>
|
|
67
|
+
<TableHead>Slug</TableHead>
|
|
68
|
+
<TableHead>Status</TableHead>
|
|
69
|
+
<TableHead>Updated</TableHead>
|
|
70
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
71
|
+
</TableRow>
|
|
72
|
+
</TableHeader>
|
|
73
|
+
<TableBody>
|
|
74
|
+
{pages.items.map((p) => (
|
|
75
|
+
<TableRow key={p.id}>
|
|
76
|
+
<TableCell className="font-medium">{p.title}</TableCell>
|
|
77
|
+
<TableCell className="font-mono text-sm text-muted-foreground">{p.slug}</TableCell>
|
|
78
|
+
<TableCell>
|
|
79
|
+
<span className="flex flex-wrap items-center gap-1.5">
|
|
80
|
+
<StatusBadge status={p.status} />
|
|
81
|
+
<ScheduledBadge
|
|
82
|
+
status={p.status}
|
|
83
|
+
publishAt={p.publish_at}
|
|
84
|
+
unpublishAt={p.unpublish_at}
|
|
85
|
+
/>
|
|
86
|
+
</span>
|
|
87
|
+
</TableCell>
|
|
88
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
89
|
+
{p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}
|
|
90
|
+
</TableCell>
|
|
91
|
+
<TableCell className="space-x-1 text-right">
|
|
92
|
+
{/* Stays an <a>: the e2e selects it with getByRole('link'). */}
|
|
93
|
+
{p.status === 'published' && (
|
|
94
|
+
<a
|
|
95
|
+
href={`/p/${p.slug}`}
|
|
96
|
+
target="_blank"
|
|
97
|
+
rel="noopener noreferrer"
|
|
98
|
+
className="text-sm text-primary hover:underline"
|
|
99
|
+
>
|
|
100
|
+
View
|
|
101
|
+
</a>
|
|
102
|
+
)}
|
|
103
|
+
<Button
|
|
104
|
+
variant="link"
|
|
105
|
+
size="sm"
|
|
106
|
+
onClick={() => router.visit(`/pagebuilder/${p.id}/edit`)}
|
|
107
|
+
>
|
|
108
|
+
Edit
|
|
109
|
+
</Button>
|
|
110
|
+
<Button
|
|
111
|
+
variant="link"
|
|
112
|
+
size="sm"
|
|
113
|
+
className="text-destructive"
|
|
114
|
+
disabled={busy === p.id}
|
|
115
|
+
onClick={() => handleDelete(p.id)}
|
|
116
|
+
>
|
|
117
|
+
Delete
|
|
118
|
+
</Button>
|
|
119
|
+
</TableCell>
|
|
120
|
+
</TableRow>
|
|
121
|
+
))}
|
|
122
|
+
</TableBody>
|
|
123
|
+
</Table>
|
|
124
|
+
)}
|
|
125
|
+
</PageShell>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
PageList.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
12
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
13
|
+
import type React from 'react';
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
|
|
16
|
+
import { approvePage, type PageRead, promptAndReject } from '../utils/api';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
pages: { items: PageRead[] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Approver-only queue showing pages currently in
|
|
24
|
+
* ``submitted_for_review``. Approve takes the page live; reject sends
|
|
25
|
+
* it back to draft with a required rationale (surfaced to editors in
|
|
26
|
+
* the editor header + history panel).
|
|
27
|
+
*/
|
|
28
|
+
export default function PendingReview() {
|
|
29
|
+
const { pages } = usePage<{ props: Props }>().props as unknown as Props;
|
|
30
|
+
const [busy, setBusy] = useState<number | null>(null);
|
|
31
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
const handleApprove = async (id: number) => {
|
|
34
|
+
if (!confirm('Approve and publish this page?')) return;
|
|
35
|
+
setBusy(id);
|
|
36
|
+
setMessage(null);
|
|
37
|
+
try {
|
|
38
|
+
await approvePage(id);
|
|
39
|
+
router.reload({ only: ['pages'] });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
setMessage(e instanceof Error ? e.message : 'Approve failed');
|
|
42
|
+
} finally {
|
|
43
|
+
setBusy(null);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleReject = async (id: number) => {
|
|
48
|
+
setBusy(id);
|
|
49
|
+
setMessage(null);
|
|
50
|
+
const result = await promptAndReject(id);
|
|
51
|
+
setBusy(null);
|
|
52
|
+
if ('skipped' in result) {
|
|
53
|
+
if (result.skipped !== 'cancelled') setMessage(result.skipped);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
router.reload({ only: ['pages'] });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<PageShell
|
|
61
|
+
title="Pending review"
|
|
62
|
+
description="Pages submitted for approval."
|
|
63
|
+
actions={
|
|
64
|
+
<Button variant="outline" onClick={() => router.visit('/pagebuilder')}>
|
|
65
|
+
← All pages
|
|
66
|
+
</Button>
|
|
67
|
+
}
|
|
68
|
+
>
|
|
69
|
+
{message && <p className="mb-4 text-sm text-destructive">{message}</p>}
|
|
70
|
+
|
|
71
|
+
{pages.items.length === 0 ? (
|
|
72
|
+
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
|
73
|
+
No pages are awaiting review.
|
|
74
|
+
</div>
|
|
75
|
+
) : (
|
|
76
|
+
<Table>
|
|
77
|
+
<TableHeader>
|
|
78
|
+
<TableRow>
|
|
79
|
+
<TableHead>Title</TableHead>
|
|
80
|
+
<TableHead>Slug</TableHead>
|
|
81
|
+
<TableHead>Submitted</TableHead>
|
|
82
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
83
|
+
</TableRow>
|
|
84
|
+
</TableHeader>
|
|
85
|
+
<TableBody>
|
|
86
|
+
{pages.items.map((p) => (
|
|
87
|
+
<TableRow key={p.id}>
|
|
88
|
+
<TableCell className="font-medium">{p.title}</TableCell>
|
|
89
|
+
<TableCell className="font-mono text-sm text-muted-foreground">{p.slug}</TableCell>
|
|
90
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
91
|
+
{p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}
|
|
92
|
+
</TableCell>
|
|
93
|
+
<TableCell className="space-x-1 text-right">
|
|
94
|
+
<Button
|
|
95
|
+
variant="link"
|
|
96
|
+
size="sm"
|
|
97
|
+
onClick={() => router.visit(`/pagebuilder/${p.id}/edit`)}
|
|
98
|
+
>
|
|
99
|
+
Review
|
|
100
|
+
</Button>
|
|
101
|
+
<Button
|
|
102
|
+
variant="link"
|
|
103
|
+
size="sm"
|
|
104
|
+
disabled={busy === p.id}
|
|
105
|
+
onClick={() => handleApprove(p.id)}
|
|
106
|
+
>
|
|
107
|
+
Approve
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
variant="link"
|
|
111
|
+
size="sm"
|
|
112
|
+
className="text-destructive"
|
|
113
|
+
disabled={busy === p.id}
|
|
114
|
+
onClick={() => handleReject(p.id)}
|
|
115
|
+
>
|
|
116
|
+
Reject
|
|
117
|
+
</Button>
|
|
118
|
+
</TableCell>
|
|
119
|
+
</TableRow>
|
|
120
|
+
))}
|
|
121
|
+
</TableBody>
|
|
122
|
+
</Table>
|
|
123
|
+
)}
|
|
124
|
+
</PageShell>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
PendingReview.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|