@pantheon-systems/create-p1-starter-kit 0.1.0
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.
- package/index.js +5 -0
- package/lib/cli.js +149 -0
- package/lib/copy-template.js +67 -0
- package/lib/install-deps.js +68 -0
- package/lib/messages.js +28 -0
- package/package.json +38 -0
- package/template/.env.example +10 -0
- package/template/README.md +53 -0
- package/template/__tests__/editor-integration.test.ts +53 -0
- package/template/__tests__/remote-datasource-fetchers.test.ts +226 -0
- package/template/app/[...puckPath]/client.tsx +9 -0
- package/template/app/[...puckPath]/page.tsx +131 -0
- package/template/app/collection-nav.tsx +47 -0
- package/template/app/layout.tsx +13 -0
- package/template/app/p1/[[...p1]]/editor-client.tsx +116 -0
- package/template/app/p1/[[...p1]]/page.tsx +19 -0
- package/template/app/p1/[[...p1]]/render-client.tsx +9 -0
- package/template/app/p1/api/[...p1]/route.ts +16 -0
- package/template/app/p1/auth/[...action]/route.ts +9 -0
- package/template/app/p1/merge/merge-client.tsx +635 -0
- package/template/app/p1/merge/merge.css +257 -0
- package/template/app/p1/merge/page.tsx +12 -0
- package/template/app/page.tsx +130 -0
- package/template/app/styles.css +18 -0
- package/template/components/puck/block-padding.ts +2 -0
- package/template/components/puck/button-block.tsx +41 -0
- package/template/components/puck/divider-block.tsx +10 -0
- package/template/components/puck/grid-block.tsx +80 -0
- package/template/components/puck/heading-block.tsx +38 -0
- package/template/components/puck/image-block.tsx +33 -0
- package/template/components/puck/list-block.tsx +72 -0
- package/template/components/puck/paragraph-block.tsx +44 -0
- package/template/components/puck/quote-block.tsx +23 -0
- package/template/components/puck/root.tsx +20 -0
- package/template/components/puck/spacer-block.tsx +19 -0
- package/template/eslint.config.js +161 -0
- package/template/lib/content-publisher.ts +128 -0
- package/template/lib/fetcher-helpers.ts +17 -0
- package/template/lib/monsters-api.ts +125 -0
- package/template/lib/remote-datasource-fetchers.ts +10 -0
- package/template/lib/remote-datasources.ts +154 -0
- package/template/lib/swapi.ts +75 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.mjs +13 -0
- package/template/package.json +42 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/sw.js +8 -0
- package/template/puck.config.tsx +51 -0
- package/template/tsconfig/base.json +20 -0
- package/template/tsconfig/nextjs.json +21 -0
- package/template/tsconfig.json +16 -0
- package/template/vitest.config.ts +7 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/* ========================================================================
|
|
2
|
+
Merge Preview Components
|
|
3
|
+
======================================================================== */
|
|
4
|
+
|
|
5
|
+
/* View Mode Selector */
|
|
6
|
+
.view-mode-selector {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
border: 1px solid #d1d5db;
|
|
9
|
+
border-radius: 6px;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.view-mode-selector__btn {
|
|
14
|
+
padding: 6px 14px;
|
|
15
|
+
border: none;
|
|
16
|
+
border-right: 1px solid #d1d5db;
|
|
17
|
+
background: #fff;
|
|
18
|
+
color: #374151;
|
|
19
|
+
font-size: 13px;
|
|
20
|
+
font-weight: 500;
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
transition: background 0.15s, color 0.15s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.view-mode-selector__btn:last-child {
|
|
26
|
+
border-right: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.view-mode-selector__btn:hover {
|
|
30
|
+
background: #f3f4f6;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.view-mode-selector__btn--active {
|
|
34
|
+
background: #0066cc;
|
|
35
|
+
color: #fff;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.view-mode-selector__btn--active:hover {
|
|
39
|
+
background: #0052a3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Merge Preview Renderer */
|
|
43
|
+
.merge-preview-renderer__wrapper {
|
|
44
|
+
border: 1px solid #e5e7eb;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.merge-preview-renderer__summary {
|
|
50
|
+
padding: 8px 16px;
|
|
51
|
+
background: #f9fafb;
|
|
52
|
+
border-bottom: 1px solid #e5e7eb;
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
color: #6b7280;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Side-by-side view */
|
|
58
|
+
.merge-preview-renderer--side-by-side {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: 1fr 1fr;
|
|
61
|
+
gap: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.merge-preview-renderer__panel {
|
|
65
|
+
min-height: 300px;
|
|
66
|
+
border-right: 1px solid #e5e7eb;
|
|
67
|
+
overflow: auto;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.merge-preview-renderer__panel:last-child {
|
|
71
|
+
border-right: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.merge-preview-renderer__panel-label {
|
|
75
|
+
padding: 8px 12px;
|
|
76
|
+
background: #f3f4f6;
|
|
77
|
+
border-bottom: 1px solid #e5e7eb;
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
color: #374151;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
letter-spacing: 0.5px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.merge-preview-renderer__panel-content {
|
|
86
|
+
padding: 16px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Overlay view */
|
|
90
|
+
.merge-preview-renderer--overlay {
|
|
91
|
+
position: relative;
|
|
92
|
+
min-height: 400px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.merge-preview-renderer__overlay-layer {
|
|
96
|
+
padding: 16px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.merge-preview-renderer__overlay-layer--source {
|
|
100
|
+
opacity: 0.5;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.merge-preview-renderer__overlay-layer--target {
|
|
104
|
+
position: absolute;
|
|
105
|
+
top: 0;
|
|
106
|
+
left: 0;
|
|
107
|
+
right: 0;
|
|
108
|
+
opacity: 0.5;
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Slider view */
|
|
113
|
+
.merge-preview-renderer--slider {
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.merge-preview-renderer__slider-control {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
gap: 12px;
|
|
122
|
+
padding: 12px 16px;
|
|
123
|
+
background: #f9fafb;
|
|
124
|
+
border-bottom: 1px solid #e5e7eb;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.merge-preview-renderer__slider-input {
|
|
128
|
+
flex: 1;
|
|
129
|
+
accent-color: #0066cc;
|
|
130
|
+
height: 6px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.merge-preview-renderer__slider-content {
|
|
134
|
+
position: relative;
|
|
135
|
+
min-height: 400px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.merge-preview-renderer__slider-layer {
|
|
139
|
+
padding: 16px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.merge-preview-renderer__slider-layer--source {
|
|
143
|
+
position: relative;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.merge-preview-renderer__slider-layer--target {
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: 0;
|
|
149
|
+
left: 0;
|
|
150
|
+
right: 0;
|
|
151
|
+
pointer-events: none;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Empty state */
|
|
155
|
+
.merge-preview-renderer--empty {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
justify-content: center;
|
|
159
|
+
min-height: 200px;
|
|
160
|
+
color: #9ca3af;
|
|
161
|
+
font-size: 14px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Merge Preview Panel */
|
|
165
|
+
.merge-preview-panel__title {
|
|
166
|
+
margin: 0 0 8px;
|
|
167
|
+
font-size: 16px;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.merge-preview-panel__branch-info {
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
gap: 8px;
|
|
175
|
+
margin-bottom: 16px;
|
|
176
|
+
font-size: 14px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.merge-preview-panel__branch {
|
|
180
|
+
padding: 2px 8px;
|
|
181
|
+
background: #e5e7eb;
|
|
182
|
+
border-radius: 4px;
|
|
183
|
+
font-weight: 500;
|
|
184
|
+
font-size: 13px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.merge-preview-panel__arrow {
|
|
188
|
+
color: #9ca3af;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.merge-preview-panel__empty {
|
|
192
|
+
text-align: center;
|
|
193
|
+
padding: 40px;
|
|
194
|
+
color: #9ca3af;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.merge-preview-panel__document-list {
|
|
198
|
+
list-style: none;
|
|
199
|
+
margin: 0;
|
|
200
|
+
padding: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.merge-preview-document {
|
|
204
|
+
border: 1px solid #e5e7eb;
|
|
205
|
+
border-radius: 6px;
|
|
206
|
+
margin-bottom: 8px;
|
|
207
|
+
overflow: hidden;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.merge-preview-document__row {
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
width: 100%;
|
|
214
|
+
padding: 10px 14px;
|
|
215
|
+
border: none;
|
|
216
|
+
background: #fff;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
font-size: 14px;
|
|
219
|
+
text-align: left;
|
|
220
|
+
transition: background 0.15s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.merge-preview-document__row:hover {
|
|
224
|
+
background: #f9fafb;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.merge-preview-document__row--expanded {
|
|
228
|
+
background: #f3f4f6;
|
|
229
|
+
border-bottom: 1px solid #e5e7eb;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.merge-preview-document__path {
|
|
233
|
+
font-weight: 500;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.merge-preview-document__detail {
|
|
237
|
+
padding: 16px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Diff highlight colors */
|
|
241
|
+
[data-puck-diff-type="added"] {
|
|
242
|
+
outline: 2px solid #22c55e;
|
|
243
|
+
outline-offset: 2px;
|
|
244
|
+
background-color: rgba(34, 197, 94, 0.08);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
[data-puck-diff-type="removed"] {
|
|
248
|
+
outline: 2px solid #ef4444;
|
|
249
|
+
outline-offset: 2px;
|
|
250
|
+
background-color: rgba(239, 68, 68, 0.08);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
[data-puck-diff-type="modified"] {
|
|
254
|
+
outline: 2px solid #f59e0b;
|
|
255
|
+
outline-offset: 2px;
|
|
256
|
+
background-color: rgba(245, 158, 11, 0.08);
|
|
257
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
|
|
5
|
+
const MergeReviewClient = dynamic(
|
|
6
|
+
() => import("./merge-client").then((m) => ({ default: m.MergeReviewClient })),
|
|
7
|
+
{ ssr: false },
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export default function MergeReviewPage() {
|
|
11
|
+
return <MergeReviewClient />;
|
|
12
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
listRoutes,
|
|
4
|
+
ensureInitialized,
|
|
5
|
+
getPage,
|
|
6
|
+
listRouteTemplateKeysFromDatabase,
|
|
7
|
+
resolveDataTemplates,
|
|
8
|
+
resolveStringTemplates,
|
|
9
|
+
extractReferencedDatasourceIds,
|
|
10
|
+
loadRemoteDatasourceContext,
|
|
11
|
+
} from "@pantheon-systems/puck-css/server";
|
|
12
|
+
import type { Metadata } from "next";
|
|
13
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
14
|
+
import { Client } from "./[...puckPath]/client";
|
|
15
|
+
import { CollectionNav } from "./collection-nav";
|
|
16
|
+
|
|
17
|
+
const initPromise = ensureInitialized({
|
|
18
|
+
p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
|
|
19
|
+
p1ApiKey: process.env.P1_CSS_API_KEY,
|
|
20
|
+
p1SiteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
|
|
21
|
+
p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
25
|
+
await initPromise;
|
|
26
|
+
const pageData = await getPage("/");
|
|
27
|
+
if (!pageData) {
|
|
28
|
+
return { title: "P1 Starter Kit" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rawTitle = pageData.root.props?.title;
|
|
32
|
+
if (typeof rawTitle !== "string") {
|
|
33
|
+
return { title: rawTitle };
|
|
34
|
+
}
|
|
35
|
+
if (!rawTitle.includes("{{")) {
|
|
36
|
+
return { title: rawTitle };
|
|
37
|
+
}
|
|
38
|
+
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
39
|
+
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
40
|
+
const context = await loadRemoteDatasourceContext({
|
|
41
|
+
searchParams: {},
|
|
42
|
+
fetchImpl: fetch,
|
|
43
|
+
pagePath: "/",
|
|
44
|
+
routeTemplateKeys,
|
|
45
|
+
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
46
|
+
referencedDatasourceIds,
|
|
47
|
+
});
|
|
48
|
+
return { title: await resolveStringTemplates(rawTitle, context) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default async function HomePage() {
|
|
52
|
+
await initPromise;
|
|
53
|
+
const data = await getPage("/");
|
|
54
|
+
|
|
55
|
+
if (data) {
|
|
56
|
+
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
57
|
+
const referencedDatasourceIds = extractReferencedDatasourceIds(data);
|
|
58
|
+
const context = await loadRemoteDatasourceContext({
|
|
59
|
+
searchParams: {},
|
|
60
|
+
fetchImpl: fetch,
|
|
61
|
+
pagePath: "/",
|
|
62
|
+
routeTemplateKeys,
|
|
63
|
+
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
64
|
+
referencedDatasourceIds,
|
|
65
|
+
});
|
|
66
|
+
const resolvedData = await resolveDataTemplates(data, context);
|
|
67
|
+
return <Client data={resolvedData} />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const routes = await listRoutes();
|
|
71
|
+
|
|
72
|
+
const staticPages = routes.filter((r) => r.kind === "static");
|
|
73
|
+
const templates = routes.filter((r) => r.kind === "template");
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<main className="flex min-h-screen items-center justify-center bg-gray-50">
|
|
77
|
+
<div className="mx-auto max-w-lg px-6 py-16 text-center">
|
|
78
|
+
<h1 className="text-3xl font-bold tracking-tight text-gray-900">
|
|
79
|
+
Welcome to the P1 Starter Kit
|
|
80
|
+
</h1>
|
|
81
|
+
<p className="mt-4 text-gray-600">
|
|
82
|
+
Build and manage pages with the visual editor, or head to the
|
|
83
|
+
dashboard to manage your site.
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
<nav className="mt-10 flex flex-col gap-3">
|
|
87
|
+
<Link
|
|
88
|
+
href="/p1"
|
|
89
|
+
className="rounded-lg bg-gray-900 px-5 py-3 text-sm font-medium text-white hover:bg-gray-700"
|
|
90
|
+
>
|
|
91
|
+
Open the Page Editor
|
|
92
|
+
</Link>
|
|
93
|
+
<a
|
|
94
|
+
href="https://staging.content.pantheon.io/dashboard/sites"
|
|
95
|
+
target="_blank"
|
|
96
|
+
rel="noopener noreferrer"
|
|
97
|
+
className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
|
|
98
|
+
>
|
|
99
|
+
P1 Dashboard →
|
|
100
|
+
</a>
|
|
101
|
+
</nav>
|
|
102
|
+
|
|
103
|
+
{(staticPages.length > 0 || templates.length > 0) && (
|
|
104
|
+
<div className="mt-10 text-left">
|
|
105
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4">Pages</h2>
|
|
106
|
+
<ul className="space-y-3">
|
|
107
|
+
{staticPages.map((route) => (
|
|
108
|
+
<li key={route.path}>
|
|
109
|
+
<Link
|
|
110
|
+
href={route.path}
|
|
111
|
+
className="text-sm font-mono text-blue-600 hover:underline"
|
|
112
|
+
>
|
|
113
|
+
{route.path}
|
|
114
|
+
</Link>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
{templates.map((route) => (
|
|
118
|
+
<li key={route.path}>
|
|
119
|
+
<CollectionNav templatePath={route.path} />
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</main>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const dynamic = "force-dynamic";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
margin: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Prevent Tailwind's preflight from blowing up Puck's sidebar/UI icons.
|
|
8
|
+
Puck's SVG icons rely on their inline width/height attributes;
|
|
9
|
+
Tailwind's preflight sets svg { display: block } which makes them fill their container. */
|
|
10
|
+
[class*="_Drawer"] svg,
|
|
11
|
+
[class*="_ComponentList"] svg,
|
|
12
|
+
[class*="_ActionBar"] svg,
|
|
13
|
+
[class*="_DraggableComponent"] svg,
|
|
14
|
+
[class*="_SidebarSection"] svg,
|
|
15
|
+
[class*="_Puck"] svg {
|
|
16
|
+
display: inline-block;
|
|
17
|
+
vertical-align: middle;
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { blockPaddingClass } from "./block-padding";
|
|
2
|
+
|
|
3
|
+
export const buttonBlock = {
|
|
4
|
+
label: "Button",
|
|
5
|
+
fields: {
|
|
6
|
+
label: { type: "text" as const, label: "Label" },
|
|
7
|
+
href: { type: "text" as const, label: "Link URL" },
|
|
8
|
+
openInNewTab: {
|
|
9
|
+
type: "radio" as const,
|
|
10
|
+
label: "Open in new tab",
|
|
11
|
+
options: [
|
|
12
|
+
{ label: "No", value: false },
|
|
13
|
+
{ label: "Yes", value: true },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultProps: {
|
|
18
|
+
label: "Learn more",
|
|
19
|
+
href: "#",
|
|
20
|
+
openInNewTab: false,
|
|
21
|
+
},
|
|
22
|
+
render: ({
|
|
23
|
+
label,
|
|
24
|
+
href,
|
|
25
|
+
openInNewTab,
|
|
26
|
+
}: {
|
|
27
|
+
label?: string;
|
|
28
|
+
href?: string;
|
|
29
|
+
openInNewTab?: boolean;
|
|
30
|
+
}) => (
|
|
31
|
+
<div className={blockPaddingClass}>
|
|
32
|
+
<a
|
|
33
|
+
href={href || "#"}
|
|
34
|
+
{...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
35
|
+
className="inline-block rounded-lg bg-neutral-900 px-6 py-3 font-semibold text-white no-underline"
|
|
36
|
+
>
|
|
37
|
+
{label}
|
|
38
|
+
</a>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { blockPaddingClass } from "./block-padding";
|
|
2
|
+
import { Connectable, type ConnectedItem } from "@pantheon-systems/puck-css/connectable";
|
|
3
|
+
|
|
4
|
+
function CardGrid({
|
|
5
|
+
title,
|
|
6
|
+
items,
|
|
7
|
+
}: {
|
|
8
|
+
title?: string;
|
|
9
|
+
items: ConnectedItem[];
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<section className={blockPaddingClass}>
|
|
13
|
+
{title ? (
|
|
14
|
+
<h2 className="m-0 mb-4 text-2xl font-semibold">{title}</h2>
|
|
15
|
+
) : null}
|
|
16
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
|
17
|
+
{items.map((item, index) => {
|
|
18
|
+
const id = String(item.id ?? "").trim();
|
|
19
|
+
const className =
|
|
20
|
+
"rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:border-blue-400 hover:shadow-md";
|
|
21
|
+
if (item._href) {
|
|
22
|
+
return (
|
|
23
|
+
<a
|
|
24
|
+
key={`${id}-${index}`}
|
|
25
|
+
href={item._href}
|
|
26
|
+
className={`${className} no-underline`}
|
|
27
|
+
>
|
|
28
|
+
<div className="text-sm text-slate-500">
|
|
29
|
+
{id ? `#${id}` : "No ID"}
|
|
30
|
+
</div>
|
|
31
|
+
<div className="mt-1 text-base font-semibold text-slate-900">
|
|
32
|
+
{item._title}
|
|
33
|
+
</div>
|
|
34
|
+
</a>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div key={`${id}-${index}`} className={className}>
|
|
39
|
+
<div className="text-sm text-slate-500">
|
|
40
|
+
{id ? `#${id}` : "No ID"}
|
|
41
|
+
</div>
|
|
42
|
+
<div className="mt-1 text-base font-semibold text-slate-900">
|
|
43
|
+
{item._title}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ConnectableCardGrid = Connectable(CardGrid);
|
|
54
|
+
|
|
55
|
+
export const gridBlock = {
|
|
56
|
+
label: "Card Grid",
|
|
57
|
+
fields: {
|
|
58
|
+
title: { type: "text" as const, label: "Heading" },
|
|
59
|
+
items: {
|
|
60
|
+
type: "textarea" as const,
|
|
61
|
+
label: "Items datasource (e.g. {{ swapi_list.items }})",
|
|
62
|
+
},
|
|
63
|
+
min: { type: "number" as const, label: "Min cards" },
|
|
64
|
+
max: { type: "number" as const, label: "Max cards" },
|
|
65
|
+
itemTitleTemplate: { type: "text" as const, label: "Item title template" },
|
|
66
|
+
itemUrlTemplate: {
|
|
67
|
+
type: "text" as const,
|
|
68
|
+
label: "Optional item URL template (e.g. /jedi/{id} or {{ item.url }})",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
defaultProps: {
|
|
72
|
+
title: "Cards",
|
|
73
|
+
items: "{{ swapi_list.items }}",
|
|
74
|
+
min: 1,
|
|
75
|
+
max: 12,
|
|
76
|
+
itemTitleTemplate: "title is {{ item.name }}",
|
|
77
|
+
itemUrlTemplate: "/jedi/{id}",
|
|
78
|
+
},
|
|
79
|
+
render: ConnectableCardGrid,
|
|
80
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { blockPaddingClass } from "./block-padding";
|
|
2
|
+
|
|
3
|
+
const levelClass: Record<"h1" | "h2" | "h3" | "h4", string> = {
|
|
4
|
+
h1: "text-4xl",
|
|
5
|
+
h2: "text-3xl",
|
|
6
|
+
h3: "text-2xl",
|
|
7
|
+
h4: "text-xl",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const headingBlock = {
|
|
11
|
+
label: "Heading",
|
|
12
|
+
fields: {
|
|
13
|
+
title: { type: "text" as const, label: "Text" },
|
|
14
|
+
level: {
|
|
15
|
+
type: "select" as const,
|
|
16
|
+
label: "Level",
|
|
17
|
+
options: [
|
|
18
|
+
{ label: "H1", value: "h1" },
|
|
19
|
+
{ label: "H2", value: "h2" },
|
|
20
|
+
{ label: "H3", value: "h3" },
|
|
21
|
+
{ label: "H4", value: "h4" },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultProps: {
|
|
26
|
+
title: "Heading",
|
|
27
|
+
level: "h1" as const,
|
|
28
|
+
},
|
|
29
|
+
render: ({ title, level }: { title?: string; level?: string }) => {
|
|
30
|
+
const L = (level ?? "h1") as "h1" | "h2" | "h3" | "h4";
|
|
31
|
+
const size = levelClass[L] ?? levelClass.h1;
|
|
32
|
+
return (
|
|
33
|
+
<div className={blockPaddingClass}>
|
|
34
|
+
<L className={`m-0 font-bold leading-tight ${size}`}>{title}</L>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { blockPaddingClass } from "./block-padding";
|
|
2
|
+
|
|
3
|
+
export const imageBlock = {
|
|
4
|
+
label: "Image",
|
|
5
|
+
fields: {
|
|
6
|
+
src: { type: "text" as const, label: "Image URL" },
|
|
7
|
+
alt: { type: "text" as const, label: "Alt text" },
|
|
8
|
+
caption: { type: "textarea" as const, label: "Caption (optional)" },
|
|
9
|
+
},
|
|
10
|
+
defaultProps: {
|
|
11
|
+
src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80",
|
|
12
|
+
alt: "Mountain landscape",
|
|
13
|
+
caption: "",
|
|
14
|
+
},
|
|
15
|
+
render: ({ src, alt, caption }: { src?: string; alt?: string; caption?: string }) => (
|
|
16
|
+
<figure className={`m-0 ${blockPaddingClass}`}>
|
|
17
|
+
{src ? (
|
|
18
|
+
<img
|
|
19
|
+
src={src}
|
|
20
|
+
alt={alt || ""}
|
|
21
|
+
className="block h-auto max-h-[400px] w-full max-w-4xl rounded-lg object-contain"
|
|
22
|
+
/>
|
|
23
|
+
) : (
|
|
24
|
+
<div className="flex max-h-[400px] min-h-[200px] max-w-4xl items-center justify-center rounded-lg bg-neutral-200 text-neutral-600">
|
|
25
|
+
Add an image URL
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
{caption ? (
|
|
29
|
+
<figcaption className="mt-3 max-w-4xl text-sm text-neutral-600">{caption}</figcaption>
|
|
30
|
+
) : null}
|
|
31
|
+
</figure>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { blockPaddingClass } from "./block-padding";
|
|
2
|
+
|
|
3
|
+
/** One line = `[label](href)` (from `{{ swapi_list.markdownLinks }}` or arg form) or plain text. */
|
|
4
|
+
const MARKDOWN_LINK_LINE = /^\[([^\]]*)\]\(([^)]+)\)$/;
|
|
5
|
+
|
|
6
|
+
export const listBlock = {
|
|
7
|
+
label: "List",
|
|
8
|
+
fields: {
|
|
9
|
+
ordered: {
|
|
10
|
+
type: "radio" as const,
|
|
11
|
+
label: "Style",
|
|
12
|
+
options: [
|
|
13
|
+
{ label: "Bulleted", value: false },
|
|
14
|
+
{ label: "Numbered", value: true },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
items: {
|
|
18
|
+
type: "textarea" as const,
|
|
19
|
+
label: "Items (one per line)",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultProps: {
|
|
23
|
+
ordered: false,
|
|
24
|
+
items: "First item\nSecond item\nThird item",
|
|
25
|
+
},
|
|
26
|
+
render: ({ ordered, items }: { ordered?: boolean; items?: string }) => {
|
|
27
|
+
const lines = (items || "")
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((s) => s.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
const ListTag = ordered ? "ol" : "ul";
|
|
32
|
+
const listClass = ordered ? "list-decimal" : "list-disc";
|
|
33
|
+
return (
|
|
34
|
+
<div className={blockPaddingClass}>
|
|
35
|
+
<ListTag className={`m-0 max-w-prose space-y-1 pl-6 leading-relaxed ${listClass}`}>
|
|
36
|
+
{lines.map((line, i) => {
|
|
37
|
+
const m = line.match(MARKDOWN_LINK_LINE);
|
|
38
|
+
if (m) {
|
|
39
|
+
const href = m[2];
|
|
40
|
+
const safe =
|
|
41
|
+
href.startsWith("/") ||
|
|
42
|
+
href.startsWith("./") ||
|
|
43
|
+
/^https?:\/\//i.test(href);
|
|
44
|
+
if (!safe) {
|
|
45
|
+
return (
|
|
46
|
+
<li key={i} className="break-words">
|
|
47
|
+
{line}
|
|
48
|
+
</li>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<li key={i} className="break-words">
|
|
53
|
+
<a
|
|
54
|
+
href={href}
|
|
55
|
+
className="text-blue-700 underline decoration-blue-700/40 underline-offset-2 hover:decoration-blue-700"
|
|
56
|
+
>
|
|
57
|
+
{m[1]}
|
|
58
|
+
</a>
|
|
59
|
+
</li>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
<li key={i} className="break-words">
|
|
64
|
+
{line}
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</ListTag>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
};
|