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.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- package/template/packages/shared/vitest.config.ts +8 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./schema/pageNode.js";
|
|
2
|
+
export * from "./schema/theme.js";
|
|
3
|
+
export * from "./schema/user.js";
|
|
4
|
+
export * from "./schema/media.js";
|
|
5
|
+
export * from "./schema/fields.js";
|
|
6
|
+
export * from "./schema/collections.js";
|
|
7
|
+
export * from "./schema/templates.js";
|
|
8
|
+
export * from "./components.js";
|
|
9
|
+
export * from "./tree.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { PageNodeSchema, PageStatusSchema } from "./pageNode.js";
|
|
3
|
+
|
|
4
|
+
export const CollectionSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
name: z.string(),
|
|
7
|
+
slug: z.string(),
|
|
8
|
+
createdAt: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
export type Collection = z.infer<typeof CollectionSchema>;
|
|
11
|
+
|
|
12
|
+
export const CollectionItemSchema = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
collectionId: z.string(),
|
|
15
|
+
slug: z.string(),
|
|
16
|
+
title: z.string(),
|
|
17
|
+
excerpt: z.string().nullable().optional(),
|
|
18
|
+
coverImage: z.string().nullable().optional(),
|
|
19
|
+
seoTitle: z.string().nullable().optional(),
|
|
20
|
+
seoDescription: z.string().nullable().optional(),
|
|
21
|
+
ogImage: z.string().nullable().optional(),
|
|
22
|
+
noIndex: z.boolean().optional(),
|
|
23
|
+
content: PageNodeSchema,
|
|
24
|
+
status: PageStatusSchema,
|
|
25
|
+
publishedAt: z.string().nullable().optional(),
|
|
26
|
+
locale: z.string().optional(),
|
|
27
|
+
translationOfId: z.string().nullable().optional(),
|
|
28
|
+
createdById: z.string().nullable().optional(),
|
|
29
|
+
createdAt: z.string().optional(),
|
|
30
|
+
updatedAt: z.string().optional(),
|
|
31
|
+
});
|
|
32
|
+
export type CollectionItem = z.infer<typeof CollectionItemSchema>;
|
|
33
|
+
|
|
34
|
+
export const CreateCollectionInputSchema = z.object({
|
|
35
|
+
name: z.string().min(1).max(80),
|
|
36
|
+
slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const CreateCollectionItemInputSchema = z.object({
|
|
40
|
+
title: z.string().min(1),
|
|
41
|
+
slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens"),
|
|
42
|
+
locale: z.string().min(2).max(20).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const UpdateCollectionItemInputSchema = z.object({
|
|
46
|
+
title: z.string().min(1).optional(),
|
|
47
|
+
slug: z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^[a-z0-9-]+$/)
|
|
50
|
+
.optional(),
|
|
51
|
+
excerpt: z.string().nullable().optional(),
|
|
52
|
+
coverImage: z.string().nullable().optional(),
|
|
53
|
+
seoTitle: z.string().nullable().optional(),
|
|
54
|
+
seoDescription: z.string().nullable().optional(),
|
|
55
|
+
ogImage: z.string().nullable().optional(),
|
|
56
|
+
noIndex: z.boolean().optional(),
|
|
57
|
+
content: PageNodeSchema.optional(),
|
|
58
|
+
status: PageStatusSchema.optional(),
|
|
59
|
+
locale: z.string().min(2).max(20).optional(),
|
|
60
|
+
translationOfId: z.string().nullable().optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The card-level view of an item, as returned by the public list endpoint. */
|
|
64
|
+
export type CollectionItemSummary = {
|
|
65
|
+
slug: string;
|
|
66
|
+
title: string;
|
|
67
|
+
excerpt: string | null;
|
|
68
|
+
coverImage: string | null;
|
|
69
|
+
publishedAt: string | null;
|
|
70
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type FieldType =
|
|
2
|
+
| "text"
|
|
3
|
+
| "textarea"
|
|
4
|
+
| "richtext"
|
|
5
|
+
| "image"
|
|
6
|
+
| "video"
|
|
7
|
+
| "url"
|
|
8
|
+
| "number"
|
|
9
|
+
| "boolean"
|
|
10
|
+
| "select"
|
|
11
|
+
| "array"
|
|
12
|
+
| "datetime"
|
|
13
|
+
| "color";
|
|
14
|
+
|
|
15
|
+
export type FieldDescriptor = {
|
|
16
|
+
key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
type: FieldType;
|
|
19
|
+
default?: unknown;
|
|
20
|
+
options?: { label: string; value: string }[]; // for "select"
|
|
21
|
+
itemFields?: FieldDescriptor[]; // for "array" — schema of each item
|
|
22
|
+
min?: number;
|
|
23
|
+
max?: number;
|
|
24
|
+
step?: number; // for "number" — e.g. 0.1 for a speed/opacity slider-style field
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const MediaAssetSchema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
key: z.string(),
|
|
6
|
+
url: z.string(),
|
|
7
|
+
mimeType: z.string(),
|
|
8
|
+
width: z.number().nullable().optional(),
|
|
9
|
+
height: z.number().nullable().optional(),
|
|
10
|
+
size: z.number(),
|
|
11
|
+
createdAt: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
export type MediaAsset = z.infer<typeof MediaAssetSchema>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const NodeStyleSchema = z.object({
|
|
4
|
+
backgroundColor: z.string().optional(),
|
|
5
|
+
backgroundImage: z.string().optional(),
|
|
6
|
+
backgroundVideo: z.string().optional(),
|
|
7
|
+
backgroundOverlay: z.string().optional(),
|
|
8
|
+
textColor: z.string().optional(),
|
|
9
|
+
fontFamily: z.string().optional(),
|
|
10
|
+
fontSize: z.string().optional(),
|
|
11
|
+
fontWeight: z.string().optional(),
|
|
12
|
+
paddingY: z.string().optional(),
|
|
13
|
+
paddingX: z.string().optional(),
|
|
14
|
+
// Individual sides win over the Y/X pair when both are set.
|
|
15
|
+
paddingTop: z.string().optional(),
|
|
16
|
+
paddingRight: z.string().optional(),
|
|
17
|
+
paddingBottom: z.string().optional(),
|
|
18
|
+
paddingLeft: z.string().optional(),
|
|
19
|
+
marginY: z.string().optional(),
|
|
20
|
+
marginTop: z.string().optional(),
|
|
21
|
+
marginBottom: z.string().optional(),
|
|
22
|
+
// Flex/grid child alignment — applied by Container (grid) and Column (flex column).
|
|
23
|
+
alignItems: z.enum(["start", "center", "end", "stretch"]).optional(),
|
|
24
|
+
justifyContent: z.enum(["start", "center", "end", "between"]).optional(),
|
|
25
|
+
// A Grid Cell's own placement within its parent Grid — how many of the Grid's
|
|
26
|
+
// declared column/row tracks this cell occupies. 1 (or unset) is a normal single
|
|
27
|
+
// cell; the parent Grid's row/column *counts* live in its own props, not here.
|
|
28
|
+
gridColumnSpan: z.number().optional(),
|
|
29
|
+
gridRowSpan: z.number().optional(),
|
|
30
|
+
textAlign: z.enum(["left", "center", "right"]).optional(),
|
|
31
|
+
// Full dimension control — all six accept any CSS length (px, %, rem, vw, vh...).
|
|
32
|
+
// Drag-resize writes width/height when set, else maxWidth/minHeight, preserving
|
|
33
|
+
// whichever unit the value already uses.
|
|
34
|
+
width: z.string().optional(),
|
|
35
|
+
height: z.string().optional(),
|
|
36
|
+
minWidth: z.string().optional(),
|
|
37
|
+
minHeight: z.string().optional(),
|
|
38
|
+
maxHeight: z.string().optional(),
|
|
39
|
+
borderRadius: z.string().optional(),
|
|
40
|
+
maxWidth: z.string().optional(),
|
|
41
|
+
borderWidth: z.string().optional(),
|
|
42
|
+
borderStyle: z.enum(["none", "solid", "dashed", "dotted", "double"]).optional(),
|
|
43
|
+
borderColor: z.string().optional(),
|
|
44
|
+
boxShadow: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
|
|
45
|
+
opacity: z.number().optional(),
|
|
46
|
+
// A Container's grid track size for one of its Columns — distinct from `maxWidth`
|
|
47
|
+
// (which constrains content *within* a track) because CSS Grid track width can't be
|
|
48
|
+
// driven by a child's own max-width; the Container reads this per-child to build its
|
|
49
|
+
// own grid-template-columns. Unset means "share remaining space equally" (1fr).
|
|
50
|
+
columnWidth: z.string().optional(),
|
|
51
|
+
// Universal animation, applied to any node type by Renderer.tsx via GSAP. "none"
|
|
52
|
+
// is the off switch — every other field below is ignored when animationType is
|
|
53
|
+
// unset or "none".
|
|
54
|
+
animationType: z
|
|
55
|
+
.enum([
|
|
56
|
+
"none",
|
|
57
|
+
"fadeIn",
|
|
58
|
+
"slideUp",
|
|
59
|
+
"slideDown",
|
|
60
|
+
"slideLeft",
|
|
61
|
+
"slideRight",
|
|
62
|
+
"zoomIn",
|
|
63
|
+
"scrollReveal",
|
|
64
|
+
"rotateIn",
|
|
65
|
+
"pulse",
|
|
66
|
+
"blurIn",
|
|
67
|
+
"flipIn",
|
|
68
|
+
"bounceIn",
|
|
69
|
+
])
|
|
70
|
+
.optional(),
|
|
71
|
+
// onLoad/onScroll play once automatically (a reveal); onHover/onFocus play forward
|
|
72
|
+
// and reverse back out on mouse-leave/blur (a toggle); onClick replays from the start
|
|
73
|
+
// on every click (a tap acknowledgement).
|
|
74
|
+
animationTrigger: z.enum(["onLoad", "onScroll", "onHover", "onFocus", "onClick"]).optional(),
|
|
75
|
+
animationDuration: z.number().optional(),
|
|
76
|
+
animationDelay: z.number().optional(),
|
|
77
|
+
animationEasing: z.string().optional(),
|
|
78
|
+
// Only meaningful for onLoad/onScroll — repeats the reveal indefinitely, alternating
|
|
79
|
+
// direction each pass, for continuous decorative motion instead of a one-time reveal.
|
|
80
|
+
animationLoop: z.boolean().optional(),
|
|
81
|
+
// Only meaningful for onLoad/onScroll — instead of revealing the whole section as one
|
|
82
|
+
// block, its inner items (cards, paragraphs, buttons) reveal one after another,
|
|
83
|
+
// this many seconds apart.
|
|
84
|
+
animationStagger: z.number().optional(),
|
|
85
|
+
// Continuous scroll-linked drift: the section translates vertically as it moves
|
|
86
|
+
// through the viewport (-1..1, negative = drifts up against the scroll). Independent
|
|
87
|
+
// of the entrance animation.
|
|
88
|
+
parallaxSpeed: z.number().optional(),
|
|
89
|
+
// Always-on pointer effect, independent of animationType/Trigger — a modern card
|
|
90
|
+
// feel: lift (rise + shadow), tilt (3D follow the pointer), glow, or scale.
|
|
91
|
+
hoverEffect: z.enum(["none", "lift", "tilt", "glow", "scale"]).optional(),
|
|
92
|
+
// Glassmorphism: translucent frosted-glass panel (backdrop blur + soft border).
|
|
93
|
+
glass: z.boolean().optional(),
|
|
94
|
+
// Gradient-filled headings (background-clip: text) — set both to activate.
|
|
95
|
+
textGradientFrom: z.string().optional(),
|
|
96
|
+
textGradientTo: z.string().optional(),
|
|
97
|
+
// Escape hatch for anything not covered by a dedicated style field — arbitrary CSS
|
|
98
|
+
// properties (camelCase, merged into the node's inline style) and arbitrary HTML
|
|
99
|
+
// attributes (kebab-case, spread onto the rendered element) that a content editor can
|
|
100
|
+
// add without needing a code change.
|
|
101
|
+
customCss: z.record(z.string(), z.string()).optional(),
|
|
102
|
+
customAttributes: z.record(z.string(), z.string()).optional(),
|
|
103
|
+
});
|
|
104
|
+
export type NodeStyle = z.infer<typeof NodeStyleSchema>;
|
|
105
|
+
|
|
106
|
+
// Recursive PageNode type: id + component type + arbitrary content props + style + children.
|
|
107
|
+
// styleTablet/styleMobile are sparse per-breakpoint overrides merged over `style`:
|
|
108
|
+
// tablet applies ≤1023px, mobile applies ≤767px (on top of tablet).
|
|
109
|
+
export type PageNode = {
|
|
110
|
+
id: string;
|
|
111
|
+
type: string;
|
|
112
|
+
props: Record<string, unknown>;
|
|
113
|
+
style: NodeStyle;
|
|
114
|
+
styleTablet?: NodeStyle;
|
|
115
|
+
styleMobile?: NodeStyle;
|
|
116
|
+
children: PageNode[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const PageNodeSchema: z.ZodType<PageNode> = z.lazy(() =>
|
|
120
|
+
z.object({
|
|
121
|
+
id: z.string(),
|
|
122
|
+
type: z.string(),
|
|
123
|
+
props: z.record(z.string(), z.unknown()),
|
|
124
|
+
style: NodeStyleSchema,
|
|
125
|
+
styleTablet: NodeStyleSchema.optional(),
|
|
126
|
+
styleMobile: NodeStyleSchema.optional(),
|
|
127
|
+
children: z.array(PageNodeSchema),
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
|
132
|
+
|
|
133
|
+
export const BREAKPOINT_MAX_WIDTH: Record<Exclude<Breakpoint, "desktop">, number> = {
|
|
134
|
+
tablet: 1023,
|
|
135
|
+
mobile: 767,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** The style a node effectively has at a breakpoint: base, with the tablet override
|
|
139
|
+
* applied for tablet AND mobile, and the mobile override applied on top for mobile —
|
|
140
|
+
* mirroring how the emitted max-width media queries cascade on the public site. */
|
|
141
|
+
export function resolveNodeStyle(node: PageNode, breakpoint: Breakpoint): NodeStyle {
|
|
142
|
+
if (breakpoint === "desktop") return node.style;
|
|
143
|
+
if (breakpoint === "tablet") return { ...node.style, ...node.styleTablet };
|
|
144
|
+
return { ...node.style, ...node.styleTablet, ...node.styleMobile };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const PageStatusSchema = z.enum(["DRAFT", "PUBLISHED"]);
|
|
148
|
+
export type PageStatus = z.infer<typeof PageStatusSchema>;
|
|
149
|
+
|
|
150
|
+
export const PageSchema = z.object({
|
|
151
|
+
id: z.string(),
|
|
152
|
+
slug: z.string(),
|
|
153
|
+
title: z.string(),
|
|
154
|
+
status: PageStatusSchema,
|
|
155
|
+
content: PageNodeSchema,
|
|
156
|
+
seoTitle: z.string().nullable().optional(),
|
|
157
|
+
seoDescription: z.string().nullable().optional(),
|
|
158
|
+
ogImage: z.string().nullable().optional(),
|
|
159
|
+
noIndex: z.boolean().optional(),
|
|
160
|
+
previewToken: z.string().optional(),
|
|
161
|
+
publishAt: z.string().nullable().optional(),
|
|
162
|
+
locale: z.string().optional(),
|
|
163
|
+
translationOfId: z.string().nullable().optional(),
|
|
164
|
+
createdById: z.string().nullable().optional(),
|
|
165
|
+
createdAt: z.string().optional(),
|
|
166
|
+
updatedAt: z.string().optional(),
|
|
167
|
+
});
|
|
168
|
+
export type Page = z.infer<typeof PageSchema>;
|
|
169
|
+
|
|
170
|
+
/** Locales offered in the page-settings dropdown. `label` is the language's own name
|
|
171
|
+
* (how switchers conventionally show it); any BCP-47 tag typed into the API works too. */
|
|
172
|
+
export const SUPPORTED_LOCALES: { code: string; label: string; rtl?: boolean }[] = [
|
|
173
|
+
{ code: "en", label: "English" },
|
|
174
|
+
{ code: "es", label: "Español" },
|
|
175
|
+
{ code: "fr", label: "Français" },
|
|
176
|
+
{ code: "de", label: "Deutsch" },
|
|
177
|
+
{ code: "pt", label: "Português" },
|
|
178
|
+
{ code: "it", label: "Italiano" },
|
|
179
|
+
{ code: "bn", label: "বাংলা" },
|
|
180
|
+
{ code: "hi", label: "हिन्दी" },
|
|
181
|
+
{ code: "zh", label: "中文" },
|
|
182
|
+
{ code: "ja", label: "日本語" },
|
|
183
|
+
{ code: "ar", label: "العربية", rtl: true },
|
|
184
|
+
{ code: "he", label: "עברית", rtl: true },
|
|
185
|
+
{ code: "fa", label: "فارسی", rtl: true },
|
|
186
|
+
{ code: "ur", label: "اردو", rtl: true },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const RTL_PREFIXES = new Set(["ar", "he", "fa", "ur", "ps", "sd", "ckb", "dv", "yi"]);
|
|
190
|
+
|
|
191
|
+
/** Whether a locale is written right-to-left — drives dir="rtl" on the page wrapper. */
|
|
192
|
+
export function isRtlLocale(locale: string | undefined): boolean {
|
|
193
|
+
if (!locale) return false;
|
|
194
|
+
return RTL_PREFIXES.has(locale.toLowerCase().split(/[-_]/)[0]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One entry of a page's translation group, as returned by the public translations
|
|
198
|
+
* endpoint — everything a language switcher needs. */
|
|
199
|
+
export type PageTranslationInfo = { locale: string; slug: string; title: string };
|
|
200
|
+
|
|
201
|
+
export const CreatePageInputSchema = z.object({
|
|
202
|
+
title: z.string().min(1),
|
|
203
|
+
slug: z
|
|
204
|
+
.string()
|
|
205
|
+
.regex(/^[a-z0-9/-]*$/, "Slug must be lowercase letters, numbers, hyphens, and slashes only"),
|
|
206
|
+
locale: z.string().min(2).max(20).optional(),
|
|
207
|
+
// When set, the new page's content is seeded from this PageTemplate (deep-cloned
|
|
208
|
+
// with fresh node ids) instead of starting blank.
|
|
209
|
+
templateId: z.string().optional(),
|
|
210
|
+
});
|
|
211
|
+
export type CreatePageInput = z.infer<typeof CreatePageInputSchema>;
|
|
212
|
+
|
|
213
|
+
export const UpdatePageInputSchema = z.object({
|
|
214
|
+
title: z.string().min(1).optional(),
|
|
215
|
+
slug: z
|
|
216
|
+
.string()
|
|
217
|
+
// Allow "/" so localized slugs can be path-prefixed, e.g. "es/servicios".
|
|
218
|
+
.regex(/^[a-z0-9/-]*$/)
|
|
219
|
+
.optional(),
|
|
220
|
+
content: PageNodeSchema.optional(),
|
|
221
|
+
status: PageStatusSchema.optional(),
|
|
222
|
+
seoTitle: z.string().nullable().optional(),
|
|
223
|
+
seoDescription: z.string().nullable().optional(),
|
|
224
|
+
ogImage: z.string().nullable().optional(),
|
|
225
|
+
noIndex: z.boolean().optional(),
|
|
226
|
+
publishAt: z.string().nullable().optional(),
|
|
227
|
+
locale: z.string().min(2).max(20).optional(),
|
|
228
|
+
translationOfId: z.string().nullable().optional(),
|
|
229
|
+
});
|
|
230
|
+
export type UpdatePageInput = z.infer<typeof UpdatePageInputSchema>;
|
|
231
|
+
|
|
232
|
+
export const PageRevisionSchema = z.object({
|
|
233
|
+
id: z.string(),
|
|
234
|
+
pageId: z.string(),
|
|
235
|
+
title: z.string(),
|
|
236
|
+
content: PageNodeSchema,
|
|
237
|
+
createdAt: z.string(),
|
|
238
|
+
});
|
|
239
|
+
export type PageRevision = z.infer<typeof PageRevisionSchema>;
|
|
240
|
+
|
|
241
|
+
/** Whether a page is actually visible to the public right now — PUBLISHED alone isn't
|
|
242
|
+
* enough if a future publishAt has been set (scheduled publishing). Accepts Date
|
|
243
|
+
* objects from Prisma as well as ISO strings from the API JSON layer. */
|
|
244
|
+
export function isPagePubliclyVisible(page: {
|
|
245
|
+
status: string;
|
|
246
|
+
publishAt?: string | Date | null;
|
|
247
|
+
}): boolean {
|
|
248
|
+
if (page.status !== "PUBLISHED") return false;
|
|
249
|
+
if (!page.publishAt) return true;
|
|
250
|
+
const at =
|
|
251
|
+
page.publishAt instanceof Date
|
|
252
|
+
? page.publishAt.getTime()
|
|
253
|
+
: new Date(page.publishAt).getTime();
|
|
254
|
+
return at <= Date.now();
|
|
255
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { PageNodeSchema } from "./pageNode.js";
|
|
3
|
+
|
|
4
|
+
export const PageTemplateSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
name: z.string(),
|
|
7
|
+
category: z.string(),
|
|
8
|
+
thumbnail: z.string().nullable().optional(),
|
|
9
|
+
content: PageNodeSchema,
|
|
10
|
+
createdAt: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
export type PageTemplate = z.infer<typeof PageTemplateSchema>;
|
|
13
|
+
|
|
14
|
+
export const CreatePageTemplateInputSchema = z.object({
|
|
15
|
+
name: z.string().min(1).max(80),
|
|
16
|
+
category: z.string().min(1).max(40).optional(),
|
|
17
|
+
thumbnail: z.string().nullable().optional(),
|
|
18
|
+
content: PageNodeSchema,
|
|
19
|
+
});
|
|
20
|
+
export type CreatePageTemplateInput = z.infer<typeof CreatePageTemplateInputSchema>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const SiteThemeSchema = z.object({
|
|
4
|
+
id: z.string().optional(),
|
|
5
|
+
primaryColor: z.string().default("#2563eb"),
|
|
6
|
+
secondaryColor: z.string().default("#0f172a"),
|
|
7
|
+
backgroundColor: z.string().default("#ffffff"),
|
|
8
|
+
textColor: z.string().default("#111827"),
|
|
9
|
+
headingFont: z.string().default("Poppins"),
|
|
10
|
+
bodyFont: z.string().default("Inter"),
|
|
11
|
+
logoUrl: z.string().nullable().optional(),
|
|
12
|
+
faviconUrl: z.string().nullable().optional(),
|
|
13
|
+
siteName: z.string().default("My Site"),
|
|
14
|
+
customCss: z.string().nullable().optional(),
|
|
15
|
+
darkModeEnabled: z.boolean().default(false),
|
|
16
|
+
primaryColorDark: z.string().nullable().optional(),
|
|
17
|
+
backgroundColorDark: z.string().nullable().optional(),
|
|
18
|
+
textColorDark: z.string().nullable().optional(),
|
|
19
|
+
analyticsProvider: z.enum(["none", "ga4", "plausible", "custom"]).default("none"),
|
|
20
|
+
analyticsId: z.string().nullable().optional(),
|
|
21
|
+
analyticsCustomScript: z.string().nullable().optional(),
|
|
22
|
+
});
|
|
23
|
+
export type SiteTheme = z.infer<typeof SiteThemeSchema>;
|
|
24
|
+
|
|
25
|
+
export const UpdateThemeInputSchema = SiteThemeSchema.partial();
|
|
26
|
+
export type UpdateThemeInput = z.infer<typeof UpdateThemeInputSchema>;
|
|
27
|
+
|
|
28
|
+
export const FONT_OPTIONS = [
|
|
29
|
+
"Inter",
|
|
30
|
+
"Poppins",
|
|
31
|
+
"Roboto",
|
|
32
|
+
"Montserrat",
|
|
33
|
+
"Playfair Display",
|
|
34
|
+
"Merriweather",
|
|
35
|
+
"Lato",
|
|
36
|
+
"Nunito",
|
|
37
|
+
"Oswald",
|
|
38
|
+
"Raleway",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export const NavItemSchema = z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
label: z.string(),
|
|
44
|
+
href: z.string(),
|
|
45
|
+
order: z.number(),
|
|
46
|
+
parentId: z.string().nullable().optional(),
|
|
47
|
+
});
|
|
48
|
+
export type NavItem = z.infer<typeof NavItemSchema>;
|
|
49
|
+
|
|
50
|
+
export const UpsertNavItemInputSchema = z.object({
|
|
51
|
+
label: z.string().min(1),
|
|
52
|
+
href: z.string().min(1),
|
|
53
|
+
order: z.number().default(0),
|
|
54
|
+
parentId: z.string().nullable().optional(),
|
|
55
|
+
});
|
|
56
|
+
export type UpsertNavItemInput = z.infer<typeof UpsertNavItemInputSchema>;
|
|
57
|
+
|
|
58
|
+
export type NavItemWithChildren = NavItem & { children: NavItem[] };
|
|
59
|
+
|
|
60
|
+
/** Groups flat nav items into top-level entries with their submenu items attached,
|
|
61
|
+
* each sorted by `order`. Only one level deep — a submenu item's own children (if any
|
|
62
|
+
* exist from bad data) are ignored, matching typical site nav patterns. */
|
|
63
|
+
export function buildNavTree(items: NavItem[]): NavItemWithChildren[] {
|
|
64
|
+
const topLevel = items.filter((n) => !n.parentId).sort((a, b) => a.order - b.order);
|
|
65
|
+
return topLevel.map((item) => ({
|
|
66
|
+
...item,
|
|
67
|
+
children: items.filter((n) => n.parentId === item.id).sort((a, b) => a.order - b.order),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const UserRoleSchema = z.enum(["ADMIN", "EDITOR", "AUTHOR"]);
|
|
4
|
+
export type UserRole = z.infer<typeof UserRoleSchema>;
|
|
5
|
+
|
|
6
|
+
export const UserSchema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
email: z.string().email(),
|
|
9
|
+
role: UserRoleSchema,
|
|
10
|
+
createdAt: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
export type User = z.infer<typeof UserSchema>;
|
|
13
|
+
|
|
14
|
+
export const LoginInputSchema = z.object({
|
|
15
|
+
email: z.string().email(),
|
|
16
|
+
password: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
export type LoginInput = z.infer<typeof LoginInputSchema>;
|
|
19
|
+
|
|
20
|
+
export const CreateUserInputSchema = z.object({
|
|
21
|
+
email: z.string().email(),
|
|
22
|
+
password: z.string().min(8),
|
|
23
|
+
role: UserRoleSchema.default("EDITOR"),
|
|
24
|
+
});
|
|
25
|
+
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createNode,
|
|
4
|
+
createEmptyPage,
|
|
5
|
+
insertNode,
|
|
6
|
+
removeNode,
|
|
7
|
+
moveNode,
|
|
8
|
+
duplicateNode,
|
|
9
|
+
findNode,
|
|
10
|
+
findParent,
|
|
11
|
+
cloneWithNewIds,
|
|
12
|
+
} from "./tree.js";
|
|
13
|
+
|
|
14
|
+
describe("tree helpers", () => {
|
|
15
|
+
it("creates an empty root page", () => {
|
|
16
|
+
const page = createEmptyPage();
|
|
17
|
+
expect(page.type).toBe("root");
|
|
18
|
+
expect(page.children).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("inserts and finds a node", () => {
|
|
22
|
+
const page = createEmptyPage();
|
|
23
|
+
const hero = createNode("hero");
|
|
24
|
+
const next = insertNode(page, page.id, hero, 0);
|
|
25
|
+
expect(findNode(next, hero.id)?.type).toBe("hero");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("removes a node", () => {
|
|
29
|
+
const page = createEmptyPage();
|
|
30
|
+
const hero = createNode("hero");
|
|
31
|
+
const withHero = insertNode(page, page.id, hero, 0);
|
|
32
|
+
const without = removeNode(withHero, hero.id);
|
|
33
|
+
expect(findNode(without, hero.id)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("moves a node between parents", () => {
|
|
37
|
+
const page = createEmptyPage();
|
|
38
|
+
const container = createNode("container");
|
|
39
|
+
const col = container.children[0];
|
|
40
|
+
const hero = createNode("hero");
|
|
41
|
+
const cta = createNode("cta");
|
|
42
|
+
let tree = insertNode(page, page.id, container, 0);
|
|
43
|
+
tree = insertNode(tree, page.id, hero, 0);
|
|
44
|
+
tree = insertNode(tree, col.id, cta, 0);
|
|
45
|
+
tree = moveNode(tree, hero.id, col.id, 0);
|
|
46
|
+
expect(findParent(tree, hero.id)?.parent.id).toBe(col.id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("duplicates a subtree with fresh ids", () => {
|
|
50
|
+
const page = createEmptyPage();
|
|
51
|
+
const hero = createNode("hero");
|
|
52
|
+
const tree = insertNode(page, page.id, hero, 0);
|
|
53
|
+
const duped = duplicateNode(tree, hero.id);
|
|
54
|
+
const allHeroes = duped.children.filter((c) => c.type === "hero");
|
|
55
|
+
expect(allHeroes.length).toBe(2);
|
|
56
|
+
expect(allHeroes[0].id).not.toBe(allHeroes[1].id);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("cloneWithNewIds produces independent copies", () => {
|
|
60
|
+
const hero = createNode("hero");
|
|
61
|
+
const copy = cloneWithNewIds(hero);
|
|
62
|
+
expect(copy.id).not.toBe(hero.id);
|
|
63
|
+
expect(copy.type).toBe("hero");
|
|
64
|
+
});
|
|
65
|
+
});
|