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,117 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Globe, MapPin, Phone, Mail } from "lucide-react";
|
|
3
|
+
import { useSiteData } from "@/components/theme/SiteDataContext";
|
|
4
|
+
import type { SectionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
// lucide removed brand logos from the library, so social links render as simple
|
|
7
|
+
// monogram badges (an original, trademark-free treatment) — the platform's initials
|
|
8
|
+
// in a circle, sized to match the surrounding inline icons.
|
|
9
|
+
const SOCIAL_MONOGRAMS: Record<string, string> = {
|
|
10
|
+
facebook: "f",
|
|
11
|
+
twitter: "x",
|
|
12
|
+
instagram: "ig",
|
|
13
|
+
linkedin: "in",
|
|
14
|
+
youtube: "yt",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function SocialBadge({ platform }: { platform: string }) {
|
|
18
|
+
const monogram = SOCIAL_MONOGRAMS[platform];
|
|
19
|
+
if (!monogram) return <Globe size={18} />;
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
aria-hidden
|
|
23
|
+
className="inline-flex items-center justify-center w-[18px] h-[18px] rounded-full border border-current text-[9px] font-bold leading-none lowercase"
|
|
24
|
+
>
|
|
25
|
+
{monogram}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type LinkColumn = { title: string; links: { label: string; href: string }[] };
|
|
31
|
+
type ContactInfo = { address?: string; phone?: string; email?: string; extraLine?: string };
|
|
32
|
+
|
|
33
|
+
export function Footer({ node }: SectionProps) {
|
|
34
|
+
const { theme } = useSiteData();
|
|
35
|
+
const text = (node.props.text as string) ?? "";
|
|
36
|
+
const description = (node.props.description as string) ?? "";
|
|
37
|
+
const socialLinks = (node.props.socialLinks as { platform: string; url: string }[]) ?? [];
|
|
38
|
+
const linkColumns = (node.props.linkColumns as LinkColumn[]) ?? [];
|
|
39
|
+
const contactInfo = (node.props.contactInfo as ContactInfo) ?? {};
|
|
40
|
+
const bottomLinks = (node.props.bottomLinks as { label: string; href: string }[]) ?? [];
|
|
41
|
+
|
|
42
|
+
const hasContactInfo = contactInfo.address || contactInfo.phone || contactInfo.email || contactInfo.extraLine;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="px-4">
|
|
46
|
+
{(description || linkColumns.length > 0 || hasContactInfo) && (
|
|
47
|
+
<div className="grid gap-10 pb-10 mb-8 border-b border-white/10" style={{ gridTemplateColumns: "2fr repeat(auto-fit, minmax(140px, 1fr))" }}>
|
|
48
|
+
<div className="space-y-3">
|
|
49
|
+
<div className="font-heading text-lg font-semibold">{theme.siteName}</div>
|
|
50
|
+
{description && <p className="text-sm opacity-70 max-w-sm">{description}</p>}
|
|
51
|
+
</div>
|
|
52
|
+
{linkColumns.map((col, i) => (
|
|
53
|
+
<div key={i} className="space-y-2">
|
|
54
|
+
<div className="text-sm font-semibold opacity-90">{col.title}</div>
|
|
55
|
+
<ul className="space-y-1.5">
|
|
56
|
+
{col.links.map((link, j) => (
|
|
57
|
+
<li key={j}>
|
|
58
|
+
<Link href={link.href} className="text-sm opacity-70 hover:opacity-100">
|
|
59
|
+
{link.label}
|
|
60
|
+
</Link>
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
{hasContactInfo && (
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<div className="text-sm font-semibold opacity-90">Get In Touch</div>
|
|
69
|
+
<ul className="space-y-2">
|
|
70
|
+
{contactInfo.address && (
|
|
71
|
+
<li className="flex items-start gap-2 text-sm opacity-70">
|
|
72
|
+
<MapPin size={14} className="mt-0.5 shrink-0" /> {contactInfo.address}
|
|
73
|
+
</li>
|
|
74
|
+
)}
|
|
75
|
+
{contactInfo.phone && (
|
|
76
|
+
<li className="flex items-center gap-2 text-sm opacity-70">
|
|
77
|
+
<Phone size={14} className="shrink-0" /> {contactInfo.phone}
|
|
78
|
+
</li>
|
|
79
|
+
)}
|
|
80
|
+
{contactInfo.email && (
|
|
81
|
+
<li className="flex items-center gap-2 text-sm opacity-70">
|
|
82
|
+
<Mail size={14} className="shrink-0" /> {contactInfo.email}
|
|
83
|
+
</li>
|
|
84
|
+
)}
|
|
85
|
+
{contactInfo.extraLine && <li className="text-xs opacity-50">{contactInfo.extraLine}</li>}
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
93
|
+
<div className="flex flex-col md:flex-row items-center gap-3 text-center md:text-left">
|
|
94
|
+
<p className="text-sm opacity-80">{text}</p>
|
|
95
|
+
{bottomLinks.length > 0 && (
|
|
96
|
+
<div className="flex items-center gap-3 flex-wrap justify-center">
|
|
97
|
+
{bottomLinks.map((link, i) => (
|
|
98
|
+
<Link key={i} href={link.href} className="text-xs opacity-70 hover:opacity-100 underline underline-offset-2">
|
|
99
|
+
{link.label}
|
|
100
|
+
</Link>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
{socialLinks.length > 0 && (
|
|
106
|
+
<div className="flex items-center gap-4">
|
|
107
|
+
{socialLinks.map((link, i) => (
|
|
108
|
+
<a key={i} href={link.url} target="_blank" rel="noreferrer" aria-label={link.platform}>
|
|
109
|
+
<SocialBadge platform={link.platform} />
|
|
110
|
+
</a>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CmsImage } from "../CmsImage";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
type GalleryImage = { src: string; alt?: string };
|
|
5
|
+
|
|
6
|
+
export function Gallery({ node }: SectionProps) {
|
|
7
|
+
const images = (node.props.images as GalleryImage[]) ?? [];
|
|
8
|
+
const columns = Math.max(1, Math.min(6, Number(node.props.columns) || 3));
|
|
9
|
+
|
|
10
|
+
if (images.length === 0) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="h-48 flex items-center justify-center bg-slate-100 text-slate-400 text-sm">
|
|
13
|
+
No images added yet
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
20
|
+
{images.map((img, i) => (
|
|
21
|
+
<div key={i} className="relative w-full h-40 rounded-lg overflow-hidden">
|
|
22
|
+
<CmsImage src={img.src} alt={img.alt ?? ""} fill className="object-cover" sizes={`${Math.round(100 / columns)}vw`} />
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as LucideIcons from "lucide-react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { nodeStyleToCss } from "@/lib/style";
|
|
6
|
+
import type { SectionProps } from "./types";
|
|
7
|
+
|
|
8
|
+
const SIZE_CLASSES = {
|
|
9
|
+
sm: "w-8 h-8 text-sm",
|
|
10
|
+
md: "w-12 h-12 text-xl",
|
|
11
|
+
lg: "w-16 h-16 text-2xl"
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export function GoToTop({ node }: SectionProps) {
|
|
15
|
+
const enabled = node.props.enabled !== false;
|
|
16
|
+
const [visible, setVisible] = useState(false);
|
|
17
|
+
const showAfterScroll = Number(node.props.showAfterScroll) || 300;
|
|
18
|
+
const iconName = (node.props.icon as string) || "ArrowUp";
|
|
19
|
+
const size = (node.props.size as keyof typeof SIZE_CLASSES) || "md";
|
|
20
|
+
const css = nodeStyleToCss(node.style, true);
|
|
21
|
+
|
|
22
|
+
const Icon = (LucideIcons as any)[iconName] || LucideIcons.ArrowUp;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!enabled) return;
|
|
26
|
+
|
|
27
|
+
const handleScroll = () => {
|
|
28
|
+
setVisible(window.scrollY > showAfterScroll);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
handleScroll();
|
|
32
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
33
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
34
|
+
}, [enabled, showAfterScroll]);
|
|
35
|
+
|
|
36
|
+
const goToTop = () => {
|
|
37
|
+
window.scrollTo({
|
|
38
|
+
top: 0,
|
|
39
|
+
behavior: "smooth"
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!enabled) return null;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
onClick={goToTop}
|
|
48
|
+
className={`fixed bottom-8 right-8 z-50 flex items-center justify-center cursor-pointer transition-all duration-300 hover:scale-110 ${SIZE_CLASSES[size]} ${visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10 pointer-events-none"}`}
|
|
49
|
+
style={css}
|
|
50
|
+
>
|
|
51
|
+
<Icon size="1em" />
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A true 2D CSS Grid — Container/Column only ever lay out a single row of columns.
|
|
6
|
+
* Row/column counts live in props (rows/columns, adjusted via the Inspector's
|
|
7
|
+
* stepper pair, which seeds/grows the matching "gridCell" children). A cell's own
|
|
8
|
+
* span across multiple tracks is a NodeStyle field on the cell itself
|
|
9
|
+
* (gridColumnSpan/gridRowSpan — see lib/style.ts) since it has to land on that cell's
|
|
10
|
+
* own rendered element to actually affect its placement as a grid item.
|
|
11
|
+
*/
|
|
12
|
+
export function Grid({ node, children }: SectionProps) {
|
|
13
|
+
const rows = Math.max(1, Number(node.props.rows) || 1);
|
|
14
|
+
const columns = Math.max(1, Number(node.props.columns) || 2);
|
|
15
|
+
const gap = (node.props.gap as string) || "24px";
|
|
16
|
+
const alignItems = node.style.alignItems || undefined;
|
|
17
|
+
const justifyContent = node.style.justifyContent === "between" ? "space-between" : node.style.justifyContent || undefined;
|
|
18
|
+
|
|
19
|
+
// Per-track sizes (see setGridTrackSize) — dragging a cell's resize handle sets the
|
|
20
|
+
// specific column/row track it belongs to, same as resizing a column in a
|
|
21
|
+
// spreadsheet. Missing/short arrays fall back to the original even-split behavior.
|
|
22
|
+
const columnTracks = Array.isArray(node.props.columnTracks) ? (node.props.columnTracks as string[]) : [];
|
|
23
|
+
const rowTracks = Array.isArray(node.props.rowTracks) ? (node.props.rowTracks as string[]) : [];
|
|
24
|
+
const gridTemplateColumns = Array.from({ length: columns }, (_, i) => columnTracks[i] || "minmax(0, 1fr)").join(" ");
|
|
25
|
+
const gridTemplateRows = Array.from({ length: rows }, (_, i) => rowTracks[i] || "minmax(80px, auto)").join(" ");
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="grid"
|
|
30
|
+
style={
|
|
31
|
+
{
|
|
32
|
+
gridTemplateColumns,
|
|
33
|
+
gridTemplateRows,
|
|
34
|
+
gap,
|
|
35
|
+
// Governs how each cell's own section sizes/sits within its row (a section
|
|
36
|
+
// shorter than the row when alignItems isn't "stretch").
|
|
37
|
+
alignItems,
|
|
38
|
+
justifyContent,
|
|
39
|
+
// The same choice ALSO cascades into every cell's own inner content
|
|
40
|
+
// alignment (see GridCell.tsx) via inherited CSS custom properties — custom
|
|
41
|
+
// properties pass straight through a grid item's box to its descendants, so
|
|
42
|
+
// this reaches each cell without the Grid needing to know its children. A
|
|
43
|
+
// cell can still override just itself from its own Style tab. Defaults
|
|
44
|
+
// match the pre-existing unset look (full-width content, top-anchored)
|
|
45
|
+
// rather than the CSS spec defaults for these vars' *own* initial values.
|
|
46
|
+
"--pgcms-grid-align-items": alignItems ?? "stretch",
|
|
47
|
+
"--pgcms-grid-justify-content": justifyContent ?? "start",
|
|
48
|
+
} as CSSProperties
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One slot of a parent Grid. The cell's span across the parent's tracks is applied to
|
|
6
|
+
* its own outer element via NodeStyle (gridColumnSpan/gridRowSpan → nodeStyleToCss),
|
|
7
|
+
* not here, since a grid item's placement is controlled by properties on the item
|
|
8
|
+
* itself, not its children.
|
|
9
|
+
*
|
|
10
|
+
* Content alignment falls back to the parent Grid's own choice (via the
|
|
11
|
+
* --pgcms-grid-align-items/-justify-content custom properties it sets — see Grid.tsx)
|
|
12
|
+
* whenever this cell hasn't set its own explicit alignment, so choosing an alignment
|
|
13
|
+
* once on the Grid applies uniformly to every cell instead of requiring each one
|
|
14
|
+
* configured individually. The var() fallbacks (stretch/start) only matter if a cell
|
|
15
|
+
* somehow renders outside a Grid — normally the parent always provides the vars.
|
|
16
|
+
*/
|
|
17
|
+
export function GridCell({ node, children }: SectionProps) {
|
|
18
|
+
const justifyContent = node.style.justifyContent
|
|
19
|
+
? node.style.justifyContent === "between"
|
|
20
|
+
? "space-between"
|
|
21
|
+
: node.style.justifyContent
|
|
22
|
+
: "var(--pgcms-grid-justify-content, start)";
|
|
23
|
+
const alignItems = node.style.alignItems || "var(--pgcms-grid-align-items, stretch)";
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="flex flex-col gap-4 min-h-[40px] h-full"
|
|
28
|
+
style={{ justifyContent, alignItems } as CSSProperties}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { Menu, X, ChevronDown } from "lucide-react";
|
|
6
|
+
import { buildNavTree } from "@pgcms/shared";
|
|
7
|
+
import { useSiteData } from "@/components/theme/SiteDataContext";
|
|
8
|
+
import { CmsImage } from "../CmsImage";
|
|
9
|
+
import type { SectionProps } from "./types";
|
|
10
|
+
|
|
11
|
+
export function Header({ node, children }: SectionProps) {
|
|
12
|
+
const sticky = node.props.sticky !== false;
|
|
13
|
+
const hasZones = node.children.length > 0;
|
|
14
|
+
const [scrolled, setScrolled] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!sticky) return;
|
|
18
|
+
const onScroll = () => setScrolled(window.scrollY > 8);
|
|
19
|
+
onScroll();
|
|
20
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
21
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
22
|
+
}, [sticky]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
// Stickiness itself is applied by the Renderer to the section element — an inner
|
|
26
|
+
// sticky div can't work (its containing block is the section, exactly its own
|
|
27
|
+
// height). This component only adds the scrolled-state shadow.
|
|
28
|
+
<div>
|
|
29
|
+
<div
|
|
30
|
+
className={`px-4 py-4 transition-all duration-200 ${sticky && scrolled ? "shadow-md" : ""}`}
|
|
31
|
+
>
|
|
32
|
+
{hasZones ? (
|
|
33
|
+
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<LegacyHeader />
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Headers saved before left/center/right zones existed have no children — keep them
|
|
45
|
+
* rendering exactly as before instead of going blank. New headers use the zone layout. */
|
|
46
|
+
function LegacyHeader() {
|
|
47
|
+
const { theme, navItems } = useSiteData();
|
|
48
|
+
const [open, setOpen] = useState(false);
|
|
49
|
+
const items = buildNavTree(navItems);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative flex items-center justify-between">
|
|
53
|
+
<div className="flex items-center gap-2 font-heading text-lg font-semibold">
|
|
54
|
+
{theme.logoUrl ? (
|
|
55
|
+
<CmsImage src={theme.logoUrl} alt={theme.siteName} width={200} height={64} className="h-8 w-auto" style={{ height: "2rem", width: "auto" }} />
|
|
56
|
+
) : (
|
|
57
|
+
<span>{theme.siteName}</span>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
<nav className="hidden md:flex items-center gap-6">
|
|
61
|
+
{items.map((item) =>
|
|
62
|
+
item.children.length > 0 ? (
|
|
63
|
+
<div key={item.id} className="relative group">
|
|
64
|
+
<Link
|
|
65
|
+
href={item.href}
|
|
66
|
+
className="flex items-center gap-1 text-sm font-medium hover:opacity-70 py-2"
|
|
67
|
+
>
|
|
68
|
+
{item.label}
|
|
69
|
+
<ChevronDown size={14} />
|
|
70
|
+
</Link>
|
|
71
|
+
<div
|
|
72
|
+
className="absolute left-0 top-full min-w-[180px] rounded-md border bg-white text-slate-900 shadow-lg py-1.5 z-40
|
|
73
|
+
opacity-0 invisible translate-y-1 transition-all duration-150
|
|
74
|
+
group-hover:opacity-100 group-hover:visible group-hover:translate-y-0
|
|
75
|
+
group-focus-within:opacity-100 group-focus-within:visible group-focus-within:translate-y-0"
|
|
76
|
+
>
|
|
77
|
+
{item.children.map((child) => (
|
|
78
|
+
<Link
|
|
79
|
+
key={child.id}
|
|
80
|
+
href={child.href}
|
|
81
|
+
className="block px-4 py-2 text-sm hover:bg-slate-50 whitespace-nowrap"
|
|
82
|
+
>
|
|
83
|
+
{child.label}
|
|
84
|
+
</Link>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
<Link
|
|
90
|
+
key={item.id}
|
|
91
|
+
href={item.href}
|
|
92
|
+
className="text-sm font-medium hover:opacity-70"
|
|
93
|
+
>
|
|
94
|
+
{item.label}
|
|
95
|
+
</Link>
|
|
96
|
+
),
|
|
97
|
+
)}
|
|
98
|
+
</nav>
|
|
99
|
+
<button
|
|
100
|
+
className="md:hidden"
|
|
101
|
+
onClick={() => setOpen(!open)}
|
|
102
|
+
aria-label="Toggle menu"
|
|
103
|
+
>
|
|
104
|
+
{open ? <X size={22} /> : <Menu size={22} />}
|
|
105
|
+
</button>
|
|
106
|
+
{open && (
|
|
107
|
+
<nav className="md:hidden absolute left-0 right-0 top-full bg-inherit flex flex-col gap-1 px-4 pb-4">
|
|
108
|
+
{items.map((item) => (
|
|
109
|
+
<div key={item.id} className="flex flex-col">
|
|
110
|
+
<Link href={item.href} className="text-sm font-medium py-2">
|
|
111
|
+
{item.label}
|
|
112
|
+
</Link>
|
|
113
|
+
{item.children.length > 0 && (
|
|
114
|
+
<div className="flex flex-col pl-4 border-l ml-1">
|
|
115
|
+
{item.children.map((child) => (
|
|
116
|
+
<Link
|
|
117
|
+
key={child.id}
|
|
118
|
+
href={child.href}
|
|
119
|
+
className="text-sm py-1.5 opacity-80"
|
|
120
|
+
>
|
|
121
|
+
{child.label}
|
|
122
|
+
</Link>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</nav>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import { Button } from "@/components/renderer/sections/Button";
|
|
5
|
+
import { useThreeBackgroundScene, type ThreeBackgroundPreset } from "@/lib/threeBackgroundScene";
|
|
6
|
+
import type { SectionProps } from "./types";
|
|
7
|
+
|
|
8
|
+
const THREE_PRESETS = new Set(["particles", "waves", "shapes", "stars", "network", "rings"]);
|
|
9
|
+
|
|
10
|
+
export function Hero({ node, children }: SectionProps) {
|
|
11
|
+
const { heading, subheading, primaryButtonText, primaryButtonHref, secondaryButtonText, secondaryButtonHref } =
|
|
12
|
+
node.props as Record<string, string>;
|
|
13
|
+
// A separate on/off switch rather than folding "off" into the preset list — flipping
|
|
14
|
+
// this back on always restores whichever style/colors/speed were last configured,
|
|
15
|
+
// instead of losing them the moment "None" was picked.
|
|
16
|
+
const enabled = node.props.backgroundAnimationEnabled === true;
|
|
17
|
+
const preset = (node.props.preset as string) || "gradient";
|
|
18
|
+
const accentColor = (node.props.accentColor as string) || "#60a5fa";
|
|
19
|
+
const secondColor = (node.props.secondColor as string) || "#0f172a";
|
|
20
|
+
const gradientAngle = Number(node.props.gradientAngle) || 135;
|
|
21
|
+
const gradientAnimated = node.props.gradientAnimated === true;
|
|
22
|
+
const density = Number(node.props.density) || 80;
|
|
23
|
+
const speed = Number(node.props.speed) || 1;
|
|
24
|
+
const mouseFollow = node.props.mouseFollow === true;
|
|
25
|
+
const bgOpacity = node.props.bgOpacity !== undefined ? Number(node.props.bgOpacity) : 1;
|
|
26
|
+
const is3D = enabled && THREE_PRESETS.has(preset);
|
|
27
|
+
const isGradient = enabled && preset === "gradient";
|
|
28
|
+
|
|
29
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
31
|
+
useThreeBackgroundScene(canvasRef, containerRef, is3D, {
|
|
32
|
+
preset: (is3D ? preset : "particles") as ThreeBackgroundPreset,
|
|
33
|
+
color: accentColor,
|
|
34
|
+
secondColor,
|
|
35
|
+
density,
|
|
36
|
+
speed,
|
|
37
|
+
mouseFollow,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div ref={containerRef} className="relative flex flex-col items-center gap-6 overflow-hidden h-full min-h-[inherit]">
|
|
42
|
+
{isGradient && (
|
|
43
|
+
<div
|
|
44
|
+
className={gradientAnimated ? "absolute inset-0 pgcms-animated-gradient" : "absolute inset-0"}
|
|
45
|
+
style={{
|
|
46
|
+
// The animated variant needs a third stop so the drifting background-position
|
|
47
|
+
// has somewhere to travel — A → B → A reads as a continuous color sweep.
|
|
48
|
+
background: gradientAnimated
|
|
49
|
+
? `linear-gradient(${gradientAngle}deg, ${accentColor}, ${secondColor}, ${accentColor})`
|
|
50
|
+
: `linear-gradient(${gradientAngle}deg, ${accentColor}, ${secondColor})`,
|
|
51
|
+
opacity: bgOpacity,
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
{is3D && <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" style={{ opacity: bgOpacity }} />}
|
|
56
|
+
<div className="relative z-10 flex flex-col items-center gap-6">
|
|
57
|
+
{heading && <h1 className="text-4xl md:text-5xl font-bold">{heading}</h1>}
|
|
58
|
+
{subheading && <p className="text-lg opacity-90">{subheading}</p>}
|
|
59
|
+
{(primaryButtonText || secondaryButtonText) && (
|
|
60
|
+
<div className="flex flex-wrap gap-4 justify-center mt-2">
|
|
61
|
+
{primaryButtonText && (
|
|
62
|
+
<Button text={primaryButtonText} href={primaryButtonHref || "#"} variant="primary" />
|
|
63
|
+
)}
|
|
64
|
+
{secondaryButtonText && (
|
|
65
|
+
<Button text={secondaryButtonText} href={secondaryButtonHref || "#"} variant="outline" />
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
{children && <div className="w-full">{children}</div>}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Button } from "./Button";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
/** The overlay layer now renders universally for every node type (see
|
|
5
|
+
* NodeBackgroundLayers) and the image itself is plain CSS background — this component
|
|
6
|
+
* only lays out the built-in content and any child components dropped in. */
|
|
7
|
+
export function ImageBackgroundSection({ node, children }: SectionProps) {
|
|
8
|
+
const { heading, subheading, buttonText, buttonHref } = node.props as Record<string, string>;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[inherit] text-center gap-4">
|
|
12
|
+
<div className="flex flex-col items-center gap-4 max-w-2xl mx-auto">
|
|
13
|
+
{heading && <h2 className="text-3xl md:text-4xl font-bold">{heading}</h2>}
|
|
14
|
+
{subheading && <p className="text-lg opacity-90">{subheading}</p>}
|
|
15
|
+
{buttonText && <Button text={buttonText} href={buttonHref || "#"} variant="primary" />}
|
|
16
|
+
</div>
|
|
17
|
+
{children && <div className="w-full">{children}</div>}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function ImageBlock({ node }: SectionProps) {
|
|
5
|
+
const src = (node.props.src as string) ?? "";
|
|
6
|
+
const alt = (node.props.alt as string) ?? "";
|
|
7
|
+
const caption = (node.props.caption as string) ?? "";
|
|
8
|
+
const imgWidth = (node.props.imgWidth as string) || undefined;
|
|
9
|
+
const imgHeight = (node.props.imgHeight as string) || undefined;
|
|
10
|
+
const align = (node.props.align as string) || "center";
|
|
11
|
+
|
|
12
|
+
if (!src) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="h-64 flex items-center justify-center bg-slate-100 text-slate-400 text-sm">
|
|
15
|
+
No image selected
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const useOptimized = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("/");
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<figure className={align === "center" ? "flex flex-col items-center" : align === "right" ? "flex flex-col items-end" : "flex flex-col items-start"}>
|
|
24
|
+
{useOptimized ? (
|
|
25
|
+
<Image
|
|
26
|
+
src={src}
|
|
27
|
+
alt={alt}
|
|
28
|
+
width={1200}
|
|
29
|
+
height={800}
|
|
30
|
+
className="rounded-lg max-w-full h-auto"
|
|
31
|
+
style={{
|
|
32
|
+
width: imgWidth || "100%",
|
|
33
|
+
height: imgHeight || "auto",
|
|
34
|
+
maxWidth: "100%",
|
|
35
|
+
objectFit: imgHeight ? "cover" : undefined,
|
|
36
|
+
}}
|
|
37
|
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
|
|
38
|
+
/>
|
|
39
|
+
) : (
|
|
40
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
41
|
+
<img
|
|
42
|
+
src={src}
|
|
43
|
+
alt={alt}
|
|
44
|
+
className="rounded-lg max-w-full"
|
|
45
|
+
style={{
|
|
46
|
+
width: imgWidth || "100%",
|
|
47
|
+
height: imgHeight || "auto",
|
|
48
|
+
objectFit: imgHeight ? "cover" : undefined,
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
{caption && <figcaption className="text-sm opacity-70 mt-2 text-center">{caption}</figcaption>}
|
|
53
|
+
</figure>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Languages } from "lucide-react";
|
|
5
|
+
import { SUPPORTED_LOCALES } from "@pgcms/shared";
|
|
6
|
+
import { useSiteData } from "@/components/theme/SiteDataContext";
|
|
7
|
+
import type { SectionProps } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Lists the current page's translation group as links — the group is resolved
|
|
10
|
+
* server-side per page and provided through SiteDataContext, so this component has
|
|
11
|
+
* no props to configure beyond appearance. */
|
|
12
|
+
export function LanguageSwitcher({ node }: SectionProps) {
|
|
13
|
+
const { pageLocale, translations } = useSiteData();
|
|
14
|
+
const compact = node.props.compact === true;
|
|
15
|
+
const items = translations ?? [];
|
|
16
|
+
|
|
17
|
+
if (items.length === 0) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="text-xs opacity-60 flex items-center gap-1.5 justify-center">
|
|
20
|
+
<Languages size={14} /> No translations linked to this page yet
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function labelFor(locale: string) {
|
|
26
|
+
return SUPPORTED_LOCALES.find((l) => l.code === locale)?.label ?? locale;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<nav className="flex items-center gap-2 justify-center flex-wrap" aria-label="Page language">
|
|
31
|
+
<Languages size={16} className="opacity-60" />
|
|
32
|
+
{items.map((t) => {
|
|
33
|
+
const active = t.locale === pageLocale;
|
|
34
|
+
return (
|
|
35
|
+
<Link
|
|
36
|
+
key={t.locale}
|
|
37
|
+
href={`/${t.slug}`}
|
|
38
|
+
hrefLang={t.locale}
|
|
39
|
+
className={
|
|
40
|
+
active
|
|
41
|
+
? "text-sm font-semibold underline underline-offset-4"
|
|
42
|
+
: "text-sm opacity-70 hover:opacity-100"
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
{compact ? t.locale.toUpperCase() : labelFor(t.locale)}
|
|
46
|
+
</Link>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</nav>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { useSiteData } from "@/components/theme/SiteDataContext";
|
|
3
|
+
import { CmsImage } from "../CmsImage";
|
|
4
|
+
import type { SectionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export function Logo({ node }: SectionProps) {
|
|
7
|
+
const { theme } = useSiteData();
|
|
8
|
+
const useSiteLogo = node.props.useSiteLogo !== false;
|
|
9
|
+
const customImage = node.props.customImage as string;
|
|
10
|
+
const href = (node.props.href as string) || "/";
|
|
11
|
+
const height = (node.props.height as string) || "36px";
|
|
12
|
+
|
|
13
|
+
const src = useSiteLogo ? theme.logoUrl : customImage || theme.logoUrl;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Link href={href} className="inline-flex items-center font-heading text-lg font-semibold shrink-0">
|
|
17
|
+
{src ? (
|
|
18
|
+
<CmsImage src={src} alt={theme.siteName} width={240} height={80} style={{ height, width: "auto" }} />
|
|
19
|
+
) : (
|
|
20
|
+
<span>{theme.siteName}</span>
|
|
21
|
+
)}
|
|
22
|
+
</Link>
|
|
23
|
+
);
|
|
24
|
+
}
|