@backmatter/next 0.0.22 → 0.0.24
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/package.json +9 -5
- package/src/cms-proxy.ts +69 -11
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +159 -41
- package/src/devbar-motion.ts +58 -0
- package/src/devbar.tsx +24 -3
- package/src/gate.ts +85 -0
- package/src/page-render.ts +13 -3
- package/src/preview-refresh.tsx +5 -14
- package/src/preview-route.ts +147 -8
- package/src/request-url.ts +13 -0
- package/src/site-editor.tsx +187 -57
- package/src/site-pointer.ts +199 -0
- package/src/studio-client.tsx +80 -0
- package/src/studio.tsx +157 -0
package/src/preview-refresh.tsx
CHANGED
|
@@ -39,17 +39,6 @@ function referrerOrigin(): string {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function dashboardOriginFromMeta(): string {
|
|
43
|
-
const marked = document
|
|
44
|
-
.querySelector('meta[name="backmatter:dashboard"]')
|
|
45
|
-
?.getAttribute("content");
|
|
46
|
-
if (marked) return marked.replace(/\/$/, "");
|
|
47
|
-
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
|
|
48
|
-
return `${location.protocol}//${location.hostname}:5173`;
|
|
49
|
-
}
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
42
|
function subscribeDocStream(
|
|
54
43
|
ref: { locale: string; type: string; slug: string },
|
|
55
44
|
onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
|
|
@@ -169,13 +158,15 @@ export function PreviewClient({
|
|
|
169
158
|
...(branch ? { branch } : {}),
|
|
170
159
|
...(tree ? { tree } : {}),
|
|
171
160
|
};
|
|
161
|
+
// Framed by the dashboard's split view: the parent is the dashboard.
|
|
162
|
+
// Otherwise the Studio mounted on this site posts from same-origin frames.
|
|
172
163
|
const parentOrigin = referrerOrigin();
|
|
173
|
-
const
|
|
174
|
-
const allowed = [...originsKey.split("\0"), parentOrigin,
|
|
164
|
+
const selfOrigin = window.location.origin;
|
|
165
|
+
const allowed = [...originsKey.split("\0"), parentOrigin, selfOrigin].filter(
|
|
175
166
|
(origin) => origin.length > 0,
|
|
176
167
|
);
|
|
177
168
|
const framed = window.parent !== window;
|
|
178
|
-
const targetOrigin = framed ? parentOrigin || allowed[0] || "" :
|
|
169
|
+
const targetOrigin = framed ? parentOrigin || allowed[0] || "" : selfOrigin;
|
|
179
170
|
const windowTransport: PreviewTransport | null = targetOrigin
|
|
180
171
|
? createWindowPreviewTransport({
|
|
181
172
|
target: framed ? window.parent : { postMessage() {} },
|
package/src/preview-route.ts
CHANGED
|
@@ -7,20 +7,50 @@ import {
|
|
|
7
7
|
PREVIEW_SESSION_TTL_MS,
|
|
8
8
|
previewLocationWithCheckout,
|
|
9
9
|
readPreviewCookie,
|
|
10
|
+
readPreviewSession,
|
|
10
11
|
readPreviewTicket,
|
|
12
|
+
resolveDashboardUrl,
|
|
13
|
+
studioEnterUrl,
|
|
11
14
|
writePreviewCookie,
|
|
12
15
|
type PreviewCheckout,
|
|
13
16
|
} from "@backmatter/core";
|
|
14
17
|
import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
|
|
15
18
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
19
|
+
import { MARKER_COOKIE, MARKER_TTL_S, safeReturnPath } from "./gate";
|
|
20
|
+
import { requestUrlFrom } from "./request-url";
|
|
16
21
|
|
|
17
22
|
export type PreviewRouteDeps = {
|
|
18
23
|
enableDraft?: () => void | Promise<void>;
|
|
19
24
|
disableDraft?: () => void | Promise<void>;
|
|
20
25
|
isDraftEnabled?: () => boolean | Promise<boolean>;
|
|
21
26
|
now?: () => number;
|
|
27
|
+
/** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
|
|
28
|
+
dashboardUrl?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Exchange the current site session for a fresh token. Default calls
|
|
31
|
+
* `POST /preview/session/renew` on the API. Null means the session is dead.
|
|
32
|
+
* Throw when the API cannot be reached; that becomes a 503 and keeps the cookies.
|
|
33
|
+
*/
|
|
34
|
+
renewSession?: (token: string) => Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Ask the API whether this site-session token is still logged in, epoch
|
|
37
|
+
* included. Default calls `GET /preview/session`.
|
|
38
|
+
*/
|
|
39
|
+
confirmSession?: (token: string) => Promise<"ok" | "invalid" | "unavailable">;
|
|
22
40
|
};
|
|
23
41
|
|
|
42
|
+
/** The gate's marker: readable by the page script, so never HttpOnly. */
|
|
43
|
+
function markerCookieHeader(secure: boolean, on: boolean): string {
|
|
44
|
+
const parts = [
|
|
45
|
+
`${MARKER_COOKIE}=${on ? "1" : ""}`,
|
|
46
|
+
"Path=/",
|
|
47
|
+
`Max-Age=${on ? MARKER_TTL_S : 0}`,
|
|
48
|
+
"SameSite=Lax",
|
|
49
|
+
];
|
|
50
|
+
if (secure) parts.push("Secure");
|
|
51
|
+
return parts.join("; ");
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
|
|
25
55
|
// HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
|
|
26
56
|
// not sent there, so draftMode can be on while the checkout cookie is missing
|
|
@@ -55,6 +85,42 @@ async function nextDraftEnabled(): Promise<boolean> {
|
|
|
55
85
|
return (await mod.draftMode()).isEnabled;
|
|
56
86
|
}
|
|
57
87
|
|
|
88
|
+
async function renewSessionOnApi(apiUrl: string, token: string): Promise<string | null> {
|
|
89
|
+
let res: Response;
|
|
90
|
+
try {
|
|
91
|
+
res = await fetch(`${apiUrl}/preview/session/renew`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { authorization: `Bearer ${token}` },
|
|
94
|
+
cache: "no-store",
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error("renew unavailable");
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 401 || res.status === 403) return null;
|
|
100
|
+
if (!res.ok) throw new Error(`renew unavailable: ${res.status}`);
|
|
101
|
+
const body = (await res.json()) as { token?: unknown };
|
|
102
|
+
return typeof body.token === "string" && body.token ? body.token : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function confirmSessionOnApi(
|
|
106
|
+
apiUrl: string | undefined,
|
|
107
|
+
token: string,
|
|
108
|
+
): Promise<"ok" | "invalid" | "unavailable"> {
|
|
109
|
+
const base = apiUrl?.replace(/\/$/, "");
|
|
110
|
+
if (!base) return "unavailable";
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`${base}/preview/session`, {
|
|
113
|
+
headers: { authorization: `Bearer ${token}` },
|
|
114
|
+
cache: "no-store",
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 401 || res.status === 403) return "invalid";
|
|
117
|
+
if (!res.ok) return "unavailable";
|
|
118
|
+
return "ok";
|
|
119
|
+
} catch {
|
|
120
|
+
return "unavailable";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
58
124
|
function cookieFrom(req: Request, name: string): string | undefined {
|
|
59
125
|
const header = req.headers.get("cookie");
|
|
60
126
|
if (!header) return undefined;
|
|
@@ -84,15 +150,56 @@ export function createPreviewRoute(
|
|
|
84
150
|
const secure = url.protocol === "https:";
|
|
85
151
|
|
|
86
152
|
if (url.searchParams.get("session") === "1") {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
153
|
+
// The bar's only login signal. The checkout cookie and draft mode still
|
|
154
|
+
// render drafts; they do not mean the visitor is signed in.
|
|
155
|
+
const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
|
|
156
|
+
const claims = secret && token ? await readPreviewSession(secret, token) : null;
|
|
157
|
+
const workspaceOk = !cms.config.workspaceId || claims?.workspaceId === cms.config.workspaceId;
|
|
158
|
+
const clearLogin = async () => {
|
|
159
|
+
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
160
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
161
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
162
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
163
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, false));
|
|
164
|
+
return Response.json({ mode: "live", reason: "invalid" }, { headers });
|
|
165
|
+
};
|
|
166
|
+
if (!claims || !token || !workspaceOk) {
|
|
167
|
+
if (token) return clearLogin();
|
|
168
|
+
return Response.json(
|
|
169
|
+
{ mode: "live", reason: "anonymous" },
|
|
170
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const confirm = deps.confirmSession ?? ((current: string) => confirmSessionOnApi(cms.config.apiUrl, current));
|
|
174
|
+
const verdict = await confirm(token);
|
|
175
|
+
if (verdict === "unavailable") {
|
|
176
|
+
return Response.json(
|
|
177
|
+
{ mode: "live", reason: "unavailable" },
|
|
178
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (verdict !== "ok") return clearLogin();
|
|
182
|
+
return Response.json(
|
|
183
|
+
{ mode: "preview", branch: claims.branch, tree: claims.tree },
|
|
184
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The bar found the marker but no session: go top-level to the dashboard,
|
|
189
|
+
// which mints a ticket after login and returns to this page.
|
|
190
|
+
if (url.searchParams.get("enter") === "1") {
|
|
191
|
+
if (!cms.config.workspaceId) {
|
|
192
|
+
return Response.json({ error: "preview_not_configured" }, { status: 503 });
|
|
93
193
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
194
|
+
const returnUrl = requestUrlFrom(
|
|
195
|
+
(name) => req.headers.get(name),
|
|
196
|
+
safeReturnPath(url.searchParams.get("return")),
|
|
197
|
+
);
|
|
198
|
+
const dashboardUrl = resolveDashboardUrl(deps.dashboardUrl);
|
|
199
|
+
return Response.redirect(
|
|
200
|
+
studioEnterUrl(dashboardUrl, { workspaceId: cms.config.workspaceId, returnUrl }),
|
|
201
|
+
302,
|
|
202
|
+
);
|
|
96
203
|
}
|
|
97
204
|
|
|
98
205
|
if (url.searchParams.get("stream") === "1") {
|
|
@@ -169,15 +276,47 @@ export function createPreviewRoute(
|
|
|
169
276
|
),
|
|
170
277
|
);
|
|
171
278
|
}
|
|
279
|
+
// Entry opts the visitor in, so the bar shows on later pages without a param.
|
|
280
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, true));
|
|
172
281
|
return new Response(null, { status: 302, headers });
|
|
173
282
|
};
|
|
174
283
|
|
|
175
284
|
const POST = async (req: Request): Promise<Response> => {
|
|
285
|
+
const url = new URL(req.url);
|
|
286
|
+
// Slide the site session while the bar is open. The cookie is HttpOnly, so
|
|
287
|
+
// the browser cannot renew it itself; a dead session is a 401 and the bar
|
|
288
|
+
// bounces to the dashboard instead of failing the next chat send.
|
|
289
|
+
if (url.searchParams.get("renew") === "1") {
|
|
290
|
+
const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
|
|
291
|
+
const apiUrl = cms.config.apiUrl?.replace(/\/$/, "");
|
|
292
|
+
if (!token || !apiUrl) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
293
|
+
let renewed: string | null;
|
|
294
|
+
try {
|
|
295
|
+
renewed = await (deps.renewSession ?? ((current) => renewSessionOnApi(apiUrl, current)))(token);
|
|
296
|
+
} catch {
|
|
297
|
+
return Response.json({ error: "unavailable" }, { status: 503 });
|
|
298
|
+
}
|
|
299
|
+
if (!renewed) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
300
|
+
const headers = new Headers();
|
|
301
|
+
headers.append(
|
|
302
|
+
"Set-Cookie",
|
|
303
|
+
cookieBase(
|
|
304
|
+
PREVIEW_SESSION_COOKIE,
|
|
305
|
+
url.protocol === "https:",
|
|
306
|
+
Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
|
|
307
|
+
renewed,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
return new Response(null, { status: 204, headers });
|
|
311
|
+
}
|
|
312
|
+
|
|
176
313
|
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
177
314
|
const secure = new URL(req.url).protocol === "https:";
|
|
178
315
|
const headers = new Headers();
|
|
179
316
|
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
180
317
|
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
318
|
+
// Exit also drops the marker; otherwise the next page would bounce straight back in.
|
|
319
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, false));
|
|
181
320
|
return new Response(null, { status: 204, headers });
|
|
182
321
|
};
|
|
183
322
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// packages/next/src/request-url.ts
|
|
2
|
+
|
|
3
|
+
/** Absolute URL of the current request, from forwarded headers when behind a proxy. */
|
|
4
|
+
export function requestUrlFrom(
|
|
5
|
+
get: (name: string) => string | null,
|
|
6
|
+
pathWithSearch: string,
|
|
7
|
+
): string {
|
|
8
|
+
const host = get("x-forwarded-host") ?? get("host") ?? "localhost:3000";
|
|
9
|
+
const proto =
|
|
10
|
+
get("x-forwarded-proto") ??
|
|
11
|
+
(host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https");
|
|
12
|
+
return `${proto}://${host}${pathWithSearch}`;
|
|
13
|
+
}
|
package/src/site-editor.tsx
CHANGED
|
@@ -3,8 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
import { usePathname, useRouter } from "next/navigation";
|
|
5
5
|
import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
|
+
import {
|
|
7
|
+
DEVBAR_CLASS,
|
|
8
|
+
DEVBAR_CLEARANCE,
|
|
9
|
+
DEVBAR_ENTER_CSS,
|
|
10
|
+
DEVBAR_RIGHT,
|
|
11
|
+
devbarShellStyle,
|
|
12
|
+
} from "./devbar-motion";
|
|
13
|
+
import { markerCookie } from "./gate";
|
|
6
14
|
import { subscribeSiteDoc } from "./site-doc-bridge";
|
|
7
15
|
import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
|
|
16
|
+
import { installSitePointer } from "./site-pointer";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
19
|
+
/** Slide the 12h site session while the bar is on screen. */
|
|
20
|
+
const SESSION_RENEW_MS = 30 * 60 * 1000;
|
|
8
21
|
|
|
9
22
|
function useSiteDoc(): SiteDocRef | null {
|
|
10
23
|
const pathname = usePathname();
|
|
@@ -32,14 +45,17 @@ function useSiteDoc(): SiteDocRef | null {
|
|
|
32
45
|
return doc;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
/** Default route the host mounts `@backmatter/studio` on. */
|
|
49
|
+
export const DEFAULT_STUDIO_PATH = "/backmatter";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The panels are frames of the Studio mounted on this same origin. Same
|
|
53
|
+
* origin means the site session cookie is first-party and postMessage needs
|
|
54
|
+
* no foreign origin.
|
|
55
|
+
*/
|
|
56
|
+
function studioBase(studioPath: string): string {
|
|
57
|
+
if (typeof location === "undefined") return "";
|
|
58
|
+
return `${location.origin}${studioPath.replace(/\/+$/, "")}`;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export function SiteChrome({
|
|
@@ -47,32 +63,38 @@ export function SiteChrome({
|
|
|
47
63
|
tree,
|
|
48
64
|
exiting,
|
|
49
65
|
onExit,
|
|
66
|
+
studioPath = DEFAULT_STUDIO_PATH,
|
|
67
|
+
previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
|
|
50
68
|
}: {
|
|
51
69
|
branch?: string;
|
|
52
70
|
tree?: string;
|
|
53
71
|
exiting?: boolean;
|
|
54
72
|
onExit: () => void;
|
|
73
|
+
/** Where `@backmatter/studio` is mounted on this site. */
|
|
74
|
+
studioPath?: string;
|
|
75
|
+
/** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
|
|
76
|
+
previewEntryPath?: string;
|
|
55
77
|
}) {
|
|
56
78
|
const router = useRouter();
|
|
57
79
|
const doc = useSiteDoc();
|
|
58
80
|
const [editorMounted, setEditorMounted] = useState(false);
|
|
59
81
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
60
|
-
const [chatMounted, setChatMounted] = useState(false);
|
|
61
82
|
const [chatOpen, setChatOpen] = useState(false);
|
|
62
83
|
const [publishOpen, setPublishOpen] = useState(false);
|
|
63
84
|
const [changeCount, setChangeCount] = useState(0);
|
|
64
|
-
|
|
85
|
+
// Set in an effect so SSR and the first client render agree.
|
|
86
|
+
const [base, setBase] = useState("");
|
|
87
|
+
const origin = typeof location === "undefined" ? "" : location.origin;
|
|
65
88
|
const editorFrame = useRef<HTMLIFrameElement>(null);
|
|
66
89
|
|
|
67
90
|
useEffect(() => {
|
|
68
|
-
|
|
69
|
-
}, []);
|
|
91
|
+
setBase(studioBase(studioPath));
|
|
92
|
+
}, [studioPath]);
|
|
70
93
|
|
|
71
94
|
useEffect(() => {
|
|
72
95
|
function onKey(event: KeyboardEvent) {
|
|
73
96
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
|
|
74
97
|
event.preventDefault();
|
|
75
|
-
setChatMounted(true);
|
|
76
98
|
setChatOpen((open) => !open);
|
|
77
99
|
}
|
|
78
100
|
document.addEventListener("keydown", onKey, true);
|
|
@@ -97,7 +119,6 @@ export function SiteChrome({
|
|
|
97
119
|
data.key?.toLowerCase() === "j" &&
|
|
98
120
|
(data.metaKey || data.ctrlKey)
|
|
99
121
|
) {
|
|
100
|
-
setChatMounted(true);
|
|
101
122
|
setChatOpen((open) => !open);
|
|
102
123
|
}
|
|
103
124
|
if (data?.type === "backmatter:published") {
|
|
@@ -111,10 +132,52 @@ export function SiteChrome({
|
|
|
111
132
|
return () => window.removeEventListener("message", onMessage);
|
|
112
133
|
}, [origin, router]);
|
|
113
134
|
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
let stopped = false;
|
|
137
|
+
let left = false;
|
|
138
|
+
function leaveEditor() {
|
|
139
|
+
if (left || stopped) return;
|
|
140
|
+
left = true;
|
|
141
|
+
void (async () => {
|
|
142
|
+
try {
|
|
143
|
+
await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
|
|
144
|
+
} catch {
|
|
145
|
+
// The cookies expire on their own. Still drop the bar.
|
|
146
|
+
}
|
|
147
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
148
|
+
location.reload();
|
|
149
|
+
})();
|
|
150
|
+
}
|
|
151
|
+
async function renew() {
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${previewEntryPath}?renew=1`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
credentials: "same-origin",
|
|
156
|
+
cache: "no-store",
|
|
157
|
+
});
|
|
158
|
+
if (res.status === 401) leaveEditor();
|
|
159
|
+
} catch {
|
|
160
|
+
// Offline. The next send, or the next interval, tries again.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
void renew();
|
|
164
|
+
const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
|
|
165
|
+
function onExpired(event: MessageEvent) {
|
|
166
|
+
if (event.origin !== origin) return;
|
|
167
|
+
if (event.data?.type === "backmatter:session-expired") leaveEditor();
|
|
168
|
+
}
|
|
169
|
+
window.addEventListener("message", onExpired);
|
|
170
|
+
return () => {
|
|
171
|
+
stopped = true;
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
window.removeEventListener("message", onExpired);
|
|
174
|
+
};
|
|
175
|
+
}, [origin, previewEntryPath]);
|
|
176
|
+
|
|
114
177
|
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
115
178
|
const editorSrc =
|
|
116
|
-
doc &&
|
|
117
|
-
? `${
|
|
179
|
+
doc && base
|
|
180
|
+
? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
|
|
118
181
|
: "";
|
|
119
182
|
|
|
120
183
|
function onChatWrote() {
|
|
@@ -124,7 +187,33 @@ export function SiteChrome({
|
|
|
124
187
|
|
|
125
188
|
return (
|
|
126
189
|
<>
|
|
127
|
-
<style>{
|
|
190
|
+
<style>{`
|
|
191
|
+
${DEVBAR_ENTER_CSS}
|
|
192
|
+
@media (hover: hover) {
|
|
193
|
+
.${DEVBAR_CLASS} button:hover:not(:disabled) {
|
|
194
|
+
background: #f4f4f5;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
.${DEVBAR_CLASS} button:focus-visible {
|
|
198
|
+
outline: 2px solid #18181b;
|
|
199
|
+
outline-offset: 2px;
|
|
200
|
+
}
|
|
201
|
+
.${DEVBAR_CLASS} button:disabled {
|
|
202
|
+
cursor: default;
|
|
203
|
+
opacity: 0.5;
|
|
204
|
+
}
|
|
205
|
+
html.backmatter-pointing,
|
|
206
|
+
html.backmatter-pointing * {
|
|
207
|
+
cursor: crosshair !important;
|
|
208
|
+
}
|
|
209
|
+
html.backmatter-pointing .${DEVBAR_CLASS},
|
|
210
|
+
html.backmatter-pointing .${DEVBAR_CLASS} * {
|
|
211
|
+
cursor: pointer !important;
|
|
212
|
+
}
|
|
213
|
+
html.backmatter-pointing .${DEVBAR_CLASS} button:disabled {
|
|
214
|
+
cursor: default !important;
|
|
215
|
+
}
|
|
216
|
+
`}</style>
|
|
128
217
|
{editorMounted && doc && editorSrc ? (
|
|
129
218
|
<iframe
|
|
130
219
|
ref={editorFrame}
|
|
@@ -137,18 +226,19 @@ export function SiteChrome({
|
|
|
137
226
|
}}
|
|
138
227
|
/>
|
|
139
228
|
) : null}
|
|
140
|
-
{
|
|
229
|
+
{doc && base ? (
|
|
141
230
|
<CmsChat
|
|
142
231
|
doc={doc}
|
|
143
232
|
hidden={!chatOpen}
|
|
144
233
|
onWrote={onChatWrote}
|
|
145
234
|
onClosed={() => setChatOpen(false)}
|
|
235
|
+
base={base}
|
|
146
236
|
/>
|
|
147
237
|
) : null}
|
|
148
|
-
{doc &&
|
|
238
|
+
{doc && base ? (
|
|
149
239
|
<iframe
|
|
150
240
|
title="Publish"
|
|
151
|
-
src={`${
|
|
241
|
+
src={`${base}/embed/publish/${doc.workspaceId}`}
|
|
152
242
|
style={{
|
|
153
243
|
...publishStyle,
|
|
154
244
|
visibility: publishOpen ? "visible" : "hidden",
|
|
@@ -156,7 +246,7 @@ export function SiteChrome({
|
|
|
156
246
|
}}
|
|
157
247
|
/>
|
|
158
248
|
) : null}
|
|
159
|
-
<footer aria-label="Backmatter" style={barStyle}>
|
|
249
|
+
<footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
|
|
160
250
|
<button
|
|
161
251
|
type="button"
|
|
162
252
|
aria-pressed={editorOpen}
|
|
@@ -172,17 +262,13 @@ export function SiteChrome({
|
|
|
172
262
|
<BarText>Preview</BarText>
|
|
173
263
|
{branch ? <BarText>{branch}</BarText> : null}
|
|
174
264
|
{tree ? <BarText>draft</BarText> : null}
|
|
175
|
-
<span style={{ flex: 1 }} />
|
|
176
265
|
<button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
|
|
177
266
|
Publish{changeCount ? ` ${changeCount}` : ""}
|
|
178
267
|
</button>
|
|
179
268
|
<button
|
|
180
269
|
type="button"
|
|
181
270
|
aria-pressed={chatOpen}
|
|
182
|
-
onClick={() =>
|
|
183
|
-
setChatMounted(true);
|
|
184
|
-
setChatOpen((open) => !open);
|
|
185
|
-
}}
|
|
271
|
+
onClick={() => setChatOpen((open) => !open)}
|
|
186
272
|
title="Toggle AI chat (⌘J)"
|
|
187
273
|
style={barButton}
|
|
188
274
|
>
|
|
@@ -201,11 +287,14 @@ function CmsChat({
|
|
|
201
287
|
hidden,
|
|
202
288
|
onWrote,
|
|
203
289
|
onClosed,
|
|
290
|
+
base,
|
|
204
291
|
}: {
|
|
205
292
|
doc: SiteDocRef;
|
|
206
293
|
hidden: boolean;
|
|
207
294
|
onWrote: () => void;
|
|
208
295
|
onClosed: () => void;
|
|
296
|
+
/** Studio origin plus base path. */
|
|
297
|
+
base: string;
|
|
209
298
|
}) {
|
|
210
299
|
const frame = useRef<HTMLIFrameElement>(null);
|
|
211
300
|
const onWroteRef = useRef(onWrote);
|
|
@@ -214,7 +303,7 @@ function CmsChat({
|
|
|
214
303
|
onWroteRef.current = onWrote;
|
|
215
304
|
onClosedRef.current = onClosed;
|
|
216
305
|
hiddenRef.current = hidden;
|
|
217
|
-
const origin =
|
|
306
|
+
const origin = base ? new URL(base).origin : "";
|
|
218
307
|
|
|
219
308
|
function openChat() {
|
|
220
309
|
const frameEl = frame.current;
|
|
@@ -227,6 +316,9 @@ function CmsChat({
|
|
|
227
316
|
if (!hidden) openChat();
|
|
228
317
|
}, [hidden, origin]);
|
|
229
318
|
|
|
319
|
+
const docRef = useRef(doc);
|
|
320
|
+
docRef.current = doc;
|
|
321
|
+
|
|
230
322
|
useEffect(() => {
|
|
231
323
|
const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
|
|
232
324
|
function postFocus() {
|
|
@@ -246,12 +338,47 @@ function CmsChat({
|
|
|
246
338
|
return () => window.removeEventListener("message", onMessage);
|
|
247
339
|
}, [doc.locale, doc.type, doc.slug, origin]);
|
|
248
340
|
|
|
249
|
-
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!origin) return;
|
|
343
|
+
const pointer = installSitePointer(document, {
|
|
344
|
+
bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
|
|
345
|
+
ignore: (target) => {
|
|
346
|
+
if (!(target instanceof Element)) return true;
|
|
347
|
+
const frameEl = frame.current;
|
|
348
|
+
if (frameEl && (target === frameEl || frameEl.contains(target))) return true;
|
|
349
|
+
return target.closest("iframe") !== null;
|
|
350
|
+
},
|
|
351
|
+
onGrab: (grab) => {
|
|
352
|
+
frame.current?.contentWindow?.postMessage(
|
|
353
|
+
{ type: "backmatter:pointer-grab", grab, doc: docRef.current },
|
|
354
|
+
origin,
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
onEscape: () => {
|
|
358
|
+
frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
function onMessage(event: MessageEvent) {
|
|
362
|
+
if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
|
|
363
|
+
const data = event.data as { type?: string; on?: boolean };
|
|
364
|
+
if (data?.type !== "backmatter:pointer" || typeof data.on !== "boolean") return;
|
|
365
|
+
if (data.on && !hiddenRef.current) pointer.arm();
|
|
366
|
+
else pointer.disarm();
|
|
367
|
+
}
|
|
368
|
+
window.addEventListener("message", onMessage);
|
|
369
|
+
return () => {
|
|
370
|
+
window.removeEventListener("message", onMessage);
|
|
371
|
+
pointer.dispose();
|
|
372
|
+
frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
|
|
373
|
+
};
|
|
374
|
+
}, [origin, hidden]);
|
|
375
|
+
|
|
376
|
+
if (!base) return null;
|
|
250
377
|
return (
|
|
251
378
|
<iframe
|
|
252
379
|
ref={frame}
|
|
253
380
|
title="AI chat"
|
|
254
|
-
src={`${
|
|
381
|
+
src={`${base}/embed/chat/${doc.workspaceId}`}
|
|
255
382
|
style={{
|
|
256
383
|
...chatStyle,
|
|
257
384
|
visibility: hidden ? "hidden" : "visible",
|
|
@@ -266,48 +393,51 @@ function BarText({ children }: { children: ReactNode }) {
|
|
|
266
393
|
}
|
|
267
394
|
|
|
268
395
|
const barStyle: CSSProperties = {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
zIndex: 2147483000,
|
|
274
|
-
display: "flex",
|
|
275
|
-
alignItems: "center",
|
|
276
|
-
gap: 12,
|
|
277
|
-
height: 36,
|
|
278
|
-
padding: "0 12px",
|
|
279
|
-
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
280
|
-
boxSizing: "border-box",
|
|
281
|
-
borderTop: "1px solid #e4e4e7",
|
|
282
|
-
background: "#f4f4f5",
|
|
283
|
-
color: "#71717a",
|
|
284
|
-
font: "12px/1 ui-sans-serif, system-ui, sans-serif",
|
|
396
|
+
...devbarShellStyle,
|
|
397
|
+
gap: 2,
|
|
398
|
+
padding: "0 6px 0 10px",
|
|
399
|
+
font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
285
400
|
};
|
|
286
401
|
|
|
287
402
|
const barButton: CSSProperties = {
|
|
288
403
|
border: 0,
|
|
404
|
+
borderRadius: 8,
|
|
289
405
|
background: "transparent",
|
|
290
406
|
color: "#18181b",
|
|
291
407
|
font: "inherit",
|
|
292
408
|
cursor: "pointer",
|
|
293
|
-
padding: "
|
|
409
|
+
padding: "0 10px",
|
|
410
|
+
minHeight: 44,
|
|
294
411
|
};
|
|
295
412
|
|
|
296
413
|
const docButton: CSSProperties = {
|
|
297
414
|
...barButton,
|
|
298
415
|
fontWeight: 600,
|
|
416
|
+
maxWidth: 200,
|
|
417
|
+
overflow: "hidden",
|
|
418
|
+
textOverflow: "ellipsis",
|
|
419
|
+
whiteSpace: "nowrap",
|
|
299
420
|
};
|
|
300
421
|
|
|
301
|
-
const mutedStyle: CSSProperties = {
|
|
422
|
+
const mutedStyle: CSSProperties = {
|
|
423
|
+
color: "#71717a",
|
|
424
|
+
fontSize: 12,
|
|
425
|
+
margin: 0,
|
|
426
|
+
padding: "0 6px",
|
|
427
|
+
maxWidth: 140,
|
|
428
|
+
overflow: "hidden",
|
|
429
|
+
textOverflow: "ellipsis",
|
|
430
|
+
whiteSpace: "nowrap",
|
|
431
|
+
};
|
|
302
432
|
|
|
303
433
|
const editorStyle: CSSProperties = {
|
|
304
434
|
position: "fixed",
|
|
305
435
|
left: 0,
|
|
306
436
|
right: 0,
|
|
307
|
-
bottom:
|
|
437
|
+
bottom: DEVBAR_CLEARANCE,
|
|
308
438
|
zIndex: 2147482999,
|
|
309
439
|
width: "100%",
|
|
310
|
-
height: "min(78vh, calc(100vh -
|
|
440
|
+
height: "min(78vh, calc(100vh - 104px))",
|
|
311
441
|
border: 0,
|
|
312
442
|
borderTop: "1px solid #e4e4e7",
|
|
313
443
|
background: "#ffffff",
|
|
@@ -316,11 +446,11 @@ const editorStyle: CSSProperties = {
|
|
|
316
446
|
|
|
317
447
|
const chatStyle: CSSProperties = {
|
|
318
448
|
position: "fixed",
|
|
319
|
-
right:
|
|
320
|
-
bottom:
|
|
449
|
+
right: DEVBAR_RIGHT,
|
|
450
|
+
bottom: DEVBAR_CLEARANCE,
|
|
321
451
|
zIndex: 2147483001,
|
|
322
|
-
width: "min(440px, calc(100vw -
|
|
323
|
-
height: "min(640px, calc(100vh -
|
|
452
|
+
width: "min(440px, calc(100vw - 32px))",
|
|
453
|
+
height: "min(640px, calc(100vh - 148px))",
|
|
324
454
|
border: 0,
|
|
325
455
|
borderRadius: 14,
|
|
326
456
|
background: "#f4f4f5",
|
|
@@ -329,11 +459,11 @@ const chatStyle: CSSProperties = {
|
|
|
329
459
|
|
|
330
460
|
const publishStyle: CSSProperties = {
|
|
331
461
|
position: "fixed",
|
|
332
|
-
right:
|
|
333
|
-
bottom:
|
|
462
|
+
right: DEVBAR_RIGHT,
|
|
463
|
+
bottom: DEVBAR_CLEARANCE,
|
|
334
464
|
zIndex: 2147483001,
|
|
335
|
-
width: "min(420px, calc(100vw -
|
|
336
|
-
height: "min(520px, calc(100vh -
|
|
465
|
+
width: "min(420px, calc(100vw - 32px))",
|
|
466
|
+
height: "min(520px, calc(100vh - 148px))",
|
|
337
467
|
border: "1px solid #e4e4e7",
|
|
338
468
|
borderRadius: 8,
|
|
339
469
|
background: "#ffffff",
|