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,45 @@
1
+ {
2
+ "name": "@pgcms/api",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/index.ts",
8
+ "build": "tsc -p tsconfig.json",
9
+ "start": "node dist/index.js",
10
+ "db:migrate": "prisma migrate dev",
11
+ "db:generate": "prisma generate",
12
+ "db:seed": "tsx prisma/seed.ts",
13
+ "db:seed:demo-charities": "tsx prisma/seed-our-charities.ts",
14
+ "db:seed:templates": "tsx prisma/seed-templates.ts"
15
+ },
16
+ "dependencies": {
17
+ "@aws-sdk/client-s3": "^3.665.0",
18
+ "@aws-sdk/s3-request-presigner": "^3.665.0",
19
+ "@azure/storage-blob": "^12.33.0",
20
+ "@pgcms/shared": "*",
21
+ "@prisma/client": "^5.20.0",
22
+ "bcryptjs": "^2.4.3",
23
+ "cloudinary": "^2.10.0",
24
+ "cookie-parser": "^1.4.6",
25
+ "cors": "^2.8.5",
26
+ "dotenv": "^16.4.5",
27
+ "express": "^4.21.0",
28
+ "jsonwebtoken": "^9.0.2",
29
+ "multer": "^2.0.0",
30
+ "nanoid": "^5.0.7",
31
+ "zod": "^3.23.8"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bcryptjs": "^2.4.6",
35
+ "@types/cookie-parser": "^1.4.7",
36
+ "@types/cors": "^2.8.17",
37
+ "@types/express": "^4.17.21",
38
+ "@types/jsonwebtoken": "^9.0.7",
39
+ "@types/multer": "^1.4.12",
40
+ "@types/node": "^24.13.2",
41
+ "prisma": "^5.20.0",
42
+ "tsx": "^4.22.4",
43
+ "typescript": "^5.9.3"
44
+ }
45
+ }
@@ -0,0 +1,31 @@
1
+ // One-off: populates the new `searchText` column (added alongside the standard-CMS
2
+ // platform pass) for every Page/CollectionItem row that predates it. Going forward,
3
+ // pages.routes.ts/collections.routes.ts keep it in sync on every save — this only
4
+ // needs to run once, against existing data.
5
+ import { PrismaClient } from "@prisma/client";
6
+ import { extractSearchText, type PageNode } from "@pgcms/shared";
7
+
8
+ const prisma = new PrismaClient();
9
+
10
+ async function main() {
11
+ const pages = await prisma.page.findMany({ where: { searchText: "" } });
12
+ for (const page of pages) {
13
+ const searchText = extractSearchText(page.content as unknown as PageNode);
14
+ await prisma.page.update({ where: { id: page.id }, data: { searchText } });
15
+ }
16
+ console.log(`Backfilled searchText for ${pages.length} page(s).`);
17
+
18
+ const items = await prisma.collectionItem.findMany({ where: { searchText: "" } });
19
+ for (const item of items) {
20
+ const searchText = extractSearchText(item.content as unknown as PageNode);
21
+ await prisma.collectionItem.update({ where: { id: item.id }, data: { searchText } });
22
+ }
23
+ console.log(`Backfilled searchText for ${items.length} collection item(s).`);
24
+ }
25
+
26
+ main()
27
+ .catch((err) => {
28
+ console.error(err);
29
+ process.exitCode = 1;
30
+ })
31
+ .finally(() => prisma.$disconnect());
@@ -0,0 +1,86 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'EDITOR');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "PageStatus" AS ENUM ('DRAFT', 'PUBLISHED');
6
+
7
+ -- CreateTable
8
+ CREATE TABLE "User" (
9
+ "id" TEXT NOT NULL,
10
+ "email" TEXT NOT NULL,
11
+ "passwordHash" TEXT NOT NULL,
12
+ "role" "UserRole" NOT NULL DEFAULT 'EDITOR',
13
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+
15
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
16
+ );
17
+
18
+ -- CreateTable
19
+ CREATE TABLE "Page" (
20
+ "id" TEXT NOT NULL,
21
+ "slug" TEXT NOT NULL,
22
+ "title" TEXT NOT NULL,
23
+ "status" "PageStatus" NOT NULL DEFAULT 'DRAFT',
24
+ "content" JSONB NOT NULL,
25
+ "seoTitle" TEXT,
26
+ "seoDescription" TEXT,
27
+ "ogImage" TEXT,
28
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ "updatedAt" TIMESTAMP(3) NOT NULL,
30
+
31
+ CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
32
+ );
33
+
34
+ -- CreateTable
35
+ CREATE TABLE "MediaAsset" (
36
+ "id" TEXT NOT NULL,
37
+ "key" TEXT NOT NULL,
38
+ "url" TEXT NOT NULL,
39
+ "mimeType" TEXT NOT NULL,
40
+ "width" INTEGER,
41
+ "height" INTEGER,
42
+ "size" INTEGER NOT NULL,
43
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
44
+
45
+ CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
46
+ );
47
+
48
+ -- CreateTable
49
+ CREATE TABLE "SiteTheme" (
50
+ "id" TEXT NOT NULL,
51
+ "primaryColor" TEXT NOT NULL DEFAULT '#2563eb',
52
+ "secondaryColor" TEXT NOT NULL DEFAULT '#0f172a',
53
+ "backgroundColor" TEXT NOT NULL DEFAULT '#ffffff',
54
+ "textColor" TEXT NOT NULL DEFAULT '#111827',
55
+ "headingFont" TEXT NOT NULL DEFAULT 'Poppins',
56
+ "bodyFont" TEXT NOT NULL DEFAULT 'Inter',
57
+ "logoUrl" TEXT,
58
+ "faviconUrl" TEXT,
59
+ "siteName" TEXT NOT NULL DEFAULT 'My Site',
60
+ "customCss" TEXT,
61
+
62
+ CONSTRAINT "SiteTheme_pkey" PRIMARY KEY ("id")
63
+ );
64
+
65
+ -- CreateTable
66
+ CREATE TABLE "NavItem" (
67
+ "id" TEXT NOT NULL,
68
+ "label" TEXT NOT NULL,
69
+ "href" TEXT NOT NULL,
70
+ "order" INTEGER NOT NULL DEFAULT 0,
71
+ "parentId" TEXT,
72
+
73
+ CONSTRAINT "NavItem_pkey" PRIMARY KEY ("id")
74
+ );
75
+
76
+ -- CreateIndex
77
+ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
78
+
79
+ -- CreateIndex
80
+ CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
81
+
82
+ -- CreateIndex
83
+ CREATE UNIQUE INDEX "MediaAsset_key_key" ON "MediaAsset"("key");
84
+
85
+ -- AddForeignKey
86
+ ALTER TABLE "NavItem" ADD CONSTRAINT "NavItem_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "NavItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Page" ADD COLUMN "publishAt" TIMESTAMP(3);
3
+
4
+ -- CreateTable
5
+ CREATE TABLE "PageRevision" (
6
+ "id" TEXT NOT NULL,
7
+ "pageId" TEXT NOT NULL,
8
+ "title" TEXT NOT NULL,
9
+ "content" JSONB NOT NULL,
10
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11
+
12
+ CONSTRAINT "PageRevision_pkey" PRIMARY KEY ("id")
13
+ );
14
+
15
+ -- CreateIndex
16
+ CREATE INDEX "PageRevision_pageId_createdAt_idx" ON "PageRevision"("pageId", "createdAt");
17
+
18
+ -- AddForeignKey
19
+ ALTER TABLE "PageRevision" ADD CONSTRAINT "PageRevision_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
1
+ -- CreateTable
2
+ CREATE TABLE "FormSubmission" (
3
+ "id" TEXT NOT NULL,
4
+ "formName" TEXT NOT NULL,
5
+ "pageSlug" TEXT NOT NULL,
6
+ "data" JSONB NOT NULL,
7
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+
9
+ CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
10
+ );
11
+
12
+ -- CreateIndex
13
+ CREATE INDEX "FormSubmission_formName_createdAt_idx" ON "FormSubmission"("formName", "createdAt");
@@ -0,0 +1,24 @@
1
+ -- CreateTable
2
+ CREATE TABLE "GlobalSection" (
3
+ "id" TEXT NOT NULL,
4
+ "kind" TEXT NOT NULL,
5
+ "enabled" BOOLEAN NOT NULL DEFAULT false,
6
+ "content" JSONB NOT NULL,
7
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+ "updatedAt" TIMESTAMP(3) NOT NULL,
9
+
10
+ CONSTRAINT "GlobalSection_pkey" PRIMARY KEY ("id")
11
+ );
12
+
13
+ -- CreateTable
14
+ CREATE TABLE "SavedBlock" (
15
+ "id" TEXT NOT NULL,
16
+ "name" TEXT NOT NULL,
17
+ "content" JSONB NOT NULL,
18
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+
20
+ CONSTRAINT "SavedBlock_pkey" PRIMARY KEY ("id")
21
+ );
22
+
23
+ -- CreateIndex
24
+ CREATE UNIQUE INDEX "GlobalSection_kind_key" ON "GlobalSection"("kind");
@@ -0,0 +1,6 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Page" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en',
3
+ ADD COLUMN "translationOfId" TEXT;
4
+
5
+ -- AddForeignKey
6
+ ALTER TABLE "Page" ADD CONSTRAINT "Page_translationOfId_fkey" FOREIGN KEY ("translationOfId") REFERENCES "Page"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
1
+ -- CreateTable
2
+ CREATE TABLE "Collection" (
3
+ "id" TEXT NOT NULL,
4
+ "name" TEXT NOT NULL,
5
+ "slug" TEXT NOT NULL,
6
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7
+
8
+ CONSTRAINT "Collection_pkey" PRIMARY KEY ("id")
9
+ );
10
+
11
+ -- CreateTable
12
+ CREATE TABLE "CollectionItem" (
13
+ "id" TEXT NOT NULL,
14
+ "collectionId" TEXT NOT NULL,
15
+ "slug" TEXT NOT NULL,
16
+ "title" TEXT NOT NULL,
17
+ "excerpt" TEXT,
18
+ "coverImage" TEXT,
19
+ "content" JSONB NOT NULL,
20
+ "status" "PageStatus" NOT NULL DEFAULT 'DRAFT',
21
+ "publishedAt" TIMESTAMP(3),
22
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ "updatedAt" TIMESTAMP(3) NOT NULL,
24
+
25
+ CONSTRAINT "CollectionItem_pkey" PRIMARY KEY ("id")
26
+ );
27
+
28
+ -- CreateIndex
29
+ CREATE UNIQUE INDEX "Collection_slug_key" ON "Collection"("slug");
30
+
31
+ -- CreateIndex
32
+ CREATE INDEX "CollectionItem_collectionId_status_publishedAt_idx" ON "CollectionItem"("collectionId", "status", "publishedAt");
33
+
34
+ -- CreateIndex
35
+ CREATE UNIQUE INDEX "CollectionItem_collectionId_slug_key" ON "CollectionItem"("collectionId", "slug");
36
+
37
+ -- AddForeignKey
38
+ ALTER TABLE "CollectionItem" ADD CONSTRAINT "CollectionItem_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Page" ADD COLUMN "noIndex" BOOLEAN NOT NULL DEFAULT false;
3
+ ALTER TABLE "Page" ADD COLUMN "previewToken" TEXT;
4
+
5
+ -- Backfill preview tokens for existing pages
6
+ UPDATE "Page" SET "previewToken" = 'prev_' || "id" WHERE "previewToken" IS NULL;
7
+
8
+ ALTER TABLE "Page" ALTER COLUMN "previewToken" SET NOT NULL;
9
+ CREATE UNIQUE INDEX "Page_previewToken_key" ON "Page"("previewToken");
10
+
11
+ -- AlterTable
12
+ ALTER TABLE "CollectionItem" ADD COLUMN "seoTitle" TEXT;
13
+ ALTER TABLE "CollectionItem" ADD COLUMN "seoDescription" TEXT;
14
+ ALTER TABLE "CollectionItem" ADD COLUMN "ogImage" TEXT;
15
+ ALTER TABLE "CollectionItem" ADD COLUMN "noIndex" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,17 @@
1
+ -- AlterTable
2
+ ALTER TABLE "MediaAsset" ADD COLUMN "alt" TEXT,
3
+ ADD COLUMN "filename" TEXT NOT NULL DEFAULT '';
4
+
5
+ -- CreateTable
6
+ CREATE TABLE "Redirect" (
7
+ "id" TEXT NOT NULL,
8
+ "fromPath" TEXT NOT NULL,
9
+ "toPath" TEXT NOT NULL,
10
+ "permanent" BOOLEAN NOT NULL DEFAULT true,
11
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+
13
+ CONSTRAINT "Redirect_pkey" PRIMARY KEY ("id")
14
+ );
15
+
16
+ -- CreateIndex
17
+ CREATE UNIQUE INDEX "Redirect_fromPath_key" ON "Redirect"("fromPath");
@@ -0,0 +1,18 @@
1
+ -- CreateTable
2
+ CREATE TABLE "AuditLog" (
3
+ "id" TEXT NOT NULL,
4
+ "userId" TEXT,
5
+ "userEmail" TEXT,
6
+ "action" TEXT NOT NULL,
7
+ "entityPath" TEXT NOT NULL,
8
+ "status" INTEGER NOT NULL,
9
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+
11
+ CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
12
+ );
13
+
14
+ -- CreateIndex
15
+ CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
16
+
17
+ -- CreateIndex
18
+ CREATE INDEX "AuditLog_userEmail_createdAt_idx" ON "AuditLog"("userEmail", "createdAt");
@@ -0,0 +1,48 @@
1
+ -- AlterEnum
2
+ ALTER TYPE "UserRole" ADD VALUE 'AUTHOR';
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "CollectionItem" ADD COLUMN "createdById" TEXT,
6
+ ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en',
7
+ ADD COLUMN "searchText" TEXT NOT NULL DEFAULT '',
8
+ ADD COLUMN "translationOfId" TEXT;
9
+
10
+ -- AlterTable
11
+ ALTER TABLE "Page" ADD COLUMN "createdById" TEXT,
12
+ ADD COLUMN "searchText" TEXT NOT NULL DEFAULT '';
13
+
14
+ -- AlterTable
15
+ ALTER TABLE "SavedBlock" ADD COLUMN "createdById" TEXT;
16
+
17
+ -- AlterTable
18
+ ALTER TABLE "SiteTheme" ADD COLUMN "analyticsCustomScript" TEXT,
19
+ ADD COLUMN "analyticsId" TEXT,
20
+ ADD COLUMN "analyticsProvider" TEXT NOT NULL DEFAULT 'none',
21
+ ADD COLUMN "backgroundColorDark" TEXT,
22
+ ADD COLUMN "darkModeEnabled" BOOLEAN NOT NULL DEFAULT false,
23
+ ADD COLUMN "primaryColorDark" TEXT,
24
+ ADD COLUMN "textColorDark" TEXT;
25
+
26
+ -- CreateTable
27
+ CREATE TABLE "PageTemplate" (
28
+ "id" TEXT NOT NULL,
29
+ "name" TEXT NOT NULL,
30
+ "category" TEXT NOT NULL DEFAULT 'General',
31
+ "thumbnail" TEXT,
32
+ "content" JSONB NOT NULL,
33
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+
35
+ CONSTRAINT "PageTemplate_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ -- AddForeignKey
39
+ ALTER TABLE "Page" ADD CONSTRAINT "Page_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
40
+
41
+ -- AddForeignKey
42
+ ALTER TABLE "CollectionItem" ADD CONSTRAINT "CollectionItem_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
43
+
44
+ -- AddForeignKey
45
+ ALTER TABLE "CollectionItem" ADD CONSTRAINT "CollectionItem_translationOfId_fkey" FOREIGN KEY ("translationOfId") REFERENCES "CollectionItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
46
+
47
+ -- AddForeignKey
48
+ ALTER TABLE "SavedBlock" ADD CONSTRAINT "SavedBlock_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "postgresql"
@@ -0,0 +1,19 @@
1
+ import "dotenv/config";
2
+ import { PrismaClient } from "@prisma/client";
3
+
4
+ const prisma = new PrismaClient();
5
+
6
+ async function main() {
7
+ const email = "admin@example.com";
8
+ await prisma.user.deleteMany({ where: { email } });
9
+ console.log("Deleted existing admin user");
10
+ }
11
+
12
+ main()
13
+ .catch((err) => {
14
+ console.error(err);
15
+ process.exit(1);
16
+ })
17
+ .finally(async () => {
18
+ await prisma.$disconnect();
19
+ });
@@ -0,0 +1,251 @@
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "postgresql"
7
+ url = env("DATABASE_URL")
8
+ }
9
+
10
+ enum UserRole {
11
+ ADMIN
12
+ EDITOR
13
+ AUTHOR
14
+ }
15
+
16
+ enum PageStatus {
17
+ DRAFT
18
+ PUBLISHED
19
+ }
20
+
21
+ model User {
22
+ id String @id @default(cuid())
23
+ email String @unique
24
+ passwordHash String
25
+ role UserRole @default(EDITOR)
26
+ createdAt DateTime @default(now())
27
+ pages Page[] @relation("PageAuthor")
28
+ collectionItems CollectionItem[] @relation("CollectionItemAuthor")
29
+ savedBlocks SavedBlock[] @relation("SavedBlockAuthor")
30
+ }
31
+
32
+ model Page {
33
+ id String @id @default(cuid())
34
+ slug String @unique
35
+ title String
36
+ status PageStatus @default(DRAFT)
37
+ content Json
38
+ seoTitle String?
39
+ seoDescription String?
40
+ ogImage String?
41
+ // BCP-47 language tag for this page's content ("en", "es", "ar"...). Slugs stay
42
+ // globally unique — localized pages get their own slug (e.g. "es/servicios") and are
43
+ // linked to their source page via translationOfId so a language switcher can list
44
+ // the whole translation group.
45
+ locale String @default("en")
46
+ translationOfId String?
47
+ translationOf Page? @relation("PageTranslations", fields: [translationOfId], references: [id], onDelete: SetNull)
48
+ translations Page[] @relation("PageTranslations")
49
+ // When set alongside status=PUBLISHED, the page only actually becomes publicly
50
+ // visible once this moment passes — checked lazily at request time rather than via
51
+ // a background job, since "is it visible" is just "status=PUBLISHED AND (no
52
+ // publishAt OR publishAt <= now)".
53
+ publishAt DateTime?
54
+ noIndex Boolean @default(false)
55
+ previewToken String @unique @default(cuid())
56
+ // Denormalized plain-text extraction of `content` (see extractSearchText in
57
+ // packages/shared), kept in sync on every save so Postgres full-text search doesn't
58
+ // need to parse the JSON tree at query time.
59
+ searchText String @default("")
60
+ createdById String?
61
+ createdBy User? @relation("PageAuthor", fields: [createdById], references: [id], onDelete: SetNull)
62
+ createdAt DateTime @default(now())
63
+ updatedAt DateTime @updatedAt
64
+ revisions PageRevision[]
65
+ }
66
+
67
+ // A lightweight snapshot taken on every Save, so an editor can look back at and
68
+ // restore an earlier version of a page. Capped and pruned in application code rather
69
+ // than modeled here, to keep the schema simple.
70
+ model PageRevision {
71
+ id String @id @default(cuid())
72
+ pageId String
73
+ page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
74
+ title String
75
+ content Json
76
+ createdAt DateTime @default(now())
77
+
78
+ @@index([pageId, createdAt])
79
+ }
80
+
81
+ model MediaAsset {
82
+ id String @id @default(cuid())
83
+ key String @unique
84
+ url String
85
+ mimeType String
86
+ width Int?
87
+ height Int?
88
+ size Int
89
+ // Original filename kept for search; alt text for accessibility wherever the asset
90
+ // is used without an explicit per-usage alt.
91
+ filename String @default("")
92
+ alt String?
93
+ createdAt DateTime @default(now())
94
+ }
95
+
96
+ // Old URL → new URL mappings, consulted when a public path doesn't match any page or
97
+ // collection entry — keeps inbound links alive across restructures.
98
+ model Redirect {
99
+ id String @id @default(cuid())
100
+ fromPath String @unique
101
+ toPath String
102
+ permanent Boolean @default(true)
103
+ createdAt DateTime @default(now())
104
+ }
105
+
106
+ model SiteTheme {
107
+ id String @id @default(cuid())
108
+ primaryColor String @default("#2563eb")
109
+ secondaryColor String @default("#0f172a")
110
+ backgroundColor String @default("#ffffff")
111
+ textColor String @default("#111827")
112
+ headingFont String @default("Poppins")
113
+ bodyFont String @default("Inter")
114
+ logoUrl String?
115
+ faviconUrl String?
116
+ siteName String @default("My Site")
117
+ customCss String?
118
+ // Dark-mode token overrides — used only when darkModeEnabled; left blank fields fall
119
+ // back to sensible computed defaults (see ThemeStyleTag) rather than the light colors.
120
+ darkModeEnabled Boolean @default(false)
121
+ primaryColorDark String?
122
+ backgroundColorDark String?
123
+ textColorDark String?
124
+ // "none" | "ga4" | "plausible" | "custom" — kept as a plain string (not a Prisma enum)
125
+ // since it's validated at the Zod boundary and an enum migration is heavier for a
126
+ // field this low-stakes.
127
+ analyticsProvider String @default("none")
128
+ analyticsId String?
129
+ analyticsCustomScript String?
130
+ }
131
+
132
+ model NavItem {
133
+ id String @id @default(cuid())
134
+ label String
135
+ href String
136
+ order Int @default(0)
137
+ parentId String?
138
+ parent NavItem? @relation("NavItemChildren", fields: [parentId], references: [id], onDelete: Cascade)
139
+ children NavItem[] @relation("NavItemChildren")
140
+ }
141
+
142
+ // A dynamic content type — Blog, News, Events — whose items share a URL prefix
143
+ // (`/{collection.slug}/{item.slug}`) and are listed by the "Collection List" block.
144
+ model Collection {
145
+ id String @id @default(cuid())
146
+ name String
147
+ slug String @unique
148
+ items CollectionItem[]
149
+ createdAt DateTime @default(now())
150
+ }
151
+
152
+ // One entry in a collection (a post, an event...). `content` is a PageNode tree edited
153
+ // with the same visual builder as pages, so items get the full component library.
154
+ model CollectionItem {
155
+ id String @id @default(cuid())
156
+ collectionId String
157
+ collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
158
+ slug String
159
+ title String
160
+ excerpt String?
161
+ coverImage String?
162
+ content Json
163
+ status PageStatus @default(DRAFT)
164
+ publishedAt DateTime?
165
+ seoTitle String?
166
+ seoDescription String?
167
+ ogImage String?
168
+ noIndex Boolean @default(false)
169
+ searchText String @default("")
170
+ createdById String?
171
+ createdBy User? @relation("CollectionItemAuthor", fields: [createdById], references: [id], onDelete: SetNull)
172
+ // Mirrors Page's locale/translation pattern exactly, but scoped to items within the
173
+ // same collection rather than site-wide, since a "translation of" only makes sense
174
+ // among items sharing a collection.
175
+ locale String @default("en")
176
+ translationOfId String?
177
+ translationOf CollectionItem? @relation("CollectionItemTranslations", fields: [translationOfId], references: [id], onDelete: SetNull)
178
+ translations CollectionItem[] @relation("CollectionItemTranslations")
179
+ createdAt DateTime @default(now())
180
+ updatedAt DateTime @updatedAt
181
+
182
+ @@unique([collectionId, slug])
183
+ @@index([collectionId, status, publishedAt])
184
+ }
185
+
186
+ // Site-level sections rendered on every page: the global header above each page's own
187
+ // content and the global footer below it. `kind` is "header" or "footer"; `content` is
188
+ // a PageNode root tree (so a global header can be several stacked sections — utility
189
+ // bar + header, say). Disabled rows are kept so switching off doesn't lose the layout.
190
+ model GlobalSection {
191
+ id String @id @default(cuid())
192
+ kind String @unique
193
+ enabled Boolean @default(false)
194
+ content Json
195
+ createdAt DateTime @default(now())
196
+ updatedAt DateTime @updatedAt
197
+ }
198
+
199
+ // A section (or whole subtree) an editor saved for reuse. Inserting a block deep-clones
200
+ // its content with fresh node ids, so instances are independent copies (not synced).
201
+ model SavedBlock {
202
+ id String @id @default(cuid())
203
+ name String
204
+ content Json
205
+ createdById String?
206
+ createdBy User? @relation("SavedBlockAuthor", fields: [createdById], references: [id], onDelete: SetNull)
207
+ createdAt DateTime @default(now())
208
+ }
209
+
210
+ // A starter PageNode tree an editor can pick when creating a new page instead of
211
+ // starting blank. `content` is a full root PageNode, same shape as Page.content, so
212
+ // applying a template is just cloning it with fresh node ids (cloneWithNewIds).
213
+ model PageTemplate {
214
+ id String @id @default(cuid())
215
+ name String
216
+ category String @default("General")
217
+ thumbnail String?
218
+ content Json
219
+ createdAt DateTime @default(now())
220
+ }
221
+
222
+ // Who changed what, when — one row per successful mutating API call, captured by
223
+ // middleware rather than per-route calls so nothing is forgotten as routes are added.
224
+ // `action` is "METHOD /path" and `entityPath` the concrete URL hit.
225
+ model AuditLog {
226
+ id String @id @default(cuid())
227
+ userId String?
228
+ userEmail String?
229
+ action String
230
+ entityPath String
231
+ status Int
232
+ createdAt DateTime @default(now())
233
+
234
+ @@index([createdAt])
235
+ @@index([userEmail, createdAt])
236
+ }
237
+
238
+ // One row per submission of any "Contact Form" block on the site. `formName` lets
239
+ // multiple distinct forms (e.g. "Contact", "Newsletter") share one table while still
240
+ // being filterable in the admin submissions view. `data` holds whatever fields that
241
+ // particular form instance defined — kept schemaless since form fields are authored
242
+ // per-component, not modeled globally.
243
+ model FormSubmission {
244
+ id String @id @default(cuid())
245
+ formName String
246
+ pageSlug String
247
+ data Json
248
+ createdAt DateTime @default(now())
249
+
250
+ @@index([formName, createdAt])
251
+ }