modern-cms 1.0.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 (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. package/template/packages/shared/vitest.config.ts +8 -0
@@ -0,0 +1,375 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import {
6
+ DndContext,
7
+ DragOverlay,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ type DragStartEvent,
13
+ } from "@dnd-kit/core";
14
+ import { ArrowLeft, Save, Globe2, ImagePlus, Languages } from "lucide-react";
15
+ import {
16
+ cloneWithNewIds,
17
+ createNode,
18
+ findNode,
19
+ findParent,
20
+ findAncestorChain,
21
+ insertNode,
22
+ isNodeOrDescendant,
23
+ moveNode,
24
+ removeNode,
25
+ duplicateNode,
26
+ updateNode,
27
+ getComponentDefinition,
28
+ SUPPORTED_LOCALES,
29
+ type CollectionItem,
30
+ type NavItem,
31
+ type PageNode,
32
+ type SiteTheme,
33
+ } from "@pgcms/shared";
34
+ import { api } from "@/lib/api";
35
+ import { Palette } from "@/components/builder/Palette";
36
+ import { Canvas } from "@/components/builder/Canvas";
37
+ import { Inspector } from "@/components/builder/Inspector";
38
+ import { IconRenderer } from "@/components/builder/IconRenderer";
39
+ import { MediaPickerModal } from "@/components/builder/MediaPickerModal";
40
+
41
+ type ItemWithCollection = CollectionItem & { collection: { name: string; slug: string } };
42
+
43
+ /** The same visual builder as pages, editing one collection item's content plus its
44
+ * card metadata (excerpt, cover image) shown by Collection List blocks. */
45
+ export default function CollectionItemBuilder() {
46
+ const params = useParams<{ itemId: string }>();
47
+ const router = useRouter();
48
+
49
+ const [item, setItem] = useState<ItemWithCollection | null>(null);
50
+ const [tree, setTree] = useState<PageNode | null>(null);
51
+ const [excerpt, setExcerpt] = useState("");
52
+ const [coverImage, setCoverImage] = useState("");
53
+ const [seoTitle, setSeoTitle] = useState("");
54
+ const [seoDescription, setSeoDescription] = useState("");
55
+ const [ogImage, setOgImage] = useState("");
56
+ const [noIndex, setNoIndex] = useState(false);
57
+ const [locale, setLocale] = useState("en");
58
+ const [translationOfId, setTranslationOfId] = useState("");
59
+ const [siblingItems, setSiblingItems] = useState<
60
+ Pick<CollectionItem, "id" | "title" | "slug" | "locale" | "translationOfId">[]
61
+ >([]);
62
+ const [seoOpen, setSeoOpen] = useState(false);
63
+ const [localeOpen, setLocaleOpen] = useState(false);
64
+ const [coverPicker, setCoverPicker] = useState(false);
65
+ const [theme, setTheme] = useState<SiteTheme | null>(null);
66
+ const [navItems, setNavItems] = useState<NavItem[]>([]);
67
+ const [selectedId, setSelectedId] = useState<string | null>(null);
68
+ const [activeDragType, setActiveDragType] = useState<string | null>(null);
69
+ const [saving, setSaving] = useState(false);
70
+ const [savedAt, setSavedAt] = useState<Date | null>(null);
71
+
72
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
73
+
74
+ useEffect(() => {
75
+ api.get<ItemWithCollection>(`/api/collections/items/${params.itemId}`).then((it) => {
76
+ setItem(it);
77
+ setTree(it.content);
78
+ setExcerpt(it.excerpt ?? "");
79
+ setCoverImage(it.coverImage ?? "");
80
+ setSeoTitle(it.seoTitle ?? "");
81
+ setSeoDescription(it.seoDescription ?? "");
82
+ setOgImage(it.ogImage ?? "");
83
+ setNoIndex(it.noIndex ?? false);
84
+ setLocale(it.locale ?? "en");
85
+ setTranslationOfId(it.translationOfId ?? "");
86
+ // "Translation of" candidates are scoped to this item's own collection — a
87
+ // translation only makes sense among items sharing a collection.
88
+ api
89
+ .get<typeof siblingItems>(`/api/collections/${it.collectionId}/items`)
90
+ .then(setSiblingItems)
91
+ .catch(() => setSiblingItems([]));
92
+ });
93
+ api.get<SiteTheme>("/api/theme").then(setTheme);
94
+ api.get<NavItem[]>("/api/theme/nav").then(setNavItems);
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [params.itemId]);
97
+
98
+ function updateTree(updater: (prev: PageNode) => PageNode) {
99
+ setTree((prev) => (prev ? updater(prev) : prev));
100
+ }
101
+
102
+ function handleDragStart(event: DragStartEvent) {
103
+ const id = String(event.active.id);
104
+ if (id.startsWith("palette:")) setActiveDragType(id.slice("palette:".length));
105
+ else setActiveDragType((tree && findNode(tree, id)?.type) ?? null);
106
+ }
107
+
108
+ function handleDragEnd(event: DragEndEvent) {
109
+ setActiveDragType(null);
110
+ const { active, over } = event;
111
+ if (!over || !tree) return;
112
+
113
+ const activeId = String(active.id);
114
+ const overId = String(over.id);
115
+ const overData = over.data.current as { type?: string; parentId?: string } | undefined;
116
+
117
+ let targetParentId: string;
118
+ let targetIndex: number;
119
+ if (overData?.type === "container" && overData.parentId) {
120
+ targetParentId = overData.parentId;
121
+ targetIndex = findNode(tree, targetParentId)?.children.length ?? 0;
122
+ } else {
123
+ const parentInfo = findParent(tree, overId);
124
+ if (!parentInfo) return;
125
+ targetParentId = parentInfo.parent.id;
126
+ targetIndex = parentInfo.index;
127
+ }
128
+
129
+ const targetDef = getComponentDefinition(findNode(tree, targetParentId)?.type ?? "");
130
+ if (targetParentId !== tree.id && !targetDef?.allowsChildren) return;
131
+
132
+ if (activeId.startsWith("palette:")) {
133
+ const newNode = createNode(activeId.slice("palette:".length));
134
+ updateTree((prev) => insertNode(prev, targetParentId, newNode, targetIndex));
135
+ setSelectedId(newNode.id);
136
+ return;
137
+ }
138
+ if (activeId.startsWith("block:")) {
139
+ const blockId = activeId.slice("block:".length);
140
+ api
141
+ .get<{ id: string; content: PageNode }[]>("/api/blocks")
142
+ .then((blocks) => {
143
+ const block = blocks.find((b) => b.id === blockId);
144
+ if (!block) return;
145
+ const copy = cloneWithNewIds(block.content);
146
+ updateTree((prev) => insertNode(prev, targetParentId, copy, targetIndex));
147
+ setSelectedId(copy.id);
148
+ })
149
+ .catch(console.error);
150
+ return;
151
+ }
152
+
153
+ const activeNode = findNode(tree, activeId);
154
+ if (!activeNode || isNodeOrDescendant(activeNode, targetParentId) || activeId === overId) return;
155
+ updateTree((prev) => {
156
+ const before = findParent(prev, activeId);
157
+ let idx = targetIndex;
158
+ if (before && before.parent.id === targetParentId && before.index < targetIndex) idx = targetIndex - 1;
159
+ return moveNode(prev, activeId, targetParentId, idx);
160
+ });
161
+ }
162
+
163
+ async function save(publishToggle?: boolean) {
164
+ if (!item || !tree) return;
165
+ setSaving(true);
166
+ try {
167
+ const nextStatus = publishToggle ? (item.status === "PUBLISHED" ? "DRAFT" : "PUBLISHED") : undefined;
168
+ const updated = await api.put<ItemWithCollection>(`/api/collections/items/${item.id}`, {
169
+ content: tree,
170
+ excerpt: excerpt || null,
171
+ coverImage: coverImage || null,
172
+ seoTitle: seoTitle || null,
173
+ seoDescription: seoDescription || null,
174
+ ogImage: ogImage || null,
175
+ noIndex,
176
+ locale,
177
+ translationOfId: translationOfId || null,
178
+ ...(nextStatus ? { status: nextStatus } : {}),
179
+ });
180
+ setItem((prev) => (prev ? { ...prev, ...updated, collection: prev.collection } : prev));
181
+ setSavedAt(new Date());
182
+ } finally {
183
+ setSaving(false);
184
+ }
185
+ }
186
+
187
+ const selectedNode = selectedId && tree ? findNode(tree, selectedId) : null;
188
+ const ancestors = selectedId && tree ? findAncestorChain(tree, selectedId) : [];
189
+
190
+ if (!item || !tree || !theme) {
191
+ return <div className="p-8 text-sm text-slate-400">Loading item…</div>;
192
+ }
193
+
194
+ return (
195
+ <div className="flex flex-col h-full">
196
+ <div className="flex items-center justify-between border-b bg-white px-4 py-2 shrink-0">
197
+ <div className="flex items-center gap-3 min-w-0">
198
+ <button onClick={() => router.push("/admin/collections")} className="text-slate-500 hover:text-slate-800">
199
+ <ArrowLeft size={18} />
200
+ </button>
201
+ <div className="min-w-0">
202
+ <div className="font-semibold text-sm truncate">{item.title}</div>
203
+ <div className="text-xs text-slate-400 truncate">
204
+ /{item.collection.slug}/{item.slug} · {item.status}
205
+ {savedAt && ` · saved ${savedAt.toLocaleTimeString()}`}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ <div className="flex items-center gap-2">
210
+ <input
211
+ value={excerpt}
212
+ onChange={(e) => setExcerpt(e.target.value)}
213
+ placeholder="Excerpt (shown on listing cards)"
214
+ className="w-72 border rounded-md px-2 py-1.5 text-xs"
215
+ />
216
+ <button
217
+ onClick={() => setCoverPicker(true)}
218
+ title="Cover image (shown on listing cards)"
219
+ className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 ${
220
+ coverImage ? "text-green-700 border-green-300" : "text-slate-600"
221
+ }`}
222
+ >
223
+ <ImagePlus size={14} /> {coverImage ? "Cover ✓" : "Cover"}
224
+ </button>
225
+ <button
226
+ onClick={() => setLocaleOpen((o) => !o)}
227
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 text-slate-600"
228
+ >
229
+ <Languages size={14} /> {locale.toUpperCase()} {localeOpen ? "▲" : "▼"}
230
+ </button>
231
+ <button
232
+ onClick={() => setSeoOpen((o) => !o)}
233
+ className="px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 text-slate-600"
234
+ >
235
+ SEO {seoOpen ? "▲" : "▼"}
236
+ </button>
237
+ <button
238
+ onClick={() => save()}
239
+ disabled={saving}
240
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50"
241
+ >
242
+ <Save size={15} /> Save
243
+ </button>
244
+ <button
245
+ onClick={() => save(true)}
246
+ disabled={saving}
247
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
248
+ >
249
+ <Globe2 size={15} /> {item.status === "PUBLISHED" ? "Unpublish" : "Publish"}
250
+ </button>
251
+ </div>
252
+ </div>
253
+
254
+ {localeOpen && (
255
+ <div className="border-b bg-slate-50 px-4 py-3 grid grid-cols-2 gap-3 text-sm shrink-0">
256
+ <div className="space-y-1">
257
+ <label className="text-xs font-medium text-slate-600">Language</label>
258
+ <select
259
+ value={locale}
260
+ onChange={(e) => setLocale(e.target.value)}
261
+ className="w-full border rounded-md px-2 py-1.5 text-xs bg-white"
262
+ >
263
+ {SUPPORTED_LOCALES.map((l) => (
264
+ <option key={l.code} value={l.code}>
265
+ {l.label} ({l.code}){l.rtl ? " · RTL" : ""}
266
+ </option>
267
+ ))}
268
+ </select>
269
+ </div>
270
+ <div className="space-y-1">
271
+ <label className="text-xs font-medium text-slate-600">Translation of</label>
272
+ <select
273
+ value={translationOfId}
274
+ onChange={(e) => setTranslationOfId(e.target.value)}
275
+ className="w-full border rounded-md px-2 py-1.5 text-xs bg-white"
276
+ >
277
+ <option value="">Not a translation (original item)</option>
278
+ {siblingItems
279
+ .filter((s) => s.id !== item.id && !s.translationOfId)
280
+ .map((s) => (
281
+ <option key={s.id} value={s.id}>
282
+ {s.title} (/{s.slug} · {s.locale ?? "en"})
283
+ </option>
284
+ ))}
285
+ </select>
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ {seoOpen && (
291
+ <div className="border-b bg-slate-50 px-4 py-3 grid grid-cols-2 gap-3 text-sm shrink-0">
292
+ <input
293
+ value={seoTitle}
294
+ onChange={(e) => setSeoTitle(e.target.value)}
295
+ placeholder="SEO title"
296
+ className="border rounded-md px-2 py-1.5 text-xs"
297
+ />
298
+ <input
299
+ value={ogImage}
300
+ onChange={(e) => setOgImage(e.target.value)}
301
+ placeholder="OG image URL (or use cover)"
302
+ className="border rounded-md px-2 py-1.5 text-xs"
303
+ />
304
+ <textarea
305
+ value={seoDescription}
306
+ onChange={(e) => setSeoDescription(e.target.value)}
307
+ placeholder="SEO description"
308
+ rows={2}
309
+ className="col-span-2 border rounded-md px-2 py-1.5 text-xs"
310
+ />
311
+ <label className="col-span-2 flex items-center gap-2 text-xs text-slate-600">
312
+ <input type="checkbox" checked={noIndex} onChange={(e) => setNoIndex(e.target.checked)} />
313
+ Hide from search engines (noindex)
314
+ </label>
315
+ </div>
316
+ )}
317
+
318
+ <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
319
+ <div className="flex flex-1 overflow-hidden">
320
+ <Palette />
321
+ <Canvas
322
+ tree={tree}
323
+ theme={theme}
324
+ navItems={navItems}
325
+ selectedId={selectedId}
326
+ onSelect={(id) => setSelectedId(id === "" ? null : id)}
327
+ onDelete={(id) => {
328
+ updateTree((prev) => removeNode(prev, id));
329
+ setSelectedId((sel) => (sel === id ? null : sel));
330
+ }}
331
+ onDuplicate={(id) => updateTree((prev) => duplicateNode(prev, id))}
332
+ onToggleHidden={(id) =>
333
+ updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, props: { ...n.props, hidden: !n.props.hidden } })))
334
+ }
335
+ onChangeStyle={(id, style) => updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, style })))}
336
+ />
337
+ <Inspector
338
+ node={selectedNode ?? null}
339
+ ancestors={ancestors}
340
+ onSelect={(id) => setSelectedId(id)}
341
+ onChangeProps={(props) =>
342
+ selectedId && updateTree((prev) => updateNode(prev, selectedId, (n) => ({ ...n, props })))
343
+ }
344
+ onChangeStyle={(style) =>
345
+ selectedId && updateTree((prev) => updateNode(prev, selectedId, (n) => ({ ...n, style })))
346
+ }
347
+ onChangeColumnCount={() => {}}
348
+ onChangeZoneCount={() => {}}
349
+ onChangeGridDimensions={() => {}}
350
+ onEnableHeaderZones={() => {}}
351
+ />
352
+ </div>
353
+ <DragOverlay>
354
+ {activeDragType && (
355
+ <div className="bg-slate-900 text-white text-xs rounded px-3 py-2 flex items-center gap-2 shadow-lg">
356
+ <IconRenderer name={getComponentDefinition(activeDragType)?.icon ?? "Square"} size={14} />
357
+ {getComponentDefinition(activeDragType)?.label ?? activeDragType}
358
+ </div>
359
+ )}
360
+ </DragOverlay>
361
+ </DndContext>
362
+
363
+ {coverPicker && (
364
+ <MediaPickerModal
365
+ accept="image"
366
+ onSelect={(url) => {
367
+ setCoverImage(url);
368
+ setCoverPicker(false);
369
+ }}
370
+ onClose={() => setCoverPicker(false)}
371
+ />
372
+ )}
373
+ </div>
374
+ );
375
+ }
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import Link from "next/link";
5
+ import { Plus, Trash2, Newspaper, PencilLine } from "lucide-react";
6
+ import type { Collection, PageStatus } from "@pgcms/shared";
7
+ import { api } from "@/lib/api";
8
+
9
+ type CollectionWithCount = Collection & { _count: { items: number } };
10
+ type ItemRow = { id: string; slug: string; title: string; status: PageStatus; publishedAt: string | null; updatedAt: string };
11
+
12
+ /** Master–detail: collections on the left, the selected collection's items on the
13
+ * right. Item content itself is edited in the full builder (Edit → item builder). */
14
+ export default function CollectionsAdmin() {
15
+ const [collections, setCollections] = useState<CollectionWithCount[]>([]);
16
+ const [selectedId, setSelectedId] = useState<string | null>(null);
17
+ const [items, setItems] = useState<ItemRow[] | null>(null);
18
+ const [newName, setNewName] = useState("");
19
+ const [newSlug, setNewSlug] = useState("");
20
+ const [newItemTitle, setNewItemTitle] = useState("");
21
+ const [newItemSlug, setNewItemSlug] = useState("");
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ const selected = collections.find((c) => c.id === selectedId) ?? null;
25
+
26
+ async function loadCollections() {
27
+ const rows = await api.get<CollectionWithCount[]>("/api/collections");
28
+ setCollections(rows);
29
+ if (!selectedId && rows.length > 0) setSelectedId(rows[0].id);
30
+ }
31
+
32
+ useEffect(() => {
33
+ loadCollections().catch(console.error);
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ if (!selectedId) return;
39
+ setItems(null);
40
+ api.get<ItemRow[]>(`/api/collections/${selectedId}/items`).then(setItems).catch(console.error);
41
+ }, [selectedId]);
42
+
43
+ async function createCollection() {
44
+ setError(null);
45
+ try {
46
+ const created = await api.post<Collection>("/api/collections", { name: newName, slug: newSlug });
47
+ setNewName("");
48
+ setNewSlug("");
49
+ await loadCollections();
50
+ setSelectedId(created.id);
51
+ } catch (err) {
52
+ setError(err instanceof Error ? err.message : "Failed to create collection");
53
+ }
54
+ }
55
+
56
+ async function deleteCollection(id: string) {
57
+ if (!confirm("Delete this collection AND all its items? This cannot be undone.")) return;
58
+ await api.delete(`/api/collections/${id}`);
59
+ setSelectedId(null);
60
+ await loadCollections();
61
+ }
62
+
63
+ async function createItem() {
64
+ if (!selectedId) return;
65
+ setError(null);
66
+ try {
67
+ await api.post(`/api/collections/${selectedId}/items`, { title: newItemTitle, slug: newItemSlug });
68
+ setNewItemTitle("");
69
+ setNewItemSlug("");
70
+ const rows = await api.get<ItemRow[]>(`/api/collections/${selectedId}/items`);
71
+ setItems(rows);
72
+ await loadCollections();
73
+ } catch (err) {
74
+ setError(err instanceof Error ? err.message : "Failed to create item");
75
+ }
76
+ }
77
+
78
+ async function deleteItem(id: string) {
79
+ if (!confirm("Delete this item?")) return;
80
+ await api.delete(`/api/collections/items/${id}`);
81
+ if (selectedId) {
82
+ const rows = await api.get<ItemRow[]>(`/api/collections/${selectedId}/items`);
83
+ setItems(rows);
84
+ }
85
+ await loadCollections();
86
+ }
87
+
88
+ const slugify = (v: string) =>
89
+ v
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, "-")
92
+ .replace(/^-|-$/g, "");
93
+
94
+ return (
95
+ <div className="p-6 max-w-5xl">
96
+ <h1 className="text-xl font-semibold mb-1">Collections</h1>
97
+ <p className="text-sm text-slate-500 mb-6">
98
+ Dynamic content types — a Blog, News, Events. Items live at /{"{collection}"}/{"{item}"} and are listed
99
+ anywhere via the &quot;Collection List&quot; block.
100
+ </p>
101
+ {error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">{error}</div>}
102
+
103
+ <div className="grid grid-cols-[260px_1fr] gap-6">
104
+ <div className="space-y-2">
105
+ {collections.map((c) => (
106
+ <div
107
+ key={c.id}
108
+ className={`flex items-center justify-between border rounded-md px-3 py-2 cursor-pointer ${
109
+ selectedId === c.id ? "border-blue-400 bg-blue-50" : "bg-white hover:bg-slate-50"
110
+ }`}
111
+ onClick={() => setSelectedId(c.id)}
112
+ >
113
+ <div className="flex items-center gap-2 text-sm min-w-0">
114
+ <Newspaper size={15} className="text-slate-400 shrink-0" />
115
+ <div className="min-w-0">
116
+ <div className="font-medium truncate">{c.name}</div>
117
+ <div className="text-xs text-slate-400 truncate">
118
+ /{c.slug} · {c._count.items} item{c._count.items === 1 ? "" : "s"}
119
+ </div>
120
+ </div>
121
+ </div>
122
+ <button
123
+ onClick={(e) => {
124
+ e.stopPropagation();
125
+ deleteCollection(c.id);
126
+ }}
127
+ className="text-slate-300 hover:text-red-600 shrink-0"
128
+ >
129
+ <Trash2 size={14} />
130
+ </button>
131
+ </div>
132
+ ))}
133
+
134
+ <div className="border rounded-md p-3 space-y-2 bg-slate-50">
135
+ <div className="text-xs font-medium text-slate-500">New collection</div>
136
+ <input
137
+ value={newName}
138
+ onChange={(e) => {
139
+ setNewName(e.target.value);
140
+ setNewSlug(slugify(e.target.value));
141
+ }}
142
+ placeholder="Name (e.g. Blog)"
143
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
144
+ />
145
+ <input
146
+ value={newSlug}
147
+ onChange={(e) => setNewSlug(slugify(e.target.value))}
148
+ placeholder="Slug (e.g. blog)"
149
+ className="w-full border rounded-md px-2 py-1.5 text-sm font-mono"
150
+ />
151
+ <button
152
+ onClick={createCollection}
153
+ disabled={!newName || !newSlug}
154
+ className="w-full flex items-center justify-center gap-1.5 bg-blue-600 text-white rounded-md py-1.5 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
155
+ >
156
+ <Plus size={14} /> Create
157
+ </button>
158
+ </div>
159
+ </div>
160
+
161
+ <div>
162
+ {!selected && <div className="text-sm text-slate-400">Create or select a collection.</div>}
163
+ {selected && (
164
+ <>
165
+ <div className="flex items-center gap-3 mb-3">
166
+ <input
167
+ value={newItemTitle}
168
+ onChange={(e) => {
169
+ setNewItemTitle(e.target.value);
170
+ setNewItemSlug(slugify(e.target.value));
171
+ }}
172
+ placeholder={`New ${selected.name} item title`}
173
+ className="flex-1 border rounded-md px-3 py-2 text-sm"
174
+ />
175
+ <input
176
+ value={newItemSlug}
177
+ onChange={(e) => setNewItemSlug(slugify(e.target.value))}
178
+ placeholder="slug"
179
+ className="w-44 border rounded-md px-3 py-2 text-sm font-mono"
180
+ />
181
+ <button
182
+ onClick={createItem}
183
+ disabled={!newItemTitle || !newItemSlug}
184
+ className="flex items-center gap-1.5 bg-blue-600 text-white rounded-md px-3 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
185
+ >
186
+ <Plus size={14} /> Add
187
+ </button>
188
+ </div>
189
+
190
+ {items === null && <div className="text-sm text-slate-400">Loading items…</div>}
191
+ {items?.length === 0 && <div className="text-sm text-slate-400">No items yet — add the first one above.</div>}
192
+ <div className="space-y-2">
193
+ {items?.map((item) => (
194
+ <div key={item.id} className="flex items-center justify-between border rounded-md px-4 py-3 bg-white">
195
+ <div className="text-sm min-w-0">
196
+ <div className="font-medium truncate">{item.title}</div>
197
+ <div className="text-xs text-slate-400">
198
+ /{selected.slug}/{item.slug} ·{" "}
199
+ <span className={item.status === "PUBLISHED" ? "text-green-600" : "text-amber-600"}>
200
+ {item.status}
201
+ </span>
202
+ </div>
203
+ </div>
204
+ <div className="flex items-center gap-2 shrink-0">
205
+ <Link
206
+ href={`/admin/collections/item/${item.id}`}
207
+ className="flex items-center gap-1.5 text-xs border rounded-md px-2.5 py-1.5 hover:bg-slate-50"
208
+ >
209
+ <PencilLine size={13} /> Edit
210
+ </Link>
211
+ <button onClick={() => deleteItem(item.id)} className="text-slate-300 hover:text-red-600">
212
+ <Trash2 size={15} />
213
+ </button>
214
+ </div>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ </>
219
+ )}
220
+ </div>
221
+ </div>
222
+ </div>
223
+ );
224
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Trash2, Download } from "lucide-react";
5
+ import { api } from "@/lib/api";
6
+
7
+ type Submission = { id: string; formName: string; pageSlug: string; data: Record<string, string>; createdAt: string };
8
+
9
+ export default function FormSubmissionsPage() {
10
+ const [submissions, setSubmissions] = useState<Submission[] | null>(null);
11
+ const [formFilter, setFormFilter] = useState<string>("");
12
+
13
+ function refresh() {
14
+ const query = formFilter ? `?formName=${encodeURIComponent(formFilter)}` : "";
15
+ api.get<Submission[]>(`/api/forms/submissions${query}`).then(setSubmissions);
16
+ }
17
+
18
+ useEffect(refresh, [formFilter]);
19
+
20
+ async function remove(id: string) {
21
+ if (!confirm("Delete this submission?")) return;
22
+ await api.delete(`/api/forms/submissions/${id}`);
23
+ refresh();
24
+ }
25
+
26
+ function exportCsv() {
27
+ const query = formFilter ? `?formName=${encodeURIComponent(formFilter)}` : "";
28
+ window.open(`/api/forms/submissions/export${query}`, "_blank");
29
+ }
30
+
31
+ const formNames = Array.from(new Set(submissions?.map((s) => s.formName) ?? []));
32
+
33
+ return (
34
+ <div className="p-8 max-w-4xl">
35
+ <div className="flex items-center justify-between mb-6 gap-3 flex-wrap">
36
+ <h1 className="text-2xl font-semibold">Form Submissions</h1>
37
+ <div className="flex items-center gap-2">
38
+ <select
39
+ value={formFilter}
40
+ onChange={(e) => setFormFilter(e.target.value)}
41
+ className="border rounded-md px-3 py-2 text-sm bg-white"
42
+ >
43
+ <option value="">All forms</option>
44
+ {formNames.map((name) => (
45
+ <option key={name} value={name}>
46
+ {name}
47
+ </option>
48
+ ))}
49
+ </select>
50
+ <button
51
+ onClick={exportCsv}
52
+ className="flex items-center gap-1.5 border rounded-md px-3 py-2 text-sm hover:bg-slate-50"
53
+ >
54
+ <Download size={15} /> Export CSV
55
+ </button>
56
+ </div>
57
+ </div>
58
+
59
+ <div className="bg-white border rounded-lg divide-y">
60
+ {submissions?.length === 0 && <div className="p-6 text-sm text-slate-500">No submissions yet.</div>}
61
+ {submissions?.map((sub) => (
62
+ <div key={sub.id} className="flex items-start justify-between gap-4 px-4 py-3">
63
+ <div className="min-w-0">
64
+ <div className="flex items-center gap-2 text-xs text-slate-400 mb-1">
65
+ <span className="font-medium text-slate-600">{sub.formName}</span>
66
+ <span>·</span>
67
+ <span>{sub.pageSlug || "/"}</span>
68
+ <span>·</span>
69
+ <span>{new Date(sub.createdAt).toLocaleString()}</span>
70
+ </div>
71
+ <dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-sm">
72
+ {Object.entries(sub.data).map(([key, value]) => (
73
+ <div key={key} className="contents">
74
+ <dt className="text-slate-400 capitalize">{key}:</dt>
75
+ <dd className="text-slate-800 break-words">{value}</dd>
76
+ </div>
77
+ ))}
78
+ </dl>
79
+ </div>
80
+ <button onClick={() => remove(sub.id)} className="text-slate-400 hover:text-red-600 shrink-0">
81
+ <Trash2 size={16} />
82
+ </button>
83
+ </div>
84
+ ))}
85
+ </div>
86
+ </div>
87
+ );
88
+ }