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,245 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, type RefObject } from "react";
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
6
|
+
import type { NodeStyle } from "@pgcms/shared";
|
|
7
|
+
|
|
8
|
+
let pluginRegistered = false;
|
|
9
|
+
|
|
10
|
+
// One-shot reveal tweens — element starts at these values and animates to its natural
|
|
11
|
+
// (pre-tween) state. Used for onLoad/onScroll.
|
|
12
|
+
const FROM_VARS: Record<string, gsap.TweenVars> = {
|
|
13
|
+
fadeIn: { opacity: 0 },
|
|
14
|
+
slideUp: { opacity: 0, y: 40 },
|
|
15
|
+
slideDown: { opacity: 0, y: -40 },
|
|
16
|
+
slideLeft: { opacity: 0, x: 40 },
|
|
17
|
+
slideRight: { opacity: 0, x: -40 },
|
|
18
|
+
zoomIn: { opacity: 0, scale: 0.92 },
|
|
19
|
+
scrollReveal: { opacity: 0, y: 24 },
|
|
20
|
+
rotateIn: { opacity: 0, rotation: -12 },
|
|
21
|
+
pulse: { opacity: 0, scale: 0.9 },
|
|
22
|
+
blurIn: { opacity: 0, filter: "blur(14px)" },
|
|
23
|
+
flipIn: { opacity: 0, rotationX: -65, transformOrigin: "50% 0%" },
|
|
24
|
+
bounceIn: { opacity: 0, scale: 0.5, y: -40 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Effects whose motion only reads right with a specific ease — used as the default
|
|
28
|
+
// when the user hasn't picked one explicitly.
|
|
29
|
+
const EFFECT_DEFAULT_EASE: Record<string, string> = {
|
|
30
|
+
bounceIn: "bounce.out",
|
|
31
|
+
flipIn: "back.out(1.4)",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Toggle tweens — element starts at its natural state and animates *to* these values;
|
|
35
|
+
// used for onHover/onFocus (which reverse back out) and onClick (which restarts).
|
|
36
|
+
const INTERACTIVE_VARS: Record<string, gsap.TweenVars> = {
|
|
37
|
+
fadeIn: { opacity: 0.55 },
|
|
38
|
+
slideUp: { y: -6 },
|
|
39
|
+
slideDown: { y: 6 },
|
|
40
|
+
slideLeft: { x: -6 },
|
|
41
|
+
slideRight: { x: 6 },
|
|
42
|
+
zoomIn: { scale: 1.05 },
|
|
43
|
+
scrollReveal: { y: -4 },
|
|
44
|
+
rotateIn: { rotation: -4 },
|
|
45
|
+
pulse: { scale: 1.08 },
|
|
46
|
+
blurIn: { opacity: 0.7, filter: "blur(2px)" },
|
|
47
|
+
flipIn: { rotationX: 8, transformOrigin: "50% 0%" },
|
|
48
|
+
bounceIn: { scale: 1.06 },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const INTERACTIVE_TRIGGERS = new Set(["onHover", "onFocus", "onClick"]);
|
|
52
|
+
|
|
53
|
+
/** Universal animation for any rendered node — driven entirely by NodeStyle fields set
|
|
54
|
+
* from the Inspector, so no per-component-type wiring is needed. Applied only on the
|
|
55
|
+
* public renderer (see Renderer.tsx); the builder canvas renders nodes in their settled
|
|
56
|
+
* final state so editing stays stable. */
|
|
57
|
+
export function useEntranceAnimation(ref: RefObject<HTMLElement | null>, style: NodeStyle) {
|
|
58
|
+
const {
|
|
59
|
+
animationType,
|
|
60
|
+
animationTrigger,
|
|
61
|
+
animationDuration,
|
|
62
|
+
animationDelay,
|
|
63
|
+
animationEasing,
|
|
64
|
+
animationLoop,
|
|
65
|
+
animationStagger,
|
|
66
|
+
} = style;
|
|
67
|
+
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
const el = ref.current;
|
|
70
|
+
if (!el || !animationType || animationType === "none") return;
|
|
71
|
+
|
|
72
|
+
if (!pluginRegistered) {
|
|
73
|
+
gsap.registerPlugin(ScrollTrigger);
|
|
74
|
+
pluginRegistered = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const trigger = animationTrigger ?? (animationType === "scrollReveal" ? "onScroll" : "onLoad");
|
|
78
|
+
const duration = animationDuration ?? 0.8;
|
|
79
|
+
const delay = animationDelay ?? 0;
|
|
80
|
+
const ease = animationEasing || EFFECT_DEFAULT_EASE[animationType] || "power2.out";
|
|
81
|
+
|
|
82
|
+
if (INTERACTIVE_TRIGGERS.has(trigger)) {
|
|
83
|
+
// A paused toggle tween never renders anything until played, so — unlike the
|
|
84
|
+
// one-shot "from" tweens below — React Strict Mode's dev-only double-invoke of
|
|
85
|
+
// this effect can't leave it in a half-animated state; plain kill() is enough.
|
|
86
|
+
const toVars = INTERACTIVE_VARS[animationType] ?? INTERACTIVE_VARS.fadeIn;
|
|
87
|
+
// onClick is a self-contained "tap" gesture — play forward then automatically back
|
|
88
|
+
// out, rather than needing a second click to undo it like hover/focus's reverse().
|
|
89
|
+
const tween = gsap.to(el, {
|
|
90
|
+
...toVars,
|
|
91
|
+
duration,
|
|
92
|
+
ease,
|
|
93
|
+
paused: true,
|
|
94
|
+
...(trigger === "onClick" ? { repeat: 1, yoyo: true } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let removeListeners = () => {};
|
|
98
|
+
if (trigger === "onHover") {
|
|
99
|
+
const enter = () => tween.play();
|
|
100
|
+
const leave = () => tween.reverse();
|
|
101
|
+
el.addEventListener("mouseenter", enter);
|
|
102
|
+
el.addEventListener("mouseleave", leave);
|
|
103
|
+
removeListeners = () => {
|
|
104
|
+
el.removeEventListener("mouseenter", enter);
|
|
105
|
+
el.removeEventListener("mouseleave", leave);
|
|
106
|
+
};
|
|
107
|
+
} else if (trigger === "onFocus") {
|
|
108
|
+
// focusin/focusout bubble from whichever focusable descendant (button, link,
|
|
109
|
+
// input) actually receives focus — the section itself doesn't need a tabindex.
|
|
110
|
+
const focusIn = () => tween.play();
|
|
111
|
+
const focusOut = () => tween.reverse();
|
|
112
|
+
el.addEventListener("focusin", focusIn);
|
|
113
|
+
el.addEventListener("focusout", focusOut);
|
|
114
|
+
removeListeners = () => {
|
|
115
|
+
el.removeEventListener("focusin", focusIn);
|
|
116
|
+
el.removeEventListener("focusout", focusOut);
|
|
117
|
+
};
|
|
118
|
+
} else {
|
|
119
|
+
const onClick = () => tween.restart();
|
|
120
|
+
el.addEventListener("click", onClick);
|
|
121
|
+
removeListeners = () => el.removeEventListener("click", onClick);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
removeListeners();
|
|
126
|
+
tween.kill();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// React 18 Strict Mode double-invokes effects on mount (create, cleanup, create
|
|
131
|
+
// again) purely as a dev-mode sanity check. `tween.kill()` alone doesn't undo the
|
|
132
|
+
// inline styles gsap.from() already applied, so the second invocation's "from" call
|
|
133
|
+
// would capture the barely-animated first tween's paused values as its target and
|
|
134
|
+
// visibly never move. `gsap.context().revert()` undoes those inline styles too, so
|
|
135
|
+
// the second invocation starts from the element's true original state.
|
|
136
|
+
const ctx = gsap.context(() => {
|
|
137
|
+
const fromVars = FROM_VARS[animationType] ?? FROM_VARS.fadeIn;
|
|
138
|
+
// With a stagger, reveal the section's inner items one after another instead of
|
|
139
|
+
// the whole block at once. The DOM depth of "the items" varies per component
|
|
140
|
+
// (cards in a grid, paragraphs in prose, buttons in a row), so find the densest
|
|
141
|
+
// shallow group: the element with the most direct children near the top.
|
|
142
|
+
const targets = animationStagger ? findStaggerTargets(el) : el;
|
|
143
|
+
gsap.from(targets, {
|
|
144
|
+
...fromVars,
|
|
145
|
+
duration,
|
|
146
|
+
delay,
|
|
147
|
+
ease,
|
|
148
|
+
stagger: animationStagger || 0,
|
|
149
|
+
repeat: animationLoop ? -1 : 0,
|
|
150
|
+
yoyo: animationLoop || undefined,
|
|
151
|
+
scrollTrigger: trigger === "onScroll" ? { trigger: el, start: "top 85%", once: true } : undefined,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return () => ctx.revert();
|
|
156
|
+
}, [
|
|
157
|
+
ref,
|
|
158
|
+
animationType,
|
|
159
|
+
animationTrigger,
|
|
160
|
+
animationDuration,
|
|
161
|
+
animationDelay,
|
|
162
|
+
animationEasing,
|
|
163
|
+
animationLoop,
|
|
164
|
+
animationStagger,
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The stagger targets for a section: walk a few levels down and pick the element
|
|
169
|
+
* holding the most direct children — for a CardGrid that's the grid (cards), for
|
|
170
|
+
* prose it's the paragraph list, for a CTA it's the flex column (heading/sub/button).
|
|
171
|
+
* Falls back to the section itself when nothing groups. */
|
|
172
|
+
function findStaggerTargets(el: HTMLElement): HTMLElement | Element[] {
|
|
173
|
+
let best: Element | null = null;
|
|
174
|
+
let bestCount = 1;
|
|
175
|
+
const queue: { node: Element; depth: number }[] = [{ node: el, depth: 0 }];
|
|
176
|
+
while (queue.length) {
|
|
177
|
+
const { node, depth } = queue.shift()!;
|
|
178
|
+
if (node.children.length > bestCount) {
|
|
179
|
+
best = node;
|
|
180
|
+
bestCount = node.children.length;
|
|
181
|
+
}
|
|
182
|
+
if (depth < 4) {
|
|
183
|
+
for (const child of Array.from(node.children)) queue.push({ node: child, depth: depth + 1 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return best ? Array.from(best.children) : el;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Always-on pointer/scroll effects, independent of the entrance animation:
|
|
190
|
+
* `parallaxSpeed` scrubs the section vertically against the scroll, and
|
|
191
|
+
* `hoverEffect: "tilt"` makes it follow the pointer in 3D. (The other hover effects —
|
|
192
|
+
* lift/glow/scale — are pure CSS classes applied by Renderer; only tilt needs JS.) */
|
|
193
|
+
export function useModernEffects(ref: RefObject<HTMLElement | null>, style: NodeStyle) {
|
|
194
|
+
const { parallaxSpeed, hoverEffect } = style;
|
|
195
|
+
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
const el = ref.current;
|
|
198
|
+
if (!el) return;
|
|
199
|
+
if (!parallaxSpeed && hoverEffect !== "tilt") return;
|
|
200
|
+
|
|
201
|
+
if (!pluginRegistered) {
|
|
202
|
+
gsap.registerPlugin(ScrollTrigger);
|
|
203
|
+
pluginRegistered = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ctx = gsap.context(() => {
|
|
207
|
+
if (parallaxSpeed) {
|
|
208
|
+
gsap.to(el, {
|
|
209
|
+
y: parallaxSpeed * 120,
|
|
210
|
+
ease: "none",
|
|
211
|
+
scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
let removeTilt = () => {};
|
|
217
|
+
if (hoverEffect === "tilt") {
|
|
218
|
+
const rotX = gsap.quickTo(el, "rotationX", { duration: 0.4, ease: "power2.out" });
|
|
219
|
+
const rotY = gsap.quickTo(el, "rotationY", { duration: 0.4, ease: "power2.out" });
|
|
220
|
+
gsap.set(el, { transformPerspective: 900 });
|
|
221
|
+
const onMove = (e: PointerEvent) => {
|
|
222
|
+
const rect = el.getBoundingClientRect();
|
|
223
|
+
const px = (e.clientX - rect.left) / rect.width - 0.5;
|
|
224
|
+
const py = (e.clientY - rect.top) / rect.height - 0.5;
|
|
225
|
+
rotY(px * 10);
|
|
226
|
+
rotX(py * -10);
|
|
227
|
+
};
|
|
228
|
+
const onLeave = () => {
|
|
229
|
+
rotX(0);
|
|
230
|
+
rotY(0);
|
|
231
|
+
};
|
|
232
|
+
el.addEventListener("pointermove", onMove);
|
|
233
|
+
el.addEventListener("pointerleave", onLeave);
|
|
234
|
+
removeTilt = () => {
|
|
235
|
+
el.removeEventListener("pointermove", onMove);
|
|
236
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return () => {
|
|
241
|
+
removeTilt();
|
|
242
|
+
ctx.revert();
|
|
243
|
+
};
|
|
244
|
+
}, [ref, parallaxSpeed, hoverEffect]);
|
|
245
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const API_URL = process.env.API_URL ?? "http://localhost:4000";
|
|
2
|
+
|
|
3
|
+
/** @type {import('next').NextConfig} */
|
|
4
|
+
const nextConfig = {
|
|
5
|
+
transpilePackages: ["@pgcms/shared"],
|
|
6
|
+
output: "standalone",
|
|
7
|
+
images: {
|
|
8
|
+
remotePatterns: [
|
|
9
|
+
{ protocol: "http", hostname: "localhost" },
|
|
10
|
+
{ protocol: "https", hostname: "**" },
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
// Proxy API calls through the Next.js origin so the auth cookie set by the
|
|
14
|
+
// Express API is treated as first-party by the browser (same-origin as the web app).
|
|
15
|
+
async rewrites() {
|
|
16
|
+
return {
|
|
17
|
+
// Route handlers in app/api (e.g. /api/revalidate) match before afterFiles rewrites.
|
|
18
|
+
afterFiles: [{ source: "/api/:path*", destination: `${API_URL}/api/:path*` }],
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
// @pgcms/shared uses NodeNext-style ".js" specifiers in its own source (required
|
|
22
|
+
// for tsx/Node ESM resolution in apps/api). Webpack doesn't know those map to
|
|
23
|
+
// sibling ".ts" files, so teach it to.
|
|
24
|
+
webpack(config) {
|
|
25
|
+
config.resolve.extensionAlias = {
|
|
26
|
+
".js": [".js", ".ts", ".tsx"],
|
|
27
|
+
};
|
|
28
|
+
return config;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default nextConfig;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pgcms/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --webpack -p 3000",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start -p 3000",
|
|
9
|
+
"lint": "eslint ."
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@dnd-kit/core": "^6.1.0",
|
|
13
|
+
"@dnd-kit/sortable": "^8.0.0",
|
|
14
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
15
|
+
"@pgcms/shared": "*",
|
|
16
|
+
"@tiptap/extension-color": "^2.27.2",
|
|
17
|
+
"@tiptap/extension-link": "^2.8.0",
|
|
18
|
+
"@tiptap/extension-text-align": "^2.27.2",
|
|
19
|
+
"@tiptap/extension-text-style": "^2.27.2",
|
|
20
|
+
"@tiptap/extension-underline": "^2.27.2",
|
|
21
|
+
"@tiptap/react": "^2.8.0",
|
|
22
|
+
"@tiptap/starter-kit": "^2.8.0",
|
|
23
|
+
"clsx": "^2.1.1",
|
|
24
|
+
"gsap": "^3.15.0",
|
|
25
|
+
"isomorphic-dompurify": "^3.18.0",
|
|
26
|
+
"lucide-react": "^1.23.0",
|
|
27
|
+
"nanoid": "^5.0.7",
|
|
28
|
+
"next": "^16.2.10",
|
|
29
|
+
"react": "^19.2.7",
|
|
30
|
+
"react-colorful": "^5.6.1",
|
|
31
|
+
"react-dom": "^19.2.7",
|
|
32
|
+
"three": "^0.185.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.13.2",
|
|
36
|
+
"@types/react": "^19.2.17",
|
|
37
|
+
"@types/react-dom": "^19.2.3",
|
|
38
|
+
"@types/three": "^0.185.0",
|
|
39
|
+
"autoprefixer": "^10.4.20",
|
|
40
|
+
"eslint": "^10.6.0",
|
|
41
|
+
"eslint-config-next": "^16.2.10",
|
|
42
|
+
"postcss": "^8.4.47",
|
|
43
|
+
"tailwindcss": "^3.4.13",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
|
|
3
|
+
const COOKIE_NAME = process.env.COOKIE_NAME ?? "pgcms_token";
|
|
4
|
+
|
|
5
|
+
// This only gates the UX (redirect to /admin/login when no session cookie is present).
|
|
6
|
+
// The Express API independently verifies the JWT and enforces roles on every request -
|
|
7
|
+
// that's the real security boundary, not this proxy.
|
|
8
|
+
export function proxy(req: NextRequest) {
|
|
9
|
+
const { pathname } = req.nextUrl;
|
|
10
|
+
if (pathname === "/admin/login") return NextResponse.next();
|
|
11
|
+
|
|
12
|
+
const token = req.cookies.get(COOKIE_NAME);
|
|
13
|
+
if (!token) {
|
|
14
|
+
const loginUrl = new URL("/admin/login", req.url);
|
|
15
|
+
loginUrl.searchParams.set("next", pathname);
|
|
16
|
+
return NextResponse.redirect(loginUrl);
|
|
17
|
+
}
|
|
18
|
+
return NextResponse.next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const config = {
|
|
22
|
+
matcher: ["/admin/:path*"],
|
|
23
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Config } from "tailwindcss";
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
|
5
|
+
theme: {
|
|
6
|
+
extend: {
|
|
7
|
+
colors: {
|
|
8
|
+
theme: {
|
|
9
|
+
primary: "var(--theme-primary)",
|
|
10
|
+
secondary: "var(--theme-secondary)",
|
|
11
|
+
bg: "var(--theme-bg)",
|
|
12
|
+
text: "var(--theme-text)",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
fontFamily: {
|
|
16
|
+
heading: "var(--theme-heading-font)",
|
|
17
|
+
body: "var(--theme-body-font)",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
plugins: [],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default config;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": false,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"module": "esnext",
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"incremental": true,
|
|
20
|
+
"plugins": [
|
|
21
|
+
{
|
|
22
|
+
"name": "next"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": [
|
|
27
|
+
"./*"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": [
|
|
32
|
+
"next-env.d.ts",
|
|
33
|
+
"**/*.ts",
|
|
34
|
+
"**/*.tsx",
|
|
35
|
+
".next/types/**/*.ts",
|
|
36
|
+
".next/dev/types/**/*.ts"
|
|
37
|
+
],
|
|
38
|
+
"exclude": [
|
|
39
|
+
"node_modules"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_USER: ${POSTGRES_USER:-pgcms}
|
|
7
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pgcms_dev_password}
|
|
8
|
+
POSTGRES_DB: ${POSTGRES_DB:-pgcms}
|
|
9
|
+
ports:
|
|
10
|
+
# Host port 5433 collides with another project's Postgres container on this
|
|
11
|
+
# machine (postgres_db_dev) — 5434 avoids it. Container-internal port stays 5432.
|
|
12
|
+
- "5434:5432"
|
|
13
|
+
volumes:
|
|
14
|
+
- postgres-data:/var/lib/postgresql/data
|
|
15
|
+
healthcheck:
|
|
16
|
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pgcms}"]
|
|
17
|
+
interval: 5s
|
|
18
|
+
timeout: 5s
|
|
19
|
+
retries: 10
|
|
20
|
+
|
|
21
|
+
minio:
|
|
22
|
+
image: minio/minio:latest
|
|
23
|
+
restart: unless-stopped
|
|
24
|
+
command: server /data --console-address ":9001"
|
|
25
|
+
environment:
|
|
26
|
+
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-pgcms_minio}
|
|
27
|
+
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-pgcms_minio_secret}
|
|
28
|
+
ports:
|
|
29
|
+
# Host ports 9000/9001 collide with another project's MinIO container on this
|
|
30
|
+
# machine (minio_dev) — 9010/9011 avoid it. Container-internal ports are unchanged.
|
|
31
|
+
- "9010:9000"
|
|
32
|
+
- "9011:9001"
|
|
33
|
+
volumes:
|
|
34
|
+
- minio-data:/data
|
|
35
|
+
healthcheck:
|
|
36
|
+
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
37
|
+
interval: 5s
|
|
38
|
+
timeout: 5s
|
|
39
|
+
retries: 10
|
|
40
|
+
|
|
41
|
+
minio-init:
|
|
42
|
+
image: minio/mc:latest
|
|
43
|
+
depends_on:
|
|
44
|
+
minio:
|
|
45
|
+
condition: service_healthy
|
|
46
|
+
environment:
|
|
47
|
+
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-pgcms_minio}
|
|
48
|
+
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-pgcms_minio_secret}
|
|
49
|
+
S3_BUCKET: ${S3_BUCKET:-pgcms-media}
|
|
50
|
+
entrypoint: >
|
|
51
|
+
/bin/sh -c "
|
|
52
|
+
mc alias set local http://minio:9000 $$S3_ACCESS_KEY_ID $$S3_SECRET_ACCESS_KEY &&
|
|
53
|
+
(mc mb local/$$S3_BUCKET || true) &&
|
|
54
|
+
mc anonymous set download local/$$S3_BUCKET &&
|
|
55
|
+
echo 'MinIO bucket ready'
|
|
56
|
+
"
|
|
57
|
+
|
|
58
|
+
volumes:
|
|
59
|
+
postgres-data:
|
|
60
|
+
minio-data:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pg-cms",
|
|
3
|
+
"private": true,
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": "^24.0.0",
|
|
6
|
+
"npm": "^11.0.0"
|
|
7
|
+
},
|
|
8
|
+
"workspaces": [
|
|
9
|
+
"packages/*",
|
|
10
|
+
"apps/*"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "concurrently -n api,web -c blue,green \"npm run dev -w apps/api\" \"npm run dev -w apps/web\"",
|
|
14
|
+
"dev:api": "npm run dev -w apps/api",
|
|
15
|
+
"dev:web": "npm run dev -w apps/web",
|
|
16
|
+
"build": "npm run build -w packages/shared && npm run build -w apps/api && npm run build -w apps/web",
|
|
17
|
+
"test": "npm run test -w packages/shared",
|
|
18
|
+
"docker:up": "docker compose up -d",
|
|
19
|
+
"docker:down": "docker compose down",
|
|
20
|
+
"db:migrate": "npm run db:migrate -w apps/api",
|
|
21
|
+
"db:seed": "npm run db:seed -w apps/api"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "^19.2.17",
|
|
25
|
+
"@types/react-dom": "^19.2.3",
|
|
26
|
+
"concurrently": "^9.1.0",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pgcms/shared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"build": "npm run typecheck",
|
|
11
|
+
"test": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"nanoid": "^5.0.7",
|
|
15
|
+
"zod": "^3.23.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.9.3",
|
|
19
|
+
"vitest": "^3.2.6"
|
|
20
|
+
}
|
|
21
|
+
}
|