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,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { FONT_OPTIONS, type SiteTheme } from "@pgcms/shared";
|
|
5
|
+
import { api } from "@/lib/api";
|
|
6
|
+
import { ColorPickerField } from "@/components/builder/ColorPickerField";
|
|
7
|
+
import { MediaPickerModal } from "@/components/builder/MediaPickerModal";
|
|
8
|
+
|
|
9
|
+
export default function ThemeSettingsPage() {
|
|
10
|
+
const [theme, setTheme] = useState<SiteTheme | null>(null);
|
|
11
|
+
const [saving, setSaving] = useState(false);
|
|
12
|
+
const [logoPicker, setLogoPicker] = useState(false);
|
|
13
|
+
const [faviconPicker, setFaviconPicker] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
api.get<SiteTheme>("/api/theme").then(setTheme);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
function set<K extends keyof SiteTheme>(key: K, value: SiteTheme[K]) {
|
|
20
|
+
setTheme((prev) => (prev ? { ...prev, [key]: value } : prev));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function save() {
|
|
24
|
+
if (!theme) return;
|
|
25
|
+
setSaving(true);
|
|
26
|
+
try {
|
|
27
|
+
const updated = await api.put<SiteTheme>("/api/theme", theme);
|
|
28
|
+
setTheme(updated);
|
|
29
|
+
} finally {
|
|
30
|
+
setSaving(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!theme) return <div className="p-8 text-sm text-slate-400">Loading...</div>;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="p-8 max-w-2xl space-y-6">
|
|
38
|
+
<h1 className="text-2xl font-semibold">Theme Settings</h1>
|
|
39
|
+
<p className="text-sm text-slate-500">
|
|
40
|
+
Sets the site-wide defaults (brand colors, fonts, logo). Individual components can still override these per-page.
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<div className="bg-white border rounded-lg p-6 space-y-5">
|
|
44
|
+
<div className="space-y-1">
|
|
45
|
+
<label className="text-sm font-medium">Site name</label>
|
|
46
|
+
<input
|
|
47
|
+
value={theme.siteName}
|
|
48
|
+
onChange={(e) => set("siteName", e.target.value)}
|
|
49
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="grid grid-cols-2 gap-4">
|
|
54
|
+
<ColorPickerField label="Primary color" value={theme.primaryColor} onChange={(v) => set("primaryColor", v)} />
|
|
55
|
+
<ColorPickerField label="Secondary color" value={theme.secondaryColor} onChange={(v) => set("secondaryColor", v)} />
|
|
56
|
+
<ColorPickerField label="Background color" value={theme.backgroundColor} onChange={(v) => set("backgroundColor", v)} />
|
|
57
|
+
<ColorPickerField label="Text color" value={theme.textColor} onChange={(v) => set("textColor", v)} />
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="grid grid-cols-2 gap-4">
|
|
61
|
+
<div className="space-y-1">
|
|
62
|
+
<label className="text-sm font-medium">Heading font</label>
|
|
63
|
+
<select
|
|
64
|
+
value={theme.headingFont}
|
|
65
|
+
onChange={(e) => set("headingFont", e.target.value)}
|
|
66
|
+
className="w-full border rounded-md px-3 py-2 text-sm bg-white"
|
|
67
|
+
>
|
|
68
|
+
{FONT_OPTIONS.map((f) => (
|
|
69
|
+
<option key={f} value={f}>
|
|
70
|
+
{f}
|
|
71
|
+
</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-1">
|
|
76
|
+
<label className="text-sm font-medium">Body font</label>
|
|
77
|
+
<select
|
|
78
|
+
value={theme.bodyFont}
|
|
79
|
+
onChange={(e) => set("bodyFont", e.target.value)}
|
|
80
|
+
className="w-full border rounded-md px-3 py-2 text-sm bg-white"
|
|
81
|
+
>
|
|
82
|
+
{FONT_OPTIONS.map((f) => (
|
|
83
|
+
<option key={f} value={f}>
|
|
84
|
+
{f}
|
|
85
|
+
</option>
|
|
86
|
+
))}
|
|
87
|
+
</select>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="grid grid-cols-2 gap-4">
|
|
92
|
+
<div className="space-y-2">
|
|
93
|
+
<label className="text-sm font-medium">Logo</label>
|
|
94
|
+
{theme.logoUrl && (
|
|
95
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
96
|
+
<img src={theme.logoUrl} alt="Logo" className="h-12 border rounded-md px-2 py-1 bg-slate-50" />
|
|
97
|
+
)}
|
|
98
|
+
<button
|
|
99
|
+
onClick={() => setLogoPicker(true)}
|
|
100
|
+
className="w-full border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
|
|
101
|
+
>
|
|
102
|
+
{theme.logoUrl ? "Change logo" : "Select logo"}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="space-y-2">
|
|
106
|
+
<label className="text-sm font-medium">Favicon</label>
|
|
107
|
+
{theme.faviconUrl && (
|
|
108
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
109
|
+
<img src={theme.faviconUrl} alt="Favicon" className="h-12 w-12 border rounded-md p-1 bg-slate-50" />
|
|
110
|
+
)}
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => setFaviconPicker(true)}
|
|
113
|
+
className="w-full border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
|
|
114
|
+
>
|
|
115
|
+
{theme.faviconUrl ? "Change favicon" : "Select favicon"}
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="space-y-3 border-t pt-5">
|
|
121
|
+
<label className="flex items-center gap-2 text-sm font-medium">
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
checked={theme.darkModeEnabled}
|
|
125
|
+
onChange={(e) => set("darkModeEnabled", e.target.checked)}
|
|
126
|
+
/>
|
|
127
|
+
Enable dark mode
|
|
128
|
+
</label>
|
|
129
|
+
<p className="text-xs text-slate-500">
|
|
130
|
+
Adds a light/dark toggle to the public site. Leave the colors below blank to use sensible defaults.
|
|
131
|
+
</p>
|
|
132
|
+
{theme.darkModeEnabled && (
|
|
133
|
+
<div className="grid grid-cols-3 gap-4">
|
|
134
|
+
<ColorPickerField
|
|
135
|
+
label="Dark primary color"
|
|
136
|
+
value={theme.primaryColorDark ?? undefined}
|
|
137
|
+
onChange={(v) => set("primaryColorDark", v)}
|
|
138
|
+
/>
|
|
139
|
+
<ColorPickerField
|
|
140
|
+
label="Dark background"
|
|
141
|
+
value={theme.backgroundColorDark ?? undefined}
|
|
142
|
+
onChange={(v) => set("backgroundColorDark", v)}
|
|
143
|
+
/>
|
|
144
|
+
<ColorPickerField
|
|
145
|
+
label="Dark text color"
|
|
146
|
+
value={theme.textColorDark ?? undefined}
|
|
147
|
+
onChange={(v) => set("textColorDark", v)}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="space-y-3 border-t pt-5">
|
|
154
|
+
<label className="text-sm font-medium">Analytics</label>
|
|
155
|
+
<div className="grid grid-cols-2 gap-4">
|
|
156
|
+
<div className="space-y-1">
|
|
157
|
+
<label className="text-xs font-medium text-slate-600">Provider</label>
|
|
158
|
+
<select
|
|
159
|
+
value={theme.analyticsProvider}
|
|
160
|
+
onChange={(e) => set("analyticsProvider", e.target.value as SiteTheme["analyticsProvider"])}
|
|
161
|
+
className="w-full border rounded-md px-3 py-2 text-sm bg-white"
|
|
162
|
+
>
|
|
163
|
+
<option value="none">None</option>
|
|
164
|
+
<option value="ga4">Google Analytics (GA4)</option>
|
|
165
|
+
<option value="plausible">Plausible</option>
|
|
166
|
+
<option value="custom">Custom script</option>
|
|
167
|
+
</select>
|
|
168
|
+
</div>
|
|
169
|
+
{theme.analyticsProvider !== "none" && theme.analyticsProvider !== "custom" && (
|
|
170
|
+
<div className="space-y-1">
|
|
171
|
+
<label className="text-xs font-medium text-slate-600">
|
|
172
|
+
{theme.analyticsProvider === "ga4" ? "Measurement ID (G-XXXXXXX)" : "Domain"}
|
|
173
|
+
</label>
|
|
174
|
+
<input
|
|
175
|
+
value={theme.analyticsId ?? ""}
|
|
176
|
+
onChange={(e) => set("analyticsId", e.target.value)}
|
|
177
|
+
placeholder={theme.analyticsProvider === "ga4" ? "G-XXXXXXXXXX" : "example.com"}
|
|
178
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
{theme.analyticsProvider === "custom" && (
|
|
184
|
+
<div className="space-y-1">
|
|
185
|
+
<label className="text-xs font-medium text-slate-600">Custom script (inserted as-is on every public page)</label>
|
|
186
|
+
<textarea
|
|
187
|
+
value={theme.analyticsCustomScript ?? ""}
|
|
188
|
+
onChange={(e) => set("analyticsCustomScript", e.target.value)}
|
|
189
|
+
rows={4}
|
|
190
|
+
className="w-full border rounded-md px-3 py-2 text-sm font-mono"
|
|
191
|
+
placeholder="<script>...</script>"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="space-y-1">
|
|
198
|
+
<label className="text-sm font-medium">Custom CSS (advanced)</label>
|
|
199
|
+
<textarea
|
|
200
|
+
value={theme.customCss ?? ""}
|
|
201
|
+
onChange={(e) => set("customCss", e.target.value)}
|
|
202
|
+
rows={5}
|
|
203
|
+
className="w-full border rounded-md px-3 py-2 text-sm font-mono"
|
|
204
|
+
placeholder=".my-class { color: red; }"
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<button
|
|
209
|
+
onClick={save}
|
|
210
|
+
disabled={saving}
|
|
211
|
+
className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
212
|
+
>
|
|
213
|
+
{saving ? "Saving..." : "Save theme"}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{logoPicker && (
|
|
218
|
+
<MediaPickerModal
|
|
219
|
+
accept="image"
|
|
220
|
+
onSelect={(url) => {
|
|
221
|
+
set("logoUrl", url);
|
|
222
|
+
setLogoPicker(false);
|
|
223
|
+
}}
|
|
224
|
+
onClose={() => setLogoPicker(false)}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
{faviconPicker && (
|
|
228
|
+
<MediaPickerModal
|
|
229
|
+
accept="image"
|
|
230
|
+
onSelect={(url) => {
|
|
231
|
+
set("faviconUrl", url);
|
|
232
|
+
setFaviconPicker(false);
|
|
233
|
+
}}
|
|
234
|
+
onClose={() => setFaviconPicker(false)}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
5
|
+
import type { User, UserRole } from "@pgcms/shared";
|
|
6
|
+
import { api, ApiError } from "@/lib/api";
|
|
7
|
+
|
|
8
|
+
export default function UsersPage() {
|
|
9
|
+
const [users, setUsers] = useState<User[] | null>(null);
|
|
10
|
+
const [email, setEmail] = useState("");
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const [role, setRole] = useState<UserRole>("EDITOR");
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
function refresh() {
|
|
16
|
+
api.get<User[]>("/api/users").then(setUsers);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
useEffect(refresh, []);
|
|
20
|
+
|
|
21
|
+
async function addUser() {
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
await api.post("/api/users", { email, password, role });
|
|
25
|
+
setEmail("");
|
|
26
|
+
setPassword("");
|
|
27
|
+
setRole("EDITOR");
|
|
28
|
+
refresh();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
setError(err instanceof ApiError ? err.message : "Failed to create user");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function removeUser(id: string) {
|
|
35
|
+
if (!confirm("Remove this user?")) return;
|
|
36
|
+
await api.delete(`/api/users/${id}`);
|
|
37
|
+
refresh();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="p-8 max-w-2xl space-y-6">
|
|
42
|
+
<h1 className="text-2xl font-semibold">Users</h1>
|
|
43
|
+
|
|
44
|
+
<div className="bg-white border rounded-lg p-4 space-y-3">
|
|
45
|
+
{error && <div className="text-sm text-red-600">{error}</div>}
|
|
46
|
+
<div className="flex gap-3">
|
|
47
|
+
<input
|
|
48
|
+
placeholder="Email"
|
|
49
|
+
value={email}
|
|
50
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
51
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
|
52
|
+
/>
|
|
53
|
+
<input
|
|
54
|
+
placeholder="Password"
|
|
55
|
+
type="password"
|
|
56
|
+
value={password}
|
|
57
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
58
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
|
59
|
+
/>
|
|
60
|
+
<select
|
|
61
|
+
value={role}
|
|
62
|
+
onChange={(e) => setRole(e.target.value as UserRole)}
|
|
63
|
+
className="border rounded-md px-3 py-2 text-sm bg-white"
|
|
64
|
+
>
|
|
65
|
+
<option value="AUTHOR">Author (own pages/items only)</option>
|
|
66
|
+
<option value="EDITOR">Editor</option>
|
|
67
|
+
<option value="ADMIN">Admin</option>
|
|
68
|
+
</select>
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
onClick={addUser}
|
|
72
|
+
disabled={!email || !password}
|
|
73
|
+
className="flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
74
|
+
>
|
|
75
|
+
<Plus size={15} /> Add user
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="bg-white border rounded-lg divide-y">
|
|
80
|
+
{users?.map((u) => (
|
|
81
|
+
<div key={u.id} className="flex items-center justify-between px-4 py-3">
|
|
82
|
+
<div>
|
|
83
|
+
<div className="font-medium text-sm">{u.email}</div>
|
|
84
|
+
<div className="text-xs text-slate-400">{u.role}</div>
|
|
85
|
+
</div>
|
|
86
|
+
<button onClick={() => removeUser(u.id)} className="text-slate-400 hover:text-red-600">
|
|
87
|
+
<Trash2 size={16} />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Suspense, useState, type FormEvent } from "react";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import { api, ApiError } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
function LoginForm() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const searchParams = useSearchParams();
|
|
10
|
+
const [email, setEmail] = useState("");
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const [error, setError] = useState<string | null>(null);
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function onSubmit(e: FormEvent) {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
setError(null);
|
|
18
|
+
setLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
await api.post("/api/auth/login", { email, password });
|
|
21
|
+
router.push(searchParams.get("next") || "/admin");
|
|
22
|
+
router.refresh();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
setError(err instanceof ApiError ? err.message : "Login failed");
|
|
25
|
+
} finally {
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<form onSubmit={onSubmit} className="w-full max-w-sm bg-white rounded-xl shadow-sm border p-8 space-y-4">
|
|
32
|
+
<div>
|
|
33
|
+
<h1 className="text-xl font-semibold">pg-cms admin</h1>
|
|
34
|
+
<p className="text-sm text-slate-500">Sign in to manage your site.</p>
|
|
35
|
+
</div>
|
|
36
|
+
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">{error}</div>}
|
|
37
|
+
<div className="space-y-1">
|
|
38
|
+
<label className="text-sm font-medium">Email</label>
|
|
39
|
+
<input
|
|
40
|
+
type="email"
|
|
41
|
+
required
|
|
42
|
+
value={email}
|
|
43
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
44
|
+
className="w-full rounded-md border px-3 py-2 text-sm"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="space-y-1">
|
|
48
|
+
<label className="text-sm font-medium">Password</label>
|
|
49
|
+
<input
|
|
50
|
+
type="password"
|
|
51
|
+
required
|
|
52
|
+
value={password}
|
|
53
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
54
|
+
className="w-full rounded-md border px-3 py-2 text-sm"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
<button
|
|
58
|
+
type="submit"
|
|
59
|
+
disabled={loading}
|
|
60
|
+
className="w-full bg-blue-600 text-white rounded-md py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-60"
|
|
61
|
+
>
|
|
62
|
+
{loading ? "Signing in..." : "Sign in"}
|
|
63
|
+
</button>
|
|
64
|
+
</form>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function LoginPage() {
|
|
69
|
+
return (
|
|
70
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
71
|
+
<Suspense fallback={<div className="text-sm text-slate-400">Loading…</div>}>
|
|
72
|
+
<LoginForm />
|
|
73
|
+
</Suspense>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
/** Called by the Express API after publish/save to bust ISR cache tags. */
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const secret = process.env.REVALIDATE_SECRET;
|
|
7
|
+
if (!secret) {
|
|
8
|
+
return NextResponse.json({ error: "Revalidation not configured" }, { status: 503 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const auth = req.headers.get("authorization");
|
|
12
|
+
if (auth !== `Bearer ${secret}`) {
|
|
13
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let body: { tags?: unknown };
|
|
17
|
+
try {
|
|
18
|
+
body = await req.json();
|
|
19
|
+
} catch {
|
|
20
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!Array.isArray(body.tags) || body.tags.some((t) => typeof t !== "string")) {
|
|
24
|
+
return NextResponse.json({ error: "tags must be a string array" }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const tag of body.tags) {
|
|
28
|
+
// Next 16 requires a cache-life profile; "max" expires the tag everywhere
|
|
29
|
+
// immediately, matching the pre-16 single-argument behavior.
|
|
30
|
+
revalidateTag(tag, "max");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return NextResponse.json({ revalidated: true, tags: body.tags });
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--theme-primary: #2563eb;
|
|
7
|
+
--theme-secondary: #0f172a;
|
|
8
|
+
--theme-bg: #ffffff;
|
|
9
|
+
--theme-text: #111827;
|
|
10
|
+
--theme-heading-font: "Poppins", sans-serif;
|
|
11
|
+
--theme-body-font: "Inter", sans-serif;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
background-color: var(--theme-bg);
|
|
16
|
+
color: var(--theme-text);
|
|
17
|
+
font-family: var(--theme-body-font);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
h1, h2, h3, h4, h5, h6 {
|
|
21
|
+
font-family: var(--theme-heading-font);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.pgcms-editable-selected {
|
|
25
|
+
outline: 2px solid #2563eb;
|
|
26
|
+
outline-offset: -2px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.pgcms-editable-hover:hover {
|
|
30
|
+
outline: 1px dashed #94a3b8;
|
|
31
|
+
outline-offset: -1px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* --- Modern effect utilities, driven by NodeStyle fields (see Renderer.tsx) --- */
|
|
35
|
+
|
|
36
|
+
/* Glassmorphism panel — translucent frosted glass. The background only applies when
|
|
37
|
+
the node has no explicit backgroundColor of its own (inline style wins over this). */
|
|
38
|
+
.pgcms-glass {
|
|
39
|
+
background-color: rgb(255 255 255 / 0.12);
|
|
40
|
+
backdrop-filter: blur(14px) saturate(1.4);
|
|
41
|
+
-webkit-backdrop-filter: blur(14px) saturate(1.4);
|
|
42
|
+
border: 1px solid rgb(255 255 255 / 0.25);
|
|
43
|
+
box-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Gradient-filled headings — colors come from --pgcms-tg-from / --pgcms-tg-to set
|
|
47
|
+
inline by the Renderer. */
|
|
48
|
+
.pgcms-text-gradient h1,
|
|
49
|
+
.pgcms-text-gradient h2,
|
|
50
|
+
.pgcms-text-gradient h3 {
|
|
51
|
+
background-image: linear-gradient(100deg, var(--pgcms-tg-from), var(--pgcms-tg-to));
|
|
52
|
+
background-clip: text;
|
|
53
|
+
-webkit-background-clip: text;
|
|
54
|
+
color: transparent;
|
|
55
|
+
-webkit-text-fill-color: transparent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Hover effects (tilt is JS-driven in useModernEffects; these three are pure CSS). */
|
|
59
|
+
.pgcms-hover-lift {
|
|
60
|
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
61
|
+
}
|
|
62
|
+
.pgcms-hover-lift:hover {
|
|
63
|
+
transform: translateY(-8px);
|
|
64
|
+
box-shadow: 0 20px 40px -12px rgb(0 0 0 / 0.25);
|
|
65
|
+
}
|
|
66
|
+
.pgcms-hover-scale {
|
|
67
|
+
transition: transform 0.3s ease;
|
|
68
|
+
}
|
|
69
|
+
.pgcms-hover-scale:hover {
|
|
70
|
+
transform: scale(1.03);
|
|
71
|
+
}
|
|
72
|
+
.pgcms-hover-glow {
|
|
73
|
+
transition: box-shadow 0.3s ease;
|
|
74
|
+
}
|
|
75
|
+
.pgcms-hover-glow:hover {
|
|
76
|
+
/* Fallback for browsers without relative color syntax, then the themed version. */
|
|
77
|
+
box-shadow: 0 0 32px rgb(37 99 235 / 0.45);
|
|
78
|
+
box-shadow: 0 0 0 1px rgb(from var(--theme-primary) r g b / 0.4),
|
|
79
|
+
0 0 32px rgb(from var(--theme-primary) r g b / 0.45);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Animated gradient background (Hero's "animate gradient" option) — the gradient is
|
|
83
|
+
oversized and its position drifts back and forth. */
|
|
84
|
+
.pgcms-animated-gradient {
|
|
85
|
+
background-size: 300% 300% !important;
|
|
86
|
+
animation: pgcms-gradient-drift 8s ease-in-out infinite;
|
|
87
|
+
}
|
|
88
|
+
@keyframes pgcms-gradient-drift {
|
|
89
|
+
0% { background-position: 0% 50%; }
|
|
90
|
+
50% { background-position: 100% 50%; }
|
|
91
|
+
100% { background-position: 0% 50%; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Marquee ticker — the track holds two copies of the items; translating it by -50%
|
|
95
|
+
loops seamlessly. Duration/direction are set inline by the component. */
|
|
96
|
+
.pgcms-marquee {
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
white-space: nowrap;
|
|
99
|
+
}
|
|
100
|
+
.pgcms-marquee-track {
|
|
101
|
+
display: inline-flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
animation: pgcms-marquee-scroll linear infinite;
|
|
104
|
+
animation-duration: var(--pgcms-marquee-duration, 20s);
|
|
105
|
+
animation-direction: var(--pgcms-marquee-direction, normal);
|
|
106
|
+
}
|
|
107
|
+
.pgcms-marquee:hover .pgcms-marquee-track[data-pause-on-hover="true"] {
|
|
108
|
+
animation-play-state: paused;
|
|
109
|
+
}
|
|
110
|
+
@keyframes pgcms-marquee-scroll {
|
|
111
|
+
from { transform: translateX(0); }
|
|
112
|
+
to { transform: translateX(-50%); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@media (prefers-reduced-motion: reduce) {
|
|
116
|
+
.pgcms-marquee-track,
|
|
117
|
+
.pgcms-animated-gradient {
|
|
118
|
+
animation: none;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import Script from "next/script";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "pg-cms",
|
|
7
|
+
description: "Drag-and-drop website CMS",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Runs before hydration so a dark-mode visitor never sees a light flash: the
|
|
11
|
+
// `data-theme` attribute set here is what ThemeStyleTag's `:root[data-theme="dark"]`
|
|
12
|
+
// block targets. Harmless when a site hasn't enabled dark mode — that CSS block simply
|
|
13
|
+
// doesn't exist, so setting the attribute has no visual effect.
|
|
14
|
+
const THEME_INIT_SCRIPT = `
|
|
15
|
+
(function () {
|
|
16
|
+
try {
|
|
17
|
+
var saved = localStorage.getItem("pgcms-theme");
|
|
18
|
+
var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
19
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
20
|
+
} catch (e) {}
|
|
21
|
+
})();
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
25
|
+
return (
|
|
26
|
+
// suppressHydrationWarning is scoped to this element's own attributes only (it does
|
|
27
|
+
// not suppress mismatches in children) — needed because the beforeInteractive script
|
|
28
|
+
// below sets data-theme before React hydrates, which would otherwise be flagged as a
|
|
29
|
+
// server/client mismatch even though it's intentional.
|
|
30
|
+
<html lang="en" suppressHydrationWarning>
|
|
31
|
+
<head>
|
|
32
|
+
<Script id="pgcms-theme-init" strategy="beforeInteractive">
|
|
33
|
+
{THEME_INIT_SCRIPT}
|
|
34
|
+
</Script>
|
|
35
|
+
</head>
|
|
36
|
+
<body>{children}</body>
|
|
37
|
+
</html>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { isRtlLocale, type NavItem, type Page, type PageNode, type SiteTheme } from "@pgcms/shared";
|
|
4
|
+
import { serverGet } from "@/lib/serverApi";
|
|
5
|
+
import { ThemeStyleTag } from "@/components/theme/ThemeProvider";
|
|
6
|
+
import { PublicPageView } from "@/components/renderer/PublicPageView";
|
|
7
|
+
import { JsonLd } from "@/components/seo/JsonLd";
|
|
8
|
+
|
|
9
|
+
type GlobalSection = { kind: string; enabled: boolean; content: PageNode };
|
|
10
|
+
|
|
11
|
+
const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
|
|
12
|
+
|
|
13
|
+
async function loadPreview(token: string) {
|
|
14
|
+
try {
|
|
15
|
+
const [page, theme, navItems, globalSections] = await Promise.all([
|
|
16
|
+
serverGet<Page>(`/api/pages/preview/${token}`, { tags: [`preview:${token}`], revalidate: 0 }),
|
|
17
|
+
serverGet<SiteTheme>(`/api/theme/public`, { tags: ["cms", "cms-theme"] }),
|
|
18
|
+
serverGet<NavItem[]>(`/api/theme/nav/public`, { tags: ["cms", "cms-nav"] }),
|
|
19
|
+
serverGet<GlobalSection[]>(`/api/global-sections`).catch(() => [] as GlobalSection[]),
|
|
20
|
+
]);
|
|
21
|
+
const globalHeader = globalSections.find((s) => s.kind === "header" && s.enabled)?.content ?? null;
|
|
22
|
+
const globalFooter = globalSections.find((s) => s.kind === "footer" && s.enabled)?.content ?? null;
|
|
23
|
+
return { page, theme, navItems, globalHeader, globalFooter };
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateMetadata({
|
|
30
|
+
params,
|
|
31
|
+
}: {
|
|
32
|
+
params: Promise<{ token: string }>;
|
|
33
|
+
}): Promise<Metadata> {
|
|
34
|
+
const { token } = await params;
|
|
35
|
+
const data = await loadPreview(token);
|
|
36
|
+
if (!data) return { title: "Preview not found" };
|
|
37
|
+
return {
|
|
38
|
+
title: `[Preview] ${data.page.seoTitle || data.page.title}`,
|
|
39
|
+
robots: { index: false, follow: false },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default async function PreviewPage({ params }: { params: Promise<{ token: string }> }) {
|
|
44
|
+
const { token } = await params;
|
|
45
|
+
const data = await loadPreview(token);
|
|
46
|
+
if (!data) notFound();
|
|
47
|
+
|
|
48
|
+
const locale = data.page.locale || "en";
|
|
49
|
+
const slug = data.page.slug || "";
|
|
50
|
+
const pageUrl = `${SITE_URL}/${slug}`;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div lang={locale} dir={isRtlLocale(locale) ? "rtl" : "ltr"}>
|
|
54
|
+
<div className="bg-amber-500 text-amber-950 text-center text-xs py-1.5 font-medium">
|
|
55
|
+
Draft preview — this page is not published
|
|
56
|
+
</div>
|
|
57
|
+
<JsonLd page={data.page} theme={data.theme} url={pageUrl} />
|
|
58
|
+
<ThemeStyleTag theme={data.theme} />
|
|
59
|
+
<PublicPageView
|
|
60
|
+
page={data.page}
|
|
61
|
+
theme={data.theme}
|
|
62
|
+
navItems={data.navItems}
|
|
63
|
+
globalHeader={data.globalHeader}
|
|
64
|
+
globalFooter={data.globalFooter}
|
|
65
|
+
pageLocale={locale}
|
|
66
|
+
translations={[]}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
|
|
3
|
+
const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
|
|
4
|
+
|
|
5
|
+
/** /robots.txt — allow everything except the admin, and point crawlers at the sitemap. */
|
|
6
|
+
export default function robots(): MetadataRoute.Robots {
|
|
7
|
+
return {
|
|
8
|
+
rules: [{ userAgent: "*", allow: "/", disallow: ["/admin", "/preview"] }],
|
|
9
|
+
sitemap: `${SITE_URL}/sitemap.xml`,
|
|
10
|
+
};
|
|
11
|
+
}
|