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,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useDraggable } from "@dnd-kit/core";
|
|
5
|
+
import { Trash2 } from "lucide-react";
|
|
6
|
+
import { COMPONENT_CATEGORIES, COMPONENT_REGISTRY, type PageNode } from "@pgcms/shared";
|
|
7
|
+
import { api } from "@/lib/api";
|
|
8
|
+
import { IconRenderer } from "./IconRenderer";
|
|
9
|
+
|
|
10
|
+
export type SavedBlock = { id: string; name: string; content: PageNode };
|
|
11
|
+
|
|
12
|
+
/** Fired (on window) whenever a block is saved or deleted, so every mounted Palette
|
|
13
|
+
* refreshes its Saved Blocks list without prop-drilling through the builder. */
|
|
14
|
+
export const BLOCKS_CHANGED_EVENT = "pgcms:blocks-changed";
|
|
15
|
+
|
|
16
|
+
function PaletteItem({ type, label, icon }: { type: string; label: string; icon: string }) {
|
|
17
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
18
|
+
id: `palette:${type}`,
|
|
19
|
+
data: { type: "palette", componentType: type },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
ref={setNodeRef}
|
|
25
|
+
{...listeners}
|
|
26
|
+
{...attributes}
|
|
27
|
+
className="w-full flex items-center gap-2 px-3 py-2 rounded-md border bg-white text-sm text-left hover:border-blue-400 hover:bg-blue-50 cursor-grab active:cursor-grabbing"
|
|
28
|
+
style={{ opacity: isDragging ? 0.4 : 1 }}
|
|
29
|
+
>
|
|
30
|
+
<IconRenderer name={icon} size={16} className="text-slate-500" />
|
|
31
|
+
{label}
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function BlockItem({ block, onDelete }: { block: SavedBlock; onDelete: (id: string) => void }) {
|
|
37
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
38
|
+
id: `block:${block.id}`,
|
|
39
|
+
data: { type: "block", blockId: block.id },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
ref={setNodeRef}
|
|
45
|
+
className="w-full flex items-center gap-2 px-3 py-2 rounded-md border bg-white text-sm hover:border-blue-400 hover:bg-blue-50"
|
|
46
|
+
style={{ opacity: isDragging ? 0.4 : 1 }}
|
|
47
|
+
>
|
|
48
|
+
<button {...listeners} {...attributes} className="flex items-center gap-2 flex-1 text-left cursor-grab active:cursor-grabbing min-w-0">
|
|
49
|
+
<IconRenderer name="Bookmark" size={16} className="text-amber-500 shrink-0" />
|
|
50
|
+
<span className="truncate">{block.name}</span>
|
|
51
|
+
</button>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => onDelete(block.id)}
|
|
54
|
+
title="Delete saved block"
|
|
55
|
+
className="text-slate-300 hover:text-red-600 shrink-0"
|
|
56
|
+
>
|
|
57
|
+
<Trash2 size={14} />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Palette() {
|
|
64
|
+
const [blocks, setBlocks] = useState<SavedBlock[]>([]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const load = () => api.get<SavedBlock[]>("/api/blocks").then(setBlocks).catch(() => setBlocks([]));
|
|
68
|
+
load();
|
|
69
|
+
window.addEventListener(BLOCKS_CHANGED_EVENT, load);
|
|
70
|
+
return () => window.removeEventListener(BLOCKS_CHANGED_EVENT, load);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
async function deleteBlock(id: string) {
|
|
74
|
+
await api.delete(`/api/blocks/${id}`);
|
|
75
|
+
window.dispatchEvent(new Event(BLOCKS_CHANGED_EVENT));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="w-64 shrink-0 border-r bg-slate-50 overflow-y-auto p-3 space-y-5">
|
|
80
|
+
{blocks.length > 0 && (
|
|
81
|
+
<div>
|
|
82
|
+
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">Saved Blocks</div>
|
|
83
|
+
<div className="space-y-2">
|
|
84
|
+
{blocks.map((block) => (
|
|
85
|
+
<BlockItem key={block.id} block={block} onDelete={deleteBlock} />
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{COMPONENT_CATEGORIES.map((category) => {
|
|
91
|
+
const items = COMPONENT_REGISTRY.filter((c) => c.category === category && !c.hidden);
|
|
92
|
+
if (items.length === 0) return null;
|
|
93
|
+
return (
|
|
94
|
+
<div key={category}>
|
|
95
|
+
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">{category}</div>
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
{items.map((item) => (
|
|
98
|
+
<PaletteItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
4
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
5
|
+
import Link from "@tiptap/extension-link";
|
|
6
|
+
import Underline from "@tiptap/extension-underline";
|
|
7
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
8
|
+
import TextStyle from "@tiptap/extension-text-style";
|
|
9
|
+
import Color from "@tiptap/extension-color";
|
|
10
|
+
import {
|
|
11
|
+
Bold,
|
|
12
|
+
Italic,
|
|
13
|
+
UnderlineIcon,
|
|
14
|
+
Strikethrough,
|
|
15
|
+
Link as LinkIcon,
|
|
16
|
+
List,
|
|
17
|
+
ListOrdered,
|
|
18
|
+
Heading1,
|
|
19
|
+
Heading2,
|
|
20
|
+
Heading3,
|
|
21
|
+
Quote,
|
|
22
|
+
AlignLeft,
|
|
23
|
+
AlignCenter,
|
|
24
|
+
AlignRight,
|
|
25
|
+
Undo2,
|
|
26
|
+
Redo2,
|
|
27
|
+
Eraser,
|
|
28
|
+
} from "lucide-react";
|
|
29
|
+
import clsx from "clsx";
|
|
30
|
+
|
|
31
|
+
const TEXT_COLORS = ["#0f172a", "#dc2626", "#d97706", "#16a34a", "#2563eb", "#7c3aed", "#ffffff"];
|
|
32
|
+
|
|
33
|
+
export function RichTextEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) {
|
|
34
|
+
const editor = useEditor({
|
|
35
|
+
extensions: [
|
|
36
|
+
StarterKit,
|
|
37
|
+
Link.configure({ openOnClick: false }),
|
|
38
|
+
Underline,
|
|
39
|
+
TextStyle,
|
|
40
|
+
Color,
|
|
41
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
42
|
+
],
|
|
43
|
+
content: value,
|
|
44
|
+
immediatelyRender: false,
|
|
45
|
+
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!editor) return null;
|
|
49
|
+
|
|
50
|
+
const btn = (active: boolean) => clsx("p-1.5 rounded hover:bg-slate-100 shrink-0", active && "bg-slate-200");
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="border rounded-md">
|
|
54
|
+
<div className="flex items-center gap-0.5 border-b px-2 py-1 flex-wrap">
|
|
55
|
+
<button type="button" className={btn(false)} onClick={() => editor.chain().focus().undo().run()} title="Undo">
|
|
56
|
+
<Undo2 size={14} />
|
|
57
|
+
</button>
|
|
58
|
+
<button type="button" className={btn(false)} onClick={() => editor.chain().focus().redo().run()} title="Redo">
|
|
59
|
+
<Redo2 size={14} />
|
|
60
|
+
</button>
|
|
61
|
+
<span className="w-px h-4 bg-slate-200 mx-1" />
|
|
62
|
+
<button type="button" className={btn(editor.isActive("bold"))} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">
|
|
63
|
+
<Bold size={14} />
|
|
64
|
+
</button>
|
|
65
|
+
<button type="button" className={btn(editor.isActive("italic"))} onClick={() => editor.chain().focus().toggleItalic().run()} title="Italic">
|
|
66
|
+
<Italic size={14} />
|
|
67
|
+
</button>
|
|
68
|
+
<button type="button" className={btn(editor.isActive("underline"))} onClick={() => editor.chain().focus().toggleUnderline().run()} title="Underline">
|
|
69
|
+
<UnderlineIcon size={14} />
|
|
70
|
+
</button>
|
|
71
|
+
<button type="button" className={btn(editor.isActive("strike"))} onClick={() => editor.chain().focus().toggleStrike().run()} title="Strikethrough">
|
|
72
|
+
<Strikethrough size={14} />
|
|
73
|
+
</button>
|
|
74
|
+
<span className="w-px h-4 bg-slate-200 mx-1" />
|
|
75
|
+
<button type="button" className={btn(editor.isActive("heading", { level: 1 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} title="Heading 1">
|
|
76
|
+
<Heading1 size={14} />
|
|
77
|
+
</button>
|
|
78
|
+
<button type="button" className={btn(editor.isActive("heading", { level: 2 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} title="Heading 2">
|
|
79
|
+
<Heading2 size={14} />
|
|
80
|
+
</button>
|
|
81
|
+
<button type="button" className={btn(editor.isActive("heading", { level: 3 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} title="Heading 3">
|
|
82
|
+
<Heading3 size={14} />
|
|
83
|
+
</button>
|
|
84
|
+
<span className="w-px h-4 bg-slate-200 mx-1" />
|
|
85
|
+
<button type="button" className={btn(editor.isActive("bulletList"))} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Bullet list">
|
|
86
|
+
<List size={14} />
|
|
87
|
+
</button>
|
|
88
|
+
<button type="button" className={btn(editor.isActive("orderedList"))} onClick={() => editor.chain().focus().toggleOrderedList().run()} title="Numbered list">
|
|
89
|
+
<ListOrdered size={14} />
|
|
90
|
+
</button>
|
|
91
|
+
<button type="button" className={btn(editor.isActive("blockquote"))} onClick={() => editor.chain().focus().toggleBlockquote().run()} title="Quote">
|
|
92
|
+
<Quote size={14} />
|
|
93
|
+
</button>
|
|
94
|
+
<span className="w-px h-4 bg-slate-200 mx-1" />
|
|
95
|
+
<button type="button" className={btn(editor.isActive({ textAlign: "left" }))} onClick={() => editor.chain().focus().setTextAlign("left").run()} title="Align left">
|
|
96
|
+
<AlignLeft size={14} />
|
|
97
|
+
</button>
|
|
98
|
+
<button type="button" className={btn(editor.isActive({ textAlign: "center" }))} onClick={() => editor.chain().focus().setTextAlign("center").run()} title="Align center">
|
|
99
|
+
<AlignCenter size={14} />
|
|
100
|
+
</button>
|
|
101
|
+
<button type="button" className={btn(editor.isActive({ textAlign: "right" }))} onClick={() => editor.chain().focus().setTextAlign("right").run()} title="Align right">
|
|
102
|
+
<AlignRight size={14} />
|
|
103
|
+
</button>
|
|
104
|
+
<span className="w-px h-4 bg-slate-200 mx-1" />
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className={btn(editor.isActive("link"))}
|
|
108
|
+
title="Link"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
const url = window.prompt("Link URL");
|
|
111
|
+
if (url) editor.chain().focus().setLink({ href: url }).run();
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<LinkIcon size={14} />
|
|
115
|
+
</button>
|
|
116
|
+
<div className="flex items-center gap-0.5">
|
|
117
|
+
{TEXT_COLORS.map((c) => (
|
|
118
|
+
<button
|
|
119
|
+
key={c}
|
|
120
|
+
type="button"
|
|
121
|
+
title={`Text color ${c}`}
|
|
122
|
+
onClick={() => editor.chain().focus().setColor(c).run()}
|
|
123
|
+
className="w-4 h-4 rounded-full border shrink-0"
|
|
124
|
+
style={{ backgroundColor: c }}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
className={btn(false)}
|
|
131
|
+
title="Clear formatting"
|
|
132
|
+
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
|
133
|
+
>
|
|
134
|
+
<Eraser size={14} />
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
<EditorContent editor={editor} className="prose prose-sm max-w-none p-3 min-h-[120px] [&_.ProseMirror]:outline-none" />
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
type CmsImageProps = {
|
|
5
|
+
src: string;
|
|
6
|
+
alt?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: CSSProperties;
|
|
9
|
+
/** Fills a positioned ancestor (that ancestor must set its own height/aspect ratio)
|
|
10
|
+
* — for cover-cropped photos (gallery tiles, card thumbnails, hero backgrounds).
|
|
11
|
+
* Omit for intrinsic-aspect images (logos, inline icons) sized by `width`/`height`. */
|
|
12
|
+
fill?: boolean;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
sizes?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Generalizes ImageBlock's next/image-vs-plain-<img> decision (relative/absolute URLs
|
|
19
|
+
* get Next's optimizer; anything else — a bare filename, a data URI — falls back to a
|
|
20
|
+
* plain tag) so every renderer component gets optimization/lazy-loading consistently
|
|
21
|
+
* instead of each reimplementing the same check. */
|
|
22
|
+
export function CmsImage({ src, alt = "", className, style, fill, width, height, sizes }: CmsImageProps) {
|
|
23
|
+
if (!src) return null;
|
|
24
|
+
const useOptimized = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("/");
|
|
25
|
+
|
|
26
|
+
if (!useOptimized) {
|
|
27
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
28
|
+
return <img src={src} alt={alt} className={className} style={style} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (fill) {
|
|
32
|
+
return <Image src={src} alt={alt} fill className={className} style={style} sizes={sizes ?? "100vw"} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Image
|
|
37
|
+
src={src}
|
|
38
|
+
alt={alt}
|
|
39
|
+
width={width ?? 1200}
|
|
40
|
+
height={height ?? 800}
|
|
41
|
+
className={className}
|
|
42
|
+
style={style}
|
|
43
|
+
sizes={sizes}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NodeStyle } from "@pgcms/shared";
|
|
2
|
+
|
|
3
|
+
/** Whether a node's style needs the layered-background treatment (video and/or a
|
|
4
|
+
* tint overlay). A plain background image needs no layer — nodeStyleToCss handles it
|
|
5
|
+
* as CSS background-image — so image-only sections skip the extra wrapper. */
|
|
6
|
+
export function hasBackgroundLayers(style: NodeStyle): boolean {
|
|
7
|
+
return Boolean(style.backgroundVideo || style.backgroundOverlay);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Absolute-positioned background media layers for a section: looping muted video,
|
|
11
|
+
* then a tint overlay above it. Rendered inside the section element (which is always
|
|
12
|
+
* position: relative via nodeStyleToCss); the section's content must sit in a
|
|
13
|
+
* `position: relative` wrapper to paint above these. Works on ANY node type — this is
|
|
14
|
+
* what lets one section combine an image background (CSS), a video, an overlay, and
|
|
15
|
+
* arbitrary child components at once. */
|
|
16
|
+
export function NodeBackgroundLayers({ style }: { style: NodeStyle }) {
|
|
17
|
+
if (!hasBackgroundLayers(style)) return null;
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{style.backgroundVideo && (
|
|
21
|
+
<video
|
|
22
|
+
src={style.backgroundVideo}
|
|
23
|
+
autoPlay
|
|
24
|
+
muted
|
|
25
|
+
loop
|
|
26
|
+
playsInline
|
|
27
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
{style.backgroundOverlay && (
|
|
31
|
+
<div className="absolute inset-0" style={{ backgroundColor: style.backgroundOverlay }} />
|
|
32
|
+
)}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { treeContainsType, type NavItem, type Page, type PageNode, type PageTranslationInfo, type SiteTheme } from "@pgcms/shared";
|
|
4
|
+
import { SiteDataContext } from "@/components/theme/SiteDataContext";
|
|
5
|
+
import { RendererContext } from "./RendererContext";
|
|
6
|
+
import { Renderer } from "./Renderer";
|
|
7
|
+
|
|
8
|
+
/** When a global section actually carries the site's real chrome (a Header component
|
|
9
|
+
* in the global header, a Footer in the global footer), pages that still contain
|
|
10
|
+
* their own copy would show it twice — logo and nav doubled. The global one wins;
|
|
11
|
+
* page-level copies of that type are skipped. A global tree that's only decoration
|
|
12
|
+
* (utility bar, announcement strip) suppresses nothing. */
|
|
13
|
+
function suppressDuplicateChrome(content: PageNode, globalHeader: PageNode | null, globalFooter: PageNode | null): PageNode {
|
|
14
|
+
const dropHeader = !!globalHeader && treeContainsType(globalHeader, "header");
|
|
15
|
+
const dropFooter = !!globalFooter && treeContainsType(globalFooter, "footer");
|
|
16
|
+
if (!dropHeader && !dropFooter) return content;
|
|
17
|
+
return {
|
|
18
|
+
...content,
|
|
19
|
+
children: content.children.filter(
|
|
20
|
+
(child) => !(dropHeader && child.type === "header") && !(dropFooter && child.type === "footer")
|
|
21
|
+
),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function PublicPageView({
|
|
26
|
+
page,
|
|
27
|
+
theme,
|
|
28
|
+
navItems,
|
|
29
|
+
globalHeader = null,
|
|
30
|
+
globalFooter = null,
|
|
31
|
+
globalGoToTop = null,
|
|
32
|
+
pageLocale,
|
|
33
|
+
translations,
|
|
34
|
+
}: {
|
|
35
|
+
page: Page;
|
|
36
|
+
theme: SiteTheme;
|
|
37
|
+
navItems: NavItem[];
|
|
38
|
+
/** Site-wide chrome (root trees) rendered above/below every page's own content —
|
|
39
|
+
* edited once in Settings → Global Sections instead of per page. */
|
|
40
|
+
globalHeader?: PageNode | null;
|
|
41
|
+
globalFooter?: PageNode | null;
|
|
42
|
+
globalGoToTop?: PageNode | null;
|
|
43
|
+
pageLocale?: string;
|
|
44
|
+
translations?: PageTranslationInfo[];
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<SiteDataContext.Provider value={{ theme, navItems, pageLocale, translations }}>
|
|
48
|
+
<RendererContext.Provider value={{ mode: "view", selectedId: null }}>
|
|
49
|
+
{globalHeader && <Renderer node={globalHeader} />}
|
|
50
|
+
<Renderer node={suppressDuplicateChrome(page.content, globalHeader, globalFooter)} />
|
|
51
|
+
{globalFooter && <Renderer node={globalFooter} />}
|
|
52
|
+
{globalGoToTop && <Renderer node={globalGoToTop} />}
|
|
53
|
+
</RendererContext.Provider>
|
|
54
|
+
</SiteDataContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import type { PageNode } from "@pgcms/shared";
|
|
5
|
+
import { nodeStyleToCss, nodeCustomAttributes } from "@/lib/style";
|
|
6
|
+
import { buildNodeResponsiveCss } from "@/lib/responsiveCss";
|
|
7
|
+
import {
|
|
8
|
+
useEntranceAnimation,
|
|
9
|
+
useModernEffects,
|
|
10
|
+
} from "@/lib/useEntranceAnimation";
|
|
11
|
+
import {
|
|
12
|
+
NodeBackgroundLayers,
|
|
13
|
+
hasBackgroundLayers,
|
|
14
|
+
} from "./NodeBackgroundLayers";
|
|
15
|
+
import { SECTION_COMPONENTS } from "./registry";
|
|
16
|
+
import { useRendererContext } from "./RendererContext";
|
|
17
|
+
import clsx from "clsx";
|
|
18
|
+
|
|
19
|
+
export function Renderer({ node }: { node: PageNode }) {
|
|
20
|
+
const { mode, selectedId, onSelect } = useRendererContext();
|
|
21
|
+
const ref = useRef<HTMLElement | null>(null);
|
|
22
|
+
// In edit mode the canvas shows the settled final state — no entrance/pointer motion
|
|
23
|
+
// fighting selection and resize.
|
|
24
|
+
useEntranceAnimation(ref, mode === "edit" ? {} : node.style);
|
|
25
|
+
useModernEffects(ref, mode === "edit" ? {} : node.style);
|
|
26
|
+
|
|
27
|
+
// Non-destructive show/hide: hidden nodes keep their configured content but never
|
|
28
|
+
// render on the live site. They still render in the builder (see BuilderCanvasNode)
|
|
29
|
+
// so the user can find and re-enable them.
|
|
30
|
+
if (node.props.hidden === true) return null;
|
|
31
|
+
|
|
32
|
+
if (node.type === "root") {
|
|
33
|
+
return (
|
|
34
|
+
// display: contents — the wrapper must not create a box, or it becomes a sticky
|
|
35
|
+
// header's containing block (exactly the header's own height, so it could never
|
|
36
|
+
// stick). With no box, sections sit directly in the page-height wrapper and
|
|
37
|
+
// position: sticky gets the whole page to travel.
|
|
38
|
+
<div style={{ display: "contents" }}>
|
|
39
|
+
{node.children.map((child) => (
|
|
40
|
+
<Renderer key={child.id} node={child} />
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const Component = SECTION_COMPONENTS[node.type];
|
|
47
|
+
if (!Component) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="p-4 text-sm text-red-600 bg-red-50 border border-red-200">
|
|
50
|
+
Unknown component type: {node.type}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const children = node.children.map((child) => (
|
|
56
|
+
<Renderer key={child.id} node={child} />
|
|
57
|
+
));
|
|
58
|
+
const { attrs: customAttrs, extraClassName } = nodeCustomAttributes(
|
|
59
|
+
node.style,
|
|
60
|
+
);
|
|
61
|
+
const responsiveCss = buildNodeResponsiveCss(node);
|
|
62
|
+
|
|
63
|
+
const hasTextGradient =
|
|
64
|
+
node.style.textGradientFrom && node.style.textGradientTo;
|
|
65
|
+
const css = nodeStyleToCss(
|
|
66
|
+
node.style,
|
|
67
|
+
node.type === "header" || node.type === "goToTop",
|
|
68
|
+
) as React.CSSProperties & Record<string, string | number | undefined>;
|
|
69
|
+
if (hasTextGradient) {
|
|
70
|
+
css["--pgcms-tg-from"] = node.style.textGradientFrom;
|
|
71
|
+
css["--pgcms-tg-to"] = node.style.textGradientTo;
|
|
72
|
+
}
|
|
73
|
+
// Sticky lives on the section element itself, not inside the Header component — an
|
|
74
|
+
// inner sticky div's containing block would be this very section (exactly its own
|
|
75
|
+
// height), leaving it zero room to stick. Only on the live site: a header pinned to
|
|
76
|
+
// the canvas viewport while editing would sit on top of unrelated sections.
|
|
77
|
+
if (node.type === "header" && node.props.sticky !== false && mode !== "edit") {
|
|
78
|
+
css.position = "sticky";
|
|
79
|
+
css.top = 0;
|
|
80
|
+
css.zIndex = 50;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Don't wrap goToTop in a section in view mode
|
|
84
|
+
if (node.type === "goToTop" && mode !== "edit") {
|
|
85
|
+
return <Component node={node}>{children}</Component>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const content = (
|
|
89
|
+
<section
|
|
90
|
+
ref={ref}
|
|
91
|
+
{...customAttrs}
|
|
92
|
+
style={css}
|
|
93
|
+
className={clsx(
|
|
94
|
+
mode === "edit" && "pgcms-editable-hover",
|
|
95
|
+
node.style.glass && "pgcms-glass",
|
|
96
|
+
hasTextGradient && "pgcms-text-gradient",
|
|
97
|
+
// Tilt is JS-driven (useModernEffects); disable all pointer effects in the
|
|
98
|
+
// builder so selecting/resizing doesn't fight a moving target.
|
|
99
|
+
mode !== "edit" &&
|
|
100
|
+
node.style.hoverEffect === "lift" &&
|
|
101
|
+
"pgcms-hover-lift",
|
|
102
|
+
mode !== "edit" &&
|
|
103
|
+
node.style.hoverEffect === "scale" &&
|
|
104
|
+
"pgcms-hover-scale",
|
|
105
|
+
mode !== "edit" &&
|
|
106
|
+
node.style.hoverEffect === "glow" &&
|
|
107
|
+
"pgcms-hover-glow",
|
|
108
|
+
extraClassName,
|
|
109
|
+
)}
|
|
110
|
+
onClick={
|
|
111
|
+
mode === "edit"
|
|
112
|
+
? (e) => {
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
onSelect?.(node.id);
|
|
115
|
+
}
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
118
|
+
data-node-id={node.id}
|
|
119
|
+
>
|
|
120
|
+
{responsiveCss && (
|
|
121
|
+
<style dangerouslySetInnerHTML={{ __html: responsiveCss }} />
|
|
122
|
+
)}
|
|
123
|
+
<NodeBackgroundLayers style={node.style} />
|
|
124
|
+
{/* Absolute background layers paint above in-flow content unless the content is
|
|
125
|
+
itself positioned — only add the wrapper when layers exist, so pages without
|
|
126
|
+
video/overlay keep their exact current DOM. */}
|
|
127
|
+
{hasBackgroundLayers(node.style) ? (
|
|
128
|
+
<div
|
|
129
|
+
className={
|
|
130
|
+
node.type === "header" || node.type === "goToTop" ? "" : "relative"
|
|
131
|
+
}
|
|
132
|
+
>
|
|
133
|
+
<Component node={node}>{children}</Component>
|
|
134
|
+
</div>
|
|
135
|
+
) : (
|
|
136
|
+
<Component node={node}>{children}</Component>
|
|
137
|
+
)}
|
|
138
|
+
</section>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (mode === "edit" && selectedId === node.id) {
|
|
142
|
+
return <div className="pgcms-editable-selected">{content}</div>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return content;
|
|
146
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
export type RendererMode = "view" | "edit";
|
|
6
|
+
|
|
7
|
+
export type RendererContextValue = {
|
|
8
|
+
mode: RendererMode;
|
|
9
|
+
selectedId: string | null;
|
|
10
|
+
onSelect?: (id: string) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const RendererContext = createContext<RendererContextValue>({
|
|
14
|
+
mode: "view",
|
|
15
|
+
selectedId: null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function useRendererContext() {
|
|
19
|
+
return useContext(RendererContext);
|
|
20
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { SectionProps } from "./sections/types";
|
|
3
|
+
import { Header } from "./sections/Header";
|
|
4
|
+
import { Footer } from "./sections/Footer";
|
|
5
|
+
import { Container } from "./sections/Container";
|
|
6
|
+
import { Column } from "./sections/Column";
|
|
7
|
+
import { Grid } from "./sections/Grid";
|
|
8
|
+
import { GridCell } from "./sections/GridCell";
|
|
9
|
+
import { NavZone } from "./sections/NavZone";
|
|
10
|
+
import { Logo } from "./sections/Logo";
|
|
11
|
+
import { NavLinks } from "./sections/NavLinks";
|
|
12
|
+
import { Hero } from "./sections/Hero";
|
|
13
|
+
import { RichText } from "./sections/RichText";
|
|
14
|
+
import { ImageBlock } from "./sections/ImageBlock";
|
|
15
|
+
import { Slider } from "./sections/Slider";
|
|
16
|
+
import { ImageBackgroundSection } from "./sections/ImageBackgroundSection";
|
|
17
|
+
import { VideoBackgroundSection } from "./sections/VideoBackgroundSection";
|
|
18
|
+
import { ThreeBackground } from "./sections/ThreeBackground";
|
|
19
|
+
import { MapEmbed } from "./sections/MapEmbed";
|
|
20
|
+
import { ButtonGroup } from "./sections/ButtonGroup";
|
|
21
|
+
import { Gallery } from "./sections/Gallery";
|
|
22
|
+
import { Cta } from "./sections/Cta";
|
|
23
|
+
import { Countdown } from "./sections/Countdown";
|
|
24
|
+
import { Stats } from "./sections/Stats";
|
|
25
|
+
import { CardGrid } from "./sections/CardGrid";
|
|
26
|
+
import { Accordion } from "./sections/Accordion";
|
|
27
|
+
import { Tabs } from "./sections/Tabs";
|
|
28
|
+
import { Marquee } from "./sections/Marquee";
|
|
29
|
+
import { LanguageSwitcher } from "./sections/LanguageSwitcher";
|
|
30
|
+
import { CollectionList } from "./sections/CollectionList";
|
|
31
|
+
import { ContactForm } from "./sections/ContactForm";
|
|
32
|
+
import { SiteSearch } from "./sections/SiteSearch";
|
|
33
|
+
import { EmbedBlock } from "./sections/EmbedBlock";
|
|
34
|
+
import { GoToTop } from "./sections/GoToTop";
|
|
35
|
+
import { Testimonial } from "./sections/Testimonial";
|
|
36
|
+
import { PricingTable } from "./sections/PricingTable";
|
|
37
|
+
import { VideoEmbed } from "./sections/VideoEmbed";
|
|
38
|
+
import { TeamGrid } from "./sections/TeamGrid";
|
|
39
|
+
import { LogoCloud } from "./sections/LogoCloud";
|
|
40
|
+
import { Timeline } from "./sections/Timeline";
|
|
41
|
+
import { BeforeAfter } from "./sections/BeforeAfter";
|
|
42
|
+
import { Divider } from "./sections/Divider";
|
|
43
|
+
import { Spacer } from "./sections/Spacer";
|
|
44
|
+
|
|
45
|
+
export const SECTION_COMPONENTS: Record<string, ComponentType<SectionProps>> = {
|
|
46
|
+
goToTop: GoToTop,
|
|
47
|
+
header: Header,
|
|
48
|
+
footer: Footer,
|
|
49
|
+
container: Container,
|
|
50
|
+
column: Column,
|
|
51
|
+
grid: Grid,
|
|
52
|
+
gridCell: GridCell,
|
|
53
|
+
navZone: NavZone,
|
|
54
|
+
logo: Logo,
|
|
55
|
+
navLinks: NavLinks,
|
|
56
|
+
hero: Hero,
|
|
57
|
+
richText: RichText,
|
|
58
|
+
imageBlock: ImageBlock,
|
|
59
|
+
slider: Slider,
|
|
60
|
+
imageBackgroundSection: ImageBackgroundSection,
|
|
61
|
+
videoBackgroundSection: VideoBackgroundSection,
|
|
62
|
+
threeBackground: ThreeBackground,
|
|
63
|
+
mapEmbed: MapEmbed,
|
|
64
|
+
buttonGroup: ButtonGroup,
|
|
65
|
+
gallery: Gallery,
|
|
66
|
+
cta: Cta,
|
|
67
|
+
countdown: Countdown,
|
|
68
|
+
stats: Stats,
|
|
69
|
+
cardGrid: CardGrid,
|
|
70
|
+
accordion: Accordion,
|
|
71
|
+
tabs: Tabs,
|
|
72
|
+
marquee: Marquee,
|
|
73
|
+
languageSwitcher: LanguageSwitcher,
|
|
74
|
+
collectionList: CollectionList,
|
|
75
|
+
contactForm: ContactForm,
|
|
76
|
+
siteSearch: SiteSearch,
|
|
77
|
+
embed: EmbedBlock,
|
|
78
|
+
testimonial: Testimonial,
|
|
79
|
+
pricingTable: PricingTable,
|
|
80
|
+
videoEmbed: VideoEmbed,
|
|
81
|
+
teamGrid: TeamGrid,
|
|
82
|
+
logoCloud: LogoCloud,
|
|
83
|
+
timeline: Timeline,
|
|
84
|
+
beforeAfter: BeforeAfter,
|
|
85
|
+
divider: Divider,
|
|
86
|
+
spacer: Spacer,
|
|
87
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import type { SectionProps } from "./types";
|
|
6
|
+
|
|
7
|
+
type AccordionItem = { question: string; answer: string };
|
|
8
|
+
|
|
9
|
+
export function Accordion({ node }: SectionProps) {
|
|
10
|
+
const heading = node.props.heading as string;
|
|
11
|
+
const items = (node.props.items as AccordionItem[]) ?? [];
|
|
12
|
+
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
|
13
|
+
|
|
14
|
+
if (items.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
|
|
19
|
+
<div className="divide-y border rounded-lg">
|
|
20
|
+
{items.map((item, i) => {
|
|
21
|
+
const open = openIndex === i;
|
|
22
|
+
return (
|
|
23
|
+
<div key={i}>
|
|
24
|
+
<button
|
|
25
|
+
onClick={() => setOpenIndex(open ? null : i)}
|
|
26
|
+
className="w-full flex items-center justify-between gap-4 px-5 py-4 text-left font-medium"
|
|
27
|
+
aria-expanded={open}
|
|
28
|
+
>
|
|
29
|
+
<span>{item.question}</span>
|
|
30
|
+
<ChevronDown size={18} className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
31
|
+
</button>
|
|
32
|
+
{open && <div className="px-5 pb-4 text-sm opacity-80">{item.answer}</div>}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
})}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|