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,27 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { serverGet } from "@/lib/serverApi";
|
|
3
|
+
|
|
4
|
+
const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
type PublicPage = { slug: string; updatedAt?: string };
|
|
7
|
+
type SitemapItem = { path: string; updatedAt: string };
|
|
8
|
+
|
|
9
|
+
/** /sitemap.xml — every publicly-visible page and collection item, straight from the
|
|
10
|
+
* API at request time so it's always current without a rebuild. */
|
|
11
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
12
|
+
const [pages, items] = await Promise.all([
|
|
13
|
+
serverGet<PublicPage[]>("/api/pages/public").catch(() => [] as PublicPage[]),
|
|
14
|
+
serverGet<SitemapItem[]>("/api/collections/public-sitemap").catch(() => [] as SitemapItem[]),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
...pages.map((p) => ({
|
|
19
|
+
url: `${SITE_URL}/${p.slug}`,
|
|
20
|
+
lastModified: p.updatedAt ? new Date(p.updatedAt) : undefined,
|
|
21
|
+
})),
|
|
22
|
+
...items.map((i) => ({
|
|
23
|
+
url: `${SITE_URL}/${i.path}`,
|
|
24
|
+
lastModified: new Date(i.updatedAt),
|
|
25
|
+
})),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
import {
|
|
7
|
+
LayoutDashboard,
|
|
8
|
+
Image as ImageIcon,
|
|
9
|
+
Palette,
|
|
10
|
+
Menu as MenuIcon,
|
|
11
|
+
Users,
|
|
12
|
+
LogOut,
|
|
13
|
+
Inbox,
|
|
14
|
+
PanelTop,
|
|
15
|
+
Newspaper,
|
|
16
|
+
CornerUpRight,
|
|
17
|
+
ScrollText,
|
|
18
|
+
DatabaseBackup,
|
|
19
|
+
type LucideIcon,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { api } from "@/lib/api";
|
|
22
|
+
import { useCurrentUser } from "@/lib/useCurrentUser";
|
|
23
|
+
import type { UserRole } from "@pgcms/shared";
|
|
24
|
+
|
|
25
|
+
type NavItem = {
|
|
26
|
+
href: string;
|
|
27
|
+
label: string;
|
|
28
|
+
icon: LucideIcon;
|
|
29
|
+
roles?: UserRole[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const NAV: NavItem[] = [
|
|
33
|
+
{ href: "/admin", label: "Pages", icon: LayoutDashboard },
|
|
34
|
+
{ href: "/admin/collections", label: "Collections", icon: Newspaper },
|
|
35
|
+
{ href: "/admin/media", label: "Media Library", icon: ImageIcon },
|
|
36
|
+
{ href: "/admin/forms", label: "Form Submissions", icon: Inbox },
|
|
37
|
+
{ href: "/admin/settings/global-sections", label: "Global Sections", icon: PanelTop },
|
|
38
|
+
{ href: "/admin/settings/theme", label: "Theme", icon: Palette, roles: ["ADMIN"] },
|
|
39
|
+
{ href: "/admin/settings/navigation", label: "Navigation", icon: MenuIcon, roles: ["ADMIN"] },
|
|
40
|
+
{ href: "/admin/settings/redirects", label: "Redirects", icon: CornerUpRight, roles: ["ADMIN"] },
|
|
41
|
+
{ href: "/admin/settings/backup", label: "Backup", icon: DatabaseBackup, roles: ["ADMIN"] },
|
|
42
|
+
{ href: "/admin/users", label: "Users", icon: Users, roles: ["ADMIN"] },
|
|
43
|
+
{ href: "/admin/audit", label: "Audit Log", icon: ScrollText, roles: ["ADMIN"] },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function AdminShell({ children }: { children: React.ReactNode }) {
|
|
47
|
+
const pathname = usePathname();
|
|
48
|
+
const router = useRouter();
|
|
49
|
+
const { user, loading } = useCurrentUser();
|
|
50
|
+
|
|
51
|
+
async function logout() {
|
|
52
|
+
await api.post("/api/auth/logout");
|
|
53
|
+
router.replace("/admin/login");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (loading) {
|
|
57
|
+
return <div className="min-h-screen flex items-center justify-center text-slate-400 text-sm">Loading...</div>;
|
|
58
|
+
}
|
|
59
|
+
if (!user) return null;
|
|
60
|
+
|
|
61
|
+
const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(user.role));
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="min-h-screen flex bg-slate-50">
|
|
65
|
+
<aside className="w-60 shrink-0 bg-white border-r flex flex-col">
|
|
66
|
+
<div className="px-5 py-5 border-b">
|
|
67
|
+
<span className="font-semibold text-lg">pg-cms</span>
|
|
68
|
+
</div>
|
|
69
|
+
<nav className="flex-1 px-3 py-4 space-y-1">
|
|
70
|
+
{visibleNav.map((item) => {
|
|
71
|
+
const active = pathname === item.href || (item.href !== "/admin" && pathname?.startsWith(item.href));
|
|
72
|
+
const Icon = item.icon;
|
|
73
|
+
return (
|
|
74
|
+
<Link
|
|
75
|
+
key={item.href}
|
|
76
|
+
href={item.href}
|
|
77
|
+
className={clsx(
|
|
78
|
+
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium",
|
|
79
|
+
active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<Icon size={18} />
|
|
83
|
+
{item.label}
|
|
84
|
+
</Link>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</nav>
|
|
88
|
+
<div className="px-3 py-4 border-t space-y-2">
|
|
89
|
+
<div className="px-3 text-xs text-slate-500 truncate">
|
|
90
|
+
{user.email} · {user.role}
|
|
91
|
+
</div>
|
|
92
|
+
<button
|
|
93
|
+
onClick={logout}
|
|
94
|
+
className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:bg-slate-100"
|
|
95
|
+
>
|
|
96
|
+
<LogOut size={18} />
|
|
97
|
+
Log out
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</aside>
|
|
101
|
+
<main className="flex-1 min-w-0">{children}</main>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useSortable } from "@dnd-kit/sortable";
|
|
6
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
7
|
+
import clsx from "clsx";
|
|
8
|
+
import {
|
|
9
|
+
GripVertical,
|
|
10
|
+
Copy,
|
|
11
|
+
Trash2,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
CornerLeftUp,
|
|
15
|
+
Bookmark,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import {
|
|
18
|
+
getComponentDefinition,
|
|
19
|
+
resolveNodeStyle,
|
|
20
|
+
type NodeStyle,
|
|
21
|
+
type PageNode,
|
|
22
|
+
} from "@pgcms/shared";
|
|
23
|
+
import { SECTION_COMPONENTS } from "@/components/renderer/registry";
|
|
24
|
+
import {
|
|
25
|
+
NodeBackgroundLayers,
|
|
26
|
+
hasBackgroundLayers,
|
|
27
|
+
} from "@/components/renderer/NodeBackgroundLayers";
|
|
28
|
+
import { nodeStyleToCss, nodeCustomAttributes } from "@/lib/style";
|
|
29
|
+
import { api } from "@/lib/api";
|
|
30
|
+
import { DroppableChildren } from "./DroppableChildren";
|
|
31
|
+
import { useCanvasOverlay } from "./CanvasOverlayContext";
|
|
32
|
+
import { useBuilderDevice } from "./BuilderDeviceContext";
|
|
33
|
+
import { BLOCKS_CHANGED_EVENT } from "./Palette";
|
|
34
|
+
|
|
35
|
+
type ResizeAxis = "width" | "height" | "both";
|
|
36
|
+
|
|
37
|
+
export function BuilderCanvasNode({
|
|
38
|
+
node,
|
|
39
|
+
parentId,
|
|
40
|
+
depth = 0,
|
|
41
|
+
selectedId,
|
|
42
|
+
onSelect,
|
|
43
|
+
onDelete,
|
|
44
|
+
onDuplicate,
|
|
45
|
+
onToggleHidden,
|
|
46
|
+
onChangeStyle,
|
|
47
|
+
onResizeGridTrack,
|
|
48
|
+
cellIndex,
|
|
49
|
+
gridColumns,
|
|
50
|
+
}: {
|
|
51
|
+
node: PageNode;
|
|
52
|
+
/** The id of this node's own parent — lets the toolbar offer "select parent" once a
|
|
53
|
+
* Container/Column is filled edge-to-edge with content and has no background left to
|
|
54
|
+
* click on directly. Undefined for top-level nodes (their parent is the page root,
|
|
55
|
+
* which isn't a real editable node). */
|
|
56
|
+
parentId?: string;
|
|
57
|
+
/** Nesting depth, used to stagger toolbar chip position — a Container whose sole
|
|
58
|
+
* Column holds a sole Hero has all three boxes sharing the exact same top-left
|
|
59
|
+
* corner, so their chips would otherwise render at the identical screen position
|
|
60
|
+
* and only the topmost one could ever be clicked. */
|
|
61
|
+
depth?: number;
|
|
62
|
+
selectedId: string | null;
|
|
63
|
+
onSelect: (id: string) => void;
|
|
64
|
+
onDelete: (id: string) => void;
|
|
65
|
+
onDuplicate: (id: string) => void;
|
|
66
|
+
onToggleHidden: (id: string) => void;
|
|
67
|
+
onChangeStyle: (id: string, style: NodeStyle) => void;
|
|
68
|
+
/** Resizes one column/row track on the parent Grid (only meaningful when this node
|
|
69
|
+
* is a "gridCell" — a grid track is shared by every cell in that column/row, so the
|
|
70
|
+
* size has to live on the Grid itself, not this cell's own style). */
|
|
71
|
+
onResizeGridTrack?: (gridId: string, axis: "column" | "row", index: number, size: string) => void;
|
|
72
|
+
/** This node's position among its parent Grid's children — only set when the parent
|
|
73
|
+
* is a "grid", used with gridColumns to derive which column/row track a drag-resize
|
|
74
|
+
* on this cell should adjust. */
|
|
75
|
+
cellIndex?: number;
|
|
76
|
+
gridColumns?: number;
|
|
77
|
+
}) {
|
|
78
|
+
const {
|
|
79
|
+
attributes,
|
|
80
|
+
listeners,
|
|
81
|
+
setNodeRef,
|
|
82
|
+
transform,
|
|
83
|
+
transition,
|
|
84
|
+
isDragging,
|
|
85
|
+
} = useSortable({ id: node.id });
|
|
86
|
+
const Component = SECTION_COMPONENTS[node.type];
|
|
87
|
+
const def = getComponentDefinition(node.type);
|
|
88
|
+
const selected = selectedId === node.id;
|
|
89
|
+
const hidden = node.props.hidden === true;
|
|
90
|
+
const device = useBuilderDevice();
|
|
91
|
+
// Preview and resize against the device-effective style — the style-write handler
|
|
92
|
+
// in the builder page diffs against the same view, so edits land in the right layer.
|
|
93
|
+
// Memoized for referential stability: it feeds the box-measure effect's deps, and a
|
|
94
|
+
// fresh object every render would make that effect setState itself into a loop.
|
|
95
|
+
const effectiveStyle = useMemo(
|
|
96
|
+
() => resolveNodeStyle(node, device),
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
[node.style, node.styleTablet, node.styleMobile, device],
|
|
99
|
+
);
|
|
100
|
+
// Per-instance hover state, not Tailwind's `group-hover` — a shared group name at
|
|
101
|
+
// every nesting depth (Header > Zone > Logo) makes hovering one leaf mark its whole
|
|
102
|
+
// ancestor chain as ":hover", which lights up every *sibling's* toolbar too (e.g.
|
|
103
|
+
// hovering the Logo also revealed Nav Links' and Buttons' toolbars). Local state is
|
|
104
|
+
// scoped to exactly the boxes the pointer is actually inside.
|
|
105
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
106
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
107
|
+
|
|
108
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
109
|
+
const setRefs = useCallback(
|
|
110
|
+
(el: HTMLDivElement | null) => {
|
|
111
|
+
setNodeRef(el);
|
|
112
|
+
boxRef.current = el;
|
|
113
|
+
},
|
|
114
|
+
[setNodeRef],
|
|
115
|
+
);
|
|
116
|
+
const sectionRef = useRef<HTMLElement | null>(null);
|
|
117
|
+
const overlayEl = useCanvasOverlay();
|
|
118
|
+
const [box, setBox] = useState<{
|
|
119
|
+
top: number;
|
|
120
|
+
left: number;
|
|
121
|
+
right: number;
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
} | null>(null);
|
|
125
|
+
|
|
126
|
+
// Measure the node's own <section>, not the sortable wrapper: a width-constrained
|
|
127
|
+
// section centers inside a still-full-width wrapper, and handles anchored to the
|
|
128
|
+
// wrapper would float in empty space beside the visible box.
|
|
129
|
+
const ownSection = useCallback((): HTMLElement | null => {
|
|
130
|
+
return sectionRef.current ?? boxRef.current;
|
|
131
|
+
}, [sectionRef]);
|
|
132
|
+
|
|
133
|
+
// The toolbar/handle chips are portaled into a flat overlay layer instead of nesting
|
|
134
|
+
// `position: absolute` inside this deeply-nested tree (see CanvasOverlayContext for
|
|
135
|
+
// why) — so their screen position has to be measured explicitly rather than
|
|
136
|
+
// expressed with CSS relative to a nearby ancestor.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!overlayEl || !boxRef.current) {
|
|
139
|
+
setBox(null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const update = () => {
|
|
143
|
+
const target = ownSection();
|
|
144
|
+
if (!target) return;
|
|
145
|
+
const boxRect = target.getBoundingClientRect();
|
|
146
|
+
const overlayRect = overlayEl.getBoundingClientRect();
|
|
147
|
+
setBox({
|
|
148
|
+
top: boxRect.top - overlayRect.top,
|
|
149
|
+
left: boxRect.left - overlayRect.left,
|
|
150
|
+
right: overlayRect.right - boxRect.right,
|
|
151
|
+
width: boxRect.width,
|
|
152
|
+
height: boxRect.height,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
update();
|
|
156
|
+
window.addEventListener("scroll", update, true);
|
|
157
|
+
window.addEventListener("resize", update);
|
|
158
|
+
return () => {
|
|
159
|
+
window.removeEventListener("scroll", update, true);
|
|
160
|
+
window.removeEventListener("resize", update);
|
|
161
|
+
};
|
|
162
|
+
}, [
|
|
163
|
+
overlayEl,
|
|
164
|
+
isHovered,
|
|
165
|
+
isResizing,
|
|
166
|
+
effectiveStyle,
|
|
167
|
+
node.props,
|
|
168
|
+
ownSection,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const startResize = useCallback(
|
|
172
|
+
(axis: ResizeAxis) => (e: React.PointerEvent) => {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
e.stopPropagation();
|
|
175
|
+
const target = ownSection();
|
|
176
|
+
if (!target) return;
|
|
177
|
+
const startRect = target.getBoundingClientRect();
|
|
178
|
+
const parentWidth = boxRef.current?.getBoundingClientRect().width ?? null;
|
|
179
|
+
const startX = e.clientX;
|
|
180
|
+
const startY = e.clientY;
|
|
181
|
+
setIsResizing(true);
|
|
182
|
+
|
|
183
|
+
// Preserve the unit an editor already typed (%, vw, rem...) instead of
|
|
184
|
+
// clobbering it with px — dragging then adjusts the value in that unit.
|
|
185
|
+
function toUnit(
|
|
186
|
+
px: number,
|
|
187
|
+
existing: string | undefined,
|
|
188
|
+
relativeTo: number | null,
|
|
189
|
+
): string {
|
|
190
|
+
const match = existing?.match(/^[\d.]+(%|vw|vh|rem|em)$/);
|
|
191
|
+
if (match) {
|
|
192
|
+
const unit = match[1];
|
|
193
|
+
if (unit === "%" && relativeTo)
|
|
194
|
+
return `${Math.round((px / relativeTo) * 1000) / 10}%`;
|
|
195
|
+
if (unit === "vw")
|
|
196
|
+
return `${Math.round((px / window.innerWidth) * 1000) / 10}vw`;
|
|
197
|
+
if (unit === "vh")
|
|
198
|
+
return `${Math.round((px / window.innerHeight) * 1000) / 10}vh`;
|
|
199
|
+
if (unit === "rem" || unit === "em")
|
|
200
|
+
return `${Math.round((px / 16) * 100) / 100}${unit}`;
|
|
201
|
+
}
|
|
202
|
+
return `${Math.round(px)}px`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onMove(moveEvent: PointerEvent) {
|
|
206
|
+
const dx = moveEvent.clientX - startX;
|
|
207
|
+
const dy = moveEvent.clientY - startY;
|
|
208
|
+
|
|
209
|
+
// A Grid Cell's width/height is entirely governed by its parent Grid's column/
|
|
210
|
+
// row track sizes (every cell in that column/row shares one track) — writing
|
|
211
|
+
// width/maxWidth on the cell itself, like other nodes do, would have zero
|
|
212
|
+
// visual effect since a CSS grid item ignores its own explicit size in favor of
|
|
213
|
+
// the track. Resize the specific track on the Grid instead.
|
|
214
|
+
if (
|
|
215
|
+
node.type === "gridCell" &&
|
|
216
|
+
parentId &&
|
|
217
|
+
onResizeGridTrack &&
|
|
218
|
+
gridColumns &&
|
|
219
|
+
cellIndex !== undefined
|
|
220
|
+
) {
|
|
221
|
+
const colIndex = cellIndex % gridColumns;
|
|
222
|
+
const rowIndex = Math.floor(cellIndex / gridColumns);
|
|
223
|
+
if (axis === "width" || axis === "both") {
|
|
224
|
+
const widthPx = Math.max(80, startRect.width + dx);
|
|
225
|
+
onResizeGridTrack(parentId, "column", colIndex, `${Math.round(widthPx)}px`);
|
|
226
|
+
}
|
|
227
|
+
if (axis === "height" || axis === "both") {
|
|
228
|
+
const heightPx = Math.max(40, startRect.height + dy);
|
|
229
|
+
onResizeGridTrack(parentId, "row", rowIndex, `${Math.round(heightPx)}px`);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const next: NodeStyle = { ...effectiveStyle };
|
|
235
|
+
if (axis === "width" || axis === "both") {
|
|
236
|
+
const widthPx = Math.max(80, startRect.width + dx);
|
|
237
|
+
// A Column's width is a CSS Grid track size owned by its parent Container
|
|
238
|
+
// (see Container.tsx) — max-width has no effect on a grid track, only on
|
|
239
|
+
// content within it — so drag it via a distinct property. For other nodes,
|
|
240
|
+
// adjust an explicit `width` if one is set, else the usual maxWidth.
|
|
241
|
+
if (node.type === "column")
|
|
242
|
+
next.columnWidth = toUnit(
|
|
243
|
+
widthPx,
|
|
244
|
+
effectiveStyle.columnWidth,
|
|
245
|
+
parentWidth,
|
|
246
|
+
);
|
|
247
|
+
else if (effectiveStyle.width)
|
|
248
|
+
next.width = toUnit(widthPx, effectiveStyle.width, parentWidth);
|
|
249
|
+
else
|
|
250
|
+
next.maxWidth = toUnit(
|
|
251
|
+
widthPx,
|
|
252
|
+
effectiveStyle.maxWidth,
|
|
253
|
+
parentWidth,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (axis === "height" || axis === "both") {
|
|
257
|
+
const heightPx = Math.max(40, startRect.height + dy);
|
|
258
|
+
if (effectiveStyle.height)
|
|
259
|
+
next.height = toUnit(heightPx, effectiveStyle.height, null);
|
|
260
|
+
else
|
|
261
|
+
next.minHeight = toUnit(heightPx, effectiveStyle.minHeight, null);
|
|
262
|
+
}
|
|
263
|
+
onChangeStyle(node.id, next);
|
|
264
|
+
}
|
|
265
|
+
function onUp() {
|
|
266
|
+
setIsResizing(false);
|
|
267
|
+
window.removeEventListener("pointermove", onMove);
|
|
268
|
+
window.removeEventListener("pointerup", onUp);
|
|
269
|
+
// The pointer has usually moved by the time it's released, so the browser's
|
|
270
|
+
// synthetic "click" lands wherever the cursor ended up — not on this handle —
|
|
271
|
+
// meaning stopPropagation() on the handle's own onClick never runs for it.
|
|
272
|
+
// Swallow that one click at the capture phase so it can't reach the canvas
|
|
273
|
+
// background's deselect-on-click handler.
|
|
274
|
+
window.addEventListener("click", suppressClick, {
|
|
275
|
+
capture: true,
|
|
276
|
+
once: true,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function suppressClick(clickEvent: MouseEvent) {
|
|
280
|
+
clickEvent.stopPropagation();
|
|
281
|
+
}
|
|
282
|
+
window.addEventListener("pointermove", onMove);
|
|
283
|
+
window.addEventListener("pointerup", onUp);
|
|
284
|
+
},
|
|
285
|
+
[
|
|
286
|
+
node.id,
|
|
287
|
+
effectiveStyle,
|
|
288
|
+
node.type,
|
|
289
|
+
onChangeStyle,
|
|
290
|
+
ownSection,
|
|
291
|
+
parentId,
|
|
292
|
+
onResizeGridTrack,
|
|
293
|
+
gridColumns,
|
|
294
|
+
cellIndex,
|
|
295
|
+
],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const { attrs: customAttrs, extraClassName } =
|
|
299
|
+
nodeCustomAttributes(effectiveStyle);
|
|
300
|
+
|
|
301
|
+
const wrapperStyle: React.CSSProperties = {
|
|
302
|
+
transform: CSS.Transform.toString(transform),
|
|
303
|
+
transition,
|
|
304
|
+
...(isDragging ? { opacity: 0.4 } : {}),
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// A Grid Cell's own box needs to actually fill its (possibly custom-sized, see
|
|
308
|
+
// setGridTrackSize) row track, or a taller row just leaves dead space below the
|
|
309
|
+
// cell's natural content height instead of visibly growing — the cell IS the grid
|
|
310
|
+
// item here, two DOM layers up through plain (non-stretching) wrapper divs. Scoped to
|
|
311
|
+
// gridCell specifically: forcing height:100% on every node type would also stretch
|
|
312
|
+
// ordinary content (a Hero dropped into an already-tall Column, say) to fill space it
|
|
313
|
+
// was never meant to claim.
|
|
314
|
+
const isGridCell = node.type === "gridCell";
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div ref={setRefs} style={wrapperStyle} className={clsx("relative", isGridCell && "h-full")}>
|
|
318
|
+
<div
|
|
319
|
+
className={clsx(
|
|
320
|
+
"relative",
|
|
321
|
+
isGridCell && "h-full",
|
|
322
|
+
selected
|
|
323
|
+
? "ring-2 ring-blue-500 z-10"
|
|
324
|
+
: isHovered
|
|
325
|
+
? "ring-1 ring-slate-300"
|
|
326
|
+
: "ring-1 ring-transparent",
|
|
327
|
+
hidden && "opacity-40",
|
|
328
|
+
)}
|
|
329
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
330
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
331
|
+
onClick={(e) => {
|
|
332
|
+
e.stopPropagation();
|
|
333
|
+
onSelect(node.id);
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
<section
|
|
337
|
+
{...customAttrs}
|
|
338
|
+
ref={sectionRef}
|
|
339
|
+
style={isGridCell ? { ...nodeStyleToCss(effectiveStyle), height: effectiveStyle.height || "100%" } : nodeStyleToCss(effectiveStyle)}
|
|
340
|
+
className={extraClassName}
|
|
341
|
+
>
|
|
342
|
+
<NodeBackgroundLayers style={effectiveStyle} />
|
|
343
|
+
{Component ? (
|
|
344
|
+
// Absolute background layers paint above unpositioned in-flow content, so
|
|
345
|
+
// when they exist the content needs its own positioned wrapper to win.
|
|
346
|
+
<div
|
|
347
|
+
className={clsx(
|
|
348
|
+
hasBackgroundLayers(effectiveStyle) && "relative",
|
|
349
|
+
isGridCell && "h-full",
|
|
350
|
+
)}
|
|
351
|
+
>
|
|
352
|
+
<Component node={node}>
|
|
353
|
+
{def?.allowsChildren ? (
|
|
354
|
+
<DroppableChildren
|
|
355
|
+
parentId={node.id}
|
|
356
|
+
childIds={node.children.map((c) => c.id)}
|
|
357
|
+
transparent
|
|
358
|
+
>
|
|
359
|
+
{node.children.map((child, childIndex) => (
|
|
360
|
+
<BuilderCanvasNode
|
|
361
|
+
key={child.id}
|
|
362
|
+
node={child}
|
|
363
|
+
parentId={node.id}
|
|
364
|
+
depth={depth + 1}
|
|
365
|
+
selectedId={selectedId}
|
|
366
|
+
onSelect={onSelect}
|
|
367
|
+
onDelete={onDelete}
|
|
368
|
+
onDuplicate={onDuplicate}
|
|
369
|
+
onToggleHidden={onToggleHidden}
|
|
370
|
+
onChangeStyle={onChangeStyle}
|
|
371
|
+
onResizeGridTrack={onResizeGridTrack}
|
|
372
|
+
cellIndex={node.type === "grid" ? childIndex : undefined}
|
|
373
|
+
gridColumns={node.type === "grid" ? Math.max(1, Number(node.props.columns) || 2) : undefined}
|
|
374
|
+
/>
|
|
375
|
+
))}
|
|
376
|
+
</DroppableChildren>
|
|
377
|
+
) : undefined}
|
|
378
|
+
</Component>
|
|
379
|
+
</div>
|
|
380
|
+
) : (
|
|
381
|
+
<div className="p-4 text-sm text-red-600 bg-red-50">
|
|
382
|
+
Unknown component type: {node.type}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</section>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{overlayEl &&
|
|
389
|
+
box &&
|
|
390
|
+
createPortal(
|
|
391
|
+
<>
|
|
392
|
+
{/* Locked structural slots (e.g. a header's zones) get no toolbar of their
|
|
393
|
+
own — "hide" is a property of the content you put in a zone, not the
|
|
394
|
+
zone slot, and a zone's tight-fitting box would otherwise sit right on
|
|
395
|
+
top of its single child's toolbar, each stealing the other's clicks. */}
|
|
396
|
+
{!def?.locked && isHovered && (
|
|
397
|
+
<div
|
|
398
|
+
className="absolute z-30 flex items-center gap-1.5 bg-slate-900 text-white text-xs rounded px-2 py-1 select-none pointer-events-auto"
|
|
399
|
+
// Nested ancestors sharing the same top-left corner (e.g. a Container
|
|
400
|
+
// whose only Column holds only a Hero) get staggered chips instead of
|
|
401
|
+
// perfectly overlapping ones that only the topmost could ever click.
|
|
402
|
+
style={{ top: box.top - 12, left: box.left + 8 + depth * 88 }}
|
|
403
|
+
// The chip is portaled outside the hoverable content's own DOM subtree,
|
|
404
|
+
// so moving the mouse onto it would otherwise register as leaving the
|
|
405
|
+
// content (mouseleave fires first) and unmount the chip before a click
|
|
406
|
+
// can land. Keep it open while the pointer is over the chip itself too.
|
|
407
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
408
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
409
|
+
>
|
|
410
|
+
<button
|
|
411
|
+
{...attributes}
|
|
412
|
+
{...listeners}
|
|
413
|
+
className="cursor-grab active:cursor-grabbing"
|
|
414
|
+
title="Drag to reorder"
|
|
415
|
+
>
|
|
416
|
+
<GripVertical size={12} />
|
|
417
|
+
</button>
|
|
418
|
+
<span>{def?.label ?? node.type}</span>
|
|
419
|
+
{parentId && (
|
|
420
|
+
<button
|
|
421
|
+
onClick={(e) => {
|
|
422
|
+
e.stopPropagation();
|
|
423
|
+
onSelect(parentId);
|
|
424
|
+
}}
|
|
425
|
+
title="Select parent"
|
|
426
|
+
>
|
|
427
|
+
<CornerLeftUp size={12} />
|
|
428
|
+
</button>
|
|
429
|
+
)}
|
|
430
|
+
<button
|
|
431
|
+
onClick={(e) => {
|
|
432
|
+
e.stopPropagation();
|
|
433
|
+
onToggleHidden(node.id);
|
|
434
|
+
}}
|
|
435
|
+
title={hidden ? "Show on live site" : "Hide from live site"}
|
|
436
|
+
>
|
|
437
|
+
{hidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
438
|
+
</button>
|
|
439
|
+
<button
|
|
440
|
+
onClick={(e) => {
|
|
441
|
+
e.stopPropagation();
|
|
442
|
+
onDuplicate(node.id);
|
|
443
|
+
}}
|
|
444
|
+
title="Duplicate"
|
|
445
|
+
>
|
|
446
|
+
<Copy size={12} />
|
|
447
|
+
</button>
|
|
448
|
+
<button
|
|
449
|
+
onClick={async (e) => {
|
|
450
|
+
e.stopPropagation();
|
|
451
|
+
const name = window.prompt(
|
|
452
|
+
"Save this section as a reusable block — name it:",
|
|
453
|
+
);
|
|
454
|
+
if (!name) return;
|
|
455
|
+
await api.post("/api/blocks", { name, content: node });
|
|
456
|
+
window.dispatchEvent(new Event(BLOCKS_CHANGED_EVENT));
|
|
457
|
+
}}
|
|
458
|
+
title="Save as reusable block"
|
|
459
|
+
>
|
|
460
|
+
<Bookmark size={12} />
|
|
461
|
+
</button>
|
|
462
|
+
<button
|
|
463
|
+
onClick={(e) => {
|
|
464
|
+
e.stopPropagation();
|
|
465
|
+
onDelete(node.id);
|
|
466
|
+
}}
|
|
467
|
+
title="Delete"
|
|
468
|
+
>
|
|
469
|
+
<Trash2 size={12} />
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
{hidden && (
|
|
474
|
+
<div
|
|
475
|
+
className="absolute z-30 bg-slate-900/80 text-white text-[10px] rounded px-1.5 py-0.5 flex items-center gap-1 pointer-events-none"
|
|
476
|
+
style={{ top: box.top + 4, right: box.right + 4 }}
|
|
477
|
+
>
|
|
478
|
+
<EyeOff size={10} /> Hidden
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
{/* Drag-to-resize handles — only for the selected node, not locked structural
|
|
482
|
+
slots. Right edge drags width (style.maxWidth), bottom edge drags height
|
|
483
|
+
(style.minHeight), the corner drags both. */}
|
|
484
|
+
{/* Live dimensions readout while dragging a resize handle. */}
|
|
485
|
+
{isResizing && (
|
|
486
|
+
<div
|
|
487
|
+
className="absolute z-40 bg-slate-900 text-white text-[11px] font-mono rounded px-1.5 py-0.5 pointer-events-none"
|
|
488
|
+
style={{
|
|
489
|
+
top: box.top + box.height + 6,
|
|
490
|
+
left: box.left + box.width / 2 - 40,
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
493
|
+
{Math.round(box.width)} × {Math.round(box.height)} px
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
{!def?.locked && selected && (
|
|
497
|
+
<>
|
|
498
|
+
<div
|
|
499
|
+
onPointerDown={startResize("width")}
|
|
500
|
+
onClick={(e) => e.stopPropagation()}
|
|
501
|
+
className="absolute z-30 pointer-events-auto cursor-ew-resize flex items-center justify-end pr-0.5"
|
|
502
|
+
// Kept fully inside the box's right edge rather than straddling it —
|
|
503
|
+
// centering exactly on the boundary pixel makes the hit-test a coin
|
|
504
|
+
// flip against whatever sits just outside (e.g. the Inspector panel).
|
|
505
|
+
style={{
|
|
506
|
+
top: box.top + box.height / 2 - 10,
|
|
507
|
+
left: box.left + box.width - 10,
|
|
508
|
+
width: 10,
|
|
509
|
+
height: 20,
|
|
510
|
+
}}
|
|
511
|
+
title="Drag to resize width"
|
|
512
|
+
>
|
|
513
|
+
<div className="w-1.5 h-5 rounded-full bg-blue-500 border border-white shadow" />
|
|
514
|
+
</div>
|
|
515
|
+
<div
|
|
516
|
+
onPointerDown={startResize("height")}
|
|
517
|
+
onClick={(e) => e.stopPropagation()}
|
|
518
|
+
className="absolute z-30 pointer-events-auto cursor-ns-resize flex items-end justify-center pb-0.5"
|
|
519
|
+
style={{
|
|
520
|
+
top: box.top + box.height - 10,
|
|
521
|
+
left: box.left + box.width / 2 - 10,
|
|
522
|
+
width: 20,
|
|
523
|
+
height: 10,
|
|
524
|
+
}}
|
|
525
|
+
title="Drag to resize height"
|
|
526
|
+
>
|
|
527
|
+
<div className="h-1.5 w-5 rounded-full bg-blue-500 border border-white shadow" />
|
|
528
|
+
</div>
|
|
529
|
+
<div
|
|
530
|
+
onPointerDown={startResize("both")}
|
|
531
|
+
onClick={(e) => e.stopPropagation()}
|
|
532
|
+
className="absolute z-30 pointer-events-auto cursor-nwse-resize w-4 h-4 rounded-sm bg-blue-500 border-2 border-white shadow"
|
|
533
|
+
style={{
|
|
534
|
+
top: box.top + box.height - 8,
|
|
535
|
+
left: box.left + box.width - 8,
|
|
536
|
+
}}
|
|
537
|
+
title="Drag to resize"
|
|
538
|
+
/>
|
|
539
|
+
</>
|
|
540
|
+
)}
|
|
541
|
+
</>,
|
|
542
|
+
overlayEl,
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import type { Breakpoint } from "@pgcms/shared";
|
|
5
|
+
|
|
6
|
+
/** Which breakpoint the builder is currently previewing/editing. Every canvas node
|
|
7
|
+
* renders its device-effective style, and every style edit is written into that
|
|
8
|
+
* device's override layer (see handleChangeStyle in the builder page). */
|
|
9
|
+
export const BuilderDeviceContext = createContext<Breakpoint>("desktop");
|
|
10
|
+
|
|
11
|
+
export function useBuilderDevice() {
|
|
12
|
+
return useContext(BuilderDeviceContext);
|
|
13
|
+
}
|