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,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** Raw JSON escape hatch for a node's props or style — for edits the guided Content/
|
|
6
|
+
* Style tabs don't expose yet. Re-syncs its local draft whenever the node's id changes
|
|
7
|
+
* (switching selection), but not on every prop change, so it doesn't fight the user's
|
|
8
|
+
* in-progress typing on every keystroke. */
|
|
9
|
+
export function JsonEditor({
|
|
10
|
+
nodeId,
|
|
11
|
+
label,
|
|
12
|
+
value,
|
|
13
|
+
onApply,
|
|
14
|
+
}: {
|
|
15
|
+
nodeId: string;
|
|
16
|
+
label: string;
|
|
17
|
+
value: Record<string, unknown>;
|
|
18
|
+
onApply: (value: Record<string, unknown>) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [text, setText] = useState(() => JSON.stringify(value, null, 2));
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [dirty, setDirty] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setText(JSON.stringify(value, null, 2));
|
|
26
|
+
setError(null);
|
|
27
|
+
setDirty(false);
|
|
28
|
+
// Only reset when a different node is selected, not on every value change —
|
|
29
|
+
// otherwise the textarea would reset out from under the user's own typing.
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [nodeId]);
|
|
32
|
+
|
|
33
|
+
function apply() {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(text);
|
|
36
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
37
|
+
setError("Must be a JSON object");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setError(null);
|
|
41
|
+
setDirty(false);
|
|
42
|
+
onApply(parsed);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setError(e instanceof Error ? e.message : "Invalid JSON");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-1.5">
|
|
50
|
+
<div className="flex items-center justify-between">
|
|
51
|
+
<label className="text-xs font-medium text-slate-600">{label}</label>
|
|
52
|
+
{dirty && (
|
|
53
|
+
<button type="button" onClick={apply} className="text-xs text-blue-600 hover:text-blue-700 font-medium">
|
|
54
|
+
Apply
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
<textarea
|
|
59
|
+
value={text}
|
|
60
|
+
onChange={(e) => {
|
|
61
|
+
setText(e.target.value);
|
|
62
|
+
setDirty(true);
|
|
63
|
+
}}
|
|
64
|
+
rows={10}
|
|
65
|
+
spellCheck={false}
|
|
66
|
+
className="w-full border rounded-md px-2 py-1.5 text-xs font-mono bg-slate-50"
|
|
67
|
+
/>
|
|
68
|
+
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/** Generic key/value list editor backing both the custom-CSS and custom-attributes
|
|
6
|
+
* escape hatches in the Inspector's Advanced panel — deliberately untyped strings so
|
|
7
|
+
* it can hold any CSS property or HTML attribute without a matching dedicated field. */
|
|
8
|
+
export function KeyValueListEditor({
|
|
9
|
+
label,
|
|
10
|
+
value,
|
|
11
|
+
onChange,
|
|
12
|
+
keyPlaceholder,
|
|
13
|
+
valuePlaceholder,
|
|
14
|
+
}: {
|
|
15
|
+
label: string;
|
|
16
|
+
value: Record<string, string> | undefined;
|
|
17
|
+
onChange: (v: Record<string, string> | undefined) => void;
|
|
18
|
+
keyPlaceholder?: string;
|
|
19
|
+
valuePlaceholder?: string;
|
|
20
|
+
}) {
|
|
21
|
+
const entries = Object.entries(value ?? {});
|
|
22
|
+
|
|
23
|
+
function commit(next: [string, string][]) {
|
|
24
|
+
onChange(next.length === 0 ? undefined : Object.fromEntries(next));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function updateKey(i: number, key: string) {
|
|
28
|
+
const next = entries.slice();
|
|
29
|
+
next[i] = [key, next[i][1]];
|
|
30
|
+
commit(next);
|
|
31
|
+
}
|
|
32
|
+
function updateValue(i: number, val: string) {
|
|
33
|
+
const next = entries.slice();
|
|
34
|
+
next[i] = [next[i][0], val];
|
|
35
|
+
commit(next);
|
|
36
|
+
}
|
|
37
|
+
function remove(i: number) {
|
|
38
|
+
commit(entries.filter((_, idx) => idx !== i));
|
|
39
|
+
}
|
|
40
|
+
function add() {
|
|
41
|
+
commit([...entries, ["", ""]]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
<div className="flex items-center justify-between">
|
|
47
|
+
<label className="text-xs font-medium text-slate-600">{label}</label>
|
|
48
|
+
<button type="button" onClick={add} className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700">
|
|
49
|
+
<Plus size={12} /> Add
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="space-y-1.5">
|
|
53
|
+
{entries.map(([key, val], i) => (
|
|
54
|
+
<div key={i} className="flex items-center gap-1.5">
|
|
55
|
+
<input
|
|
56
|
+
value={key}
|
|
57
|
+
placeholder={keyPlaceholder ?? "property"}
|
|
58
|
+
onChange={(e) => updateKey(i, e.target.value)}
|
|
59
|
+
className="w-[38%] border rounded-md px-2 py-1.5 text-xs font-mono"
|
|
60
|
+
/>
|
|
61
|
+
<input
|
|
62
|
+
value={val}
|
|
63
|
+
placeholder={valuePlaceholder ?? "value"}
|
|
64
|
+
onChange={(e) => updateValue(i, e.target.value)}
|
|
65
|
+
className="flex-1 border rounded-md px-2 py-1.5 text-xs font-mono"
|
|
66
|
+
/>
|
|
67
|
+
<button type="button" onClick={() => remove(i)} className="text-slate-400 hover:text-red-600 shrink-0">
|
|
68
|
+
<Trash2 size={14} />
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
{entries.length === 0 && <div className="text-xs text-slate-400 italic">None</div>}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { X, Upload } from "lucide-react";
|
|
5
|
+
import type { MediaAsset } from "@pgcms/shared";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
|
|
8
|
+
export function MediaPickerModal({
|
|
9
|
+
accept,
|
|
10
|
+
onSelect,
|
|
11
|
+
onClose,
|
|
12
|
+
}: {
|
|
13
|
+
accept: "image" | "video";
|
|
14
|
+
onSelect: (url: string) => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}) {
|
|
17
|
+
const [assets, setAssets] = useState<MediaAsset[] | null>(null);
|
|
18
|
+
const [uploading, setUploading] = useState(false);
|
|
19
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
20
|
+
|
|
21
|
+
function refresh() {
|
|
22
|
+
api.get<MediaAsset[]>("/api/media").then(setAssets);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
useEffect(refresh, []);
|
|
26
|
+
|
|
27
|
+
const filtered = assets?.filter((a) => (accept === "image" ? a.mimeType.startsWith("image/") : a.mimeType.startsWith("video/")));
|
|
28
|
+
|
|
29
|
+
async function handleUpload(file: File) {
|
|
30
|
+
setUploading(true);
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append("file", file);
|
|
33
|
+
try {
|
|
34
|
+
const asset = await api.post<MediaAsset>("/api/media", formData);
|
|
35
|
+
setAssets((prev) => [asset, ...(prev ?? [])]);
|
|
36
|
+
onSelect(asset.url);
|
|
37
|
+
} finally {
|
|
38
|
+
setUploading(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-6" onClick={onClose}>
|
|
44
|
+
<div
|
|
45
|
+
className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col"
|
|
46
|
+
onClick={(e) => e.stopPropagation()}
|
|
47
|
+
>
|
|
48
|
+
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
49
|
+
<h2 className="font-semibold">Select {accept}</h2>
|
|
50
|
+
<button onClick={onClose}>
|
|
51
|
+
<X size={18} />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="p-5 border-b">
|
|
55
|
+
<input
|
|
56
|
+
ref={fileInput}
|
|
57
|
+
type="file"
|
|
58
|
+
accept={accept === "image" ? "image/*" : "video/*"}
|
|
59
|
+
className="hidden"
|
|
60
|
+
onChange={(e) => {
|
|
61
|
+
const file = e.target.files?.[0];
|
|
62
|
+
if (file) handleUpload(file);
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => fileInput.current?.click()}
|
|
67
|
+
disabled={uploading}
|
|
68
|
+
className="flex items-center gap-2 border-2 border-dashed rounded-md px-4 py-3 text-sm text-slate-600 hover:border-blue-400 w-full justify-center disabled:opacity-50"
|
|
69
|
+
>
|
|
70
|
+
<Upload size={16} /> {uploading ? "Uploading..." : `Upload new ${accept}`}
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex-1 overflow-y-auto p-5 grid grid-cols-4 gap-3">
|
|
74
|
+
{filtered?.length === 0 && (
|
|
75
|
+
<div className="col-span-4 text-center text-sm text-slate-400 py-10">No {accept}s uploaded yet</div>
|
|
76
|
+
)}
|
|
77
|
+
{filtered?.map((asset) => (
|
|
78
|
+
<button
|
|
79
|
+
key={asset.id}
|
|
80
|
+
onClick={() => onSelect(asset.url)}
|
|
81
|
+
className="border rounded-md overflow-hidden hover:border-blue-400 aspect-square bg-slate-50"
|
|
82
|
+
>
|
|
83
|
+
{accept === "image" ? (
|
|
84
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
85
|
+
<img src={asset.url} alt="" className="w-full h-full object-cover" />
|
|
86
|
+
) : (
|
|
87
|
+
<video src={asset.url} className="w-full h-full object-cover" muted />
|
|
88
|
+
)}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { X, ImagePlus, History, RotateCcw } from "lucide-react";
|
|
5
|
+
import { SUPPORTED_LOCALES, type Page, type PageRevision } from "@pgcms/shared";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { MediaPickerModal } from "./MediaPickerModal";
|
|
8
|
+
|
|
9
|
+
type Tab = "general" | "seo" | "publishing" | "history";
|
|
10
|
+
|
|
11
|
+
/** ISO UTC instant → the local wall-clock string a <input type="datetime-local">
|
|
12
|
+
* expects. The reverse (input → ISO) is just new Date(value).toISOString(), since the
|
|
13
|
+
* browser interprets a bare datetime-local value in the user's own timezone. Round-
|
|
14
|
+
* tripping through UTC this way is what makes scheduling timezone-correct — the old
|
|
15
|
+
* code sent the raw local string and let the SERVER's timezone reinterpret it. */
|
|
16
|
+
function isoToLocalInput(iso: string): string {
|
|
17
|
+
const d = new Date(iso);
|
|
18
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function PageSettingsModal({
|
|
23
|
+
page,
|
|
24
|
+
onClose,
|
|
25
|
+
onSaved,
|
|
26
|
+
onRestore,
|
|
27
|
+
}: {
|
|
28
|
+
page: Page;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
onSaved: (page: Page) => void;
|
|
31
|
+
onRestore: (content: Page["content"]) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [tab, setTab] = useState<Tab>("general");
|
|
34
|
+
const [title, setTitle] = useState(page.title);
|
|
35
|
+
const [slug, setSlug] = useState(page.slug);
|
|
36
|
+
const [seoTitle, setSeoTitle] = useState(page.seoTitle ?? "");
|
|
37
|
+
const [seoDescription, setSeoDescription] = useState(page.seoDescription ?? "");
|
|
38
|
+
const [ogImage, setOgImage] = useState(page.ogImage ?? "");
|
|
39
|
+
const [publishAt, setPublishAt] = useState(page.publishAt ? isoToLocalInput(page.publishAt) : "");
|
|
40
|
+
const [locale, setLocale] = useState(page.locale ?? "en");
|
|
41
|
+
const [translationOfId, setTranslationOfId] = useState(page.translationOfId ?? "");
|
|
42
|
+
const [noIndex, setNoIndex] = useState(page.noIndex ?? false);
|
|
43
|
+
const [previewToken, setPreviewToken] = useState(page.previewToken ?? "");
|
|
44
|
+
const [allPages, setAllPages] = useState<Pick<Page, "id" | "title" | "slug" | "locale" | "translationOfId">[]>([]);
|
|
45
|
+
const [ogPicker, setOgPicker] = useState(false);
|
|
46
|
+
const [saving, setSaving] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const [revisions, setRevisions] = useState<Pick<PageRevision, "id" | "title" | "createdAt">[] | null>(null);
|
|
50
|
+
const [restoring, setRestoring] = useState<string | null>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
api.get<typeof allPages>("/api/pages").then(setAllPages).catch(() => {});
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setPreviewToken(page.previewToken ?? "");
|
|
59
|
+
setNoIndex(page.noIndex ?? false);
|
|
60
|
+
}, [page.id, page.previewToken, page.noIndex]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (tab === "history" && revisions === null) {
|
|
64
|
+
api.get<typeof revisions>(`/api/pages/${page.id}/revisions`).then(setRevisions);
|
|
65
|
+
}
|
|
66
|
+
}, [tab, revisions, page.id]);
|
|
67
|
+
|
|
68
|
+
async function handleSave() {
|
|
69
|
+
setError(null);
|
|
70
|
+
setSaving(true);
|
|
71
|
+
try {
|
|
72
|
+
const updated = await api.put<Page>(`/api/pages/${page.id}`, {
|
|
73
|
+
title,
|
|
74
|
+
slug,
|
|
75
|
+
seoTitle: seoTitle || null,
|
|
76
|
+
seoDescription: seoDescription || null,
|
|
77
|
+
ogImage: ogImage || null,
|
|
78
|
+
// Send an exact UTC instant — the browser resolves the datetime-local value in
|
|
79
|
+
// the editor's timezone, so scheduling means the same moment everywhere.
|
|
80
|
+
publishAt: publishAt ? new Date(publishAt).toISOString() : null,
|
|
81
|
+
locale,
|
|
82
|
+
translationOfId: translationOfId || null,
|
|
83
|
+
noIndex,
|
|
84
|
+
});
|
|
85
|
+
onSaved(updated);
|
|
86
|
+
onClose();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setError(err instanceof Error ? err.message : "Failed to save");
|
|
89
|
+
} finally {
|
|
90
|
+
setSaving(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function regeneratePreviewToken() {
|
|
95
|
+
const updated = await api.post<Page>(`/api/pages/${page.id}/regenerate-preview-token`, {});
|
|
96
|
+
setPreviewToken(updated.previewToken ?? "");
|
|
97
|
+
onSaved(updated);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [savingTemplate, setSavingTemplate] = useState(false);
|
|
101
|
+
async function saveAsTemplate() {
|
|
102
|
+
const name = window.prompt("Template name", page.title);
|
|
103
|
+
if (!name) return;
|
|
104
|
+
setSavingTemplate(true);
|
|
105
|
+
try {
|
|
106
|
+
await api.post("/api/templates", { name, content: page.content });
|
|
107
|
+
window.alert(`Saved "${name}" — it'll show up under "Start from" the next time you create a page.`);
|
|
108
|
+
} finally {
|
|
109
|
+
setSavingTemplate(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const previewUrl =
|
|
114
|
+
typeof window !== "undefined" && previewToken
|
|
115
|
+
? `${window.location.origin}/preview/${previewToken}`
|
|
116
|
+
: previewToken
|
|
117
|
+
? `/preview/${previewToken}`
|
|
118
|
+
: "";
|
|
119
|
+
|
|
120
|
+
async function handleRestore(revisionId: string) {
|
|
121
|
+
if (!confirm("Restore this version? Your current content will be saved as a new revision first, so nothing is lost.")) return;
|
|
122
|
+
setRestoring(revisionId);
|
|
123
|
+
try {
|
|
124
|
+
const updated = await api.post<Page>(`/api/pages/${page.id}/revisions/${revisionId}/restore`, {});
|
|
125
|
+
onRestore(updated.content);
|
|
126
|
+
onSaved(updated);
|
|
127
|
+
onClose();
|
|
128
|
+
} finally {
|
|
129
|
+
setRestoring(null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-6" onClick={onClose}>
|
|
135
|
+
<div
|
|
136
|
+
className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col"
|
|
137
|
+
onClick={(e) => e.stopPropagation()}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
140
|
+
<h2 className="font-semibold">Page Settings</h2>
|
|
141
|
+
<button onClick={onClose}>
|
|
142
|
+
<X size={18} />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex border-b px-2">
|
|
146
|
+
{(["general", "seo", "publishing", "history"] as Tab[]).map((t) => (
|
|
147
|
+
<button
|
|
148
|
+
key={t}
|
|
149
|
+
onClick={() => setTab(t)}
|
|
150
|
+
className={`px-3 py-2 text-sm font-medium capitalize ${
|
|
151
|
+
tab === t ? "text-blue-600 border-b-2 border-blue-600" : "text-slate-500"
|
|
152
|
+
}`}
|
|
153
|
+
>
|
|
154
|
+
{t}
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
160
|
+
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">{error}</div>}
|
|
161
|
+
|
|
162
|
+
{tab === "general" && (
|
|
163
|
+
<>
|
|
164
|
+
<Field label="Title">
|
|
165
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm" />
|
|
166
|
+
</Field>
|
|
167
|
+
<Field label="Slug" hint="Controls the page's URL. Leave empty for the home page. Localized pages can be path-prefixed, e.g. es/servicios.">
|
|
168
|
+
<input
|
|
169
|
+
value={slug}
|
|
170
|
+
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
|
171
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
172
|
+
/>
|
|
173
|
+
</Field>
|
|
174
|
+
<Field label="Language" hint="Declared on the page for search engines and screen readers; right-to-left languages flip the whole layout automatically.">
|
|
175
|
+
<select
|
|
176
|
+
value={locale}
|
|
177
|
+
onChange={(e) => setLocale(e.target.value)}
|
|
178
|
+
className="w-full border rounded-md px-3 py-2 text-sm bg-white"
|
|
179
|
+
>
|
|
180
|
+
{SUPPORTED_LOCALES.map((l) => (
|
|
181
|
+
<option key={l.code} value={l.code}>
|
|
182
|
+
{l.label} ({l.code}){l.rtl ? " · RTL" : ""}
|
|
183
|
+
</option>
|
|
184
|
+
))}
|
|
185
|
+
</select>
|
|
186
|
+
</Field>
|
|
187
|
+
<Field
|
|
188
|
+
label="Translation of"
|
|
189
|
+
hint="Link this page to its original-language version — the Language Switcher component then lists the whole group on every member page."
|
|
190
|
+
>
|
|
191
|
+
<select
|
|
192
|
+
value={translationOfId}
|
|
193
|
+
onChange={(e) => setTranslationOfId(e.target.value)}
|
|
194
|
+
className="w-full border rounded-md px-3 py-2 text-sm bg-white"
|
|
195
|
+
>
|
|
196
|
+
<option value="">Not a translation (original page)</option>
|
|
197
|
+
{allPages
|
|
198
|
+
.filter((p) => p.id !== page.id && !p.translationOfId)
|
|
199
|
+
.map((p) => (
|
|
200
|
+
<option key={p.id} value={p.id}>
|
|
201
|
+
{p.title} (/{p.slug || ""} · {p.locale ?? "en"})
|
|
202
|
+
</option>
|
|
203
|
+
))}
|
|
204
|
+
</select>
|
|
205
|
+
</Field>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{tab === "seo" && (
|
|
210
|
+
<>
|
|
211
|
+
<Field label="SEO title" hint="Shown in search results and browser tabs. Falls back to the page title if empty.">
|
|
212
|
+
<input value={seoTitle} onChange={(e) => setSeoTitle(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm" />
|
|
213
|
+
</Field>
|
|
214
|
+
<Field label="SEO description">
|
|
215
|
+
<textarea
|
|
216
|
+
value={seoDescription}
|
|
217
|
+
onChange={(e) => setSeoDescription(e.target.value)}
|
|
218
|
+
rows={3}
|
|
219
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
220
|
+
/>
|
|
221
|
+
</Field>
|
|
222
|
+
<Field label="Social share image (OG image)">
|
|
223
|
+
{ogImage && (
|
|
224
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
225
|
+
<img src={ogImage} alt="" className="w-full h-32 object-cover rounded-md border mb-2" />
|
|
226
|
+
)}
|
|
227
|
+
<button
|
|
228
|
+
onClick={() => setOgPicker(true)}
|
|
229
|
+
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"
|
|
230
|
+
>
|
|
231
|
+
<ImagePlus size={14} /> {ogImage ? "Change" : "Select"} image
|
|
232
|
+
</button>
|
|
233
|
+
</Field>
|
|
234
|
+
<Field label="Hide from search engines" hint="Adds noindex — page stays reachable by URL but won't appear in Google or the sitemap.">
|
|
235
|
+
<label className="flex items-center gap-2 text-sm">
|
|
236
|
+
<input type="checkbox" checked={noIndex} onChange={(e) => setNoIndex(e.target.checked)} />
|
|
237
|
+
Prevent indexing (noindex)
|
|
238
|
+
</label>
|
|
239
|
+
</Field>
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{tab === "publishing" && (
|
|
244
|
+
<>
|
|
245
|
+
<Field
|
|
246
|
+
label="Draft preview link"
|
|
247
|
+
hint="Share this URL with reviewers — works even while the page is unpublished. Regenerate if the link leaks."
|
|
248
|
+
>
|
|
249
|
+
{previewUrl ? (
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
<input readOnly value={previewUrl} className="w-full border rounded-md px-3 py-2 text-xs font-mono bg-slate-50" />
|
|
252
|
+
<div className="flex gap-2">
|
|
253
|
+
<a
|
|
254
|
+
href={previewUrl}
|
|
255
|
+
target="_blank"
|
|
256
|
+
rel="noreferrer"
|
|
257
|
+
className="text-xs text-blue-600 hover:underline"
|
|
258
|
+
>
|
|
259
|
+
Open preview
|
|
260
|
+
</a>
|
|
261
|
+
<button type="button" onClick={regeneratePreviewToken} className="text-xs text-slate-500 hover:text-slate-700">
|
|
262
|
+
Regenerate link
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
) : (
|
|
267
|
+
<p className="text-xs text-slate-400">Save the page to generate a preview link.</p>
|
|
268
|
+
)}
|
|
269
|
+
</Field>
|
|
270
|
+
<Field
|
|
271
|
+
label="Publish at"
|
|
272
|
+
hint="Leave empty to publish immediately when you hit Publish. If set, the page stays hidden from visitors until this moment, even while marked Published."
|
|
273
|
+
>
|
|
274
|
+
<input
|
|
275
|
+
type="datetime-local"
|
|
276
|
+
value={publishAt}
|
|
277
|
+
onChange={(e) => setPublishAt(e.target.value)}
|
|
278
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
279
|
+
/>
|
|
280
|
+
</Field>
|
|
281
|
+
{publishAt && (
|
|
282
|
+
<button onClick={() => setPublishAt("")} className="text-xs text-slate-500 hover:text-slate-700">
|
|
283
|
+
Clear scheduled date
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
</>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{tab === "history" && (
|
|
290
|
+
<div className="space-y-2">
|
|
291
|
+
{revisions === null && <div className="text-sm text-slate-400">Loading...</div>}
|
|
292
|
+
{revisions?.length === 0 && (
|
|
293
|
+
<div className="text-sm text-slate-400 flex items-center gap-2">
|
|
294
|
+
<History size={16} /> No earlier versions yet — they're created automatically each time you save.
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
{revisions?.map((rev) => (
|
|
298
|
+
<div key={rev.id} className="flex items-center justify-between border rounded-md px-3 py-2">
|
|
299
|
+
<div className="text-sm">
|
|
300
|
+
<div className="font-medium">{rev.title}</div>
|
|
301
|
+
<div className="text-xs text-slate-400">{new Date(rev.createdAt).toLocaleString()}</div>
|
|
302
|
+
</div>
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => handleRestore(rev.id)}
|
|
305
|
+
disabled={restoring === rev.id}
|
|
306
|
+
className="flex items-center gap-1.5 text-xs border rounded-md px-2 py-1.5 hover:bg-slate-50 disabled:opacity-50"
|
|
307
|
+
>
|
|
308
|
+
<RotateCcw size={13} /> {restoring === rev.id ? "Restoring..." : "Restore"}
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{tab !== "history" && (
|
|
317
|
+
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t">
|
|
318
|
+
<button
|
|
319
|
+
onClick={saveAsTemplate}
|
|
320
|
+
disabled={savingTemplate}
|
|
321
|
+
className="text-xs text-slate-500 hover:text-slate-700 disabled:opacity-50"
|
|
322
|
+
>
|
|
323
|
+
{savingTemplate ? "Saving..." : "Save as template"}
|
|
324
|
+
</button>
|
|
325
|
+
<div className="flex items-center gap-2">
|
|
326
|
+
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600">
|
|
327
|
+
Cancel
|
|
328
|
+
</button>
|
|
329
|
+
<button
|
|
330
|
+
onClick={handleSave}
|
|
331
|
+
disabled={saving}
|
|
332
|
+
className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
333
|
+
>
|
|
334
|
+
{saving ? "Saving..." : "Save settings"}
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
{ogPicker && (
|
|
342
|
+
<MediaPickerModal
|
|
343
|
+
accept="image"
|
|
344
|
+
onSelect={(url) => {
|
|
345
|
+
setOgImage(url);
|
|
346
|
+
setOgPicker(false);
|
|
347
|
+
}}
|
|
348
|
+
onClose={() => setOgPicker(false)}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
356
|
+
return (
|
|
357
|
+
<div className="space-y-1">
|
|
358
|
+
<label className="text-sm font-medium">{label}</label>
|
|
359
|
+
{children}
|
|
360
|
+
{hint && <p className="text-xs text-slate-400">{hint}</p>}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|