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,239 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { FONT_OPTIONS, type SiteTheme } from "@pgcms/shared";
5
+ import { api } from "@/lib/api";
6
+ import { ColorPickerField } from "@/components/builder/ColorPickerField";
7
+ import { MediaPickerModal } from "@/components/builder/MediaPickerModal";
8
+
9
+ export default function ThemeSettingsPage() {
10
+ const [theme, setTheme] = useState<SiteTheme | null>(null);
11
+ const [saving, setSaving] = useState(false);
12
+ const [logoPicker, setLogoPicker] = useState(false);
13
+ const [faviconPicker, setFaviconPicker] = useState(false);
14
+
15
+ useEffect(() => {
16
+ api.get<SiteTheme>("/api/theme").then(setTheme);
17
+ }, []);
18
+
19
+ function set<K extends keyof SiteTheme>(key: K, value: SiteTheme[K]) {
20
+ setTheme((prev) => (prev ? { ...prev, [key]: value } : prev));
21
+ }
22
+
23
+ async function save() {
24
+ if (!theme) return;
25
+ setSaving(true);
26
+ try {
27
+ const updated = await api.put<SiteTheme>("/api/theme", theme);
28
+ setTheme(updated);
29
+ } finally {
30
+ setSaving(false);
31
+ }
32
+ }
33
+
34
+ if (!theme) return <div className="p-8 text-sm text-slate-400">Loading...</div>;
35
+
36
+ return (
37
+ <div className="p-8 max-w-2xl space-y-6">
38
+ <h1 className="text-2xl font-semibold">Theme Settings</h1>
39
+ <p className="text-sm text-slate-500">
40
+ Sets the site-wide defaults (brand colors, fonts, logo). Individual components can still override these per-page.
41
+ </p>
42
+
43
+ <div className="bg-white border rounded-lg p-6 space-y-5">
44
+ <div className="space-y-1">
45
+ <label className="text-sm font-medium">Site name</label>
46
+ <input
47
+ value={theme.siteName}
48
+ onChange={(e) => set("siteName", e.target.value)}
49
+ className="w-full border rounded-md px-3 py-2 text-sm"
50
+ />
51
+ </div>
52
+
53
+ <div className="grid grid-cols-2 gap-4">
54
+ <ColorPickerField label="Primary color" value={theme.primaryColor} onChange={(v) => set("primaryColor", v)} />
55
+ <ColorPickerField label="Secondary color" value={theme.secondaryColor} onChange={(v) => set("secondaryColor", v)} />
56
+ <ColorPickerField label="Background color" value={theme.backgroundColor} onChange={(v) => set("backgroundColor", v)} />
57
+ <ColorPickerField label="Text color" value={theme.textColor} onChange={(v) => set("textColor", v)} />
58
+ </div>
59
+
60
+ <div className="grid grid-cols-2 gap-4">
61
+ <div className="space-y-1">
62
+ <label className="text-sm font-medium">Heading font</label>
63
+ <select
64
+ value={theme.headingFont}
65
+ onChange={(e) => set("headingFont", e.target.value)}
66
+ className="w-full border rounded-md px-3 py-2 text-sm bg-white"
67
+ >
68
+ {FONT_OPTIONS.map((f) => (
69
+ <option key={f} value={f}>
70
+ {f}
71
+ </option>
72
+ ))}
73
+ </select>
74
+ </div>
75
+ <div className="space-y-1">
76
+ <label className="text-sm font-medium">Body font</label>
77
+ <select
78
+ value={theme.bodyFont}
79
+ onChange={(e) => set("bodyFont", e.target.value)}
80
+ className="w-full border rounded-md px-3 py-2 text-sm bg-white"
81
+ >
82
+ {FONT_OPTIONS.map((f) => (
83
+ <option key={f} value={f}>
84
+ {f}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="grid grid-cols-2 gap-4">
92
+ <div className="space-y-2">
93
+ <label className="text-sm font-medium">Logo</label>
94
+ {theme.logoUrl && (
95
+ // eslint-disable-next-line @next/next/no-img-element
96
+ <img src={theme.logoUrl} alt="Logo" className="h-12 border rounded-md px-2 py-1 bg-slate-50" />
97
+ )}
98
+ <button
99
+ onClick={() => setLogoPicker(true)}
100
+ className="w-full border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
101
+ >
102
+ {theme.logoUrl ? "Change logo" : "Select logo"}
103
+ </button>
104
+ </div>
105
+ <div className="space-y-2">
106
+ <label className="text-sm font-medium">Favicon</label>
107
+ {theme.faviconUrl && (
108
+ // eslint-disable-next-line @next/next/no-img-element
109
+ <img src={theme.faviconUrl} alt="Favicon" className="h-12 w-12 border rounded-md p-1 bg-slate-50" />
110
+ )}
111
+ <button
112
+ onClick={() => setFaviconPicker(true)}
113
+ className="w-full border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
114
+ >
115
+ {theme.faviconUrl ? "Change favicon" : "Select favicon"}
116
+ </button>
117
+ </div>
118
+ </div>
119
+
120
+ <div className="space-y-3 border-t pt-5">
121
+ <label className="flex items-center gap-2 text-sm font-medium">
122
+ <input
123
+ type="checkbox"
124
+ checked={theme.darkModeEnabled}
125
+ onChange={(e) => set("darkModeEnabled", e.target.checked)}
126
+ />
127
+ Enable dark mode
128
+ </label>
129
+ <p className="text-xs text-slate-500">
130
+ Adds a light/dark toggle to the public site. Leave the colors below blank to use sensible defaults.
131
+ </p>
132
+ {theme.darkModeEnabled && (
133
+ <div className="grid grid-cols-3 gap-4">
134
+ <ColorPickerField
135
+ label="Dark primary color"
136
+ value={theme.primaryColorDark ?? undefined}
137
+ onChange={(v) => set("primaryColorDark", v)}
138
+ />
139
+ <ColorPickerField
140
+ label="Dark background"
141
+ value={theme.backgroundColorDark ?? undefined}
142
+ onChange={(v) => set("backgroundColorDark", v)}
143
+ />
144
+ <ColorPickerField
145
+ label="Dark text color"
146
+ value={theme.textColorDark ?? undefined}
147
+ onChange={(v) => set("textColorDark", v)}
148
+ />
149
+ </div>
150
+ )}
151
+ </div>
152
+
153
+ <div className="space-y-3 border-t pt-5">
154
+ <label className="text-sm font-medium">Analytics</label>
155
+ <div className="grid grid-cols-2 gap-4">
156
+ <div className="space-y-1">
157
+ <label className="text-xs font-medium text-slate-600">Provider</label>
158
+ <select
159
+ value={theme.analyticsProvider}
160
+ onChange={(e) => set("analyticsProvider", e.target.value as SiteTheme["analyticsProvider"])}
161
+ className="w-full border rounded-md px-3 py-2 text-sm bg-white"
162
+ >
163
+ <option value="none">None</option>
164
+ <option value="ga4">Google Analytics (GA4)</option>
165
+ <option value="plausible">Plausible</option>
166
+ <option value="custom">Custom script</option>
167
+ </select>
168
+ </div>
169
+ {theme.analyticsProvider !== "none" && theme.analyticsProvider !== "custom" && (
170
+ <div className="space-y-1">
171
+ <label className="text-xs font-medium text-slate-600">
172
+ {theme.analyticsProvider === "ga4" ? "Measurement ID (G-XXXXXXX)" : "Domain"}
173
+ </label>
174
+ <input
175
+ value={theme.analyticsId ?? ""}
176
+ onChange={(e) => set("analyticsId", e.target.value)}
177
+ placeholder={theme.analyticsProvider === "ga4" ? "G-XXXXXXXXXX" : "example.com"}
178
+ className="w-full border rounded-md px-3 py-2 text-sm"
179
+ />
180
+ </div>
181
+ )}
182
+ </div>
183
+ {theme.analyticsProvider === "custom" && (
184
+ <div className="space-y-1">
185
+ <label className="text-xs font-medium text-slate-600">Custom script (inserted as-is on every public page)</label>
186
+ <textarea
187
+ value={theme.analyticsCustomScript ?? ""}
188
+ onChange={(e) => set("analyticsCustomScript", e.target.value)}
189
+ rows={4}
190
+ className="w-full border rounded-md px-3 py-2 text-sm font-mono"
191
+ placeholder="<script>...</script>"
192
+ />
193
+ </div>
194
+ )}
195
+ </div>
196
+
197
+ <div className="space-y-1">
198
+ <label className="text-sm font-medium">Custom CSS (advanced)</label>
199
+ <textarea
200
+ value={theme.customCss ?? ""}
201
+ onChange={(e) => set("customCss", e.target.value)}
202
+ rows={5}
203
+ className="w-full border rounded-md px-3 py-2 text-sm font-mono"
204
+ placeholder=".my-class { color: red; }"
205
+ />
206
+ </div>
207
+
208
+ <button
209
+ onClick={save}
210
+ disabled={saving}
211
+ className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
212
+ >
213
+ {saving ? "Saving..." : "Save theme"}
214
+ </button>
215
+ </div>
216
+
217
+ {logoPicker && (
218
+ <MediaPickerModal
219
+ accept="image"
220
+ onSelect={(url) => {
221
+ set("logoUrl", url);
222
+ setLogoPicker(false);
223
+ }}
224
+ onClose={() => setLogoPicker(false)}
225
+ />
226
+ )}
227
+ {faviconPicker && (
228
+ <MediaPickerModal
229
+ accept="image"
230
+ onSelect={(url) => {
231
+ set("faviconUrl", url);
232
+ setFaviconPicker(false);
233
+ }}
234
+ onClose={() => setFaviconPicker(false)}
235
+ />
236
+ )}
237
+ </div>
238
+ );
239
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Plus, Trash2 } from "lucide-react";
5
+ import type { User, UserRole } from "@pgcms/shared";
6
+ import { api, ApiError } from "@/lib/api";
7
+
8
+ export default function UsersPage() {
9
+ const [users, setUsers] = useState<User[] | null>(null);
10
+ const [email, setEmail] = useState("");
11
+ const [password, setPassword] = useState("");
12
+ const [role, setRole] = useState<UserRole>("EDITOR");
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ function refresh() {
16
+ api.get<User[]>("/api/users").then(setUsers);
17
+ }
18
+
19
+ useEffect(refresh, []);
20
+
21
+ async function addUser() {
22
+ setError(null);
23
+ try {
24
+ await api.post("/api/users", { email, password, role });
25
+ setEmail("");
26
+ setPassword("");
27
+ setRole("EDITOR");
28
+ refresh();
29
+ } catch (err) {
30
+ setError(err instanceof ApiError ? err.message : "Failed to create user");
31
+ }
32
+ }
33
+
34
+ async function removeUser(id: string) {
35
+ if (!confirm("Remove this user?")) return;
36
+ await api.delete(`/api/users/${id}`);
37
+ refresh();
38
+ }
39
+
40
+ return (
41
+ <div className="p-8 max-w-2xl space-y-6">
42
+ <h1 className="text-2xl font-semibold">Users</h1>
43
+
44
+ <div className="bg-white border rounded-lg p-4 space-y-3">
45
+ {error && <div className="text-sm text-red-600">{error}</div>}
46
+ <div className="flex gap-3">
47
+ <input
48
+ placeholder="Email"
49
+ value={email}
50
+ onChange={(e) => setEmail(e.target.value)}
51
+ className="flex-1 border rounded-md px-3 py-2 text-sm"
52
+ />
53
+ <input
54
+ placeholder="Password"
55
+ type="password"
56
+ value={password}
57
+ onChange={(e) => setPassword(e.target.value)}
58
+ className="flex-1 border rounded-md px-3 py-2 text-sm"
59
+ />
60
+ <select
61
+ value={role}
62
+ onChange={(e) => setRole(e.target.value as UserRole)}
63
+ className="border rounded-md px-3 py-2 text-sm bg-white"
64
+ >
65
+ <option value="AUTHOR">Author (own pages/items only)</option>
66
+ <option value="EDITOR">Editor</option>
67
+ <option value="ADMIN">Admin</option>
68
+ </select>
69
+ </div>
70
+ <button
71
+ onClick={addUser}
72
+ disabled={!email || !password}
73
+ className="flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
74
+ >
75
+ <Plus size={15} /> Add user
76
+ </button>
77
+ </div>
78
+
79
+ <div className="bg-white border rounded-lg divide-y">
80
+ {users?.map((u) => (
81
+ <div key={u.id} className="flex items-center justify-between px-4 py-3">
82
+ <div>
83
+ <div className="font-medium text-sm">{u.email}</div>
84
+ <div className="text-xs text-slate-400">{u.role}</div>
85
+ </div>
86
+ <button onClick={() => removeUser(u.id)} className="text-slate-400 hover:text-red-600">
87
+ <Trash2 size={16} />
88
+ </button>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { Suspense, useState, type FormEvent } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { api, ApiError } from "@/lib/api";
6
+
7
+ function LoginForm() {
8
+ const router = useRouter();
9
+ const searchParams = useSearchParams();
10
+ const [email, setEmail] = useState("");
11
+ const [password, setPassword] = useState("");
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [loading, setLoading] = useState(false);
14
+
15
+ async function onSubmit(e: FormEvent) {
16
+ e.preventDefault();
17
+ setError(null);
18
+ setLoading(true);
19
+ try {
20
+ await api.post("/api/auth/login", { email, password });
21
+ router.push(searchParams.get("next") || "/admin");
22
+ router.refresh();
23
+ } catch (err) {
24
+ setError(err instanceof ApiError ? err.message : "Login failed");
25
+ } finally {
26
+ setLoading(false);
27
+ }
28
+ }
29
+
30
+ return (
31
+ <form onSubmit={onSubmit} className="w-full max-w-sm bg-white rounded-xl shadow-sm border p-8 space-y-4">
32
+ <div>
33
+ <h1 className="text-xl font-semibold">pg-cms admin</h1>
34
+ <p className="text-sm text-slate-500">Sign in to manage your site.</p>
35
+ </div>
36
+ {error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">{error}</div>}
37
+ <div className="space-y-1">
38
+ <label className="text-sm font-medium">Email</label>
39
+ <input
40
+ type="email"
41
+ required
42
+ value={email}
43
+ onChange={(e) => setEmail(e.target.value)}
44
+ className="w-full rounded-md border px-3 py-2 text-sm"
45
+ />
46
+ </div>
47
+ <div className="space-y-1">
48
+ <label className="text-sm font-medium">Password</label>
49
+ <input
50
+ type="password"
51
+ required
52
+ value={password}
53
+ onChange={(e) => setPassword(e.target.value)}
54
+ className="w-full rounded-md border px-3 py-2 text-sm"
55
+ />
56
+ </div>
57
+ <button
58
+ type="submit"
59
+ disabled={loading}
60
+ className="w-full bg-blue-600 text-white rounded-md py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-60"
61
+ >
62
+ {loading ? "Signing in..." : "Sign in"}
63
+ </button>
64
+ </form>
65
+ );
66
+ }
67
+
68
+ export default function LoginPage() {
69
+ return (
70
+ <div className="min-h-screen flex items-center justify-center bg-slate-50">
71
+ <Suspense fallback={<div className="text-sm text-slate-400">Loading…</div>}>
72
+ <LoginForm />
73
+ </Suspense>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,34 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+
4
+ /** Called by the Express API after publish/save to bust ISR cache tags. */
5
+ export async function POST(req: NextRequest) {
6
+ const secret = process.env.REVALIDATE_SECRET;
7
+ if (!secret) {
8
+ return NextResponse.json({ error: "Revalidation not configured" }, { status: 503 });
9
+ }
10
+
11
+ const auth = req.headers.get("authorization");
12
+ if (auth !== `Bearer ${secret}`) {
13
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14
+ }
15
+
16
+ let body: { tags?: unknown };
17
+ try {
18
+ body = await req.json();
19
+ } catch {
20
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
21
+ }
22
+
23
+ if (!Array.isArray(body.tags) || body.tags.some((t) => typeof t !== "string")) {
24
+ return NextResponse.json({ error: "tags must be a string array" }, { status: 400 });
25
+ }
26
+
27
+ for (const tag of body.tags) {
28
+ // Next 16 requires a cache-life profile; "max" expires the tag everywhere
29
+ // immediately, matching the pre-16 single-argument behavior.
30
+ revalidateTag(tag, "max");
31
+ }
32
+
33
+ return NextResponse.json({ revalidated: true, tags: body.tags });
34
+ }
@@ -0,0 +1,120 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --theme-primary: #2563eb;
7
+ --theme-secondary: #0f172a;
8
+ --theme-bg: #ffffff;
9
+ --theme-text: #111827;
10
+ --theme-heading-font: "Poppins", sans-serif;
11
+ --theme-body-font: "Inter", sans-serif;
12
+ }
13
+
14
+ body {
15
+ background-color: var(--theme-bg);
16
+ color: var(--theme-text);
17
+ font-family: var(--theme-body-font);
18
+ }
19
+
20
+ h1, h2, h3, h4, h5, h6 {
21
+ font-family: var(--theme-heading-font);
22
+ }
23
+
24
+ .pgcms-editable-selected {
25
+ outline: 2px solid #2563eb;
26
+ outline-offset: -2px;
27
+ }
28
+
29
+ .pgcms-editable-hover:hover {
30
+ outline: 1px dashed #94a3b8;
31
+ outline-offset: -1px;
32
+ }
33
+
34
+ /* --- Modern effect utilities, driven by NodeStyle fields (see Renderer.tsx) --- */
35
+
36
+ /* Glassmorphism panel — translucent frosted glass. The background only applies when
37
+ the node has no explicit backgroundColor of its own (inline style wins over this). */
38
+ .pgcms-glass {
39
+ background-color: rgb(255 255 255 / 0.12);
40
+ backdrop-filter: blur(14px) saturate(1.4);
41
+ -webkit-backdrop-filter: blur(14px) saturate(1.4);
42
+ border: 1px solid rgb(255 255 255 / 0.25);
43
+ box-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
44
+ }
45
+
46
+ /* Gradient-filled headings — colors come from --pgcms-tg-from / --pgcms-tg-to set
47
+ inline by the Renderer. */
48
+ .pgcms-text-gradient h1,
49
+ .pgcms-text-gradient h2,
50
+ .pgcms-text-gradient h3 {
51
+ background-image: linear-gradient(100deg, var(--pgcms-tg-from), var(--pgcms-tg-to));
52
+ background-clip: text;
53
+ -webkit-background-clip: text;
54
+ color: transparent;
55
+ -webkit-text-fill-color: transparent;
56
+ }
57
+
58
+ /* Hover effects (tilt is JS-driven in useModernEffects; these three are pure CSS). */
59
+ .pgcms-hover-lift {
60
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
61
+ }
62
+ .pgcms-hover-lift:hover {
63
+ transform: translateY(-8px);
64
+ box-shadow: 0 20px 40px -12px rgb(0 0 0 / 0.25);
65
+ }
66
+ .pgcms-hover-scale {
67
+ transition: transform 0.3s ease;
68
+ }
69
+ .pgcms-hover-scale:hover {
70
+ transform: scale(1.03);
71
+ }
72
+ .pgcms-hover-glow {
73
+ transition: box-shadow 0.3s ease;
74
+ }
75
+ .pgcms-hover-glow:hover {
76
+ /* Fallback for browsers without relative color syntax, then the themed version. */
77
+ box-shadow: 0 0 32px rgb(37 99 235 / 0.45);
78
+ box-shadow: 0 0 0 1px rgb(from var(--theme-primary) r g b / 0.4),
79
+ 0 0 32px rgb(from var(--theme-primary) r g b / 0.45);
80
+ }
81
+
82
+ /* Animated gradient background (Hero's "animate gradient" option) — the gradient is
83
+ oversized and its position drifts back and forth. */
84
+ .pgcms-animated-gradient {
85
+ background-size: 300% 300% !important;
86
+ animation: pgcms-gradient-drift 8s ease-in-out infinite;
87
+ }
88
+ @keyframes pgcms-gradient-drift {
89
+ 0% { background-position: 0% 50%; }
90
+ 50% { background-position: 100% 50%; }
91
+ 100% { background-position: 0% 50%; }
92
+ }
93
+
94
+ /* Marquee ticker — the track holds two copies of the items; translating it by -50%
95
+ loops seamlessly. Duration/direction are set inline by the component. */
96
+ .pgcms-marquee {
97
+ overflow: hidden;
98
+ white-space: nowrap;
99
+ }
100
+ .pgcms-marquee-track {
101
+ display: inline-flex;
102
+ align-items: center;
103
+ animation: pgcms-marquee-scroll linear infinite;
104
+ animation-duration: var(--pgcms-marquee-duration, 20s);
105
+ animation-direction: var(--pgcms-marquee-direction, normal);
106
+ }
107
+ .pgcms-marquee:hover .pgcms-marquee-track[data-pause-on-hover="true"] {
108
+ animation-play-state: paused;
109
+ }
110
+ @keyframes pgcms-marquee-scroll {
111
+ from { transform: translateX(0); }
112
+ to { transform: translateX(-50%); }
113
+ }
114
+
115
+ @media (prefers-reduced-motion: reduce) {
116
+ .pgcms-marquee-track,
117
+ .pgcms-animated-gradient {
118
+ animation: none;
119
+ }
120
+ }
@@ -0,0 +1,39 @@
1
+ import type { Metadata } from "next";
2
+ import Script from "next/script";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "pg-cms",
7
+ description: "Drag-and-drop website CMS",
8
+ };
9
+
10
+ // Runs before hydration so a dark-mode visitor never sees a light flash: the
11
+ // `data-theme` attribute set here is what ThemeStyleTag's `:root[data-theme="dark"]`
12
+ // block targets. Harmless when a site hasn't enabled dark mode — that CSS block simply
13
+ // doesn't exist, so setting the attribute has no visual effect.
14
+ const THEME_INIT_SCRIPT = `
15
+ (function () {
16
+ try {
17
+ var saved = localStorage.getItem("pgcms-theme");
18
+ var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
19
+ document.documentElement.setAttribute("data-theme", theme);
20
+ } catch (e) {}
21
+ })();
22
+ `;
23
+
24
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
25
+ return (
26
+ // suppressHydrationWarning is scoped to this element's own attributes only (it does
27
+ // not suppress mismatches in children) — needed because the beforeInteractive script
28
+ // below sets data-theme before React hydrates, which would otherwise be flagged as a
29
+ // server/client mismatch even though it's intentional.
30
+ <html lang="en" suppressHydrationWarning>
31
+ <head>
32
+ <Script id="pgcms-theme-init" strategy="beforeInteractive">
33
+ {THEME_INIT_SCRIPT}
34
+ </Script>
35
+ </head>
36
+ <body>{children}</body>
37
+ </html>
38
+ );
39
+ }
@@ -0,0 +1,70 @@
1
+ import { notFound } from "next/navigation";
2
+ import type { Metadata } from "next";
3
+ import { isRtlLocale, type NavItem, type Page, type PageNode, type SiteTheme } from "@pgcms/shared";
4
+ import { serverGet } from "@/lib/serverApi";
5
+ import { ThemeStyleTag } from "@/components/theme/ThemeProvider";
6
+ import { PublicPageView } from "@/components/renderer/PublicPageView";
7
+ import { JsonLd } from "@/components/seo/JsonLd";
8
+
9
+ type GlobalSection = { kind: string; enabled: boolean; content: PageNode };
10
+
11
+ const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
12
+
13
+ async function loadPreview(token: string) {
14
+ try {
15
+ const [page, theme, navItems, globalSections] = await Promise.all([
16
+ serverGet<Page>(`/api/pages/preview/${token}`, { tags: [`preview:${token}`], revalidate: 0 }),
17
+ serverGet<SiteTheme>(`/api/theme/public`, { tags: ["cms", "cms-theme"] }),
18
+ serverGet<NavItem[]>(`/api/theme/nav/public`, { tags: ["cms", "cms-nav"] }),
19
+ serverGet<GlobalSection[]>(`/api/global-sections`).catch(() => [] as GlobalSection[]),
20
+ ]);
21
+ const globalHeader = globalSections.find((s) => s.kind === "header" && s.enabled)?.content ?? null;
22
+ const globalFooter = globalSections.find((s) => s.kind === "footer" && s.enabled)?.content ?? null;
23
+ return { page, theme, navItems, globalHeader, globalFooter };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export async function generateMetadata({
30
+ params,
31
+ }: {
32
+ params: Promise<{ token: string }>;
33
+ }): Promise<Metadata> {
34
+ const { token } = await params;
35
+ const data = await loadPreview(token);
36
+ if (!data) return { title: "Preview not found" };
37
+ return {
38
+ title: `[Preview] ${data.page.seoTitle || data.page.title}`,
39
+ robots: { index: false, follow: false },
40
+ };
41
+ }
42
+
43
+ export default async function PreviewPage({ params }: { params: Promise<{ token: string }> }) {
44
+ const { token } = await params;
45
+ const data = await loadPreview(token);
46
+ if (!data) notFound();
47
+
48
+ const locale = data.page.locale || "en";
49
+ const slug = data.page.slug || "";
50
+ const pageUrl = `${SITE_URL}/${slug}`;
51
+
52
+ return (
53
+ <div lang={locale} dir={isRtlLocale(locale) ? "rtl" : "ltr"}>
54
+ <div className="bg-amber-500 text-amber-950 text-center text-xs py-1.5 font-medium">
55
+ Draft preview — this page is not published
56
+ </div>
57
+ <JsonLd page={data.page} theme={data.theme} url={pageUrl} />
58
+ <ThemeStyleTag theme={data.theme} />
59
+ <PublicPageView
60
+ page={data.page}
61
+ theme={data.theme}
62
+ navItems={data.navItems}
63
+ globalHeader={data.globalHeader}
64
+ globalFooter={data.globalFooter}
65
+ pageLocale={locale}
66
+ translations={[]}
67
+ />
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,11 @@
1
+ import type { MetadataRoute } from "next";
2
+
3
+ const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
4
+
5
+ /** /robots.txt — allow everything except the admin, and point crawlers at the sitemap. */
6
+ export default function robots(): MetadataRoute.Robots {
7
+ return {
8
+ rules: [{ userAgent: "*", allow: "/", disallow: ["/admin", "/preview"] }],
9
+ sitemap: `${SITE_URL}/sitemap.xml`,
10
+ };
11
+ }