@guard-rail/core 0.11.0 → 0.13.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 +102 -7
- package/dist/adapters/supabase/db.js +37 -1
- package/dist/adapters/supabase/db.js.map +1 -1
- package/dist/cli/doctor.js +37 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/adapter-types.d.ts +39 -3
- package/dist/core/seo.d.ts +69 -0
- package/dist/core/seo.js +23 -0
- package/dist/core/seo.js.map +1 -0
- package/dist/index.client.d.ts +5 -3
- package/dist/index.client.js +10 -1
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/react/Admin.js +14 -1
- package/dist/react/Admin.js.map +1 -1
- package/dist/react/GRail/client.js +59 -9
- package/dist/react/GRail/client.js.map +1 -1
- package/dist/react/Icons.d.ts +5 -1
- package/dist/react/Icons.js +10 -0
- package/dist/react/Icons.js.map +1 -1
- package/dist/react/PageSettings.d.ts +12 -0
- package/dist/react/PageSettings.js +275 -0
- package/dist/react/PageSettings.js.map +1 -0
- package/dist/react/guardrail.css +8 -0
- package/dist/react/revert.d.ts +8 -0
- package/dist/react/revert.js +14 -0
- package/dist/react/revert.js.map +1 -0
- package/dist/server/actions.d.ts +37 -2
- package/dist/server/actions.js +112 -0
- package/dist/server/actions.js.map +1 -1
- package/dist/server/loader.js +3 -1
- package/dist/server/loader.js.map +1 -1
- package/dist/server/seo.d.ts +33 -0
- package/dist/server/seo.js +51 -0
- package/dist/server/seo.js.map +1 -0
- package/package.json +1 -1
- package/supabase/admin/check-grants.sql +12 -3
- package/supabase/migrations/20261103000000_content_history.sql +211 -0
package/dist/react/Icons.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n\nexport const OpenIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n <polyline points=\"15 3 21 3 21 9\" />\n <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\" />\n </svg>\n);\n\nexport const KeyIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4\" />\n </svg>\n);\n"],"mappings":"AAmBI,
|
|
1
|
+
{"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\n/** A circular arrow, for reverting a field to its previous value. */\nexport const RevertIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"1 4 1 10 7 10\" />\n <path d=\"M3.51 15a9 9 0 1 0 2.13-9.36L1 10\" />\n </svg>\n);\n\n/** A gear, for page settings. */\nexport const SettingsIcon = () => (\n <svg {...base} strokeWidth={2.4} aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n\nexport const OpenIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n <polyline points=\"15 3 21 3 21 9\" />\n <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\" />\n </svg>\n);\n\nexport const KeyIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4\" />\n </svg>\n);\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-page title, description, social image and "hide from search engines",
|
|
5
|
+
* saved as ordinary content rows (so they get history and revert like
|
|
6
|
+
* everything else) and applied by guardrailMetadata() in the page.
|
|
7
|
+
*/
|
|
8
|
+
declare function PageSettingsDrawer({ onClose }: {
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}): react_jsx_runtime.JSX.Element;
|
|
11
|
+
|
|
12
|
+
export { PageSettingsDrawer };
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React, { useCallback, useEffect, useState, useTransition } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { getPageSettings, revertField, savePageSettings } from "../server/actions";
|
|
6
|
+
import { CloseIcon, RevertIcon } from "./Icons";
|
|
7
|
+
import { MediaPicker } from "./MediaPicker";
|
|
8
|
+
import { revertLabel } from "./revert";
|
|
9
|
+
import {
|
|
10
|
+
SEO_LIMITS,
|
|
11
|
+
normalizePagePath,
|
|
12
|
+
seoKey
|
|
13
|
+
} from "../core/seo";
|
|
14
|
+
const toForm = (s) => ({
|
|
15
|
+
title: s.title,
|
|
16
|
+
description: s.description,
|
|
17
|
+
image: s.image,
|
|
18
|
+
noindex: s.saved.noindex ? s.noindex : null
|
|
19
|
+
});
|
|
20
|
+
function readHead() {
|
|
21
|
+
const meta = (selector) => document.querySelector(selector)?.content ?? "";
|
|
22
|
+
return {
|
|
23
|
+
title: document.title,
|
|
24
|
+
description: meta('meta[name="description"]'),
|
|
25
|
+
image: meta('meta[property="og:image"]'),
|
|
26
|
+
noindex: /noindex/i.test(meta('meta[name="robots"]'))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const TITLE_HINT = 60;
|
|
30
|
+
const DESCRIPTION_HINT = 160;
|
|
31
|
+
function PageSettingsDrawer({ onClose }) {
|
|
32
|
+
const pathname = usePathname();
|
|
33
|
+
const path = normalizePagePath(pathname) ?? "/";
|
|
34
|
+
const [settings, setSettings] = useState(null);
|
|
35
|
+
const [form, setForm] = useState(null);
|
|
36
|
+
const [head, setHead] = useState(readHead);
|
|
37
|
+
const [error, setError] = useState(null);
|
|
38
|
+
const [notice, setNotice] = useState(null);
|
|
39
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
40
|
+
const [pending, startTransition] = useTransition();
|
|
41
|
+
const load = useCallback(async () => {
|
|
42
|
+
const res = await getPageSettings(path);
|
|
43
|
+
if (!res.success) {
|
|
44
|
+
setError(res.error);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setSettings(res.settings);
|
|
48
|
+
setForm(toForm(res.settings));
|
|
49
|
+
}, [path]);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
void load();
|
|
52
|
+
}, [load]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const observer = new MutationObserver(() => setHead(readHead()));
|
|
55
|
+
observer.observe(document.head, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
56
|
+
return () => observer.disconnect();
|
|
57
|
+
}, []);
|
|
58
|
+
const [closing, setClosing] = useState(false);
|
|
59
|
+
const requestClose = useCallback(() => {
|
|
60
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return onClose();
|
|
61
|
+
setClosing(true);
|
|
62
|
+
}, [onClose]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!closing) return;
|
|
65
|
+
const t = window.setTimeout(onClose, 400);
|
|
66
|
+
return () => window.clearTimeout(t);
|
|
67
|
+
}, [closing, onClose]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const onKey = (e) => e.key === "Escape" && !pickerOpen && requestClose();
|
|
70
|
+
window.addEventListener("keydown", onKey);
|
|
71
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
72
|
+
}, [requestClose, pickerOpen]);
|
|
73
|
+
const initial = settings ? toForm(settings) : null;
|
|
74
|
+
const dirty = {
|
|
75
|
+
title: !!form && !!initial && form.title !== initial.title,
|
|
76
|
+
description: !!form && !!initial && form.description !== initial.description,
|
|
77
|
+
image: !!form && !!initial && (form.image.src !== initial.image.src || form.image.alt !== initial.image.alt),
|
|
78
|
+
noindex: !!form && !!initial && form.noindex !== initial.noindex
|
|
79
|
+
};
|
|
80
|
+
const anyDirty = Object.values(dirty).some(Boolean);
|
|
81
|
+
const save = () => startTransition(async () => {
|
|
82
|
+
if (!form) return;
|
|
83
|
+
setError(null);
|
|
84
|
+
setNotice(null);
|
|
85
|
+
const patch = {};
|
|
86
|
+
if (dirty.title) patch.title = form.title;
|
|
87
|
+
if (dirty.description) patch.description = form.description;
|
|
88
|
+
if (dirty.image) patch.image = form.image.src ? form.image : null;
|
|
89
|
+
if (dirty.noindex && form.noindex !== null) patch.noindex = form.noindex;
|
|
90
|
+
const res = await savePageSettings(path, patch);
|
|
91
|
+
if (!res.success) setError(res.error ?? "Couldn't save that.");
|
|
92
|
+
else setNotice("Saved.");
|
|
93
|
+
await load();
|
|
94
|
+
});
|
|
95
|
+
const revert = (field) => startTransition(async () => {
|
|
96
|
+
setError(null);
|
|
97
|
+
setNotice(null);
|
|
98
|
+
const res = await revertField(seoKey(path, field));
|
|
99
|
+
if (!res.success) setError(res.error ?? "Couldn't revert that.");
|
|
100
|
+
else setNotice(res.action === "restored" ? "Reverted to the previous version." : "Back to the page's default.");
|
|
101
|
+
await load();
|
|
102
|
+
});
|
|
103
|
+
const revertButton = (field) => {
|
|
104
|
+
const info = settings?.revert[field];
|
|
105
|
+
if (!info || info.kind === "none") return null;
|
|
106
|
+
return /* @__PURE__ */ jsx(
|
|
107
|
+
"button",
|
|
108
|
+
{
|
|
109
|
+
type: "button",
|
|
110
|
+
className: "gr-icon-btn gr-text-muted gr-size-22",
|
|
111
|
+
onClick: () => revert(field),
|
|
112
|
+
disabled: pending,
|
|
113
|
+
title: revertLabel(info),
|
|
114
|
+
"aria-label": revertLabel(info),
|
|
115
|
+
children: /* @__PURE__ */ jsx(RevertIcon, {})
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
const counter = (length, hint) => /* @__PURE__ */ jsxs("p", { className: `gr-counter${length > hint ? " gr-text-danger" : ""}`, children: [
|
|
120
|
+
length,
|
|
121
|
+
" characters \xB7 search results show about ",
|
|
122
|
+
hint
|
|
123
|
+
] });
|
|
124
|
+
const noindexChecked = form ? form.noindex ?? head.noindex : false;
|
|
125
|
+
const savedImage = !!settings?.saved.image && !!settings.image.src;
|
|
126
|
+
const placeholder = (field, fromHead) => settings?.saved[field] && settings[field] ? `Cleared \u2014 the page's default ${field} is used` : fromHead;
|
|
127
|
+
const shownImage = form?.image.src || (savedImage ? "" : head.image);
|
|
128
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
129
|
+
/* @__PURE__ */ jsx(
|
|
130
|
+
"div",
|
|
131
|
+
{
|
|
132
|
+
"data-guardrail": true,
|
|
133
|
+
"data-guardrail-scrim": true,
|
|
134
|
+
...closing ? { "data-closing": "" } : {},
|
|
135
|
+
className: "gr-scrim",
|
|
136
|
+
onClick: requestClose
|
|
137
|
+
}
|
|
138
|
+
),
|
|
139
|
+
/* @__PURE__ */ jsxs(
|
|
140
|
+
"aside",
|
|
141
|
+
{
|
|
142
|
+
"data-guardrail": true,
|
|
143
|
+
"data-guardrail-team": true,
|
|
144
|
+
...closing ? { "data-closing": "" } : {},
|
|
145
|
+
onAnimationEnd: (e) => {
|
|
146
|
+
if (closing && e.target === e.currentTarget) onClose();
|
|
147
|
+
},
|
|
148
|
+
className: "gr-drawer",
|
|
149
|
+
role: "dialog",
|
|
150
|
+
"aria-label": "Page settings",
|
|
151
|
+
children: [
|
|
152
|
+
/* @__PURE__ */ jsxs("header", { className: "gr-drawer-head", children: [
|
|
153
|
+
/* @__PURE__ */ jsx("h2", { className: "gr-drawer-title", children: "Page settings" }),
|
|
154
|
+
/* @__PURE__ */ jsx("button", { className: "gr-icon-btn gr-text-muted", onClick: requestClose, "aria-label": "Close", children: /* @__PURE__ */ jsx(CloseIcon, {}) })
|
|
155
|
+
] }),
|
|
156
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-drawer-body", children: [
|
|
157
|
+
/* @__PURE__ */ jsxs("p", { className: "gr-fs-12 gr-text-muted gr-mb-12", children: [
|
|
158
|
+
"How ",
|
|
159
|
+
/* @__PURE__ */ jsx("strong", { children: path }),
|
|
160
|
+
" appears in search results and when it's shared."
|
|
161
|
+
] }),
|
|
162
|
+
error && /* @__PURE__ */ jsx("div", { className: "gr-error gr-mb-12", children: error }),
|
|
163
|
+
notice && /* @__PURE__ */ jsx("div", { className: "gr-fs-12 gr-text-muted gr-mb-12", role: "status", children: notice }),
|
|
164
|
+
!form ? !error && /* @__PURE__ */ jsx("p", { className: "gr-fs-13 gr-text-muted", children: "Loading\u2026" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
165
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-meta-row", children: [
|
|
166
|
+
/* @__PURE__ */ jsx("label", { className: "gr-label", htmlFor: "gr-meta-title", children: "Title" }),
|
|
167
|
+
revertButton("title")
|
|
168
|
+
] }),
|
|
169
|
+
/* @__PURE__ */ jsx(
|
|
170
|
+
"input",
|
|
171
|
+
{
|
|
172
|
+
id: "gr-meta-title",
|
|
173
|
+
className: "gr-field",
|
|
174
|
+
value: form.title,
|
|
175
|
+
placeholder: placeholder("title", head.title),
|
|
176
|
+
maxLength: SEO_LIMITS.title,
|
|
177
|
+
onChange: (e) => setForm({ ...form, title: e.target.value })
|
|
178
|
+
}
|
|
179
|
+
),
|
|
180
|
+
counter(form.title.length || head.title.length, TITLE_HINT),
|
|
181
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-meta-row", children: [
|
|
182
|
+
/* @__PURE__ */ jsx("label", { className: "gr-label", htmlFor: "gr-meta-description", children: "Description" }),
|
|
183
|
+
revertButton("description")
|
|
184
|
+
] }),
|
|
185
|
+
/* @__PURE__ */ jsx(
|
|
186
|
+
"textarea",
|
|
187
|
+
{
|
|
188
|
+
id: "gr-meta-description",
|
|
189
|
+
className: "gr-field gr-field-area",
|
|
190
|
+
value: form.description,
|
|
191
|
+
placeholder: placeholder("description", head.description),
|
|
192
|
+
maxLength: SEO_LIMITS.description,
|
|
193
|
+
onChange: (e) => setForm({ ...form, description: e.target.value })
|
|
194
|
+
}
|
|
195
|
+
),
|
|
196
|
+
counter(form.description.length || head.description.length, DESCRIPTION_HINT),
|
|
197
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-meta-row", children: [
|
|
198
|
+
/* @__PURE__ */ jsx("span", { className: "gr-label", children: "Social image" }),
|
|
199
|
+
revertButton("image")
|
|
200
|
+
] }),
|
|
201
|
+
shownImage ? (
|
|
202
|
+
// Plain img: the admin UI mustn't depend on next/image's remotePatterns.
|
|
203
|
+
/* @__PURE__ */ jsx("img", { className: "gr-meta-thumb", src: shownImage, alt: "" })
|
|
204
|
+
) : !savedImage && /* @__PURE__ */ jsx("p", { className: "gr-fs-12 gr-text-muted gr-mb-8", children: "No image set." }),
|
|
205
|
+
!form.image.src && savedImage && /* @__PURE__ */ jsx("p", { className: "gr-counter", children: "The saved image will be removed; the page's default is used." }),
|
|
206
|
+
!form.image.src && !savedImage && head.image && /* @__PURE__ */ jsx("p", { className: "gr-counter", children: "The page's default image." }),
|
|
207
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-flex gr-gap-8 gr-mb-8", children: [
|
|
208
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "gr-secondary-btn gr-w-auto gr-mb-0", onClick: () => setPickerOpen(true), children: "Choose image" }),
|
|
209
|
+
form.image.src && /* @__PURE__ */ jsx(
|
|
210
|
+
"button",
|
|
211
|
+
{
|
|
212
|
+
type: "button",
|
|
213
|
+
className: "gr-text-btn",
|
|
214
|
+
onClick: () => setForm({ ...form, image: { src: "", alt: "" } }),
|
|
215
|
+
children: "Remove"
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
] }),
|
|
219
|
+
form.image.src && /* @__PURE__ */ jsx(
|
|
220
|
+
"input",
|
|
221
|
+
{
|
|
222
|
+
className: "gr-field",
|
|
223
|
+
value: form.image.alt,
|
|
224
|
+
placeholder: "Describe the image",
|
|
225
|
+
"aria-label": "Image description",
|
|
226
|
+
onChange: (e) => setForm({ ...form, image: { ...form.image, alt: e.target.value } })
|
|
227
|
+
}
|
|
228
|
+
),
|
|
229
|
+
/* @__PURE__ */ jsxs("div", { className: "gr-meta-row gr-mt-8", children: [
|
|
230
|
+
/* @__PURE__ */ jsxs("label", { className: "gr-check", children: [
|
|
231
|
+
/* @__PURE__ */ jsx(
|
|
232
|
+
"input",
|
|
233
|
+
{
|
|
234
|
+
type: "checkbox",
|
|
235
|
+
checked: noindexChecked,
|
|
236
|
+
onChange: (e) => setForm({ ...form, noindex: e.target.checked })
|
|
237
|
+
}
|
|
238
|
+
),
|
|
239
|
+
/* @__PURE__ */ jsx("span", { children: "Hide this page from search engines" })
|
|
240
|
+
] }),
|
|
241
|
+
revertButton("noindex")
|
|
242
|
+
] }),
|
|
243
|
+
/* @__PURE__ */ jsx(
|
|
244
|
+
"button",
|
|
245
|
+
{
|
|
246
|
+
type: "button",
|
|
247
|
+
className: "gr-primary-btn gr-w-full gr-mt-16",
|
|
248
|
+
onClick: save,
|
|
249
|
+
disabled: pending || !anyDirty,
|
|
250
|
+
children: pending ? "Saving\u2026" : "Save"
|
|
251
|
+
}
|
|
252
|
+
),
|
|
253
|
+
/* @__PURE__ */ jsx("p", { className: "gr-fs-11 gr-text-subtle gr-mt-10", children: "Applies to pages whose metadata comes from guardrailMetadata()." })
|
|
254
|
+
] })
|
|
255
|
+
] })
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
),
|
|
259
|
+
pickerOpen && form && /* @__PURE__ */ jsx(
|
|
260
|
+
MediaPicker,
|
|
261
|
+
{
|
|
262
|
+
current: form.image.src,
|
|
263
|
+
onClose: () => setPickerOpen(false),
|
|
264
|
+
onSelect: (image) => setForm({
|
|
265
|
+
...form,
|
|
266
|
+
image: { src: image.src, alt: form.image.alt, width: image.width, height: image.height }
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
] });
|
|
271
|
+
}
|
|
272
|
+
export {
|
|
273
|
+
PageSettingsDrawer
|
|
274
|
+
};
|
|
275
|
+
//# sourceMappingURL=PageSettings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/PageSettings.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useCallback, useEffect, useState, useTransition } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { getPageSettings, revertField, savePageSettings } from \"../server/actions\";\nimport { CloseIcon, RevertIcon } from \"./Icons\";\nimport { MediaPicker } from \"./MediaPicker\";\nimport { revertLabel } from \"./revert\";\nimport {\n SEO_LIMITS,\n normalizePagePath,\n seoKey,\n type GRPageImage,\n type GRPageSettings,\n type GRPageSettingsPatch,\n type SeoField,\n} from \"../core/seo\";\n\n/** The drawer's working copy. `noindex: null` means \"nothing saved — use the page's own default\". */\ninterface Form {\n title: string;\n description: string;\n image: GRPageImage;\n noindex: boolean | null;\n}\n\nconst toForm = (s: GRPageSettings): Form => ({\n title: s.title,\n description: s.description,\n image: s.image,\n noindex: s.saved.noindex ? s.noindex : null,\n});\n\n/**\n * What the page renders right now, read from the document itself. It is the\n * placeholder for anything not yet saved — the page's own defaults — and needs\n * no wiring between this drawer and the page's generateMetadata().\n */\nfunction readHead() {\n const meta = (selector: string) => document.querySelector<HTMLMetaElement>(selector)?.content ?? \"\";\n return {\n title: document.title,\n description: meta('meta[name=\"description\"]'),\n image: meta('meta[property=\"og:image\"]'),\n noindex: /noindex/i.test(meta('meta[name=\"robots\"]')),\n };\n}\n\n// Search results show roughly this much before truncating. Guidance only.\nconst TITLE_HINT = 60;\nconst DESCRIPTION_HINT = 160;\n\n/**\n * Per-page title, description, social image and \"hide from search engines\",\n * saved as ordinary content rows (so they get history and revert like\n * everything else) and applied by guardrailMetadata() in the page.\n */\nexport function PageSettingsDrawer({ onClose }: { onClose: () => void }) {\n const pathname = usePathname();\n const path = normalizePagePath(pathname) ?? \"/\";\n\n const [settings, setSettings] = useState<GRPageSettings | null>(null);\n const [form, setForm] = useState<Form | null>(null);\n const [head, setHead] = useState(readHead);\n const [error, setError] = useState<string | null>(null);\n const [notice, setNotice] = useState<string | null>(null);\n const [pickerOpen, setPickerOpen] = useState(false);\n const [pending, startTransition] = useTransition();\n\n const load = useCallback(async () => {\n const res = await getPageSettings(path);\n if (!res.success) {\n setError(res.error);\n return;\n }\n setSettings(res.settings);\n setForm(toForm(res.settings));\n }, [path]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // Saving or reverting re-renders the page's head; keep the placeholders current.\n useEffect(() => {\n const observer = new MutationObserver(() => setHead(readHead()));\n observer.observe(document.head, { childList: true, subtree: true, characterData: true, attributes: true });\n return () => observer.disconnect();\n }, []);\n\n // The same close behavior as the Team drawer: stay mounted to play the exit.\n const [closing, setClosing] = useState(false);\n const requestClose = useCallback(() => {\n if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return onClose();\n setClosing(true);\n }, [onClose]);\n\n useEffect(() => {\n if (!closing) return;\n const t = window.setTimeout(onClose, 400);\n return () => window.clearTimeout(t);\n }, [closing, onClose]);\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && !pickerOpen && requestClose();\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [requestClose, pickerOpen]);\n\n const initial = settings ? toForm(settings) : null;\n const dirty = {\n title: !!form && !!initial && form.title !== initial.title,\n description: !!form && !!initial && form.description !== initial.description,\n image: !!form && !!initial && (form.image.src !== initial.image.src || form.image.alt !== initial.image.alt),\n noindex: !!form && !!initial && form.noindex !== initial.noindex,\n };\n const anyDirty = Object.values(dirty).some(Boolean);\n\n const save = () =>\n startTransition(async () => {\n if (!form) return;\n setError(null);\n setNotice(null);\n const patch: GRPageSettingsPatch = {};\n if (dirty.title) patch.title = form.title;\n if (dirty.description) patch.description = form.description;\n if (dirty.image) patch.image = form.image.src ? form.image : null;\n if (dirty.noindex && form.noindex !== null) patch.noindex = form.noindex;\n\n const res = await savePageSettings(path, patch);\n if (!res.success) setError(res.error ?? \"Couldn't save that.\");\n else setNotice(\"Saved.\");\n await load();\n });\n\n const revert = (field: SeoField) =>\n startTransition(async () => {\n setError(null);\n setNotice(null);\n const res = await revertField(seoKey(path, field));\n if (!res.success) setError(res.error ?? \"Couldn't revert that.\");\n else setNotice(res.action === \"restored\" ? \"Reverted to the previous version.\" : \"Back to the page's default.\");\n await load();\n });\n\n const revertButton = (field: SeoField) => {\n const info = settings?.revert[field];\n if (!info || info.kind === \"none\") return null;\n return (\n <button\n type=\"button\"\n className=\"gr-icon-btn gr-text-muted gr-size-22\"\n onClick={() => revert(field)}\n disabled={pending}\n title={revertLabel(info)}\n aria-label={revertLabel(info)}\n >\n <RevertIcon />\n </button>\n );\n };\n\n const counter = (length: number, hint: number) => (\n <p className={`gr-counter${length > hint ? \" gr-text-danger\" : \"\"}`}>\n {length} characters · search results show about {hint}\n </p>\n );\n\n const noindexChecked = form ? (form.noindex ?? head.noindex) : false;\n\n // The page's tags show the *saved* value once there is one, so they only stand\n // in for \"the default\" when nothing is saved. With a saved value, clearing the\n // field falls back to a default we can't see from here.\n const savedImage = !!settings?.saved.image && !!settings.image.src;\n const placeholder = (field: \"title\" | \"description\", fromHead: string) =>\n settings?.saved[field] && settings[field] ? `Cleared — the page's default ${field} is used` : fromHead;\n const shownImage = form?.image.src || (savedImage ? \"\" : head.image);\n\n return (\n <>\n <div\n data-guardrail\n data-guardrail-scrim\n {...(closing ? { \"data-closing\": \"\" } : {})}\n className=\"gr-scrim\"\n onClick={requestClose}\n />\n <aside\n data-guardrail\n data-guardrail-team\n {...(closing ? { \"data-closing\": \"\" } : {})}\n onAnimationEnd={(e) => {\n if (closing && e.target === e.currentTarget) onClose();\n }}\n className=\"gr-drawer\"\n role=\"dialog\"\n aria-label=\"Page settings\"\n >\n <header className=\"gr-drawer-head\">\n <h2 className=\"gr-drawer-title\">Page settings</h2>\n <button className=\"gr-icon-btn gr-text-muted\" onClick={requestClose} aria-label=\"Close\">\n <CloseIcon />\n </button>\n </header>\n\n <div className=\"gr-drawer-body\">\n <p className=\"gr-fs-12 gr-text-muted gr-mb-12\">\n How <strong>{path}</strong> appears in search results and when it's shared.\n </p>\n\n {error && <div className=\"gr-error gr-mb-12\">{error}</div>}\n {notice && (\n <div className=\"gr-fs-12 gr-text-muted gr-mb-12\" role=\"status\">\n {notice}\n </div>\n )}\n\n {!form ? (\n !error && <p className=\"gr-fs-13 gr-text-muted\">Loading…</p>\n ) : (\n <>\n <div className=\"gr-meta-row\">\n <label className=\"gr-label\" htmlFor=\"gr-meta-title\">Title</label>\n {revertButton(\"title\")}\n </div>\n <input\n id=\"gr-meta-title\"\n className=\"gr-field\"\n value={form.title}\n placeholder={placeholder(\"title\", head.title)}\n maxLength={SEO_LIMITS.title}\n onChange={(e) => setForm({ ...form, title: e.target.value })}\n />\n {counter(form.title.length || head.title.length, TITLE_HINT)}\n\n <div className=\"gr-meta-row\">\n <label className=\"gr-label\" htmlFor=\"gr-meta-description\">Description</label>\n {revertButton(\"description\")}\n </div>\n <textarea\n id=\"gr-meta-description\"\n className=\"gr-field gr-field-area\"\n value={form.description}\n placeholder={placeholder(\"description\", head.description)}\n maxLength={SEO_LIMITS.description}\n onChange={(e) => setForm({ ...form, description: e.target.value })}\n />\n {counter(form.description.length || head.description.length, DESCRIPTION_HINT)}\n\n <div className=\"gr-meta-row\">\n <span className=\"gr-label\">Social image</span>\n {revertButton(\"image\")}\n </div>\n {shownImage ? (\n // Plain img: the admin UI mustn't depend on next/image's remotePatterns.\n <img className=\"gr-meta-thumb\" src={shownImage} alt=\"\" />\n ) : (\n !savedImage && <p className=\"gr-fs-12 gr-text-muted gr-mb-8\">No image set.</p>\n )}\n {!form.image.src && savedImage && (\n <p className=\"gr-counter\">The saved image will be removed; the page's default is used.</p>\n )}\n {!form.image.src && !savedImage && head.image && (\n <p className=\"gr-counter\">The page's default image.</p>\n )}\n <div className=\"gr-flex gr-gap-8 gr-mb-8\">\n <button type=\"button\" className=\"gr-secondary-btn gr-w-auto gr-mb-0\" onClick={() => setPickerOpen(true)}>\n Choose image\n </button>\n {form.image.src && (\n <button\n type=\"button\"\n className=\"gr-text-btn\"\n onClick={() => setForm({ ...form, image: { src: \"\", alt: \"\" } })}\n >\n Remove\n </button>\n )}\n </div>\n {form.image.src && (\n <input\n className=\"gr-field\"\n value={form.image.alt}\n placeholder=\"Describe the image\"\n aria-label=\"Image description\"\n onChange={(e) => setForm({ ...form, image: { ...form.image, alt: e.target.value } })}\n />\n )}\n\n <div className=\"gr-meta-row gr-mt-8\">\n <label className=\"gr-check\">\n <input\n type=\"checkbox\"\n checked={noindexChecked}\n onChange={(e) => setForm({ ...form, noindex: e.target.checked })}\n />\n <span>Hide this page from search engines</span>\n </label>\n {revertButton(\"noindex\")}\n </div>\n\n <button\n type=\"button\"\n className=\"gr-primary-btn gr-w-full gr-mt-16\"\n onClick={save}\n disabled={pending || !anyDirty}\n >\n {pending ? \"Saving…\" : \"Save\"}\n </button>\n <p className=\"gr-fs-11 gr-text-subtle gr-mt-10\">\n Applies to pages whose metadata comes from guardrailMetadata().\n </p>\n </>\n )}\n </div>\n </aside>\n\n {pickerOpen && form && (\n <MediaPicker\n current={form.image.src}\n onClose={() => setPickerOpen(false)}\n onSelect={(image) =>\n setForm({\n ...form,\n image: { src: image.src, alt: form.image.alt, width: image.width, height: image.height },\n })\n }\n />\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/react/guardrail.css
CHANGED
|
@@ -529,6 +529,14 @@
|
|
|
529
529
|
white-space: nowrap;
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
/* Page settings drawer */
|
|
533
|
+
.gr-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
|
|
534
|
+
.gr-field-area { min-height: 76px; resize: vertical; font-family: inherit; line-height: 1.4; }
|
|
535
|
+
.gr-counter { font-size: 11px; color: var(--gr-subtle); margin: -2px 0 14px; }
|
|
536
|
+
.gr-meta-thumb { display: block; max-width: 100%; max-height: 120px; border-radius: 6px; border: 1px solid var(--gr-overlay-border); margin-bottom: 8px; }
|
|
537
|
+
.gr-check { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: var(--gr-overlay-fg); cursor: pointer; }
|
|
538
|
+
.gr-check input { margin-top: 2px; }
|
|
539
|
+
|
|
532
540
|
/* ---- editable fields ------------------------------------------------------
|
|
533
541
|
* Drawn on the developer's own elements by <GRail>, which marks them with a
|
|
534
542
|
* data attribute instead of a style attribute. Images get a separate marker. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { GRRevertInfo } from '../core/adapter-types.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
import '../core/types.js';
|
|
4
|
+
|
|
5
|
+
/** "Revert to the version from Oct 28" / "Reset to default" — what a revert button will actually do. */
|
|
6
|
+
declare function revertLabel(info: GRRevertInfo): string;
|
|
7
|
+
|
|
8
|
+
export { revertLabel };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function revertLabel(info) {
|
|
2
|
+
if (info.kind === "previous" && info.at) {
|
|
3
|
+
const when = new Date(info.at);
|
|
4
|
+
if (!Number.isNaN(when.getTime())) {
|
|
5
|
+
return `Revert to the version from ${when.toLocaleDateString(void 0, { month: "short", day: "numeric" })}`;
|
|
6
|
+
}
|
|
7
|
+
return "Revert to the previous version";
|
|
8
|
+
}
|
|
9
|
+
return "Reset to default";
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
revertLabel
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=revert.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/revert.ts"],"sourcesContent":["import type { GRRevertInfo } from \"../core/adapter-types\";\n\n/** \"Revert to the version from Oct 28\" / \"Reset to default\" — what a revert button will actually do. */\nexport function revertLabel(info: GRRevertInfo): string {\n if (info.kind === \"previous\" && info.at) {\n const when = new Date(info.at);\n if (!Number.isNaN(when.getTime())) {\n return `Revert to the version from ${when.toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" })}`;\n }\n return \"Revert to the previous version\";\n }\n return \"Reset to default\";\n}\n"],"mappings":"AAGO,SAAS,YAAY,MAA4B;AACtD,MAAI,KAAK,SAAS,cAAc,KAAK,IAAI;AACvC,UAAM,OAAO,IAAI,KAAK,KAAK,EAAE;AAC7B,QAAI,CAAC,OAAO,MAAM,KAAK,QAAQ,CAAC,GAAG;AACjC,aAAO,8BAA8B,KAAK,mBAAmB,QAAW,EAAE,OAAO,SAAS,KAAK,UAAU,CAAC,CAAC;AAAA,IAC7G;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;","names":[]}
|
package/dist/server/actions.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { GRPageSettings, GRPageSettingsPatch } from '../core/seo.js';
|
|
1
2
|
import { GRRole, GRAsset, GRMember, OverrideValue, SetPasswordState, ActionState, OverrideRow } from '../core/types.js';
|
|
2
|
-
import { GRUploadTarget, AuthRedirectParams } from '../core/adapter-types.js';
|
|
3
|
+
import { GRUploadTarget, GRRevertInfo, AuthRedirectParams } from '../core/adapter-types.js';
|
|
3
4
|
import 'react';
|
|
4
5
|
|
|
5
6
|
/** `notice` is for a success the admin should still read, e.g. "no email was sent because...". */
|
|
@@ -28,6 +29,40 @@ declare function saveOverride(row: OverrideRow): Promise<Result>;
|
|
|
28
29
|
* <GRail>, and vice versa.
|
|
29
30
|
*/
|
|
30
31
|
declare function setFieldValue(id: string, value: string | OverrideValue): Promise<Result>;
|
|
32
|
+
/**
|
|
33
|
+
* What reverting this field would do — "previous" (with the date of the version
|
|
34
|
+
* it goes back to), "default" (back to what the page itself says), or "none".
|
|
35
|
+
* The editor asks when it opens, so visitors never pay for it.
|
|
36
|
+
*/
|
|
37
|
+
declare function getRevertInfo(id: string): Promise<GRRevertInfo>;
|
|
38
|
+
/**
|
|
39
|
+
* One step back for a field. Restores its previous saved value and returns it,
|
|
40
|
+
* or, with none left, removes the saved value (`action: "reset"`) so the field
|
|
41
|
+
* shows the default written in the page. Same permission as saving.
|
|
42
|
+
*/
|
|
43
|
+
declare function revertField(id: string): Promise<{
|
|
44
|
+
success: boolean;
|
|
45
|
+
error?: string;
|
|
46
|
+
action?: "restored" | "reset" | "none";
|
|
47
|
+
value?: OverrideValue;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* The saved page settings for one page, and what a revert would do to each —
|
|
51
|
+
* what the Page settings drawer shows when it opens. Editors and admins only.
|
|
52
|
+
*/
|
|
53
|
+
declare function getPageSettings(path: string): Promise<{
|
|
54
|
+
success: true;
|
|
55
|
+
settings: GRPageSettings;
|
|
56
|
+
} | {
|
|
57
|
+
success: false;
|
|
58
|
+
error: string;
|
|
59
|
+
}>;
|
|
60
|
+
/**
|
|
61
|
+
* Saves the page settings that changed. A cleared field is saved as empty
|
|
62
|
+
* rather than deleted, so clearing it is a version like any other and can be
|
|
63
|
+
* reverted; guardrailMetadata() treats empty as "use the page's default".
|
|
64
|
+
*/
|
|
65
|
+
declare function savePageSettings(path: string, patch: GRPageSettingsPatch): Promise<Result>;
|
|
31
66
|
/**
|
|
32
67
|
* Authorizes an upload and returns where the browser should send the bytes.
|
|
33
68
|
* Only metadata comes through here — the file itself goes straight to storage.
|
|
@@ -100,4 +135,4 @@ declare function skipSetPasswordAction(): Promise<{
|
|
|
100
135
|
success: boolean;
|
|
101
136
|
}>;
|
|
102
137
|
|
|
103
|
-
export { completeAuthRedirectAction, createAssetUpload, forgotPasswordAction, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, saveOverride, sendPasswordResetAction, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole };
|
|
138
|
+
export { completeAuthRedirectAction, createAssetUpload, forgotPasswordAction, getPageSettings, getRevertInfo, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, revertField, saveOverride, savePageSettings, sendPasswordResetAction, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole };
|
package/dist/server/actions.js
CHANGED
|
@@ -15,6 +15,13 @@ import { getCachedViewer } from "./session";
|
|
|
15
15
|
import { requireRole } from "./access";
|
|
16
16
|
import { rateLimited } from "./rateLimit";
|
|
17
17
|
import { cleanEmail, isRole, validateOverrideValue, validPathKey } from "../core/validate";
|
|
18
|
+
import {
|
|
19
|
+
SEO_FIELDS,
|
|
20
|
+
SEO_LIMITS,
|
|
21
|
+
normalizePagePath,
|
|
22
|
+
seoKey
|
|
23
|
+
} from "../core/seo";
|
|
24
|
+
import { loadOverride } from "./loader";
|
|
18
25
|
import { rethrowIfBailout } from "../core/bailout";
|
|
19
26
|
import {
|
|
20
27
|
GR_AUTH_ERROR_TTL_SECONDS,
|
|
@@ -57,6 +64,107 @@ async function writeOverride(rawPathKey, rawValue) {
|
|
|
57
64
|
return { success: false, error: "Database write failed." };
|
|
58
65
|
}
|
|
59
66
|
}
|
|
67
|
+
async function getRevertInfo(id) {
|
|
68
|
+
const viewer = await requireRole(["admin", "editor"]);
|
|
69
|
+
const pathKey = validPathKey(id);
|
|
70
|
+
if (!viewer || !pathKey) return { kind: "none" };
|
|
71
|
+
const db = getDB();
|
|
72
|
+
if (!db.getRevertInfo) return { kind: "none" };
|
|
73
|
+
try {
|
|
74
|
+
return await db.getRevertInfo(pathKey, await resolveSiteId());
|
|
75
|
+
} catch (e) {
|
|
76
|
+
rethrowIfBailout(e);
|
|
77
|
+
return { kind: "none" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function revertField(id) {
|
|
81
|
+
const viewer = await requireRole(["admin", "editor"]);
|
|
82
|
+
if (!viewer) return DENIED;
|
|
83
|
+
const pathKey = validPathKey(id);
|
|
84
|
+
if (!pathKey) return { success: false, error: "A valid field id is required." };
|
|
85
|
+
const db = getDB();
|
|
86
|
+
if (!db.revertOverride) {
|
|
87
|
+
return { success: false, error: "This site's database setup doesn't keep history, so there's nothing to revert to." };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const res = await db.revertOverride(pathKey, await resolveSiteId());
|
|
91
|
+
if (!res.success) return { success: false, error: res.error };
|
|
92
|
+
revalidatePath("/", "layout");
|
|
93
|
+
return res.action === "restored" ? { success: true, action: "restored", value: res.value } : { success: true, action: res.action };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
rethrowIfBailout(e);
|
|
96
|
+
console.error("Guardrail: revert failed", e);
|
|
97
|
+
return { success: false, error: "Couldn't revert that." };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function getPageSettings(path) {
|
|
101
|
+
const viewer = await requireRole(["admin", "editor"]);
|
|
102
|
+
if (!viewer) return { success: false, error: "Unauthorized." };
|
|
103
|
+
const page = normalizePagePath(path);
|
|
104
|
+
if (!page) return { success: false, error: "That page's path isn't usable." };
|
|
105
|
+
const siteId = await resolveSiteId();
|
|
106
|
+
const db = getDB();
|
|
107
|
+
const rows = await Promise.all(SEO_FIELDS.map((f) => loadOverride(seoKey(page, f))));
|
|
108
|
+
const revertInfos = await Promise.all(
|
|
109
|
+
SEO_FIELDS.map(
|
|
110
|
+
(f) => db.getRevertInfo ? db.getRevertInfo(seoKey(page, f), siteId).catch(() => ({ kind: "none" })) : { kind: "none" }
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
const byField = Object.fromEntries(SEO_FIELDS.map((f, i) => [f, rows[i]?.value ?? null]));
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
settings: {
|
|
117
|
+
path: page,
|
|
118
|
+
title: byField.title?.text ?? "",
|
|
119
|
+
description: byField.description?.text ?? "",
|
|
120
|
+
image: {
|
|
121
|
+
src: byField.image?.src ?? "",
|
|
122
|
+
alt: byField.image?.alt ?? "",
|
|
123
|
+
...byField.image?.width ? { width: byField.image.width } : {},
|
|
124
|
+
...byField.image?.height ? { height: byField.image.height } : {}
|
|
125
|
+
},
|
|
126
|
+
noindex: byField.noindex?.text === "true",
|
|
127
|
+
saved: Object.fromEntries(SEO_FIELDS.map((f) => [f, byField[f] !== null])),
|
|
128
|
+
revert: Object.fromEntries(SEO_FIELDS.map((f, i) => [f, revertInfos[i]]))
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function savePageSettings(path, patch) {
|
|
133
|
+
const viewer = await requireRole(["admin", "editor"]);
|
|
134
|
+
if (!viewer) return DENIED;
|
|
135
|
+
const page = normalizePagePath(path);
|
|
136
|
+
if (!page) return { success: false, error: "That page's path isn't usable." };
|
|
137
|
+
if (!patch || typeof patch !== "object") return { success: false, error: "Nothing to save." };
|
|
138
|
+
const writes = [];
|
|
139
|
+
if (patch.title !== void 0) {
|
|
140
|
+
if (typeof patch.title !== "string") return { success: false, error: "Invalid title." };
|
|
141
|
+
const title = patch.title.replace(/\s+/g, " ").trim();
|
|
142
|
+
if (title.length > SEO_LIMITS.title) return { success: false, error: `Keep the title under ${SEO_LIMITS.title} characters.` };
|
|
143
|
+
writes.push(["title", { type: "text", text: title }]);
|
|
144
|
+
}
|
|
145
|
+
if (patch.description !== void 0) {
|
|
146
|
+
if (typeof patch.description !== "string") return { success: false, error: "Invalid description." };
|
|
147
|
+
const description = patch.description.replace(/\s+/g, " ").trim();
|
|
148
|
+
if (description.length > SEO_LIMITS.description) return { success: false, error: `Keep the description under ${SEO_LIMITS.description} characters.` };
|
|
149
|
+
writes.push(["description", { type: "text", text: description }]);
|
|
150
|
+
}
|
|
151
|
+
if (patch.image !== void 0) {
|
|
152
|
+
const image = patch.image;
|
|
153
|
+
writes.push([
|
|
154
|
+
"image",
|
|
155
|
+
image === null ? { type: "image", src: "", alt: "" } : { type: "image", src: image?.src ?? "", alt: image?.alt ?? "", width: image?.width, height: image?.height }
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
if (patch.noindex !== void 0) {
|
|
159
|
+
writes.push(["noindex", { type: "text", text: patch.noindex === true ? "true" : "false" }]);
|
|
160
|
+
}
|
|
161
|
+
if (writes.length === 0) return { success: true };
|
|
162
|
+
for (const [field, value] of writes) {
|
|
163
|
+
const res = await writeOverride(seoKey(page, field), value);
|
|
164
|
+
if (!res.success) return res;
|
|
165
|
+
}
|
|
166
|
+
return { success: true };
|
|
167
|
+
}
|
|
60
168
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"];
|
|
61
169
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
62
170
|
async function createAssetUpload(file) {
|
|
@@ -381,13 +489,17 @@ export {
|
|
|
381
489
|
completeAuthRedirectAction,
|
|
382
490
|
createAssetUpload,
|
|
383
491
|
forgotPasswordAction,
|
|
492
|
+
getPageSettings,
|
|
493
|
+
getRevertInfo,
|
|
384
494
|
inviteMember,
|
|
385
495
|
listAssets,
|
|
386
496
|
listTeam,
|
|
387
497
|
loginAction,
|
|
388
498
|
logoutAction,
|
|
389
499
|
removeMember,
|
|
500
|
+
revertField,
|
|
390
501
|
saveOverride,
|
|
502
|
+
savePageSettings,
|
|
391
503
|
sendPasswordResetAction,
|
|
392
504
|
setFieldValue,
|
|
393
505
|
setPasswordAction,
|