@drawnagency/primitives 0.1.90 → 0.1.92
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/dist/{chunk-ZLG7S54V.js → chunk-7QEJDYTG.js} +6 -6
- package/dist/{chunk-XUIJHYKI.js → chunk-A2V7BZPD.js} +3 -3
- package/dist/{chunk-NV4R6VHN.js → chunk-CI624HRS.js} +1 -1
- package/dist/{chunk-SIYQ37QY.js → chunk-IZLXF3CR.js} +1 -1
- package/dist/{chunk-XKRQMTE4.js → chunk-LQQY2RW6.js} +2 -2
- package/dist/{chunk-KMQZYORS.js → chunk-RUKO2N3F.js} +5 -1
- package/dist/{chunk-CZ3YXGK5.js → chunk-SOILPZ7U.js} +27 -3
- package/dist/{chunk-AGWE2LJG.js → chunk-WOYGQK2D.js} +1 -1
- package/dist/{chunk-KBQNJZUX.js → chunk-YI2HUFE3.js} +1 -1
- package/dist/{chunk-MK7YB2A4.js → chunk-Z66TBL4Q.js} +1 -1
- package/dist/components/chat/ChatPanel.d.ts +14 -6
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/editor/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Fab.d.ts +37 -0
- package/dist/components/shared/Fab.d.ts.map +1 -0
- package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/ViewerLoginForm.d.ts +1 -0
- package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/lib/index.js +2 -2
- package/dist/media/compose-preview.d.ts +9 -0
- package/dist/media/compose-preview.d.ts.map +1 -1
- package/dist/media/index.js +2 -2
- package/dist/media/preview-inputs.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +51 -19
- package/src/components/chat/ChatWidget.tsx +113 -51
- package/src/components/shared/Fab.tsx +57 -0
- package/src/components/shell/BugReportFAB.tsx +3 -7
- package/src/components/shell/EditorShell.tsx +38 -19
- package/src/components/shell/ViewerLoginForm.tsx +19 -8
- package/src/media/compose-preview.ts +56 -4
- package/src/media/preview-inputs.ts +3 -1
- package/src/schemas/site-config.ts +4 -0
|
@@ -12,9 +12,9 @@ import { RemoveIcon } from "../shared/icons";
|
|
|
12
12
|
export interface ChatMessage { role: "user" | "assistant"; content: string }
|
|
13
13
|
|
|
14
14
|
/** How the conversation is framed on screen:
|
|
15
|
-
* - "floating": the compact anchored panel (
|
|
16
|
-
* - "docked": full-height right sidebar (viewer, >=768px)
|
|
17
|
-
* - "sheet": bottom sheet (viewer, phones) */
|
|
15
|
+
* - "floating": the compact anchored panel (settings test chat only)
|
|
16
|
+
* - "docked": full-height right sidebar (viewer + /edit, >=768px)
|
|
17
|
+
* - "sheet": bottom sheet (viewer + /edit, phones) */
|
|
18
18
|
export type ChatPanelMode = "floating" | "docked" | "sheet";
|
|
19
19
|
|
|
20
20
|
const SESSION_KEY = "bp-chat-session";
|
|
@@ -35,9 +35,10 @@ function sessionId(): string {
|
|
|
35
35
|
return id;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Conversation persistence (
|
|
39
|
-
* same tab, so the transcript must survive page navigation
|
|
40
|
-
*
|
|
38
|
+
/** Conversation persistence (docked/sheet modes): viewer internal links open
|
|
39
|
+
* in the same tab, so the transcript must survive page navigation — and it
|
|
40
|
+
* carries across same-tab hops between /edit and the live site. sessionStorage
|
|
41
|
+
* — per-tab, cleared when the visit ends. */
|
|
41
42
|
function loadStoredMessages(): ChatMessage[] {
|
|
42
43
|
try {
|
|
43
44
|
const parsed: unknown = JSON.parse(window.sessionStorage.getItem(MESSAGES_KEY) ?? "[]");
|
|
@@ -132,8 +133,15 @@ export async function streamChat(
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
export default function ChatPanel({ open, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
|
|
136
|
+
export default function ChatPanel({ open, closing, animateEnter, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
|
|
136
137
|
open: boolean;
|
|
138
|
+
/** Viewer modes: keeps the panel mounted through its exit slide (ChatWidget
|
|
139
|
+
* flips this off once the transition has run). */
|
|
140
|
+
closing?: boolean;
|
|
141
|
+
/** Animate the entrance (explicit user open). False for the docked restore
|
|
142
|
+
* on page load, where the column is already reserved pre-paint and a slide
|
|
143
|
+
* would replay on every navigation. */
|
|
144
|
+
animateEnter?: boolean;
|
|
137
145
|
onClose: () => void;
|
|
138
146
|
siteName: string;
|
|
139
147
|
/** Resolved assistant display name (per-audience, server-side). */
|
|
@@ -146,8 +154,9 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
|
|
|
146
154
|
draftChatbot?: unknown;
|
|
147
155
|
/** Configurator test panel only: preview this audience's prompt overlay. */
|
|
148
156
|
simulateAudience?: string;
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
157
|
+
/** Surface the chat serves: "editor" (/edit docked chat) keeps internal
|
|
158
|
+
* cross-page links in a new tab (unsaved editor state) and sizes the
|
|
159
|
+
* docked panel against the editor toolbar instead of the viewer nav. */
|
|
151
160
|
variant?: "viewer" | "editor";
|
|
152
161
|
mode?: ChatPanelMode;
|
|
153
162
|
/** Focus the composer when opened (explicit user open on desktop). Never
|
|
@@ -331,14 +340,15 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
|
|
|
331
340
|
return;
|
|
332
341
|
}
|
|
333
342
|
if (url.origin === window.location.origin) {
|
|
334
|
-
if (persistent) {
|
|
343
|
+
if (persistent && variant !== "editor") {
|
|
335
344
|
// Internal cross-page citation, viewer chat: navigate in place — the
|
|
336
345
|
// sessionStorage transcript (and docked-open state) survive the load.
|
|
337
346
|
window.location.assign(url.href);
|
|
338
347
|
return;
|
|
339
348
|
}
|
|
340
|
-
// Editor surfaces (
|
|
341
|
-
// unsaved editor state — keep internal links
|
|
349
|
+
// Editor surfaces (/edit docked chat, settings test panel): same-tab
|
|
350
|
+
// navigation would destroy unsaved editor state — keep internal links
|
|
351
|
+
// in a new tab there.
|
|
342
352
|
window.open(url.href, "_blank", "noopener");
|
|
343
353
|
return;
|
|
344
354
|
}
|
|
@@ -442,14 +452,21 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
|
|
|
442
452
|
setHistory(loadHistory(historyAudience));
|
|
443
453
|
}
|
|
444
454
|
|
|
445
|
-
if (!open) return null;
|
|
455
|
+
if (!open && !closing) return null;
|
|
446
456
|
|
|
447
457
|
const lastIndex = messages.length - 1;
|
|
448
458
|
|
|
449
459
|
return (
|
|
450
460
|
<>
|
|
451
461
|
{mode === "sheet" && (
|
|
452
|
-
<div
|
|
462
|
+
<div
|
|
463
|
+
className={cn(
|
|
464
|
+
"fixed inset-0 z-50 bg-black/50 motion-safe:transition-opacity motion-safe:duration-300",
|
|
465
|
+
open ? "opacity-100 starting:opacity-0" : "pointer-events-none opacity-0",
|
|
466
|
+
)}
|
|
467
|
+
onClick={onClose}
|
|
468
|
+
aria-hidden="true"
|
|
469
|
+
/>
|
|
453
470
|
)}
|
|
454
471
|
<div
|
|
455
472
|
ref={panelRef}
|
|
@@ -460,12 +477,27 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
|
|
|
460
477
|
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
461
478
|
className={cn(
|
|
462
479
|
"flex flex-col bg-base focus:outline-none",
|
|
463
|
-
mode === "floating" &&
|
|
464
|
-
|
|
465
|
-
|
|
480
|
+
mode === "floating" && "fixed z-40 h-[28rem] w-80 rounded-outer border border-base-200 shadow-xl bottom-20 right-4",
|
|
481
|
+
// Enter/exit slides pair with base.css's 300ms band shift (same
|
|
482
|
+
// duration + default curve); under reduced motion there's no
|
|
483
|
+
// transition and @starting-style has no effect — instant show/hide.
|
|
484
|
+
// Top offset clears the fixed top bar spanning the panel's column:
|
|
485
|
+
// the viewer nav header exists below xl (chat-enabled pages dock
|
|
486
|
+
// the nav at xl); /edit keeps its full-width h-16 toolbar at every
|
|
487
|
+
// width, so the panel always tucks below it — the toolbar never
|
|
488
|
+
// moves during the open/close slide.
|
|
489
|
+
mode === "docked" && cn(
|
|
490
|
+
"fixed right-0 bottom-0 z-40 w-[var(--chat-w)] border-l border-base-200",
|
|
491
|
+
variant === "editor" ? "top-16" : "top-16 xl:top-0",
|
|
492
|
+
"motion-safe:transition-transform motion-safe:duration-300",
|
|
493
|
+
open ? "translate-x-0" : "translate-x-full",
|
|
494
|
+
open && animateEnter && "starting:translate-x-full",
|
|
495
|
+
),
|
|
496
|
+
mode === "sheet" && cn(
|
|
497
|
+
"fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
|
|
498
|
+
"motion-safe:transition-transform motion-safe:duration-300",
|
|
499
|
+
open ? "translate-y-0 starting:translate-y-full" : "translate-y-full",
|
|
466
500
|
),
|
|
467
|
-
mode === "docked" && "fixed right-0 bottom-0 top-16 xl:top-0 z-40 w-[var(--chat-w)] border-l border-base-200",
|
|
468
|
-
mode === "sheet" && "fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
|
|
469
501
|
)}
|
|
470
502
|
style={mode === "sheet" ? { bottom: "var(--kb-offset, 0px)", maxHeight: "calc(var(--vvh, 100dvh) - 2.5rem)" } : undefined}
|
|
471
503
|
>
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// Chat launcher + shell chooser. Browser-safe: no node imports, no process.env.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// -
|
|
11
|
-
//
|
|
12
|
-
//
|
|
3
|
+
// Both variants share the same conversation experience: >=768px it docks as a
|
|
4
|
+
// full-height right sidebar (html.bp-chat-docked shifts the page band left —
|
|
5
|
+
// see base.css); below 768px it opens as a bottom sheet. Open/close is
|
|
6
|
+
// animated (sidebar slides in as the band shifts, sheet slides up, and ALL
|
|
7
|
+
// page FABs shrink/fade away); prefers-reduced-motion keeps everything
|
|
8
|
+
// instant. FABs show only while closed. Docked-open is the default and the
|
|
9
|
+
// preference persists per tab (sessionStorage, mirrored by Layout.astro's
|
|
10
|
+
// pre-paint script so the column is reserved before hydration) — shared
|
|
11
|
+
// across viewer pages and /edit.
|
|
12
|
+
//
|
|
13
|
+
// The variants differ only in the launcher's placement:
|
|
14
|
+
// - "viewer" (default): viewport corner (viewer-fab tracks the sidebar edge).
|
|
15
|
+
// - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
|
|
16
|
+
// bottom-16, bug report at bottom-28 above it), aligned to the content edge
|
|
17
|
+
// via fab-container-right like its neighbors.
|
|
13
18
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
14
|
-
import
|
|
19
|
+
import Fab from "../shared/Fab";
|
|
15
20
|
|
|
16
21
|
const ChatPanel = lazy(() => import("./ChatPanel"));
|
|
17
22
|
|
|
@@ -34,19 +39,79 @@ export interface ChatWidgetProps {
|
|
|
34
39
|
audience?: string | null;
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
/** Matches the 300ms slide transitions in base.css / ChatPanel. */
|
|
43
|
+
const CLOSE_MS = 300;
|
|
44
|
+
/** How long html.bp-chat-animating stays on: the 300ms glide plus a timer-
|
|
45
|
+
* jitter buffer. The class gates the band/nav FLIP transforms in base.css —
|
|
46
|
+
* it must outlive the transition (or the glide would cut off early) but
|
|
47
|
+
* MUST come off afterwards, because a resting transform re-anchors
|
|
48
|
+
* position:fixed descendants. */
|
|
49
|
+
const ANIM_MS = 350;
|
|
50
|
+
|
|
37
51
|
export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, variant = "viewer", audience }: ChatWidgetProps) {
|
|
38
52
|
const [open, setOpen] = useState(false);
|
|
39
53
|
const [everOpened, setEverOpened] = useState(false);
|
|
40
54
|
const [docked, setDocked] = useState(false);
|
|
41
55
|
const [userOpened, setUserOpened] = useState(false);
|
|
56
|
+
// Close is animated: the panel stays mounted while it slides out.
|
|
57
|
+
const [closing, setClosing] = useState(false);
|
|
58
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
59
|
+
const animTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
60
|
const fabRef = useRef<HTMLButtonElement>(null);
|
|
43
|
-
|
|
61
|
+
|
|
62
|
+
useEffect(() => () => {
|
|
63
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
64
|
+
if (animTimer.current) clearTimeout(animTimer.current);
|
|
65
|
+
document.documentElement.classList.remove("bp-chat-animating");
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
function clearCloseTimer() {
|
|
69
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
70
|
+
closeTimer.current = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function motionOk(): boolean {
|
|
74
|
+
return typeof window.matchMedia === "function" && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Open the FLIP window for a docked toggle: while bp-chat-animating is on
|
|
78
|
+
* <html>, base.css glides the band/nav via transform between the snapped
|
|
79
|
+
* layouts (see the @property note there). User-driven toggles only — the
|
|
80
|
+
* page-load restore must not replay the glide. */
|
|
81
|
+
function beginShiftWindow() {
|
|
82
|
+
if (!docked || !motionOk()) return;
|
|
83
|
+
document.documentElement.classList.add("bp-chat-animating");
|
|
84
|
+
if (animTimer.current) clearTimeout(animTimer.current);
|
|
85
|
+
animTimer.current = setTimeout(() => {
|
|
86
|
+
document.documentElement.classList.remove("bp-chat-animating");
|
|
87
|
+
animTimer.current = null;
|
|
88
|
+
}, ANIM_MS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function openPanel() {
|
|
92
|
+
clearCloseTimer();
|
|
93
|
+
beginShiftWindow();
|
|
94
|
+
setClosing(false);
|
|
95
|
+
setOpen(true);
|
|
96
|
+
setEverOpened(true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function closePanel() {
|
|
100
|
+
clearCloseTimer();
|
|
101
|
+
beginShiftWindow();
|
|
102
|
+
setOpen(false);
|
|
103
|
+
// Reduced motion unmounts immediately — the pre-animation behavior.
|
|
104
|
+
if (motionOk()) {
|
|
105
|
+
setClosing(true);
|
|
106
|
+
closeTimer.current = setTimeout(() => { setClosing(false); closeTimer.current = null; }, CLOSE_MS);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
44
109
|
|
|
45
110
|
// Post-hydration restore. The server renders only the FAB; Layout.astro's
|
|
46
111
|
// pre-paint script has already reserved the docked column and CSS hides the
|
|
47
112
|
// FAB under html.bp-chat-docked, so this state sync causes no layout jump.
|
|
48
113
|
useEffect(() => {
|
|
49
|
-
if (
|
|
114
|
+
if (typeof window.matchMedia !== "function") return;
|
|
50
115
|
const mq = window.matchMedia(DOCK_QUERY);
|
|
51
116
|
const apply = () => setDocked(mq.matches);
|
|
52
117
|
apply();
|
|
@@ -55,59 +120,56 @@ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks,
|
|
|
55
120
|
try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) !== "0"; } catch {}
|
|
56
121
|
if (mq.matches && storedOpen) { setOpen(true); setEverOpened(true); }
|
|
57
122
|
return () => mq.removeEventListener("change", apply);
|
|
58
|
-
}, [
|
|
123
|
+
}, []);
|
|
59
124
|
|
|
60
|
-
// Reflect
|
|
61
|
-
//
|
|
62
|
-
//
|
|
125
|
+
// Reflect open state on <html>: bp-chat-docked drives the band shift (and
|
|
126
|
+
// the /edit toolbar/FAB-column shift), bp-chat-open (any mode) drives the
|
|
127
|
+
// document FAB's hide/handoff — both animated in base.css. Persist the
|
|
128
|
+
// preference; only the docked state persists — the phone sheet always
|
|
129
|
+
// starts closed.
|
|
63
130
|
useEffect(() => {
|
|
64
|
-
if (!isViewer) return;
|
|
65
131
|
document.documentElement.classList.toggle("bp-chat-docked", open && docked);
|
|
132
|
+
document.documentElement.classList.toggle("bp-chat-open", open);
|
|
66
133
|
if (docked) {
|
|
67
134
|
try { window.sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch {}
|
|
68
135
|
}
|
|
69
|
-
return () =>
|
|
70
|
-
|
|
136
|
+
return () => {
|
|
137
|
+
document.documentElement.classList.remove("bp-chat-docked");
|
|
138
|
+
document.documentElement.classList.remove("bp-chat-open");
|
|
139
|
+
};
|
|
140
|
+
}, [open, docked]);
|
|
71
141
|
|
|
72
142
|
// Closing the sheet/sidebar returns focus to the launcher.
|
|
73
143
|
useEffect(() => {
|
|
74
|
-
if (
|
|
75
|
-
}, [open, everOpened
|
|
76
|
-
|
|
77
|
-
const showFab = !isViewer || !open;
|
|
144
|
+
if (!open && everOpened) fabRef.current?.focus();
|
|
145
|
+
}, [open, everOpened]);
|
|
78
146
|
|
|
147
|
+
// The launcher stays mounted while the chat is open so it can shrink and
|
|
148
|
+
// fade out (and reverse back in): base.css hides ALL FABs (.viewer-fab and
|
|
149
|
+
// the /edit .fab-container-right column, this button included) under
|
|
150
|
+
// html.bp-chat-open/.bp-chat-docked, with visibility dropped after the fade
|
|
151
|
+
// so no invisible button can take focus or clicks.
|
|
79
152
|
return (
|
|
80
153
|
<>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)}
|
|
94
|
-
>
|
|
95
|
-
{open ? (
|
|
96
|
-
<svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
97
|
-
<path d="m6 9 6 6 6-6" />
|
|
98
|
-
</svg>
|
|
99
|
-
) : (
|
|
100
|
-
<svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
101
|
-
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
102
|
-
</svg>
|
|
103
|
-
)}
|
|
104
|
-
</button>
|
|
105
|
-
)}
|
|
154
|
+
<Fab
|
|
155
|
+
ref={fabRef}
|
|
156
|
+
label={`Chat with ${botName}`}
|
|
157
|
+
column={variant === "editor" ? "editor" : "viewer"}
|
|
158
|
+
className="bottom-4"
|
|
159
|
+
aria-expanded={open}
|
|
160
|
+
onClick={() => { setUserOpened(true); if (open) closePanel(); else openPanel(); }}
|
|
161
|
+
>
|
|
162
|
+
<svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
163
|
+
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
164
|
+
</svg>
|
|
165
|
+
</Fab>
|
|
106
166
|
{everOpened && (
|
|
107
167
|
<Suspense fallback={null}>
|
|
108
168
|
<ChatPanel
|
|
109
169
|
open={open}
|
|
110
|
-
|
|
170
|
+
closing={closing}
|
|
171
|
+
animateEnter={userOpened}
|
|
172
|
+
onClose={closePanel}
|
|
111
173
|
siteName={siteName}
|
|
112
174
|
botName={botName}
|
|
113
175
|
thinkingPhrases={thinkingPhrases}
|
|
@@ -115,7 +177,7 @@ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks,
|
|
|
115
177
|
isEditor={isEditor}
|
|
116
178
|
draftChatbot={draftChatbot}
|
|
117
179
|
variant={variant}
|
|
118
|
-
mode={
|
|
180
|
+
mode={docked ? "docked" : "sheet"}
|
|
119
181
|
focusComposer={userOpened}
|
|
120
182
|
audience={audience}
|
|
121
183
|
/>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Floating action button — the round primary buttons floating over the page.
|
|
5
|
+
*
|
|
6
|
+
* The `column` is both the placement and the behavior hook: base.css hides
|
|
7
|
+
* every member of both columns (shrink + fade) while the chat is open, keyed
|
|
8
|
+
* on the column class, so a new FAB participates in the chat open/close
|
|
9
|
+
* choreography just by rendering through this component. Positions are
|
|
10
|
+
* static on purpose — FABs are only visible while the chat is closed
|
|
11
|
+
* (--chat-space: 0), so they never need to track the docked sidebar.
|
|
12
|
+
*
|
|
13
|
+
* - "viewer": viewport corner stack (z-40, h-12). Rendered on SSR viewer
|
|
14
|
+
* pages too — keep this component hook-free so it stays server-renderable
|
|
15
|
+
* without hydration.
|
|
16
|
+
* - "editor": the /edit FAB column (z-50, h-10), content-edge aligned at lg+
|
|
17
|
+
* via fab-container-right.
|
|
18
|
+
*
|
|
19
|
+
* Renders an <a> when `href` is set, else a <button type="button">. The
|
|
20
|
+
* caller supplies the vertical slot (bottom-*) via className and the icon as
|
|
21
|
+
* children.
|
|
22
|
+
*/
|
|
23
|
+
interface FabCommon {
|
|
24
|
+
/** Accessible name — FABs are icon-only. */
|
|
25
|
+
label: string;
|
|
26
|
+
column: "viewer" | "editor";
|
|
27
|
+
/** Vertical slot (bottom-*) plus any per-FAB extras. */
|
|
28
|
+
className?: string;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FabProps =
|
|
33
|
+
| ({ href: string } & FabCommon & Omit<ComponentPropsWithoutRef<"a">, "children" | "className" | "aria-label">)
|
|
34
|
+
| ({ href?: undefined; ref?: Ref<HTMLButtonElement> } & FabCommon &
|
|
35
|
+
Omit<ComponentPropsWithoutRef<"button">, "children" | "className" | "aria-label" | "type">);
|
|
36
|
+
|
|
37
|
+
export default function Fab({ label, column, className, children, ...rest }: FabProps) {
|
|
38
|
+
const classes = cn(
|
|
39
|
+
"cursor-pointer fixed flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
|
|
40
|
+
column === "viewer"
|
|
41
|
+
? "z-40 h-12 w-12 right-4 viewer-fab"
|
|
42
|
+
: "z-50 h-10 w-10 right-4 lg:right-auto fab-container-right",
|
|
43
|
+
className,
|
|
44
|
+
);
|
|
45
|
+
if (rest.href !== undefined) {
|
|
46
|
+
return (
|
|
47
|
+
<a aria-label={label} className={classes} {...(rest as ComponentPropsWithoutRef<"a">)}>
|
|
48
|
+
{children}
|
|
49
|
+
</a>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
<button type="button" aria-label={label} className={classes} {...(rest as ComponentPropsWithoutRef<"button"> & { ref?: Ref<HTMLButtonElement> })}>
|
|
54
|
+
{children}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|
|
2
2
|
import { EditorModal } from "./EditorModal";
|
|
3
3
|
import { useEditorContext } from "./EditorContext";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
|
+
import Fab from "../shared/Fab";
|
|
5
6
|
|
|
6
7
|
type Category = "Visual" | "Unexpected Behavior" | "Media / Media Library" | "Save / Publish" | "Feature Request" | "Other";
|
|
7
8
|
|
|
@@ -186,17 +187,12 @@ export function BugReportFAB() {
|
|
|
186
187
|
<>
|
|
187
188
|
{/* FAB button */}
|
|
188
189
|
{visible && (
|
|
189
|
-
<
|
|
190
|
-
type="button"
|
|
191
|
-
onClick={handleOpen}
|
|
192
|
-
aria-label="Report a bug"
|
|
193
|
-
className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
|
|
194
|
-
>
|
|
190
|
+
<Fab label="Report a bug" column="editor" className="bottom-28" onClick={handleOpen}>
|
|
195
191
|
{/* Bug / beetle SVG icon */}
|
|
196
192
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
197
193
|
<path d="M19 8h-1.26A6.003 6.003 0 0012 4a6.003 6.003 0 00-5.74 4H5a1 1 0 000 2h1.05A6.04 6.04 0 006 12H5a1 1 0 000 2h1c0 .34.03.67.08 1H5a1 1 0 000 2h1.26a6 6 0 0011.48 0H19a1 1 0 000-2h-1.08c.05-.33.08-.66.08-1h1a1 1 0 000-2h-1a6.04 6.04 0 00-.05-2H19a1 1 0 000-2zM12 6c1.7 0 3.16.88 4 2.19V8a1 1 0 10-2 0v.04A3.97 3.97 0 0012 8a3.97 3.97 0 00-2 .04V8a1 1 0 10-2 0v.19A4.69 4.69 0 018 6a4 4 0 014 0zm0 14a4 4 0 01-4-4v-4a4 4 0 018 0v4a4 4 0 01-4 4z"/>
|
|
198
194
|
</svg>
|
|
199
|
-
</
|
|
195
|
+
</Fab>
|
|
200
196
|
)}
|
|
201
197
|
|
|
202
198
|
{/* Modal */}
|
|
@@ -10,7 +10,7 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
|
|
|
10
10
|
import type { Audience } from "../../auth/types";
|
|
11
11
|
import type { MediaManifest } from "../../media/types";
|
|
12
12
|
import type { QueueItem } from "../../media/queue";
|
|
13
|
-
import { composePreviewImage } from "../../media/compose-preview";
|
|
13
|
+
import { composePreviewImage, PREVIEW_COMPOSE_VERSION } from "../../media/compose-preview";
|
|
14
14
|
import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
|
|
15
15
|
import { resolveMedia } from "../../media/resolve";
|
|
16
16
|
import { getMediaProvider } from "../../media";
|
|
@@ -101,6 +101,7 @@ import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectio
|
|
|
101
101
|
import { Button } from "../shared/Button";
|
|
102
102
|
import { SplitButton } from "../shared/SplitButton";
|
|
103
103
|
import { IconButton } from "../shared/IconButton";
|
|
104
|
+
import Fab from "../shared/Fab";
|
|
104
105
|
import { Tooltip } from "../shared/Tooltip";
|
|
105
106
|
import { SegmentedControl } from "../shared/SegmentedControl";
|
|
106
107
|
import { computeDisplaySections } from "./view-branch";
|
|
@@ -263,6 +264,9 @@ export default function EditorShell({
|
|
|
263
264
|
// dedupes concurrent runs against the same composition signature.
|
|
264
265
|
const previewInitializedRef = useRef(false);
|
|
265
266
|
const lastPreviewSigRef = useRef<string | null>(null);
|
|
267
|
+
// Increments per compose; the async upload callback applies its result only if
|
|
268
|
+
// still current, so a slower earlier upload can't overwrite a newer preview.
|
|
269
|
+
const previewGenRef = useRef(0);
|
|
266
270
|
|
|
267
271
|
// --- Collaboration refs ---
|
|
268
272
|
// Freshest dirty-section set for the inbound remote-section handler (never
|
|
@@ -1375,12 +1379,14 @@ export default function EditorShell({
|
|
|
1375
1379
|
};
|
|
1376
1380
|
const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
|
|
1377
1381
|
|
|
1378
|
-
// On first run: if a preview already exists
|
|
1379
|
-
// (don't recompose
|
|
1380
|
-
//
|
|
1382
|
+
// On first run: if a preview already exists AND was composed by the current
|
|
1383
|
+
// logic version, seed the signature and skip (don't recompose on every open).
|
|
1384
|
+
// A missing preview, or one stamped with an older version (or 0 for
|
|
1385
|
+
// pre-versioning rasters), falls through to recompose so stale rasters
|
|
1386
|
+
// self-heal. Subsequent runs recompose only on change.
|
|
1381
1387
|
if (!previewInitializedRef.current) {
|
|
1382
1388
|
previewInitializedRef.current = true;
|
|
1383
|
-
if (siteConfig.previewImage) {
|
|
1389
|
+
if (siteConfig.previewImage && siteConfig.previewImage.version === PREVIEW_COMPOSE_VERSION) {
|
|
1384
1390
|
lastPreviewSigRef.current = signature;
|
|
1385
1391
|
return;
|
|
1386
1392
|
}
|
|
@@ -1389,13 +1395,19 @@ export default function EditorShell({
|
|
|
1389
1395
|
|
|
1390
1396
|
const timer = setTimeout(() => {
|
|
1391
1397
|
lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
|
|
1398
|
+
const gen = ++previewGenRef.current;
|
|
1392
1399
|
(async () => {
|
|
1393
1400
|
try {
|
|
1394
1401
|
const blob = await composePreviewImage(inputs);
|
|
1395
1402
|
const file = new File([blob], "site-preview.webp", { type: "image/webp" });
|
|
1396
1403
|
const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
|
|
1397
1404
|
mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
|
|
1398
|
-
|
|
1405
|
+
// Ignore a stale upload: a newer compose has superseded this one.
|
|
1406
|
+
if (gen !== previewGenRef.current) return;
|
|
1407
|
+
handleSiteConfigChange({
|
|
1408
|
+
...siteConfigRef.current!,
|
|
1409
|
+
previewImage: { imageId, width, height, version: PREVIEW_COMPOSE_VERSION },
|
|
1410
|
+
});
|
|
1399
1411
|
}, { hidden: true });
|
|
1400
1412
|
} catch (err) {
|
|
1401
1413
|
console.error("[EditorShell] preview-image composition failed:", err);
|
|
@@ -1548,18 +1560,13 @@ export default function EditorShell({
|
|
|
1548
1560
|
scope renders the provider and can't read the context directly),
|
|
1549
1561
|
hide the FAB so it can't open the draft editor over a history view. */}
|
|
1550
1562
|
{resolvedActivePage?.document?.enabled && !historyActive && (
|
|
1551
|
-
<
|
|
1552
|
-
type="button"
|
|
1553
|
-
onClick={() => setShowDocumentEditor(true)}
|
|
1554
|
-
aria-label="Edit as document"
|
|
1555
|
-
className="cursor-pointer fixed bottom-40 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
|
|
1556
|
-
>
|
|
1563
|
+
<Fab label="Edit as document" column="editor" className="bottom-40" onClick={() => setShowDocumentEditor(true)}>
|
|
1557
1564
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
1558
1565
|
<rect x="3" y="5" width="18" height="14" rx="1" />
|
|
1559
1566
|
<path d="M7 10h10" />
|
|
1560
1567
|
<path d="M7 14h7" />
|
|
1561
1568
|
</svg>
|
|
1562
|
-
</
|
|
1569
|
+
</Fab>
|
|
1563
1570
|
)}
|
|
1564
1571
|
{showDocumentEditor && resolvedActivePage?.document?.enabled && (
|
|
1565
1572
|
<Suspense fallback={<DocumentOverlayFallback />}>
|
|
@@ -1626,6 +1633,12 @@ export default function EditorShell({
|
|
|
1626
1633
|
</div>
|
|
1627
1634
|
)}
|
|
1628
1635
|
|
|
1636
|
+
{/* bp-band-shift: rides the chat dock's FLIP glide (base.css). On
|
|
1637
|
+
/edit it lives HERE, on the content column only — Layout's
|
|
1638
|
+
<main> can't carry it because the toolbar/FABs/chat panel above
|
|
1639
|
+
are position:fixed inside main, and a transformed ancestor
|
|
1640
|
+
would re-anchor them. */}
|
|
1641
|
+
<div className="bp-band-shift">
|
|
1629
1642
|
<HistoryOrEditorContent
|
|
1630
1643
|
index={siteIndex}
|
|
1631
1644
|
activeSections={activeSections}
|
|
@@ -1654,6 +1667,7 @@ export default function EditorShell({
|
|
|
1654
1667
|
onTakeover={confirmTakeover}
|
|
1655
1668
|
/>
|
|
1656
1669
|
</HistoryOrEditorContent>
|
|
1670
|
+
</div>
|
|
1657
1671
|
<GlobalModal />
|
|
1658
1672
|
<SiteSettingsModal
|
|
1659
1673
|
isOpen={showSiteSettings}
|
|
@@ -2412,8 +2426,12 @@ function EditorToolbar({
|
|
|
2412
2426
|
return (
|
|
2413
2427
|
<>
|
|
2414
2428
|
{isEditMode && (
|
|
2429
|
+
// Full-width and static on purpose: the docked chat sidebar slides in
|
|
2430
|
+
// BELOW this bar (ChatPanel docks at top-16 on /edit), so the toolbar
|
|
2431
|
+
// never moves during the chat open/close transition. h-16 matches the
|
|
2432
|
+
// panel's top offset (and the body's pt-16) exactly.
|
|
2415
2433
|
<div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
|
|
2416
|
-
<div className="mx-auto max-w-screen-xl grid grid-cols-3 items-center px-4
|
|
2434
|
+
<div className="mx-auto max-w-screen-xl grid h-16 grid-cols-3 items-center px-4">
|
|
2417
2435
|
<div className="flex items-center gap-2">
|
|
2418
2436
|
{buttonState === "saveAndPublish" && (
|
|
2419
2437
|
<SplitButton
|
|
@@ -2490,7 +2508,7 @@ function EditorToolbar({
|
|
|
2490
2508
|
)}
|
|
2491
2509
|
{!isEditMode && (
|
|
2492
2510
|
<div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
|
|
2493
|
-
<div className="mx-auto max-w-screen-xl flex items-center justify-center px-4
|
|
2511
|
+
<div className="mx-auto max-w-screen-xl flex h-16 items-center justify-center px-4">
|
|
2494
2512
|
<SegmentedControl
|
|
2495
2513
|
options={[
|
|
2496
2514
|
{ value: "live", label: "Published" },
|
|
@@ -2503,10 +2521,11 @@ function EditorToolbar({
|
|
|
2503
2521
|
</div>
|
|
2504
2522
|
)}
|
|
2505
2523
|
{!historyState && (
|
|
2506
|
-
<
|
|
2524
|
+
<Fab
|
|
2525
|
+
label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
|
|
2526
|
+
column="editor"
|
|
2527
|
+
className="bottom-16"
|
|
2507
2528
|
onClick={toggleEditMode}
|
|
2508
|
-
className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
|
|
2509
|
-
aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
|
|
2510
2529
|
>
|
|
2511
2530
|
{isEditMode ? (
|
|
2512
2531
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
@@ -2518,7 +2537,7 @@ function EditorToolbar({
|
|
|
2518
2537
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
2519
2538
|
</svg>
|
|
2520
2539
|
)}
|
|
2521
|
-
</
|
|
2540
|
+
</Fab>
|
|
2522
2541
|
)}
|
|
2523
2542
|
</>
|
|
2524
2543
|
);
|
|
@@ -4,7 +4,7 @@ import { Button } from "../shared/Button";
|
|
|
4
4
|
import { safeRedirect } from "../../lib/safeRedirect";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
|
-
audiences: Array<{ name: string; displayName: string }>;
|
|
7
|
+
audiences: Array<{ name: string; displayName: string; hasPassword?: boolean }>;
|
|
8
8
|
next?: string | null;
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -14,12 +14,21 @@ export function ViewerLoginForm({ audiences, next }: Props) {
|
|
|
14
14
|
const [error, setError] = useState<string | null>(null);
|
|
15
15
|
const [loading, setLoading] = useState(false);
|
|
16
16
|
|
|
17
|
+
// An open (passwordless) audience needs no password field — enter with one click.
|
|
18
|
+
// Default to requiring a password until an audience is chosen so the field
|
|
19
|
+
// doesn't flash for the "Select audience..." state.
|
|
20
|
+
const selected = audiences.find((a) => a.name === audience);
|
|
21
|
+
const needsPassword = selected ? selected.hasPassword !== false : true;
|
|
22
|
+
|
|
17
23
|
async function handleSubmit(e: FormEvent) {
|
|
18
24
|
e.preventDefault();
|
|
19
25
|
setLoading(true);
|
|
20
26
|
setError(null);
|
|
21
27
|
|
|
22
|
-
const body: { audience: string; password: string; next?: string } = {
|
|
28
|
+
const body: { audience: string; password: string; next?: string } = {
|
|
29
|
+
audience,
|
|
30
|
+
password: needsPassword ? password : "",
|
|
31
|
+
};
|
|
23
32
|
if (next) body.next = next;
|
|
24
33
|
|
|
25
34
|
try {
|
|
@@ -67,12 +76,14 @@ export function ViewerLoginForm({ audiences, next }: Props) {
|
|
|
67
76
|
</select>
|
|
68
77
|
</div>
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
{needsPassword && (
|
|
80
|
+
<PasswordInput
|
|
81
|
+
label="Password"
|
|
82
|
+
value={password}
|
|
83
|
+
onChange={setPassword}
|
|
84
|
+
autoComplete="current-password"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
76
87
|
|
|
77
88
|
{error && (
|
|
78
89
|
<p className="text-sm text-red-600">{error}</p>
|