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,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { NavItem, NodeStyle, PageNode, SiteTheme } from "@pgcms/shared";
|
|
5
|
+
import { SiteDataContext } from "@/components/theme/SiteDataContext";
|
|
6
|
+
import { themeToCssVars } from "@/components/theme/ThemeProvider";
|
|
7
|
+
import { DroppableChildren } from "./DroppableChildren";
|
|
8
|
+
import { BuilderCanvasNode } from "./BuilderCanvasNode";
|
|
9
|
+
import { CanvasOverlayContext } from "./CanvasOverlayContext";
|
|
10
|
+
|
|
11
|
+
export function Canvas({
|
|
12
|
+
tree,
|
|
13
|
+
theme,
|
|
14
|
+
navItems,
|
|
15
|
+
selectedId,
|
|
16
|
+
onSelect,
|
|
17
|
+
onDelete,
|
|
18
|
+
onDuplicate,
|
|
19
|
+
onToggleHidden,
|
|
20
|
+
onChangeStyle,
|
|
21
|
+
onResizeGridTrack,
|
|
22
|
+
}: {
|
|
23
|
+
tree: PageNode;
|
|
24
|
+
theme: SiteTheme;
|
|
25
|
+
navItems: NavItem[];
|
|
26
|
+
selectedId: string | null;
|
|
27
|
+
onSelect: (id: string) => void;
|
|
28
|
+
onDelete: (id: string) => void;
|
|
29
|
+
onDuplicate: (id: string) => void;
|
|
30
|
+
onToggleHidden: (id: string) => void;
|
|
31
|
+
onChangeStyle: (id: string, style: NodeStyle) => void;
|
|
32
|
+
onResizeGridTrack?: (gridId: string, axis: "column" | "row", index: number, size: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
const [overlayEl, setOverlayEl] = useState<HTMLDivElement | null>(null);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="flex-1 overflow-y-auto bg-slate-100"
|
|
39
|
+
onClick={() => onSelect("")}
|
|
40
|
+
// Real content (Logo, Nav Links, Buttons, CTA, and now linkable Containers/Columns)
|
|
41
|
+
// renders real <a>/<Link> elements straight from the same components used on the
|
|
42
|
+
// live site. A capture-phase preventDefault runs before Next.js's Link ever gets
|
|
43
|
+
// to its own bubble-phase click handler (which checks defaultPrevented and bails
|
|
44
|
+
// out), so clicking one here selects it for editing instead of navigating away.
|
|
45
|
+
onClickCapture={(e) => e.preventDefault()}
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
className="relative isolate mx-auto bg-white min-h-full shadow-sm"
|
|
49
|
+
style={{ ...themeToCssVars(theme), fontFamily: "var(--theme-body-font)" }}
|
|
50
|
+
>
|
|
51
|
+
<SiteDataContext.Provider value={{ theme, navItems }}>
|
|
52
|
+
<CanvasOverlayContext.Provider value={overlayEl}>
|
|
53
|
+
<DroppableChildren parentId={tree.id} childIds={tree.children.map((c) => c.id)}>
|
|
54
|
+
{tree.children.map((child) => (
|
|
55
|
+
<BuilderCanvasNode
|
|
56
|
+
key={child.id}
|
|
57
|
+
node={child}
|
|
58
|
+
selectedId={selectedId}
|
|
59
|
+
onSelect={onSelect}
|
|
60
|
+
onDelete={onDelete}
|
|
61
|
+
onDuplicate={onDuplicate}
|
|
62
|
+
onToggleHidden={onToggleHidden}
|
|
63
|
+
onChangeStyle={onChangeStyle}
|
|
64
|
+
onResizeGridTrack={onResizeGridTrack}
|
|
65
|
+
/>
|
|
66
|
+
))}
|
|
67
|
+
</DroppableChildren>
|
|
68
|
+
</CanvasOverlayContext.Provider>
|
|
69
|
+
</SiteDataContext.Provider>
|
|
70
|
+
{/* Toolbar chips portal in here — a flat layer scrolling with the content,
|
|
71
|
+
immune to any ancestor's compositing/stacking-context quirks. */}
|
|
72
|
+
<div ref={setOverlayEl} className="absolute inset-0 z-40 pointer-events-none" />
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The scrollable canvas content area that floating toolbar chips portal into.
|
|
7
|
+
*
|
|
8
|
+
* Nesting a `position: absolute` toolbar deep inside the node tree isn't safe here:
|
|
9
|
+
* every node's wrapper carries dnd-kit's `transition`/`transform` styling for drag
|
|
10
|
+
* animation, and Chromium promotes elements like that to their own compositing layer
|
|
11
|
+
* whenever they're present — even at rest. That traps a nested toolbar's z-index
|
|
12
|
+
* inside its ancestor's layer instead of letting it win against sibling content
|
|
13
|
+
* elsewhere in the header/container (confirmed via document.elementFromPoint showing
|
|
14
|
+
* an unrelated ancestor div swallowing clicks meant for a deeply nested button).
|
|
15
|
+
* Portaling into one flat layer that sits alongside the scrollable content sidesteps
|
|
16
|
+
* the whole class of stacking bugs.
|
|
17
|
+
*/
|
|
18
|
+
export const CanvasOverlayContext = createContext<HTMLDivElement | null>(null);
|
|
19
|
+
|
|
20
|
+
export function useCanvasOverlay() {
|
|
21
|
+
return useContext(CanvasOverlayContext);
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { HexAlphaColorPicker } from "react-colorful";
|
|
5
|
+
|
|
6
|
+
export function ColorPickerField({
|
|
7
|
+
label,
|
|
8
|
+
value,
|
|
9
|
+
onChange,
|
|
10
|
+
}: {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string | undefined;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
}) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-1 relative">
|
|
19
|
+
<label className="text-xs font-medium text-slate-600">{label}</label>
|
|
20
|
+
<div className="flex items-center gap-2">
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={() => setOpen((o) => !o)}
|
|
24
|
+
className="w-8 h-8 rounded border shrink-0"
|
|
25
|
+
style={{ backgroundColor: value || "#ffffff" }}
|
|
26
|
+
/>
|
|
27
|
+
<input
|
|
28
|
+
value={value ?? ""}
|
|
29
|
+
onChange={(e) => onChange(e.target.value)}
|
|
30
|
+
placeholder="transparent"
|
|
31
|
+
className="flex-1 border rounded-md px-2 py-1.5 text-sm"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
{open && (
|
|
35
|
+
<div className="absolute z-40 mt-1 bg-white border rounded-md shadow-lg p-2">
|
|
36
|
+
<HexAlphaColorPicker color={value || "#ffffff"} onChange={onChange} />
|
|
37
|
+
<button
|
|
38
|
+
className="text-xs text-slate-500 mt-2 w-full text-center"
|
|
39
|
+
onClick={() => setOpen(false)}
|
|
40
|
+
>
|
|
41
|
+
Close
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useDroppable } from "@dnd-kit/core";
|
|
4
|
+
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
|
|
7
|
+
export function DroppableChildren({
|
|
8
|
+
parentId,
|
|
9
|
+
childIds,
|
|
10
|
+
children,
|
|
11
|
+
transparent = false,
|
|
12
|
+
}: {
|
|
13
|
+
parentId: string;
|
|
14
|
+
childIds: string[];
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* When true and non-empty, the wrapper renders with `display: contents` so its
|
|
18
|
+
* children (e.g. a Container's Column nodes) become direct layout children of
|
|
19
|
+
* whatever CSS grid/flex the surrounding section component declares — otherwise
|
|
20
|
+
* this wrapper div is the grid/flex's only item and everything collapses into one
|
|
21
|
+
* cell. Left opaque while empty so it stays a real, measurable drop target (a
|
|
22
|
+
* `display: contents` element has no box for dnd-kit to hit-test against).
|
|
23
|
+
*/
|
|
24
|
+
transparent?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
const { setNodeRef, isOver } = useDroppable({
|
|
27
|
+
id: `dropzone:${parentId}`,
|
|
28
|
+
data: { type: "container", parentId },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const isEmpty = childIds.length === 0;
|
|
32
|
+
const useTransparent = transparent && !isEmpty;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<SortableContext items={childIds} strategy={verticalListSortingStrategy}>
|
|
36
|
+
<div
|
|
37
|
+
ref={setNodeRef}
|
|
38
|
+
style={useTransparent ? { display: "contents" } : undefined}
|
|
39
|
+
className={clsx(!useTransparent && "min-h-[48px]", !useTransparent && isOver && "bg-blue-50/60")}
|
|
40
|
+
>
|
|
41
|
+
{isEmpty && (
|
|
42
|
+
<div className="text-xs text-slate-400 border border-dashed rounded p-6 text-center m-2">
|
|
43
|
+
Drop components here
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</SortableContext>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, Video as VideoIcon } from "lucide-react";
|
|
5
|
+
import type { FieldDescriptor } from "@pgcms/shared";
|
|
6
|
+
import { RichTextEditor } from "./RichTextEditor";
|
|
7
|
+
import { MediaPickerModal } from "./MediaPickerModal";
|
|
8
|
+
import { ColorPickerField } from "./ColorPickerField";
|
|
9
|
+
|
|
10
|
+
export function FieldRenderer({
|
|
11
|
+
field,
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
}: {
|
|
15
|
+
field: FieldDescriptor;
|
|
16
|
+
value: unknown;
|
|
17
|
+
onChange: (value: unknown) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
switch (field.type) {
|
|
22
|
+
case "text":
|
|
23
|
+
return (
|
|
24
|
+
<LabeledField label={field.label}>
|
|
25
|
+
<input
|
|
26
|
+
value={(value as string) ?? ""}
|
|
27
|
+
placeholder={field.placeholder}
|
|
28
|
+
onChange={(e) => onChange(e.target.value)}
|
|
29
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
30
|
+
/>
|
|
31
|
+
</LabeledField>
|
|
32
|
+
);
|
|
33
|
+
case "textarea":
|
|
34
|
+
return (
|
|
35
|
+
<LabeledField label={field.label}>
|
|
36
|
+
<textarea
|
|
37
|
+
value={(value as string) ?? ""}
|
|
38
|
+
placeholder={field.placeholder}
|
|
39
|
+
onChange={(e) => onChange(e.target.value)}
|
|
40
|
+
rows={3}
|
|
41
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
42
|
+
/>
|
|
43
|
+
</LabeledField>
|
|
44
|
+
);
|
|
45
|
+
case "url":
|
|
46
|
+
return (
|
|
47
|
+
<LabeledField label={field.label}>
|
|
48
|
+
<input
|
|
49
|
+
value={(value as string) ?? ""}
|
|
50
|
+
placeholder={field.placeholder ?? "https://"}
|
|
51
|
+
onChange={(e) => onChange(e.target.value)}
|
|
52
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
53
|
+
/>
|
|
54
|
+
</LabeledField>
|
|
55
|
+
);
|
|
56
|
+
case "datetime":
|
|
57
|
+
return (
|
|
58
|
+
<LabeledField label={field.label}>
|
|
59
|
+
<input
|
|
60
|
+
type="datetime-local"
|
|
61
|
+
value={(value as string) ?? ""}
|
|
62
|
+
onChange={(e) => onChange(e.target.value)}
|
|
63
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
64
|
+
/>
|
|
65
|
+
</LabeledField>
|
|
66
|
+
);
|
|
67
|
+
case "number":
|
|
68
|
+
return (
|
|
69
|
+
<LabeledField label={field.label}>
|
|
70
|
+
<input
|
|
71
|
+
type="number"
|
|
72
|
+
min={field.min}
|
|
73
|
+
max={field.max}
|
|
74
|
+
step={field.step ?? 1}
|
|
75
|
+
value={(value as number) ?? 0}
|
|
76
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
77
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
78
|
+
/>
|
|
79
|
+
</LabeledField>
|
|
80
|
+
);
|
|
81
|
+
case "boolean":
|
|
82
|
+
return (
|
|
83
|
+
<label className="flex items-center gap-2 text-sm">
|
|
84
|
+
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
|
|
85
|
+
{field.label}
|
|
86
|
+
</label>
|
|
87
|
+
);
|
|
88
|
+
case "select":
|
|
89
|
+
return (
|
|
90
|
+
<LabeledField label={field.label}>
|
|
91
|
+
<select
|
|
92
|
+
value={(value as string) ?? ""}
|
|
93
|
+
onChange={(e) => onChange(e.target.value)}
|
|
94
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
|
|
95
|
+
>
|
|
96
|
+
{field.options?.map((opt) => (
|
|
97
|
+
<option key={opt.value} value={opt.value}>
|
|
98
|
+
{opt.label}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</select>
|
|
102
|
+
</LabeledField>
|
|
103
|
+
);
|
|
104
|
+
case "color":
|
|
105
|
+
return <ColorPickerField label={field.label} value={value as string} onChange={onChange} />;
|
|
106
|
+
case "richtext":
|
|
107
|
+
return (
|
|
108
|
+
<LabeledField label={field.label}>
|
|
109
|
+
<RichTextEditor value={(value as string) ?? ""} onChange={onChange} />
|
|
110
|
+
</LabeledField>
|
|
111
|
+
);
|
|
112
|
+
case "image":
|
|
113
|
+
return (
|
|
114
|
+
<LabeledField label={field.label}>
|
|
115
|
+
<MediaField
|
|
116
|
+
kind="image"
|
|
117
|
+
value={value as string}
|
|
118
|
+
onChange={onChange}
|
|
119
|
+
open={pickerOpen}
|
|
120
|
+
setOpen={setPickerOpen}
|
|
121
|
+
/>
|
|
122
|
+
</LabeledField>
|
|
123
|
+
);
|
|
124
|
+
case "video":
|
|
125
|
+
return (
|
|
126
|
+
<LabeledField label={field.label}>
|
|
127
|
+
<MediaField
|
|
128
|
+
kind="video"
|
|
129
|
+
value={value as string}
|
|
130
|
+
onChange={onChange}
|
|
131
|
+
open={pickerOpen}
|
|
132
|
+
setOpen={setPickerOpen}
|
|
133
|
+
/>
|
|
134
|
+
</LabeledField>
|
|
135
|
+
);
|
|
136
|
+
case "array":
|
|
137
|
+
return (
|
|
138
|
+
<ArrayField
|
|
139
|
+
field={field}
|
|
140
|
+
value={(value as Record<string, unknown>[]) ?? []}
|
|
141
|
+
onChange={onChange as (v: Record<string, unknown>[]) => void}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
default:
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function LabeledField({ label, children }: { label: string; children: React.ReactNode }) {
|
|
150
|
+
return (
|
|
151
|
+
<div className="space-y-1">
|
|
152
|
+
<label className="text-xs font-medium text-slate-600">{label}</label>
|
|
153
|
+
{children}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function MediaField({
|
|
159
|
+
kind,
|
|
160
|
+
value,
|
|
161
|
+
onChange,
|
|
162
|
+
open,
|
|
163
|
+
setOpen,
|
|
164
|
+
}: {
|
|
165
|
+
kind: "image" | "video";
|
|
166
|
+
value: string;
|
|
167
|
+
onChange: (v: string) => void;
|
|
168
|
+
open: boolean;
|
|
169
|
+
setOpen: (v: boolean) => void;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="space-y-2">
|
|
173
|
+
{value ? (
|
|
174
|
+
<div className="relative border rounded-md overflow-hidden h-28 bg-slate-50">
|
|
175
|
+
{kind === "image" ? (
|
|
176
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
177
|
+
<img src={value} alt="" className="w-full h-full object-cover" />
|
|
178
|
+
) : (
|
|
179
|
+
<video src={value} className="w-full h-full object-cover" muted />
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
) : null}
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => setOpen(true)}
|
|
186
|
+
className="w-full flex items-center justify-center gap-2 border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
|
|
187
|
+
>
|
|
188
|
+
{kind === "image" ? <ImagePlus size={14} /> : <VideoIcon size={14} />}
|
|
189
|
+
{value ? "Change" : "Select"} {kind}
|
|
190
|
+
</button>
|
|
191
|
+
{open && (
|
|
192
|
+
<MediaPickerModal
|
|
193
|
+
accept={kind}
|
|
194
|
+
onSelect={(url) => {
|
|
195
|
+
onChange(url);
|
|
196
|
+
setOpen(false);
|
|
197
|
+
}}
|
|
198
|
+
onClose={() => setOpen(false)}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ArrayField({
|
|
206
|
+
field,
|
|
207
|
+
value,
|
|
208
|
+
onChange,
|
|
209
|
+
}: {
|
|
210
|
+
field: FieldDescriptor;
|
|
211
|
+
value: Record<string, unknown>[];
|
|
212
|
+
onChange: (v: Record<string, unknown>[]) => void;
|
|
213
|
+
}) {
|
|
214
|
+
const itemFields = field.itemFields ?? [];
|
|
215
|
+
|
|
216
|
+
function updateItem(index: number, key: string, itemValue: unknown) {
|
|
217
|
+
const next = value.map((item, i) => (i === index ? { ...item, [key]: itemValue } : item));
|
|
218
|
+
onChange(next);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addItem() {
|
|
222
|
+
const blank: Record<string, unknown> = {};
|
|
223
|
+
for (const f of itemFields) blank[f.key] = f.default ?? "";
|
|
224
|
+
onChange([...value, blank]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function removeItem(index: number) {
|
|
228
|
+
onChange(value.filter((_, i) => i !== index));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function move(index: number, dir: -1 | 1) {
|
|
232
|
+
const target = index + dir;
|
|
233
|
+
if (target < 0 || target >= value.length) return;
|
|
234
|
+
const next = [...value];
|
|
235
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
236
|
+
onChange(next);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="space-y-2">
|
|
241
|
+
<div className="flex items-center justify-between">
|
|
242
|
+
<label className="text-xs font-medium text-slate-600">{field.label}</label>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={addItem}
|
|
246
|
+
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
|
|
247
|
+
>
|
|
248
|
+
<Plus size={12} /> Add
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="space-y-3">
|
|
252
|
+
{value.map((item, index) => (
|
|
253
|
+
<div key={index} className="border rounded-md p-2 space-y-2 bg-slate-50">
|
|
254
|
+
<div className="flex items-center justify-between">
|
|
255
|
+
<span className="text-xs text-slate-400">#{index + 1}</span>
|
|
256
|
+
<div className="flex items-center gap-1">
|
|
257
|
+
<button type="button" onClick={() => move(index, -1)} className="text-slate-400 hover:text-slate-700">
|
|
258
|
+
<ChevronUp size={14} />
|
|
259
|
+
</button>
|
|
260
|
+
<button type="button" onClick={() => move(index, 1)} className="text-slate-400 hover:text-slate-700">
|
|
261
|
+
<ChevronDown size={14} />
|
|
262
|
+
</button>
|
|
263
|
+
<button type="button" onClick={() => removeItem(index)} className="text-slate-400 hover:text-red-600">
|
|
264
|
+
<Trash2 size={14} />
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
{itemFields.map((f) => (
|
|
269
|
+
<FieldRenderer key={f.key} field={f} value={item[f.key]} onChange={(v) => updateItem(index, f.key, v)} />
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
))}
|
|
273
|
+
{value.length === 0 && <div className="text-xs text-slate-400 italic">No items yet</div>}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as Icons from "lucide-react";
|
|
2
|
+
import type { LucideProps } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export function IconRenderer({ name, ...props }: { name: string } & LucideProps) {
|
|
5
|
+
const Icon = (Icons as unknown as Record<string, React.ComponentType<LucideProps>>)[name] ?? Icons.Square;
|
|
6
|
+
return <Icon {...props} />;
|
|
7
|
+
}
|