@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.
Files changed (52) hide show
  1. package/index.js +5 -0
  2. package/lib/cli.js +149 -0
  3. package/lib/copy-template.js +67 -0
  4. package/lib/install-deps.js +68 -0
  5. package/lib/messages.js +28 -0
  6. package/package.json +38 -0
  7. package/template/.env.example +10 -0
  8. package/template/README.md +53 -0
  9. package/template/__tests__/editor-integration.test.ts +53 -0
  10. package/template/__tests__/remote-datasource-fetchers.test.ts +226 -0
  11. package/template/app/[...puckPath]/client.tsx +9 -0
  12. package/template/app/[...puckPath]/page.tsx +131 -0
  13. package/template/app/collection-nav.tsx +47 -0
  14. package/template/app/layout.tsx +13 -0
  15. package/template/app/p1/[[...p1]]/editor-client.tsx +116 -0
  16. package/template/app/p1/[[...p1]]/page.tsx +19 -0
  17. package/template/app/p1/[[...p1]]/render-client.tsx +9 -0
  18. package/template/app/p1/api/[...p1]/route.ts +16 -0
  19. package/template/app/p1/auth/[...action]/route.ts +9 -0
  20. package/template/app/p1/merge/merge-client.tsx +635 -0
  21. package/template/app/p1/merge/merge.css +257 -0
  22. package/template/app/p1/merge/page.tsx +12 -0
  23. package/template/app/page.tsx +130 -0
  24. package/template/app/styles.css +18 -0
  25. package/template/components/puck/block-padding.ts +2 -0
  26. package/template/components/puck/button-block.tsx +41 -0
  27. package/template/components/puck/divider-block.tsx +10 -0
  28. package/template/components/puck/grid-block.tsx +80 -0
  29. package/template/components/puck/heading-block.tsx +38 -0
  30. package/template/components/puck/image-block.tsx +33 -0
  31. package/template/components/puck/list-block.tsx +72 -0
  32. package/template/components/puck/paragraph-block.tsx +44 -0
  33. package/template/components/puck/quote-block.tsx +23 -0
  34. package/template/components/puck/root.tsx +20 -0
  35. package/template/components/puck/spacer-block.tsx +19 -0
  36. package/template/eslint.config.js +161 -0
  37. package/template/lib/content-publisher.ts +128 -0
  38. package/template/lib/fetcher-helpers.ts +17 -0
  39. package/template/lib/monsters-api.ts +125 -0
  40. package/template/lib/remote-datasource-fetchers.ts +10 -0
  41. package/template/lib/remote-datasources.ts +154 -0
  42. package/template/lib/swapi.ts +75 -0
  43. package/template/next-env.d.ts +6 -0
  44. package/template/next.config.mjs +13 -0
  45. package/template/package.json +42 -0
  46. package/template/postcss.config.mjs +8 -0
  47. package/template/public/sw.js +8 -0
  48. package/template/puck.config.tsx +51 -0
  49. package/template/tsconfig/base.json +20 -0
  50. package/template/tsconfig/nextjs.json +21 -0
  51. package/template/tsconfig.json +16 -0
  52. package/template/vitest.config.ts +7 -0
@@ -0,0 +1,635 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useMemo } from "react";
4
+ import Link from "next/link";
5
+ import {
6
+ P1App,
7
+ createNextConfig,
8
+ useP1Auth,
9
+ DocumentDiffList,
10
+ PuckFieldResolutionPanel,
11
+ MergePreviewPanel,
12
+ MergePreviewRenderer,
13
+ ViewModeSelector,
14
+ createBranchDocumentComparison,
15
+ diffPuckDataWithPositions,
16
+ isPuckData,
17
+ } from "@pantheon-systems/puck-css";
18
+ import type {
19
+ BranchDocumentComparison,
20
+ DocumentDiffSummary,
21
+ ViewMode,
22
+ PuckData,
23
+ Branch,
24
+ } from "@pantheon-systems/puck-css";
25
+
26
+ import "@pantheon-systems/puck-css/styles.css";
27
+ import "@pantheon-systems/puck-css/pds/styles.css";
28
+ import "./merge.css";
29
+
30
+ import puckConfig from "../../../puck.config";
31
+
32
+ const p1Config = createNextConfig();
33
+
34
+ export function MergeReviewClient() {
35
+ return (
36
+ <P1App
37
+ config={p1Config}
38
+ loginPageProps={{
39
+ title: "P1 Starter",
40
+ subtitle: "Sign in to review merges",
41
+ }}
42
+ >
43
+ <MergeReviewContent />
44
+ </P1App>
45
+ );
46
+ }
47
+
48
+ interface MergePreviewResponse {
49
+ canMerge: boolean;
50
+ hasConflicts: boolean;
51
+ conflicts: {
52
+ documentConflicts: {
53
+ documentId: string;
54
+ documentPath: string;
55
+ conflictType: string;
56
+ sourceVersion?: number;
57
+ targetVersion?: number;
58
+ }[];
59
+ };
60
+ sourceChanges: {
61
+ documentId: string;
62
+ documentPath: string;
63
+ latestVersionId: string;
64
+ }[];
65
+ targetChanges: {
66
+ documentId: string;
67
+ documentPath: string;
68
+ latestVersionId: string;
69
+ }[];
70
+ documentDiffs?: {
71
+ documentId: string;
72
+ documentPath: string;
73
+ sourceSnapshot: Record<string, unknown> | null;
74
+ targetSnapshot: Record<string, unknown> | null;
75
+ diffOperations: unknown[];
76
+ }[];
77
+ }
78
+
79
+ type MergeTab =
80
+ | "diff-list"
81
+ | "visual-compare"
82
+ | "merge-preview"
83
+ | "conflict-resolution";
84
+
85
+ function MergeReviewContent() {
86
+ const { getToken } = useP1Auth();
87
+
88
+ const [branches, setBranches] = useState<Branch[]>([]);
89
+ const [sourceBranchId, setSourceBranchId] = useState("");
90
+ const [targetBranchId, setTargetBranchId] = useState("");
91
+ const [preview, setPreview] = useState<MergePreviewResponse | null>(null);
92
+ const [loading, setLoading] = useState(false);
93
+ const [error, setError] = useState<string | null>(null);
94
+ const [activeTab, setActiveTab] = useState<MergeTab>("diff-list");
95
+ const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
96
+ const [viewMode, setViewMode] = useState<ViewMode>("side-by-side");
97
+
98
+ const apiFetch = useCallback(
99
+ async <T,>(path: string, options?: RequestInit): Promise<T> => {
100
+ const token = await getToken();
101
+ const res = await fetch(`${p1Config.baseUrl}${path}`, {
102
+ ...options,
103
+ headers: {
104
+ "Content-Type": "application/json",
105
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
106
+ ...options?.headers,
107
+ },
108
+ });
109
+ if (!res.ok) {
110
+ const body = await res
111
+ .json()
112
+ .catch(() => ({ error: res.statusText }));
113
+ throw new Error(
114
+ (body as { error?: string }).error || `API error: ${res.status}`,
115
+ );
116
+ }
117
+ return res.json() as Promise<T>;
118
+ },
119
+ [getToken],
120
+ );
121
+
122
+ useEffect(() => {
123
+ if (!p1Config.siteId) return;
124
+ apiFetch<{ branches: Branch[] }>(
125
+ `/api/sites/${p1Config.siteId}/branches`,
126
+ )
127
+ .then((res) => {
128
+ setBranches(res.branches ?? []);
129
+ const main = res.branches?.find(
130
+ (b) => b.isMain || b.name === "main",
131
+ );
132
+ if (main) {
133
+ setTargetBranchId(main.id);
134
+ const other = res.branches?.find((b) => b.id !== main.id);
135
+ if (other) {
136
+ setSourceBranchId(other.id);
137
+ }
138
+ }
139
+ })
140
+ .catch((err: unknown) =>
141
+ setError(err instanceof Error ? err.message : String(err)),
142
+ );
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, []);
145
+
146
+ const sourceBranch = branches.find((b) => b.id === sourceBranchId);
147
+ const sourceName = sourceBranch?.name ?? "Draft";
148
+ const targetName = "Live";
149
+
150
+ const fetchPreview = useCallback(async () => {
151
+ if (
152
+ !sourceBranchId ||
153
+ !targetBranchId ||
154
+ sourceBranchId === targetBranchId
155
+ )
156
+ return;
157
+ setLoading(true);
158
+ setError(null);
159
+ setPreview(null);
160
+ try {
161
+ const result = await apiFetch<MergePreviewResponse>(
162
+ `/api/sites/${p1Config.siteId}/merge/preview`,
163
+ {
164
+ method: "POST",
165
+ body: JSON.stringify({
166
+ sourceBranchId,
167
+ targetBranchId,
168
+ includeContent: true,
169
+ }),
170
+ },
171
+ );
172
+ setPreview(result);
173
+ } catch (err) {
174
+ setError(err instanceof Error ? err.message : String(err));
175
+ } finally {
176
+ setLoading(false);
177
+ }
178
+ }, [sourceBranchId, targetBranchId, apiFetch]);
179
+
180
+ const documentComparisons = useMemo((): BranchDocumentComparison[] => {
181
+ if (!preview?.documentDiffs) return [];
182
+ return preview.documentDiffs
183
+ .filter((d) => d.sourceSnapshot || d.targetSnapshot)
184
+ .map((d) => {
185
+ const src = d.sourceSnapshot as unknown as PuckData | null;
186
+ const tgt = d.targetSnapshot as unknown as PuckData | null;
187
+ if (src && tgt && isPuckData(src) && isPuckData(tgt)) {
188
+ return createBranchDocumentComparison(
189
+ d.documentId,
190
+ d.documentPath,
191
+ src,
192
+ tgt,
193
+ );
194
+ }
195
+ return {
196
+ documentId: d.documentId,
197
+ documentPath: d.documentPath,
198
+ isPuckData: false,
199
+ diffs: [],
200
+ counts: {
201
+ added: 0,
202
+ removed: 0,
203
+ modified: d.diffOperations.length,
204
+ unchanged: 0,
205
+ },
206
+ } satisfies BranchDocumentComparison;
207
+ });
208
+ }, [preview]);
209
+
210
+ const mergePreviewDocs = useMemo((): DocumentDiffSummary[] => {
211
+ if (!preview?.documentDiffs) return [];
212
+ return preview.documentDiffs.map((d) => ({
213
+ documentId: d.documentId,
214
+ documentPath: d.documentPath,
215
+ sourceSnapshot: d.sourceSnapshot,
216
+ targetSnapshot: d.targetSnapshot,
217
+ }));
218
+ }, [preview]);
219
+
220
+ const selectedDoc = preview?.documentDiffs?.find(
221
+ (d) => d.documentId === selectedDocId,
222
+ );
223
+ const selectedDiffs = useMemo(() => {
224
+ if (!selectedDoc?.sourceSnapshot || !selectedDoc?.targetSnapshot) return [];
225
+ const src = selectedDoc.sourceSnapshot as unknown as PuckData;
226
+ const tgt = selectedDoc.targetSnapshot as unknown as PuckData;
227
+ if (isPuckData(src) && isPuckData(tgt)) {
228
+ return diffPuckDataWithPositions(tgt, src);
229
+ }
230
+ return [];
231
+ }, [selectedDoc]);
232
+
233
+ const conflictDoc = useMemo(() => {
234
+ if (!preview) return null;
235
+ const conflict = preview.conflicts.documentConflicts[0];
236
+ if (!conflict) return null;
237
+ const diff = preview.documentDiffs?.find(
238
+ (d) => d.documentId === conflict.documentId,
239
+ );
240
+ if (!diff?.sourceSnapshot || !diff?.targetSnapshot) return null;
241
+ const src = diff.sourceSnapshot as unknown as PuckData;
242
+ const tgt = diff.targetSnapshot as unknown as PuckData;
243
+ if (!isPuckData(src) || !isPuckData(tgt)) return null;
244
+ return { conflict, source: src, target: tgt, path: diff.documentPath };
245
+ }, [preview]);
246
+
247
+ if (!p1Config.siteId) {
248
+ return (
249
+ <div style={styles.page}>
250
+ <p>Set NEXT_PUBLIC_CSS_SITE_ID to use merge review.</p>
251
+ <Link href="/p1" style={styles.backLink}>
252
+ Back to editor
253
+ </Link>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ return (
259
+ <div style={styles.page}>
260
+ <header style={styles.header}>
261
+ <div style={styles.headerLeft}>
262
+ <Link href="/p1" style={styles.backLink}>
263
+ &larr; Editor
264
+ </Link>
265
+ <h1 style={styles.title}>Merge review</h1>
266
+ </div>
267
+ </header>
268
+
269
+ {/* Branch Selectors */}
270
+ <div style={styles.branchSelectors}>
271
+ <div style={styles.branchField}>
272
+ <label style={styles.label}>Draft</label>
273
+ <select
274
+ value={sourceBranchId}
275
+ onChange={(e) => setSourceBranchId(e.target.value)}
276
+ style={styles.select}
277
+ >
278
+ <option value="">Select Draft</option>
279
+ {branches
280
+ .filter((b) => !b.isMain && b.name !== "main")
281
+ .map((b) => (
282
+ <option key={b.id} value={b.id}>
283
+ {b.name}
284
+ </option>
285
+ ))}
286
+ </select>
287
+ </div>
288
+ <span style={styles.arrow}>&rarr;</span>
289
+ <div style={styles.branchField}>
290
+ <label style={styles.label}>Live</label>
291
+ <div
292
+ style={{
293
+ ...styles.select,
294
+ display: "flex",
295
+ alignItems: "center",
296
+ background: "#f5f5f5",
297
+ color: "#666",
298
+ }}
299
+ >
300
+ Live
301
+ </div>
302
+ </div>
303
+ <button
304
+ onClick={fetchPreview}
305
+ disabled={
306
+ loading ||
307
+ !sourceBranchId ||
308
+ !targetBranchId ||
309
+ sourceBranchId === targetBranchId
310
+ }
311
+ style={styles.compareBtn}
312
+ >
313
+ {loading ? "Loading..." : "Compare Draft to Live"}
314
+ </button>
315
+ </div>
316
+
317
+ {error && <div style={styles.error}>{error}</div>}
318
+
319
+ {preview && (
320
+ <>
321
+ {/* Status Banner */}
322
+ <div
323
+ style={{
324
+ ...styles.statusBanner,
325
+ backgroundColor: preview.hasConflicts ? "#fff3cd" : "#d4edda",
326
+ borderColor: preview.hasConflicts ? "#ffc107" : "#28a745",
327
+ }}
328
+ >
329
+ {preview.hasConflicts ? (
330
+ <span>
331
+ {preview.conflicts.documentConflicts.length} conflict(s)
332
+ detected between <strong>{sourceName}</strong> and{" "}
333
+ <strong>{targetName}</strong>
334
+ </span>
335
+ ) : (
336
+ <span>
337
+ No conflicts. {sourceName} can be merged into {targetName}.
338
+ </span>
339
+ )}
340
+ </div>
341
+
342
+ {/* Tab Navigation */}
343
+ <div style={styles.tabs}>
344
+ {(
345
+ [
346
+ "diff-list",
347
+ "visual-compare",
348
+ "merge-preview",
349
+ "conflict-resolution",
350
+ ] as MergeTab[]
351
+ ).map((tab) => (
352
+ <button
353
+ key={tab}
354
+ onClick={() => setActiveTab(tab)}
355
+ style={{
356
+ ...styles.tab,
357
+ ...(activeTab === tab ? styles.activeTab : {}),
358
+ }}
359
+ disabled={tab === "conflict-resolution" && !conflictDoc}
360
+ >
361
+ {tab === "diff-list" && "Document diffs"}
362
+ {tab === "visual-compare" && "Visual compare"}
363
+ {tab === "merge-preview" && "Merge preview"}
364
+ {tab === "conflict-resolution" &&
365
+ `Conflict resolution${!conflictDoc ? " (no conflicts)" : ""}`}
366
+ </button>
367
+ ))}
368
+ </div>
369
+
370
+ {/* Tab Content */}
371
+ <div style={styles.content}>
372
+ {activeTab === "diff-list" && (
373
+ <DocumentDiffList
374
+ documents={documentComparisons}
375
+ sourceBranchName={sourceName}
376
+ targetBranchName={targetName}
377
+ />
378
+ )}
379
+
380
+ {activeTab === "visual-compare" && (
381
+ <div>
382
+ {!selectedDocId && (
383
+ <div style={styles.selectPrompt}>
384
+ Select a document to compare visually:
385
+ <div style={styles.docList}>
386
+ {(preview.documentDiffs ?? []).map((d) => (
387
+ <button
388
+ key={d.documentId}
389
+ onClick={() => setSelectedDocId(d.documentId)}
390
+ style={styles.docBtn}
391
+ >
392
+ {d.documentPath}
393
+ <span style={styles.changeCount}>
394
+ {d.diffOperations.length} change(s)
395
+ </span>
396
+ </button>
397
+ ))}
398
+ </div>
399
+ </div>
400
+ )}
401
+ {selectedDocId &&
402
+ selectedDoc?.sourceSnapshot &&
403
+ selectedDoc?.targetSnapshot && (
404
+ <div>
405
+ <div style={styles.visualCompareToolbar}>
406
+ <button
407
+ onClick={() => setSelectedDocId(null)}
408
+ style={styles.backBtn}
409
+ >
410
+ &larr; Back to documents
411
+ </button>
412
+ <ViewModeSelector
413
+ viewMode={viewMode}
414
+ onViewModeChange={setViewMode}
415
+ />
416
+ </div>
417
+ <MergePreviewRenderer
418
+ sourceData={
419
+ selectedDoc.sourceSnapshot as unknown as PuckData
420
+ }
421
+ targetData={
422
+ selectedDoc.targetSnapshot as unknown as PuckData
423
+ }
424
+ diffs={selectedDiffs}
425
+ config={puckConfig}
426
+ viewMode={viewMode}
427
+ sourceBranchName={sourceName}
428
+ targetBranchName={targetName}
429
+ />
430
+ </div>
431
+ )}
432
+ </div>
433
+ )}
434
+
435
+ {activeTab === "merge-preview" && (
436
+ <MergePreviewPanel
437
+ documents={mergePreviewDocs}
438
+ sourceBranchName={sourceName}
439
+ targetBranchName={targetName}
440
+ config={puckConfig}
441
+ onDocumentSelect={(docId: string) => {
442
+ setSelectedDocId(docId);
443
+ setActiveTab("visual-compare");
444
+ }}
445
+ />
446
+ )}
447
+
448
+ {activeTab === "conflict-resolution" && conflictDoc && (
449
+ <div>
450
+ <h3 style={styles.sectionTitle}>
451
+ Resolving: {conflictDoc.path}
452
+ </h3>
453
+ <PuckFieldResolutionPanel
454
+ sourceSnapshot={conflictDoc.source}
455
+ targetSnapshot={conflictDoc.target}
456
+ baseSnapshot={null}
457
+ sourceBranchName={sourceName}
458
+ targetBranchName={targetName}
459
+ // TODO: Wire up to the merge API — currently a stub that logs the resolved snapshot.
460
+ onResolve={(merged: PuckData) => {
461
+ console.log("Resolved merge snapshot:", merged);
462
+ alert(
463
+ "Resolution applied (logged to console). In production, this would submit via the merge API.",
464
+ );
465
+ }}
466
+ />
467
+ </div>
468
+ )}
469
+ </div>
470
+ </>
471
+ )}
472
+ </div>
473
+ );
474
+ }
475
+
476
+ const styles: Record<string, React.CSSProperties> = {
477
+ page: {
478
+ fontFamily: "system-ui, -apple-system, sans-serif",
479
+ maxWidth: "1200px",
480
+ margin: "0 auto",
481
+ padding: "24px",
482
+ },
483
+ header: {
484
+ display: "flex",
485
+ justifyContent: "space-between",
486
+ alignItems: "center",
487
+ marginBottom: "24px",
488
+ },
489
+ headerLeft: {
490
+ display: "flex",
491
+ alignItems: "center",
492
+ gap: "16px",
493
+ },
494
+ backLink: {
495
+ color: "#0066cc",
496
+ textDecoration: "none",
497
+ fontSize: "14px",
498
+ },
499
+ title: {
500
+ fontSize: "24px",
501
+ fontWeight: 600,
502
+ margin: 0,
503
+ },
504
+ branchSelectors: {
505
+ display: "flex",
506
+ alignItems: "flex-end",
507
+ gap: "12px",
508
+ marginBottom: "24px",
509
+ flexWrap: "wrap" as const,
510
+ },
511
+ branchField: {
512
+ display: "flex",
513
+ flexDirection: "column" as const,
514
+ gap: "4px",
515
+ },
516
+ label: {
517
+ fontSize: "12px",
518
+ fontWeight: 500,
519
+ color: "#666",
520
+ textTransform: "uppercase" as const,
521
+ letterSpacing: "0.5px",
522
+ },
523
+ select: {
524
+ padding: "8px 12px",
525
+ borderRadius: "6px",
526
+ border: "1px solid #ccc",
527
+ fontSize: "14px",
528
+ minWidth: "200px",
529
+ },
530
+ arrow: {
531
+ fontSize: "20px",
532
+ color: "#666",
533
+ paddingBottom: "6px",
534
+ },
535
+ compareBtn: {
536
+ padding: "8px 20px",
537
+ borderRadius: "6px",
538
+ border: "none",
539
+ background: "#0066cc",
540
+ color: "white",
541
+ cursor: "pointer",
542
+ fontSize: "14px",
543
+ fontWeight: 500,
544
+ },
545
+ error: {
546
+ background: "#fde8e8",
547
+ color: "#c53030",
548
+ padding: "12px 16px",
549
+ borderRadius: "6px",
550
+ marginBottom: "16px",
551
+ },
552
+ statusBanner: {
553
+ padding: "12px 16px",
554
+ borderRadius: "6px",
555
+ border: "1px solid",
556
+ marginBottom: "16px",
557
+ fontSize: "14px",
558
+ },
559
+ tabs: {
560
+ display: "flex",
561
+ gap: "0",
562
+ borderBottom: "2px solid #e5e7eb",
563
+ marginBottom: "24px",
564
+ },
565
+ tab: {
566
+ padding: "10px 20px",
567
+ border: "none",
568
+ borderBottom: "2px solid transparent",
569
+ background: "none",
570
+ cursor: "pointer",
571
+ fontSize: "14px",
572
+ fontWeight: 500,
573
+ color: "#666",
574
+ marginBottom: "-2px",
575
+ },
576
+ activeTab: {
577
+ color: "#0066cc",
578
+ borderBottomColor: "#0066cc",
579
+ },
580
+ content: {
581
+ minHeight: "400px",
582
+ },
583
+ selectPrompt: {
584
+ textAlign: "center" as const,
585
+ padding: "40px 20px",
586
+ color: "#666",
587
+ },
588
+ docList: {
589
+ display: "flex",
590
+ flexDirection: "column" as const,
591
+ gap: "8px",
592
+ marginTop: "16px",
593
+ alignItems: "center",
594
+ },
595
+ docBtn: {
596
+ padding: "10px 20px",
597
+ border: "1px solid #ddd",
598
+ borderRadius: "6px",
599
+ background: "white",
600
+ cursor: "pointer",
601
+ fontSize: "14px",
602
+ display: "flex",
603
+ alignItems: "center",
604
+ gap: "12px",
605
+ },
606
+ changeCount: {
607
+ fontSize: "12px",
608
+ color: "#666",
609
+ background: "#f0f0f0",
610
+ padding: "2px 8px",
611
+ borderRadius: "10px",
612
+ },
613
+ sectionTitle: {
614
+ fontSize: "16px",
615
+ fontWeight: 500,
616
+ marginBottom: "16px",
617
+ },
618
+ visualCompareToolbar: {
619
+ display: "flex",
620
+ justifyContent: "space-between",
621
+ alignItems: "center",
622
+ marginBottom: "16px",
623
+ padding: "8px 0",
624
+ borderBottom: "1px solid #e5e7eb",
625
+ },
626
+ backBtn: {
627
+ padding: "6px 14px",
628
+ border: "1px solid #ddd",
629
+ borderRadius: "6px",
630
+ background: "white",
631
+ cursor: "pointer",
632
+ fontSize: "13px",
633
+ color: "#333",
634
+ },
635
+ };