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,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { MoveHorizontal } from "lucide-react";
|
|
5
|
+
import { CmsImage } from "../CmsImage";
|
|
6
|
+
import type { SectionProps } from "./types";
|
|
7
|
+
|
|
8
|
+
// A pointer-drag reveal slider: the "before" image sits on top, clipped to `percent`
|
|
9
|
+
// width, so dragging the handle reveals more/less of the "after" image underneath.
|
|
10
|
+
// Simpler cousin of BuilderCanvasNode's resize handles — one axis, no unit juggling.
|
|
11
|
+
export function BeforeAfter({ node }: SectionProps) {
|
|
12
|
+
const beforeImage = (node.props.beforeImage as string) || "";
|
|
13
|
+
const afterImage = (node.props.afterImage as string) || "";
|
|
14
|
+
const beforeLabel = (node.props.beforeLabel as string) || "Before";
|
|
15
|
+
const afterLabel = (node.props.afterLabel as string) || "After";
|
|
16
|
+
const [percent, setPercent] = useState(50);
|
|
17
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
18
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const dragging = useRef(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = containerRef.current;
|
|
23
|
+
if (!el) return;
|
|
24
|
+
const observer = new ResizeObserver((entries) => setContainerWidth(entries[0].contentRect.width));
|
|
25
|
+
observer.observe(el);
|
|
26
|
+
return () => observer.disconnect();
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const updateFromClientX = useCallback((clientX: number) => {
|
|
30
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
31
|
+
if (!rect) return;
|
|
32
|
+
const next = ((clientX - rect.left) / rect.width) * 100;
|
|
33
|
+
setPercent(Math.max(0, Math.min(100, next)));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
if (!beforeImage || !afterImage) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="h-48 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
|
|
39
|
+
Add a before and an after image
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={containerRef}
|
|
47
|
+
className="relative w-full aspect-video rounded-lg overflow-hidden select-none touch-none"
|
|
48
|
+
onPointerDown={(e) => {
|
|
49
|
+
dragging.current = true;
|
|
50
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
51
|
+
updateFromClientX(e.clientX);
|
|
52
|
+
}}
|
|
53
|
+
onPointerMove={(e) => {
|
|
54
|
+
if (dragging.current) updateFromClientX(e.clientX);
|
|
55
|
+
}}
|
|
56
|
+
onPointerUp={() => {
|
|
57
|
+
dragging.current = false;
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<CmsImage src={afterImage} alt={afterLabel} fill className="object-cover" />
|
|
61
|
+
<div className="absolute inset-0 overflow-hidden" style={{ width: `${percent}%` }}>
|
|
62
|
+
<div className="relative h-full" style={{ width: containerWidth || "100%" }}>
|
|
63
|
+
<CmsImage src={beforeImage} alt={beforeLabel} fill className="object-cover" />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="absolute inset-y-0 bg-white/80 w-0.5 pointer-events-none" style={{ left: `${percent}%` }}>
|
|
67
|
+
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-white shadow flex items-center justify-center text-slate-700">
|
|
68
|
+
<MoveHorizontal size={16} />
|
|
69
|
+
</span>
|
|
70
|
+
</div>
|
|
71
|
+
<span className="absolute top-3 left-3 text-xs font-semibold bg-black/50 text-white px-2 py-1 rounded">{beforeLabel}</span>
|
|
72
|
+
<span className="absolute top-3 right-3 text-xs font-semibold bg-black/50 text-white px-2 py-1 rounded">{afterLabel}</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
|
|
3
|
+
const SIZE_CLASSES = {
|
|
4
|
+
sm: "px-4 py-1.5 text-xs",
|
|
5
|
+
md: "px-6 py-3 text-sm",
|
|
6
|
+
lg: "px-8 py-4 text-base",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export function Button({
|
|
10
|
+
text,
|
|
11
|
+
href,
|
|
12
|
+
variant = "primary",
|
|
13
|
+
border = "default",
|
|
14
|
+
size = "md",
|
|
15
|
+
}: {
|
|
16
|
+
text: string;
|
|
17
|
+
href: string;
|
|
18
|
+
variant?: "primary" | "secondary" | "outline" | "text";
|
|
19
|
+
/** Overrides the variant's implicit border — "default" leaves the variant's own
|
|
20
|
+
* border behavior alone (outline gets one, primary/secondary don't). */
|
|
21
|
+
border?: "default" | "none" | "solid";
|
|
22
|
+
size?: "sm" | "md" | "lg";
|
|
23
|
+
}) {
|
|
24
|
+
const showBorder = border === "solid" || (border === "default" && variant === "outline");
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
href={href}
|
|
29
|
+
className={clsx(
|
|
30
|
+
"inline-flex items-center justify-center font-semibold transition-opacity hover:opacity-90",
|
|
31
|
+
SIZE_CLASSES[size] ?? SIZE_CLASSES.md,
|
|
32
|
+
variant === "primary" && "bg-theme-primary text-white rounded-lg",
|
|
33
|
+
variant === "secondary" && "bg-theme-secondary text-white rounded-lg",
|
|
34
|
+
variant === "outline" && "rounded-lg",
|
|
35
|
+
variant === "text" && "rounded-none bg-transparent",
|
|
36
|
+
showBorder && "border-2 border-current"
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{text}
|
|
40
|
+
</a>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Button } from "./Button";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
type ButtonItem = {
|
|
5
|
+
text: string;
|
|
6
|
+
href: string;
|
|
7
|
+
variant?: "primary" | "secondary" | "outline";
|
|
8
|
+
border?: "default" | "none" | "solid";
|
|
9
|
+
size?: "sm" | "md" | "lg";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function ButtonGroup({ node }: SectionProps) {
|
|
13
|
+
const buttons = (node.props.buttons as ButtonItem[]) ?? [];
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex flex-wrap gap-4 justify-center">
|
|
17
|
+
{buttons.map((btn, i) => (
|
|
18
|
+
<Button key={i} text={btn.text} href={btn.href} variant={btn.variant} border={btn.border} size={btn.size} />
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ArrowRight } from "lucide-react";
|
|
2
|
+
import { IconRenderer } from "@/components/builder/IconRenderer";
|
|
3
|
+
import { CmsImage } from "../CmsImage";
|
|
4
|
+
import type { SectionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
type Card = { image?: string; icon?: string; title: string; excerpt?: string; buttonText?: string; href: string };
|
|
7
|
+
|
|
8
|
+
export function CardGrid({ node }: SectionProps) {
|
|
9
|
+
const heading = node.props.heading as string;
|
|
10
|
+
const cards = (node.props.cards as Card[]) ?? [];
|
|
11
|
+
const columns = Math.max(1, Math.min(4, Number(node.props.columns) || 3));
|
|
12
|
+
const imageHeight = (node.props.imageHeight as string) || "160px";
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
|
|
17
|
+
{cards.length === 0 ? (
|
|
18
|
+
<div className="h-32 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
|
|
19
|
+
No cards added yet
|
|
20
|
+
</div>
|
|
21
|
+
) : (
|
|
22
|
+
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
23
|
+
{cards.map((card, i) =>
|
|
24
|
+
card.image ? (
|
|
25
|
+
<a
|
|
26
|
+
key={i}
|
|
27
|
+
href={card.href || "#"}
|
|
28
|
+
className="flex flex-col rounded-lg border overflow-hidden bg-white hover:shadow-md transition-shadow"
|
|
29
|
+
>
|
|
30
|
+
<div className="relative w-full" style={{ height: imageHeight }}>
|
|
31
|
+
<CmsImage src={card.image} alt={card.title} fill className="object-cover" sizes={`(max-width: 768px) 100vw, ${Math.round(100 / columns)}vw`} />
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex flex-col gap-2 p-4 flex-1">
|
|
34
|
+
<h3 className="font-semibold">{card.title}</h3>
|
|
35
|
+
{card.excerpt && <p className="text-sm opacity-75 flex-1">{card.excerpt}</p>}
|
|
36
|
+
{card.buttonText && (
|
|
37
|
+
<span className="inline-flex items-center gap-1 text-sm font-medium text-theme-primary mt-auto">
|
|
38
|
+
{card.buttonText} <ArrowRight size={14} />
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</a>
|
|
43
|
+
) : (
|
|
44
|
+
// No image — a lighter, icon-led tile (or plain text if no icon either),
|
|
45
|
+
// rather than a placeholder gray box that implies a missing asset.
|
|
46
|
+
<a key={i} href={card.href || "#"} className="flex flex-col items-center text-center gap-2 px-2 py-4 group">
|
|
47
|
+
{card.icon && (
|
|
48
|
+
<span className="w-14 h-14 rounded-full bg-theme-primary/10 text-theme-primary flex items-center justify-center mb-1 group-hover:bg-theme-primary/20 transition-colors">
|
|
49
|
+
<IconRenderer name={card.icon} size={24} />
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
<h3 className="font-semibold">{card.title}</h3>
|
|
53
|
+
{card.excerpt && <p className="text-sm opacity-75">{card.excerpt}</p>}
|
|
54
|
+
{card.buttonText && (
|
|
55
|
+
<span className="inline-flex items-center gap-1 text-sm font-medium text-theme-primary mt-1">
|
|
56
|
+
{card.buttonText} <ArrowRight size={14} />
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
</a>
|
|
60
|
+
)
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ArrowRight } from "lucide-react";
|
|
5
|
+
import type { CollectionItemSummary } from "@pgcms/shared";
|
|
6
|
+
import { CmsImage } from "../CmsImage";
|
|
7
|
+
import type { SectionProps } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Card grid over a collection's published items — the dynamic sibling of the static
|
|
10
|
+
* Card Grid block. Point it at a collection slug and it stays current as items are
|
|
11
|
+
* published, newest first, each card linking to /{collection}/{item}. */
|
|
12
|
+
export function CollectionList({ node }: SectionProps) {
|
|
13
|
+
const collectionSlug = (node.props.collectionSlug as string) || "";
|
|
14
|
+
const columns = Math.max(1, Math.min(4, Number(node.props.columns) || 3));
|
|
15
|
+
const limit = Math.max(1, Math.min(50, Number(node.props.limit) || 6));
|
|
16
|
+
const showExcerpt = node.props.showExcerpt !== false;
|
|
17
|
+
const showDate = node.props.showDate !== false;
|
|
18
|
+
|
|
19
|
+
const [items, setItems] = useState<CollectionItemSummary[] | null>(null);
|
|
20
|
+
const [error, setError] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!collectionSlug) return;
|
|
24
|
+
fetch(`/api/collections/public/${collectionSlug}/items?limit=${limit}`)
|
|
25
|
+
.then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
|
|
26
|
+
.then(setItems)
|
|
27
|
+
.catch(() => setError(true));
|
|
28
|
+
}, [collectionSlug, limit]);
|
|
29
|
+
|
|
30
|
+
if (!collectionSlug) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="h-24 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
|
|
33
|
+
Set a collection slug in the Inspector (e.g. "blog")
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (error) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="h-24 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
|
|
40
|
+
Collection "{collectionSlug}" not found
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (items === null) {
|
|
45
|
+
return <div className="h-24 animate-pulse bg-slate-100 rounded-lg" />;
|
|
46
|
+
}
|
|
47
|
+
if (items.length === 0) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="h-24 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
|
|
50
|
+
No published items in "{collectionSlug}" yet
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
57
|
+
{items.map((item) => (
|
|
58
|
+
<a
|
|
59
|
+
key={item.slug}
|
|
60
|
+
href={`/${collectionSlug}/${item.slug}`}
|
|
61
|
+
className="flex flex-col rounded-lg border overflow-hidden bg-white hover:shadow-md transition-shadow"
|
|
62
|
+
>
|
|
63
|
+
{item.coverImage ? (
|
|
64
|
+
<div className="relative w-full h-40">
|
|
65
|
+
<CmsImage src={item.coverImage} alt={item.title} fill className="object-cover" sizes={`(max-width: 768px) 100vw, ${Math.round(100 / columns)}vw`} />
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<div className="w-full h-40 bg-slate-100" />
|
|
69
|
+
)}
|
|
70
|
+
<div className="flex flex-col gap-2 p-4 flex-1">
|
|
71
|
+
{showDate && item.publishedAt && (
|
|
72
|
+
<span className="text-xs opacity-60">{new Date(item.publishedAt).toLocaleDateString()}</span>
|
|
73
|
+
)}
|
|
74
|
+
<h3 className="font-semibold">{item.title}</h3>
|
|
75
|
+
{showExcerpt && item.excerpt && <p className="text-sm opacity-75 flex-1">{item.excerpt}</p>}
|
|
76
|
+
<span className="inline-flex items-center gap-1 text-sm font-medium text-theme-primary mt-auto">
|
|
77
|
+
Read more <ArrowRight size={14} />
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</a>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
import { withContentLinks } from "./ContentLinks";
|
|
4
|
+
|
|
5
|
+
export function Column({ node, children }: SectionProps) {
|
|
6
|
+
const links = node.props.links as { label?: string; href: string }[];
|
|
7
|
+
// Falls back to the parent Container's cascaded choice (--pgcms-container-* custom
|
|
8
|
+
// properties set in Container.tsx) whenever this column hasn't set its own explicit
|
|
9
|
+
// alignment — same pattern as GridCell falling back to its parent Grid.
|
|
10
|
+
const justifyContent =
|
|
11
|
+
node.style.justifyContent === "between"
|
|
12
|
+
? "space-between"
|
|
13
|
+
: node.style.justifyContent || "var(--pgcms-container-justify-content, start)";
|
|
14
|
+
const alignItems = node.style.alignItems || "var(--pgcms-container-align-items, stretch)";
|
|
15
|
+
|
|
16
|
+
const content = (
|
|
17
|
+
<div
|
|
18
|
+
className="flex flex-col gap-4 min-h-[40px] h-full"
|
|
19
|
+
style={{ justifyContent, alignItems } as CSSProperties}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return withContentLinks(links, content, "block h-full");
|
|
26
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from "react";
|
|
4
|
+
import type { SectionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
type FormFieldDef = { key: string; label: string; fieldType: "text" | "email" | "tel" | "textarea"; required?: boolean };
|
|
7
|
+
|
|
8
|
+
export function ContactForm({ node }: SectionProps) {
|
|
9
|
+
const heading = node.props.heading as string;
|
|
10
|
+
const subheading = node.props.subheading as string;
|
|
11
|
+
const formName = (node.props.formName as string) || "Contact";
|
|
12
|
+
const fields = (node.props.fields as FormFieldDef[]) ?? [];
|
|
13
|
+
const submitButtonText = (node.props.submitButtonText as string) || "Send";
|
|
14
|
+
const successMessage = (node.props.successMessage as string) || "Thanks — we'll be in touch.";
|
|
15
|
+
|
|
16
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
17
|
+
const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">("idle");
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setStatus("submitting");
|
|
22
|
+
const form = e.currentTarget;
|
|
23
|
+
const hp = (form.elements.namedItem("_hp") as HTMLInputElement | null)?.value ?? "";
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch("/api/forms/submit", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
formName,
|
|
30
|
+
pageSlug: window.location.pathname,
|
|
31
|
+
data: values,
|
|
32
|
+
_hp: hp,
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) throw new Error("submit failed");
|
|
36
|
+
setStatus("done");
|
|
37
|
+
setValues({});
|
|
38
|
+
} catch {
|
|
39
|
+
setStatus("error");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (status === "done") {
|
|
44
|
+
return (
|
|
45
|
+
<div className="text-center">
|
|
46
|
+
<p className="text-lg">{successMessage}</p>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold mb-2 text-center">{heading}</h2>}
|
|
54
|
+
{subheading && <p className="text-center opacity-80 mb-6">{subheading}</p>}
|
|
55
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
56
|
+
{/* Honeypot — hidden from users, bots often fill it */}
|
|
57
|
+
<input type="text" name="_hp" tabIndex={-1} autoComplete="off" className="hidden" aria-hidden="true" />
|
|
58
|
+
{fields.map((field) => (
|
|
59
|
+
<div key={field.key} className="space-y-1">
|
|
60
|
+
<label className="text-sm font-medium block">
|
|
61
|
+
{field.label}
|
|
62
|
+
{field.required && <span className="text-red-500"> *</span>}
|
|
63
|
+
</label>
|
|
64
|
+
{field.fieldType === "textarea" ? (
|
|
65
|
+
<textarea
|
|
66
|
+
required={field.required}
|
|
67
|
+
value={values[field.key] ?? ""}
|
|
68
|
+
onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.value }))}
|
|
69
|
+
rows={4}
|
|
70
|
+
className="w-full border rounded-md px-3 py-2 text-sm text-slate-900 bg-white"
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
<input
|
|
74
|
+
type={field.fieldType}
|
|
75
|
+
required={field.required}
|
|
76
|
+
value={values[field.key] ?? ""}
|
|
77
|
+
onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.value }))}
|
|
78
|
+
className="w-full border rounded-md px-3 py-2 text-sm text-slate-900 bg-white"
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
{status === "error" && (
|
|
84
|
+
<p className="text-sm text-red-500">Something went wrong sending your message — please try again.</p>
|
|
85
|
+
)}
|
|
86
|
+
<button
|
|
87
|
+
type="submit"
|
|
88
|
+
disabled={status === "submitting"}
|
|
89
|
+
className="w-full bg-theme-primary text-white rounded-md py-2.5 font-semibold hover:opacity-90 disabled:opacity-50"
|
|
90
|
+
>
|
|
91
|
+
{status === "submitting" ? "Sending..." : submitButtonText}
|
|
92
|
+
</button>
|
|
93
|
+
</form>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
import { withContentLinks } from "./ContentLinks";
|
|
4
|
+
|
|
5
|
+
export function Container({ node, children }: SectionProps) {
|
|
6
|
+
const gap = (node.props.gap as string) || "24px";
|
|
7
|
+
const links = node.props.links as { label?: string; href: string }[];
|
|
8
|
+
|
|
9
|
+
// Each column drives its own grid track width via style.columnWidth (set by dragging
|
|
10
|
+
// its resize handle) — unset means "share remaining space equally" (1fr), same as a
|
|
11
|
+
// plain repeat(N, 1fr) grid would give every column before any of them are resized.
|
|
12
|
+
const gridTemplateColumns = node.children.map((c) => c.style.columnWidth || "minmax(0, 1fr)").join(" ");
|
|
13
|
+
|
|
14
|
+
const alignItems = node.style.alignItems || undefined;
|
|
15
|
+
const justifyContent = node.style.justifyContent === "between" ? "space-between" : node.style.justifyContent || undefined;
|
|
16
|
+
|
|
17
|
+
const grid = (
|
|
18
|
+
<div
|
|
19
|
+
className="grid"
|
|
20
|
+
style={
|
|
21
|
+
{
|
|
22
|
+
gridTemplateColumns,
|
|
23
|
+
gap,
|
|
24
|
+
// Governs how each column's own track sizes/sits within the row (only visible
|
|
25
|
+
// when a column is shorter than the row, i.e. alignItems isn't "stretch").
|
|
26
|
+
alignItems,
|
|
27
|
+
justifyContent,
|
|
28
|
+
// The same choice ALSO cascades into every column's own inner content
|
|
29
|
+
// alignment (see Column.tsx) via inherited CSS custom properties, exactly
|
|
30
|
+
// like Grid → GridCell — so choosing an alignment once on the Container
|
|
31
|
+
// applies uniformly instead of requiring each column configured individually.
|
|
32
|
+
// A column can still override just itself from its own Style tab. Defaults
|
|
33
|
+
// match the pre-existing unset look (full-width content, top-anchored).
|
|
34
|
+
"--pgcms-container-align-items": alignItems ?? "stretch",
|
|
35
|
+
"--pgcms-container-justify-content": justifyContent ?? "start",
|
|
36
|
+
} as CSSProperties
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return withContentLinks(links, grid, "block");
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
type ContentLink = { label?: string; href: string };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared rendering rule for Container/Column's "links" field: exactly one link makes
|
|
7
|
+
* the whole block clickable (wrap `children` in it); two or more can't — a block can't
|
|
8
|
+
* be two different links at once — so they render as a small link list appended below
|
|
9
|
+
* instead. Zero links renders `children` unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function withContentLinks(links: ContentLink[] | undefined, children: React.ReactNode, wrapperClassName: string) {
|
|
12
|
+
const list = (links ?? []).filter((l) => l.href);
|
|
13
|
+
|
|
14
|
+
if (list.length === 1) {
|
|
15
|
+
return (
|
|
16
|
+
<Link href={list[0].href} className={wrapperClassName}>
|
|
17
|
+
{children}
|
|
18
|
+
</Link>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (list.length > 1) {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{children}
|
|
26
|
+
<div className="flex flex-wrap gap-4 mt-4">
|
|
27
|
+
{list.map((link, i) => (
|
|
28
|
+
<Link key={i} href={link.href} className="text-sm font-medium underline underline-offset-2 hover:opacity-70">
|
|
29
|
+
{link.label || link.href}
|
|
30
|
+
</Link>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return children;
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import type { SectionProps } from "./types";
|
|
6
|
+
|
|
7
|
+
function getTimeLeft(targetDate: string) {
|
|
8
|
+
const diff = new Date(targetDate).getTime() - Date.now();
|
|
9
|
+
if (diff <= 0) return null;
|
|
10
|
+
return {
|
|
11
|
+
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
|
12
|
+
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
|
13
|
+
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
|
14
|
+
seconds: Math.floor((diff / 1000) % 60),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Countdown({ node }: SectionProps) {
|
|
19
|
+
const { heading, targetDate, expiredText, buttonText, buttonHref } = node.props as Record<string, string>;
|
|
20
|
+
const [timeLeft, setTimeLeft] = useState<ReturnType<typeof getTimeLeft>>(null);
|
|
21
|
+
const [mounted, setMounted] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setMounted(true);
|
|
25
|
+
if (!targetDate) return;
|
|
26
|
+
setTimeLeft(getTimeLeft(targetDate));
|
|
27
|
+
const id = setInterval(() => setTimeLeft(getTimeLeft(targetDate)), 1000);
|
|
28
|
+
return () => clearInterval(id);
|
|
29
|
+
}, [targetDate]);
|
|
30
|
+
|
|
31
|
+
const units = timeLeft
|
|
32
|
+
? [
|
|
33
|
+
{ value: timeLeft.days, label: "Days" },
|
|
34
|
+
{ value: timeLeft.hours, label: "Hours" },
|
|
35
|
+
{ value: timeLeft.minutes, label: "Minutes" },
|
|
36
|
+
{ value: timeLeft.seconds, label: "Seconds" },
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col items-center gap-6">
|
|
42
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold">{heading}</h2>}
|
|
43
|
+
{/* Avoid an SSR/CSR mismatch on the live countdown digits — render nothing on the
|
|
44
|
+
server, then the real countdown once mounted client-side. */}
|
|
45
|
+
{mounted && (
|
|
46
|
+
<>
|
|
47
|
+
{timeLeft ? (
|
|
48
|
+
<div className="flex flex-wrap items-center justify-center gap-4">
|
|
49
|
+
{units.map((unit) => (
|
|
50
|
+
<div
|
|
51
|
+
key={unit.label}
|
|
52
|
+
className="flex flex-col items-center justify-center bg-white/10 rounded-lg px-5 py-4 min-w-[84px]"
|
|
53
|
+
>
|
|
54
|
+
<span className="text-3xl md:text-4xl font-bold tabular-nums">
|
|
55
|
+
{String(unit.value).padStart(2, "0")}
|
|
56
|
+
</span>
|
|
57
|
+
<span className="text-xs uppercase tracking-wide opacity-75 mt-1">{unit.label}</span>
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<p className="text-lg opacity-90">{expiredText}</p>
|
|
63
|
+
)}
|
|
64
|
+
</>
|
|
65
|
+
)}
|
|
66
|
+
{buttonText && <Button text={buttonText} href={buttonHref || "#"} variant="primary" />}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Button } from "./Button";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function Cta({ node, children }: SectionProps) {
|
|
5
|
+
const { heading, subheading, buttonText, buttonHref } = node.props as Record<string, string>;
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex flex-col items-center gap-4">
|
|
9
|
+
{heading && <h2 className="text-3xl font-bold">{heading}</h2>}
|
|
10
|
+
{subheading && <p className="text-lg opacity-90">{subheading}</p>}
|
|
11
|
+
{buttonText && <Button text={buttonText} href={buttonHref || "#"} variant="secondary" />}
|
|
12
|
+
{children && <div className="w-full">{children}</div>}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function Divider({ node }: SectionProps) {
|
|
5
|
+
const lineStyle = (node.props.lineStyle as string) || "solid";
|
|
6
|
+
const thickness = (node.props.thickness as string) || "1px";
|
|
7
|
+
const color = (node.props.color as string) || "#e2e8f0";
|
|
8
|
+
const width = (node.props.width as string) || "100%";
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex justify-center">
|
|
12
|
+
<hr
|
|
13
|
+
style={
|
|
14
|
+
{
|
|
15
|
+
width,
|
|
16
|
+
border: "none",
|
|
17
|
+
borderTopWidth: thickness,
|
|
18
|
+
borderTopStyle: lineStyle,
|
|
19
|
+
borderTopColor: color,
|
|
20
|
+
margin: 0,
|
|
21
|
+
} as CSSProperties
|
|
22
|
+
}
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SectionProps } from "./types";
|
|
2
|
+
|
|
3
|
+
export function EmbedBlock({ node }: SectionProps) {
|
|
4
|
+
const mode = (node.props.mode as string) || "iframe";
|
|
5
|
+
const url = (node.props.url as string) ?? "";
|
|
6
|
+
const html = (node.props.html as string) ?? "";
|
|
7
|
+
const height = (node.props.height as string) || "400px";
|
|
8
|
+
const title = (node.props.title as string) || "Embedded content";
|
|
9
|
+
|
|
10
|
+
if (mode === "html" && html) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className="w-full overflow-auto"
|
|
14
|
+
style={{ minHeight: height }}
|
|
15
|
+
// Sandboxed custom HTML — scripts run in an isolated iframe-like context via srcdoc
|
|
16
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!url) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg"
|
|
25
|
+
style={{ height }}
|
|
26
|
+
>
|
|
27
|
+
No embed URL configured
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<iframe
|
|
34
|
+
src={url}
|
|
35
|
+
title={title}
|
|
36
|
+
className="w-full border-0 rounded-lg"
|
|
37
|
+
style={{ height }}
|
|
38
|
+
loading="lazy"
|
|
39
|
+
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
|
40
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|