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,42 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { PageNode } from "@pgcms/shared";
|
|
3
|
+
import { BREAKPOINT_MAX_WIDTH } from "@pgcms/shared";
|
|
4
|
+
import { nodeStyleToCss } from "./style";
|
|
5
|
+
|
|
6
|
+
/** camelCase CSS property → kebab-case declaration name. */
|
|
7
|
+
function kebab(key: string): string {
|
|
8
|
+
return key.startsWith("--") ? key : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function declarations(css: CSSProperties): string {
|
|
12
|
+
return Object.entries(css)
|
|
13
|
+
.filter(([key, value]) => value !== undefined && value !== null && key !== "position")
|
|
14
|
+
// Base styles are inline (highest non-!important specificity), so every responsive
|
|
15
|
+
// declaration needs !important to actually win at its breakpoint.
|
|
16
|
+
.map(([key, value]) => `${kebab(key)}: ${value} !important;`)
|
|
17
|
+
.join(" ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** CSS text applying a node's tablet/mobile style overrides via max-width media
|
|
21
|
+
* queries, scoped to the node's data-node-id. Returns null when the node has no
|
|
22
|
+
* responsive overrides at all (the common case — no <style> tag is emitted). */
|
|
23
|
+
export function buildNodeResponsiveCss(node: PageNode): string | null {
|
|
24
|
+
const blocks: string[] = [];
|
|
25
|
+
if (node.styleTablet && Object.keys(node.styleTablet).length > 0) {
|
|
26
|
+
const decls = declarations(nodeStyleToCss(node.styleTablet));
|
|
27
|
+
if (decls) {
|
|
28
|
+
blocks.push(
|
|
29
|
+
`@media (max-width: ${BREAKPOINT_MAX_WIDTH.tablet}px) { [data-node-id="${node.id}"] { ${decls} } }`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (node.styleMobile && Object.keys(node.styleMobile).length > 0) {
|
|
34
|
+
const decls = declarations(nodeStyleToCss(node.styleMobile));
|
|
35
|
+
if (decls) {
|
|
36
|
+
blocks.push(
|
|
37
|
+
`@media (max-width: ${BREAKPOINT_MAX_WIDTH.mobile}px) { [data-node-id="${node.id}"] { ${decls} } }`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return blocks.length > 0 ? blocks.join("\n") : null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2
|
+
|
|
3
|
+
/** Sanitize editor-authored rich text before it reaches dangerouslySetInnerHTML —
|
|
4
|
+
* strips scripts, event handlers, javascript: URLs, and other XSS vectors while
|
|
5
|
+
* keeping everything the rich text editor can legitimately produce (headings, lists,
|
|
6
|
+
* links, inline color styles, images, quotes, tables). Runs on the server during SSR
|
|
7
|
+
* and in the browser via isomorphic-dompurify.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT applied to the Embed block, whose whole purpose is raw third-party
|
|
10
|
+
* markup — that block should stay restricted to trusted roles instead. */
|
|
11
|
+
export function sanitizeRichHtml(html: string): string {
|
|
12
|
+
return DOMPurify.sanitize(html, {
|
|
13
|
+
USE_PROFILES: { html: true },
|
|
14
|
+
ADD_ATTR: ["target", "rel"],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ApiError } from "./api";
|
|
2
|
+
|
|
3
|
+
const API_URL = process.env.API_URL ?? "http://localhost:4000";
|
|
4
|
+
|
|
5
|
+
// Server-side client: calls the Express API directly (server-to-server), used only
|
|
6
|
+
// for public/unauthenticated data during SSR of the public site.
|
|
7
|
+
export async function serverGet<T>(path: string, options?: { tags?: string[]; revalidate?: number }): Promise<T> {
|
|
8
|
+
const res = await fetch(`${API_URL}${path}`, {
|
|
9
|
+
next: {
|
|
10
|
+
tags: options?.tags ?? ["cms"],
|
|
11
|
+
revalidate: options?.revalidate ?? 60,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
let message = res.statusText;
|
|
16
|
+
try {
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
message = body.error ? (typeof body.error === "string" ? body.error : JSON.stringify(body.error)) : message;
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
throw new ApiError(res.status, message);
|
|
23
|
+
}
|
|
24
|
+
return res.json() as Promise<T>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { NodeStyle } from "@pgcms/shared";
|
|
3
|
+
|
|
4
|
+
const BOX_SHADOWS: Record<string, string> = {
|
|
5
|
+
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
|
6
|
+
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
|
7
|
+
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
|
8
|
+
xl: "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function nodeStyleToCss(style: NodeStyle, isHeader?: boolean): CSSProperties {
|
|
12
|
+
return {
|
|
13
|
+
position: isHeader ? undefined : "relative",
|
|
14
|
+
backgroundColor: style.backgroundColor || undefined,
|
|
15
|
+
backgroundImage: style.backgroundImage ? `url(${style.backgroundImage})` : undefined,
|
|
16
|
+
backgroundSize: style.backgroundImage ? "cover" : undefined,
|
|
17
|
+
backgroundPosition: style.backgroundImage ? "center" : undefined,
|
|
18
|
+
backgroundRepeat: style.backgroundImage ? "no-repeat" : undefined,
|
|
19
|
+
color: style.textColor || undefined,
|
|
20
|
+
fontFamily: style.fontFamily ? `"${style.fontFamily}", sans-serif` : undefined,
|
|
21
|
+
fontSize: style.fontSize || undefined,
|
|
22
|
+
fontWeight: (style.fontWeight as CSSProperties["fontWeight"]) || undefined,
|
|
23
|
+
paddingTop: style.paddingTop || style.paddingY || undefined,
|
|
24
|
+
paddingBottom: style.paddingBottom || style.paddingY || undefined,
|
|
25
|
+
paddingLeft: style.paddingLeft || style.paddingX || undefined,
|
|
26
|
+
paddingRight: style.paddingRight || style.paddingX || undefined,
|
|
27
|
+
marginTop: style.marginTop || style.marginY || undefined,
|
|
28
|
+
marginBottom: style.marginBottom || style.marginY || undefined,
|
|
29
|
+
textAlign: style.textAlign || undefined,
|
|
30
|
+
width: style.width || undefined,
|
|
31
|
+
height: style.height || undefined,
|
|
32
|
+
minWidth: style.minWidth || undefined,
|
|
33
|
+
minHeight: style.minHeight || undefined,
|
|
34
|
+
maxHeight: style.maxHeight || undefined,
|
|
35
|
+
borderRadius: style.borderRadius || undefined,
|
|
36
|
+
maxWidth: style.maxWidth || undefined,
|
|
37
|
+
// A constrained-width section centers itself; without auto margins it would pin
|
|
38
|
+
// to the left edge, which is never what an editor narrowing a section wants.
|
|
39
|
+
marginLeft: style.maxWidth || style.width ? "auto" : undefined,
|
|
40
|
+
marginRight: style.maxWidth || style.width ? "auto" : undefined,
|
|
41
|
+
overflow: style.maxHeight || style.height || style.backgroundVideo ? "hidden" : undefined,
|
|
42
|
+
borderWidth: style.borderStyle && style.borderStyle !== "none" ? style.borderWidth || "1px" : undefined,
|
|
43
|
+
borderStyle: style.borderStyle && style.borderStyle !== "none" ? style.borderStyle : undefined,
|
|
44
|
+
borderColor: style.borderStyle && style.borderStyle !== "none" ? style.borderColor || "#000000" : undefined,
|
|
45
|
+
boxShadow: style.boxShadow && style.boxShadow !== "none" ? BOX_SHADOWS[style.boxShadow] : undefined,
|
|
46
|
+
opacity: style.opacity !== undefined ? style.opacity : undefined,
|
|
47
|
+
// A Grid Cell spanning multiple tracks of its parent Grid — this has to live on
|
|
48
|
+
// the node's own section (the actual CSS grid item), not inside the component's
|
|
49
|
+
// own JSX, since a nested div has no say over its ancestor's grid placement.
|
|
50
|
+
gridColumn: style.gridColumnSpan && style.gridColumnSpan > 1 ? `span ${style.gridColumnSpan}` : undefined,
|
|
51
|
+
gridRow: style.gridRowSpan && style.gridRowSpan > 1 ? `span ${style.gridRowSpan}` : undefined,
|
|
52
|
+
// Spread last so an explicit custom property always wins over a generated default
|
|
53
|
+
// above — the whole point of the escape hatch is that it can override anything.
|
|
54
|
+
...(style.customCss as CSSProperties | undefined),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Arbitrary HTML attributes (data-*, aria-*, id, ...) an editor added via the
|
|
59
|
+
* Inspector's Advanced panel, ready to spread onto the rendered element. "class" is
|
|
60
|
+
* folded into `className` so it composes with the component's own classes instead of
|
|
61
|
+
* colliding with React's reserved prop name. */
|
|
62
|
+
export function nodeCustomAttributes(style: NodeStyle): { attrs: Record<string, string>; extraClassName?: string } {
|
|
63
|
+
const custom = style.customAttributes;
|
|
64
|
+
if (!custom) return { attrs: {} };
|
|
65
|
+
const attrs: Record<string, string> = {};
|
|
66
|
+
let extraClassName: string | undefined;
|
|
67
|
+
for (const [key, value] of Object.entries(custom)) {
|
|
68
|
+
if (key === "class" || key === "className") extraClassName = value;
|
|
69
|
+
else if (key === "style") continue;
|
|
70
|
+
else attrs[key] = value;
|
|
71
|
+
}
|
|
72
|
+
return { attrs, extraClassName };
|
|
73
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, type RefObject } from "react";
|
|
4
|
+
import type * as THREE from "three";
|
|
5
|
+
|
|
6
|
+
export type ThreeBackgroundPreset = "particles" | "waves" | "shapes" | "stars" | "network" | "rings";
|
|
7
|
+
|
|
8
|
+
export type ThreeSceneOptions = {
|
|
9
|
+
preset: ThreeBackgroundPreset;
|
|
10
|
+
color: string;
|
|
11
|
+
/** Second accent color — mixed into stars/network/rings for depth; ignored by
|
|
12
|
+
* presets that read only one color. */
|
|
13
|
+
secondColor?: string;
|
|
14
|
+
density: number;
|
|
15
|
+
speed: number;
|
|
16
|
+
/** Camera gently follows the pointer for a subtle depth/parallax feel. */
|
|
17
|
+
mouseFollow?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Three.js is ~150kb+ and most pages never use a 3D background — importing it inside
|
|
21
|
+
* the effect (instead of a top-level `import * as THREE`) keeps it out of every page's
|
|
22
|
+
* bundle and only fetches it when a component actually mounts one. Shared by the
|
|
23
|
+
* dedicated "3D Background" section and Hero's background presets. */
|
|
24
|
+
async function mountScene(canvas: HTMLCanvasElement, container: HTMLElement, opts: ThreeSceneOptions) {
|
|
25
|
+
const Three = await import("three");
|
|
26
|
+
if (!canvas.isConnected) return null; // unmounted before the import resolved
|
|
27
|
+
|
|
28
|
+
const { preset, color, density, speed } = opts;
|
|
29
|
+
const secondColor = opts.secondColor || color;
|
|
30
|
+
|
|
31
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
32
|
+
const renderer = new Three.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
|
33
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
34
|
+
|
|
35
|
+
const scene = new Three.Scene();
|
|
36
|
+
const camera = new Three.PerspectiveCamera(60, 1, 0.1, 100);
|
|
37
|
+
camera.position.z = preset === "shapes" || preset === "rings" ? 9 : 6;
|
|
38
|
+
|
|
39
|
+
const disposables: { dispose: () => void }[] = [];
|
|
40
|
+
const animated: THREE.Object3D[] = [];
|
|
41
|
+
|
|
42
|
+
// Per-preset frame updater, assigned while building the scene below.
|
|
43
|
+
let update: (t: number) => void = () => {};
|
|
44
|
+
|
|
45
|
+
if (preset === "particles") {
|
|
46
|
+
const count = Math.max(10, Math.min(400, density));
|
|
47
|
+
const positions = new Float32Array(count * 3);
|
|
48
|
+
for (let i = 0; i < count * 3; i++) positions[i] = (Math.random() - 0.5) * 12;
|
|
49
|
+
const geometry = new Three.BufferGeometry();
|
|
50
|
+
geometry.setAttribute("position", new Three.BufferAttribute(positions, 3));
|
|
51
|
+
const material = new Three.PointsMaterial({ color, size: 0.06, transparent: true, opacity: 0.85 });
|
|
52
|
+
const points = new Three.Points(geometry, material);
|
|
53
|
+
scene.add(points);
|
|
54
|
+
disposables.push(geometry, material);
|
|
55
|
+
update = (t) => {
|
|
56
|
+
points.rotation.y = t * 0.05 * speed;
|
|
57
|
+
points.rotation.x = t * 0.02 * speed;
|
|
58
|
+
};
|
|
59
|
+
} else if (preset === "waves") {
|
|
60
|
+
const geometry = new Three.PlaneGeometry(16, 10, 48, 30);
|
|
61
|
+
const material = new Three.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.5 });
|
|
62
|
+
const mesh = new Three.Mesh(geometry, material);
|
|
63
|
+
mesh.rotation.x = -Math.PI / 3.2;
|
|
64
|
+
mesh.position.y = -1.5;
|
|
65
|
+
scene.add(mesh);
|
|
66
|
+
disposables.push(geometry, material);
|
|
67
|
+
update = (t) => {
|
|
68
|
+
const pos = geometry.attributes.position;
|
|
69
|
+
for (let i = 0; i < pos.count; i++) {
|
|
70
|
+
const x = pos.getX(i);
|
|
71
|
+
const y = pos.getY(i);
|
|
72
|
+
pos.setZ(i, Math.sin(x * 0.5 + t * speed) * 0.4 + Math.cos(y * 0.5 + t * speed * 0.8) * 0.4);
|
|
73
|
+
}
|
|
74
|
+
pos.needsUpdate = true;
|
|
75
|
+
};
|
|
76
|
+
} else if (preset === "shapes") {
|
|
77
|
+
const shapeCount = Math.max(3, Math.min(24, Math.round(density / 8)));
|
|
78
|
+
for (let i = 0; i < shapeCount; i++) {
|
|
79
|
+
const geometry =
|
|
80
|
+
i % 2 === 0 ? new Three.IcosahedronGeometry(0.4 + Math.random() * 0.3, 0) : new Three.TorusGeometry(0.35, 0.12, 8, 24);
|
|
81
|
+
const material = new Three.MeshBasicMaterial({
|
|
82
|
+
color: i % 3 === 0 ? secondColor : color,
|
|
83
|
+
wireframe: true,
|
|
84
|
+
transparent: true,
|
|
85
|
+
opacity: 0.6,
|
|
86
|
+
});
|
|
87
|
+
const mesh = new Three.Mesh(geometry, material);
|
|
88
|
+
mesh.position.set((Math.random() - 0.5) * 9, (Math.random() - 0.5) * 5, (Math.random() - 0.5) * 4);
|
|
89
|
+
mesh.userData.spin = 0.002 + Math.random() * 0.004;
|
|
90
|
+
mesh.userData.bobOffset = Math.random() * Math.PI * 2;
|
|
91
|
+
scene.add(mesh);
|
|
92
|
+
animated.push(mesh);
|
|
93
|
+
disposables.push(geometry, material);
|
|
94
|
+
}
|
|
95
|
+
update = (t) => {
|
|
96
|
+
for (const mesh of animated) {
|
|
97
|
+
mesh.rotation.x += mesh.userData.spin;
|
|
98
|
+
mesh.rotation.y += mesh.userData.spin * 1.3;
|
|
99
|
+
mesh.position.y += Math.sin(t * speed + mesh.userData.bobOffset) * 0.002;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
} else if (preset === "stars") {
|
|
103
|
+
// Two counter-rotating clouds at different sizes/colors read as depth layers.
|
|
104
|
+
const makeCloud = (count: number, cloudColor: string, size: number) => {
|
|
105
|
+
const positions = new Float32Array(count * 3);
|
|
106
|
+
for (let i = 0; i < count; i++) {
|
|
107
|
+
positions[i * 3] = (Math.random() - 0.5) * 24;
|
|
108
|
+
positions[i * 3 + 1] = (Math.random() - 0.5) * 14;
|
|
109
|
+
positions[i * 3 + 2] = (Math.random() - 0.5) * 14;
|
|
110
|
+
}
|
|
111
|
+
const geometry = new Three.BufferGeometry();
|
|
112
|
+
geometry.setAttribute("position", new Three.BufferAttribute(positions, 3));
|
|
113
|
+
const material = new Three.PointsMaterial({ color: cloudColor, size, transparent: true, opacity: 0.9 });
|
|
114
|
+
const points = new Three.Points(geometry, material);
|
|
115
|
+
scene.add(points);
|
|
116
|
+
disposables.push(geometry, material);
|
|
117
|
+
return points;
|
|
118
|
+
};
|
|
119
|
+
const total = Math.max(40, Math.min(800, density * 2));
|
|
120
|
+
const far = makeCloud(Math.round(total * 0.75), color, 0.045);
|
|
121
|
+
const near = makeCloud(Math.round(total * 0.25), secondColor, 0.09);
|
|
122
|
+
update = (t) => {
|
|
123
|
+
far.rotation.y = t * 0.015 * speed;
|
|
124
|
+
near.rotation.y = -t * 0.03 * speed;
|
|
125
|
+
near.rotation.z = t * 0.008 * speed;
|
|
126
|
+
};
|
|
127
|
+
} else if (preset === "network") {
|
|
128
|
+
// Drifting nodes with lines joining close pairs — the classic "constellation" look.
|
|
129
|
+
const count = Math.max(20, Math.min(120, Math.round(density / 2)));
|
|
130
|
+
const bounds = { x: 9, y: 5.5, z: 3 };
|
|
131
|
+
const nodePos = new Float32Array(count * 3);
|
|
132
|
+
const velocity = new Float32Array(count * 3);
|
|
133
|
+
for (let i = 0; i < count * 3; i++) {
|
|
134
|
+
const axis = i % 3;
|
|
135
|
+
nodePos[i] = (Math.random() - 0.5) * 2 * (axis === 0 ? bounds.x : axis === 1 ? bounds.y : bounds.z);
|
|
136
|
+
velocity[i] = (Math.random() - 0.5) * 0.012;
|
|
137
|
+
}
|
|
138
|
+
const nodeGeometry = new Three.BufferGeometry();
|
|
139
|
+
nodeGeometry.setAttribute("position", new Three.BufferAttribute(nodePos, 3));
|
|
140
|
+
const nodeMaterial = new Three.PointsMaterial({ color, size: 0.09, transparent: true, opacity: 0.9 });
|
|
141
|
+
scene.add(new Three.Points(nodeGeometry, nodeMaterial));
|
|
142
|
+
|
|
143
|
+
const maxEdges = count * 8;
|
|
144
|
+
const linePos = new Float32Array(maxEdges * 6);
|
|
145
|
+
const lineGeometry = new Three.BufferGeometry();
|
|
146
|
+
lineGeometry.setAttribute("position", new Three.BufferAttribute(linePos, 3));
|
|
147
|
+
const lineMaterial = new Three.LineBasicMaterial({ color: secondColor, transparent: true, opacity: 0.22 });
|
|
148
|
+
scene.add(new Three.LineSegments(lineGeometry, lineMaterial));
|
|
149
|
+
disposables.push(nodeGeometry, nodeMaterial, lineGeometry, lineMaterial);
|
|
150
|
+
|
|
151
|
+
const linkDistSq = 2.2 * 2.2;
|
|
152
|
+
update = () => {
|
|
153
|
+
for (let i = 0; i < count; i++) {
|
|
154
|
+
for (let a = 0; a < 3; a++) {
|
|
155
|
+
const idx = i * 3 + a;
|
|
156
|
+
nodePos[idx] += velocity[idx] * speed;
|
|
157
|
+
const limit = a === 0 ? bounds.x : a === 1 ? bounds.y : bounds.z;
|
|
158
|
+
if (Math.abs(nodePos[idx]) > limit) velocity[idx] *= -1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let edge = 0;
|
|
162
|
+
for (let i = 0; i < count && edge < maxEdges; i++) {
|
|
163
|
+
for (let j = i + 1; j < count && edge < maxEdges; j++) {
|
|
164
|
+
const dx = nodePos[i * 3] - nodePos[j * 3];
|
|
165
|
+
const dy = nodePos[i * 3 + 1] - nodePos[j * 3 + 1];
|
|
166
|
+
const dz = nodePos[i * 3 + 2] - nodePos[j * 3 + 2];
|
|
167
|
+
if (dx * dx + dy * dy + dz * dz < linkDistSq) {
|
|
168
|
+
linePos.set([nodePos[i * 3], nodePos[i * 3 + 1], nodePos[i * 3 + 2]], edge * 6);
|
|
169
|
+
linePos.set([nodePos[j * 3], nodePos[j * 3 + 1], nodePos[j * 3 + 2]], edge * 6 + 3);
|
|
170
|
+
edge++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
lineGeometry.setDrawRange(0, edge * 2);
|
|
175
|
+
lineGeometry.attributes.position.needsUpdate = true;
|
|
176
|
+
nodeGeometry.attributes.position.needsUpdate = true;
|
|
177
|
+
};
|
|
178
|
+
} else {
|
|
179
|
+
// rings — nested wireframe orbits rotating on their own axes.
|
|
180
|
+
const ringCount = 5;
|
|
181
|
+
for (let i = 0; i < ringCount; i++) {
|
|
182
|
+
const geometry = new Three.TorusGeometry(1.6 + i * 0.85, 0.02, 8, 96);
|
|
183
|
+
const material = new Three.MeshBasicMaterial({
|
|
184
|
+
color: i % 2 === 0 ? color : secondColor,
|
|
185
|
+
wireframe: true,
|
|
186
|
+
transparent: true,
|
|
187
|
+
opacity: 0.55,
|
|
188
|
+
});
|
|
189
|
+
const mesh = new Three.Mesh(geometry, material);
|
|
190
|
+
mesh.rotation.x = Math.random() * Math.PI;
|
|
191
|
+
mesh.rotation.y = Math.random() * Math.PI;
|
|
192
|
+
mesh.userData.spinX = 0.001 + Math.random() * 0.003;
|
|
193
|
+
mesh.userData.spinY = 0.001 + Math.random() * 0.003;
|
|
194
|
+
scene.add(mesh);
|
|
195
|
+
animated.push(mesh);
|
|
196
|
+
disposables.push(geometry, material);
|
|
197
|
+
}
|
|
198
|
+
update = () => {
|
|
199
|
+
for (const ring of animated) {
|
|
200
|
+
ring.rotation.x += ring.userData.spinX * speed;
|
|
201
|
+
ring.rotation.y += ring.userData.spinY * speed;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resize() {
|
|
207
|
+
const { width, height } = container.getBoundingClientRect();
|
|
208
|
+
if (width === 0 || height === 0) return;
|
|
209
|
+
renderer.setSize(width, height, false);
|
|
210
|
+
camera.aspect = width / height;
|
|
211
|
+
camera.updateProjectionMatrix();
|
|
212
|
+
}
|
|
213
|
+
resize();
|
|
214
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
215
|
+
resizeObserver.observe(container);
|
|
216
|
+
|
|
217
|
+
// Pointer-follow: the camera eases toward a small offset opposite the pointer,
|
|
218
|
+
// giving the whole scene a depth/parallax response without moving the content.
|
|
219
|
+
let targetX = 0;
|
|
220
|
+
let targetY = 0;
|
|
221
|
+
let removePointer = () => {};
|
|
222
|
+
if (opts.mouseFollow && !reducedMotion) {
|
|
223
|
+
const onMove = (e: PointerEvent) => {
|
|
224
|
+
const rect = container.getBoundingClientRect();
|
|
225
|
+
targetX = ((e.clientX - rect.left) / rect.width - 0.5) * 1.4;
|
|
226
|
+
targetY = ((e.clientY - rect.top) / rect.height - 0.5) * -0.9;
|
|
227
|
+
};
|
|
228
|
+
const onLeave = () => {
|
|
229
|
+
targetX = 0;
|
|
230
|
+
targetY = 0;
|
|
231
|
+
};
|
|
232
|
+
container.addEventListener("pointermove", onMove);
|
|
233
|
+
container.addEventListener("pointerleave", onLeave);
|
|
234
|
+
removePointer = () => {
|
|
235
|
+
container.removeEventListener("pointermove", onMove);
|
|
236
|
+
container.removeEventListener("pointerleave", onLeave);
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let raf = 0;
|
|
241
|
+
const start = performance.now();
|
|
242
|
+
|
|
243
|
+
function frame(now: number) {
|
|
244
|
+
const t = (now - start) / 1000;
|
|
245
|
+
if (!reducedMotion) update(t);
|
|
246
|
+
if (opts.mouseFollow) {
|
|
247
|
+
camera.position.x += (targetX - camera.position.x) * 0.05;
|
|
248
|
+
camera.position.y += (targetY - camera.position.y) * 0.05;
|
|
249
|
+
camera.lookAt(0, 0, 0);
|
|
250
|
+
}
|
|
251
|
+
renderer.render(scene, camera);
|
|
252
|
+
raf = requestAnimationFrame(frame);
|
|
253
|
+
}
|
|
254
|
+
raf = requestAnimationFrame(frame);
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
cancelAnimationFrame(raf);
|
|
258
|
+
resizeObserver.disconnect();
|
|
259
|
+
removePointer();
|
|
260
|
+
for (const d of disposables) d.dispose();
|
|
261
|
+
renderer.dispose();
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Mounts an animated Three.js scene into `canvasRef` sized to `containerRef`, only
|
|
266
|
+
* while `enabled` is true. Pass `enabled: false` to skip mounting entirely — no WebGL
|
|
267
|
+
* context is created at all (the component's inactive/off state). */
|
|
268
|
+
export function useThreeBackgroundScene(
|
|
269
|
+
canvasRef: RefObject<HTMLCanvasElement | null>,
|
|
270
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
271
|
+
enabled: boolean,
|
|
272
|
+
options: ThreeSceneOptions
|
|
273
|
+
) {
|
|
274
|
+
const { preset, color, secondColor, density, speed, mouseFollow } = options;
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (!enabled) return;
|
|
278
|
+
let cleanup: (() => void) | null = null;
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
if (containerRef.current && canvasRef.current) {
|
|
281
|
+
mountScene(canvasRef.current, containerRef.current, {
|
|
282
|
+
preset,
|
|
283
|
+
color,
|
|
284
|
+
secondColor,
|
|
285
|
+
density,
|
|
286
|
+
speed,
|
|
287
|
+
mouseFollow,
|
|
288
|
+
}).then((fn) => {
|
|
289
|
+
if (cancelled) fn?.();
|
|
290
|
+
else cleanup = fn;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return () => {
|
|
294
|
+
cancelled = true;
|
|
295
|
+
cleanup?.();
|
|
296
|
+
};
|
|
297
|
+
}, [canvasRef, containerRef, enabled, preset, color, secondColor, density, speed, mouseFollow]);
|
|
298
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import type { User } from "@pgcms/shared";
|
|
6
|
+
import { api, ApiError } from "./api";
|
|
7
|
+
|
|
8
|
+
export function useCurrentUser() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [user, setUser] = useState<User | null>(null);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
api
|
|
16
|
+
.get<User>("/api/auth/me")
|
|
17
|
+
.then((u) => {
|
|
18
|
+
if (!cancelled) setUser(u);
|
|
19
|
+
})
|
|
20
|
+
.catch((err) => {
|
|
21
|
+
if (cancelled) return;
|
|
22
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
23
|
+
router.replace("/admin/login");
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.finally(() => {
|
|
27
|
+
if (!cancelled) setLoading(false);
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
cancelled = true;
|
|
31
|
+
};
|
|
32
|
+
}, [router]);
|
|
33
|
+
|
|
34
|
+
return { user, loading };
|
|
35
|
+
}
|