@drawnagency/primitives 0.1.88 → 0.1.90
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-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
- package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
- package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
- package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
- package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
- package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
- package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/AudienceIndicator.d.ts +6 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
- package/dist/components/editor/DragHandle.d.ts +3 -1
- package/dist/components/editor/DragHandle.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +7 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +100 -78
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Container/Container.d.ts +3 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts +8 -2
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/SectionLayout.d.ts +3 -1
- package/dist/components/sections/SectionLayout.d.ts.map +1 -1
- package/dist/components/sections/ViewRenderer.d.ts +3 -1
- package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +8 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/core-sections.d.ts +8 -2
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Tooltip.d.ts +8 -1
- package/dist/components/shared/Tooltip.d.ts.map +1 -1
- package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
- package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +3 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/authoring-defaults.d.ts +17 -0
- package/dist/lib/authoring-defaults.d.ts.map +1 -0
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +6 -0
- package/dist/lib/container-grid.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +11 -3
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +18 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/editor/AudienceIndicator.tsx +50 -37
- package/src/components/editor/ChildBlockWrapper.tsx +8 -7
- package/src/components/editor/ColSpanControl.tsx +14 -11
- package/src/components/editor/DragHandle.tsx +5 -2
- package/src/components/editor/PagesModal.tsx +10 -10
- package/src/components/editor/SectionWrapper.tsx +32 -21
- package/src/components/editor/StatusIndicator.tsx +37 -24
- package/src/components/sections/Container/Container.tsx +185 -43
- package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
- package/src/components/sections/Container/index.tsx +45 -20
- package/src/components/sections/SectionLayout.tsx +13 -3
- package/src/components/sections/ViewRenderer.tsx +11 -2
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shared/Tooltip.tsx +18 -2
- package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
- package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +130 -13
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
- package/src/hooks/useBuildStatus.ts +35 -4
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/authoring-defaults.ts +42 -0
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/container-grid.ts +14 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/migrate-sections-transform.ts +5 -3
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +27 -2
|
@@ -1,65 +1,123 @@
|
|
|
1
|
-
// Chat launcher
|
|
2
|
-
// panel (and its code) loads lazily so viewers who never click pay almost
|
|
3
|
-
// nothing. Browser-safe: no node imports, no process.env.
|
|
1
|
+
// Chat launcher + shell chooser. Browser-safe: no node imports, no process.env.
|
|
4
2
|
//
|
|
5
|
-
//
|
|
6
|
-
// - "viewer" (default):
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
3
|
+
// Placements:
|
|
4
|
+
// - "viewer" (default): >=768px the conversation docks as a full-height right
|
|
5
|
+
// sidebar (html.bp-chat-docked shifts the page band left — see base.css);
|
|
6
|
+
// below 768px it opens as a bottom sheet. The FAB shows only while closed.
|
|
7
|
+
// Docked-open is the default and the preference persists per tab
|
|
8
|
+
// (sessionStorage, mirrored by Layout.astro's pre-paint script so the
|
|
9
|
+
// column is reserved before hydration).
|
|
10
|
+
// - "editor": unchanged floating panel at the BOTTOM of the /edit route's FAB
|
|
11
|
+
// column (edit-mode toggle at bottom-16, bug report at bottom-28 above it),
|
|
12
|
+
// aligned to the content edge via fab-container-right like its neighbors.
|
|
13
|
+
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
11
14
|
import { cn } from "../../lib/cn";
|
|
12
15
|
|
|
13
16
|
const ChatPanel = lazy(() => import("./ChatPanel"));
|
|
14
17
|
|
|
18
|
+
const OPEN_KEY = "bp-chat-open";
|
|
19
|
+
const DOCK_QUERY = "(min-width: 768px)";
|
|
20
|
+
|
|
15
21
|
export interface ChatWidgetProps {
|
|
16
22
|
siteName: string;
|
|
23
|
+
/** Resolved assistant display name (per-audience, resolved server-side). */
|
|
24
|
+
botName: string;
|
|
25
|
+
/** Configured waiting phrases ("…" appended in the panel). */
|
|
26
|
+
thinkingPhrases?: string[];
|
|
17
27
|
/** Already audience-filtered server-side — never pass unfiltered config tasks. */
|
|
18
28
|
tasks: { id: string; label: string }[];
|
|
19
29
|
isEditor?: boolean;
|
|
20
30
|
/** Editor-only: chat against the current (unsaved) chatbot config. */
|
|
21
31
|
draftChatbot?: unknown;
|
|
22
32
|
variant?: "viewer" | "editor";
|
|
33
|
+
/** Viewer's audience (null for editor sessions) — tags local chat history. */
|
|
34
|
+
audience?: string | null;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: ChatWidgetProps) {
|
|
37
|
+
export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, variant = "viewer", audience }: ChatWidgetProps) {
|
|
26
38
|
const [open, setOpen] = useState(false);
|
|
27
39
|
const [everOpened, setEverOpened] = useState(false);
|
|
40
|
+
const [docked, setDocked] = useState(false);
|
|
41
|
+
const [userOpened, setUserOpened] = useState(false);
|
|
42
|
+
const fabRef = useRef<HTMLButtonElement>(null);
|
|
43
|
+
const isViewer = variant === "viewer";
|
|
44
|
+
|
|
45
|
+
// Post-hydration restore. The server renders only the FAB; Layout.astro's
|
|
46
|
+
// pre-paint script has already reserved the docked column and CSS hides the
|
|
47
|
+
// FAB under html.bp-chat-docked, so this state sync causes no layout jump.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isViewer || typeof window.matchMedia !== "function") return;
|
|
50
|
+
const mq = window.matchMedia(DOCK_QUERY);
|
|
51
|
+
const apply = () => setDocked(mq.matches);
|
|
52
|
+
apply();
|
|
53
|
+
mq.addEventListener("change", apply);
|
|
54
|
+
let storedOpen = true; // default: sidebar open
|
|
55
|
+
try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) !== "0"; } catch {}
|
|
56
|
+
if (mq.matches && storedOpen) { setOpen(true); setEverOpened(true); }
|
|
57
|
+
return () => mq.removeEventListener("change", apply);
|
|
58
|
+
}, [isViewer]);
|
|
59
|
+
|
|
60
|
+
// Reflect docked-open on <html> (drives the band shift + FAB hiding) and
|
|
61
|
+
// persist the preference. Only the docked state persists — the phone sheet
|
|
62
|
+
// always starts closed.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!isViewer) return;
|
|
65
|
+
document.documentElement.classList.toggle("bp-chat-docked", open && docked);
|
|
66
|
+
if (docked) {
|
|
67
|
+
try { window.sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch {}
|
|
68
|
+
}
|
|
69
|
+
return () => document.documentElement.classList.remove("bp-chat-docked");
|
|
70
|
+
}, [open, docked, isViewer]);
|
|
71
|
+
|
|
72
|
+
// Closing the sheet/sidebar returns focus to the launcher.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (isViewer && !open && everOpened) fabRef.current?.focus();
|
|
75
|
+
}, [open, everOpened, isViewer]);
|
|
76
|
+
|
|
77
|
+
const showFab = !isViewer || !open;
|
|
28
78
|
|
|
29
79
|
return (
|
|
30
80
|
<>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
{showFab && (
|
|
82
|
+
<button
|
|
83
|
+
ref={fabRef}
|
|
84
|
+
type="button"
|
|
85
|
+
aria-label={open ? "Collapse chat" : `Chat with ${botName}`}
|
|
86
|
+
aria-expanded={open}
|
|
87
|
+
onClick={() => { setOpen((o) => !o); setEverOpened(true); setUserOpened(true); }}
|
|
88
|
+
className={cn(
|
|
89
|
+
"fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
|
|
90
|
+
variant === "editor"
|
|
91
|
+
? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
|
|
92
|
+
: "bottom-4 h-12 w-12 viewer-fab bp-chat-fab",
|
|
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
|
+
)}
|
|
53
106
|
{everOpened && (
|
|
54
107
|
<Suspense fallback={null}>
|
|
55
108
|
<ChatPanel
|
|
56
109
|
open={open}
|
|
57
110
|
onClose={() => setOpen(false)}
|
|
58
111
|
siteName={siteName}
|
|
112
|
+
botName={botName}
|
|
113
|
+
thinkingPhrases={thinkingPhrases}
|
|
59
114
|
tasks={tasks}
|
|
60
115
|
isEditor={isEditor}
|
|
61
116
|
draftChatbot={draftChatbot}
|
|
62
117
|
variant={variant}
|
|
118
|
+
mode={isViewer ? (docked ? "docked" : "sheet") : "floating"}
|
|
119
|
+
focusComposer={userOpened}
|
|
120
|
+
audience={audience}
|
|
63
121
|
/>
|
|
64
122
|
</Suspense>
|
|
65
123
|
)}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Local-only chat history (localStorage). The ACTIVE conversation lives in
|
|
2
|
+
// sessionStorage (per-tab, survives same-tab navigation — see ChatPanel);
|
|
3
|
+
// this module is the durable archive behind the panel's history list.
|
|
4
|
+
//
|
|
5
|
+
// Retention: pinned conversations are kept forever and sort to the top;
|
|
6
|
+
// unpinned ones expire after HISTORY_RETENTION_DAYS and are capped at
|
|
7
|
+
// HISTORY_MAX_UNPINNED overall (not per audience — one browser is one
|
|
8
|
+
// viewer in practice). Every entry is tagged with the audience it belonged
|
|
9
|
+
// to, and the list only ever shows the current audience's conversations —
|
|
10
|
+
// localStorage outlives the 24h audience cookie, so an expired session must
|
|
11
|
+
// not expose another audience's transcripts on a shared machine.
|
|
12
|
+
import type { ChatMessage } from "./ChatPanel";
|
|
13
|
+
|
|
14
|
+
const HISTORY_KEY = "bp-chat-history";
|
|
15
|
+
export const HISTORY_RETENTION_DAYS = 30;
|
|
16
|
+
export const HISTORY_MAX_UNPINNED = 20;
|
|
17
|
+
const MESSAGE_CAP = 40;
|
|
18
|
+
const TITLE_MAX = 60;
|
|
19
|
+
|
|
20
|
+
export interface StoredConversation {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
/** Audience tag ("editor" for editor sessions on viewer pages). */
|
|
24
|
+
audience: string;
|
|
25
|
+
pinned: boolean;
|
|
26
|
+
updatedAt: number;
|
|
27
|
+
messages: ChatMessage[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isMessage(m: unknown): m is ChatMessage {
|
|
31
|
+
return !!m && typeof m === "object" &&
|
|
32
|
+
((m as ChatMessage).role === "user" || (m as ChatMessage).role === "assistant") &&
|
|
33
|
+
typeof (m as ChatMessage).content === "string";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isEntry(e: unknown): e is StoredConversation {
|
|
37
|
+
if (!e || typeof e !== "object") return false;
|
|
38
|
+
const c = e as StoredConversation;
|
|
39
|
+
return typeof c.id === "string" && typeof c.title === "string" &&
|
|
40
|
+
typeof c.audience === "string" && typeof c.pinned === "boolean" &&
|
|
41
|
+
typeof c.updatedAt === "number" && Array.isArray(c.messages) && c.messages.every(isMessage);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readAll(): StoredConversation[] {
|
|
45
|
+
try {
|
|
46
|
+
const parsed: unknown = JSON.parse(window.localStorage.getItem(HISTORY_KEY) ?? "[]");
|
|
47
|
+
return Array.isArray(parsed) ? parsed.filter(isEntry) : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeAll(entries: StoredConversation[]): void {
|
|
54
|
+
try {
|
|
55
|
+
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
|
56
|
+
} catch { /* storage unavailable/full — history is best-effort */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prune(entries: StoredConversation[], now: number): StoredConversation[] {
|
|
60
|
+
const cutoff = now - HISTORY_RETENTION_DAYS * 86_400_000;
|
|
61
|
+
const pinned = entries.filter((e) => e.pinned);
|
|
62
|
+
const unpinned = entries
|
|
63
|
+
.filter((e) => !e.pinned && e.updatedAt >= cutoff)
|
|
64
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
65
|
+
.slice(0, HISTORY_MAX_UNPINNED);
|
|
66
|
+
return [...pinned, ...unpinned];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Current audience's conversations: pinned first, then newest first. */
|
|
70
|
+
export function loadHistory(audience: string, now = Date.now()): StoredConversation[] {
|
|
71
|
+
return readAll()
|
|
72
|
+
.filter((e) => e.audience === audience)
|
|
73
|
+
.filter((e) => e.pinned || e.updatedAt >= now - HISTORY_RETENTION_DAYS * 86_400_000)
|
|
74
|
+
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updatedAt - a.updatedAt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create/refresh a conversation's archive entry (preserves its pinned flag)
|
|
78
|
+
* and apply retention. No-ops on empty transcripts. */
|
|
79
|
+
export function upsertConversation(
|
|
80
|
+
input: { id: string; audience: string; messages: ChatMessage[] },
|
|
81
|
+
now = Date.now(),
|
|
82
|
+
): void {
|
|
83
|
+
const messages = input.messages.filter((m) => m.content !== "").slice(-MESSAGE_CAP);
|
|
84
|
+
if (messages.length === 0) return;
|
|
85
|
+
const all = readAll();
|
|
86
|
+
const existing = all.find((e) => e.id === input.id);
|
|
87
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
88
|
+
const entry: StoredConversation = {
|
|
89
|
+
id: input.id,
|
|
90
|
+
audience: input.audience,
|
|
91
|
+
pinned: existing?.pinned ?? false,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
title: (firstUser ?? messages[0]).content.slice(0, TITLE_MAX),
|
|
94
|
+
messages,
|
|
95
|
+
};
|
|
96
|
+
writeAll(prune([...all.filter((e) => e.id !== input.id), entry], now));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setConversationPinned(id: string, pinned: boolean): void {
|
|
100
|
+
writeAll(readAll().map((e) => (e.id === id ? { ...e, pinned } : e)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function removeConversation(id: string): void {
|
|
104
|
+
writeAll(readAll().filter((e) => e.id !== id));
|
|
105
|
+
}
|
|
@@ -4,6 +4,55 @@ import { IndicatorPill } from "./IndicatorPill";
|
|
|
4
4
|
import { PopoverItem } from "../shared/PopoverItem";
|
|
5
5
|
import type { Audience } from "../../auth/types";
|
|
6
6
|
|
|
7
|
+
interface AudienceChecklistProps {
|
|
8
|
+
access: string[];
|
|
9
|
+
audiences: Audience[];
|
|
10
|
+
onToggle: (name: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AudienceChecklist({ access, audiences, onToggle }: AudienceChecklistProps) {
|
|
14
|
+
if (audiences.length === 0) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
|
|
17
|
+
No audiences configured. Add audiences in Site Settings → Viewer Access.
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<ul role="list" className="w-full overflow-hidden py-1">
|
|
23
|
+
{audiences.map((a) => {
|
|
24
|
+
const checked = access.includes(a.name);
|
|
25
|
+
return (
|
|
26
|
+
<li key={a.name}>
|
|
27
|
+
<PopoverItem
|
|
28
|
+
role="checkbox"
|
|
29
|
+
aria-checked={checked}
|
|
30
|
+
onClick={() => onToggle(a.name)}
|
|
31
|
+
>
|
|
32
|
+
<span
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className={cn(
|
|
35
|
+
"flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors",
|
|
36
|
+
checked
|
|
37
|
+
? "border-primary bg-primary text-primary-contrast"
|
|
38
|
+
: "border-base-300 bg-base",
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{checked && <Check size={14} strokeWidth={3} />}
|
|
42
|
+
</span>
|
|
43
|
+
<span
|
|
44
|
+
className="h-3 w-3 shrink-0 rounded-full border border-base-200"
|
|
45
|
+
style={{ backgroundColor: a.color ?? "#9ca3af" }}
|
|
46
|
+
/>
|
|
47
|
+
<span className="font-medium text-base-contrast whitespace-nowrap">{a.displayName}</span>
|
|
48
|
+
</PopoverItem>
|
|
49
|
+
</li>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</ul>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
7
56
|
interface AudienceIndicatorProps {
|
|
8
57
|
access: string[];
|
|
9
58
|
audiences: Audience[];
|
|
@@ -39,43 +88,7 @@ export function AudienceIndicator({ access, audiences, onChange, disabled }: Aud
|
|
|
39
88
|
buttonClassName={selected.length === 0 ? "text-base-contrast-light" : undefined}
|
|
40
89
|
disabled={disabled}
|
|
41
90
|
clickContent={
|
|
42
|
-
|
|
43
|
-
<div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
|
|
44
|
-
No audiences configured. Add audiences in Site Settings → Viewer Access.
|
|
45
|
-
</div>
|
|
46
|
-
) : (
|
|
47
|
-
<ul role="list" className="w-full overflow-hidden py-1">
|
|
48
|
-
{audiences.map((a) => {
|
|
49
|
-
const checked = access.includes(a.name);
|
|
50
|
-
return (
|
|
51
|
-
<li key={a.name}>
|
|
52
|
-
<PopoverItem
|
|
53
|
-
role="checkbox"
|
|
54
|
-
aria-checked={checked}
|
|
55
|
-
onClick={() => toggle(a.name)}
|
|
56
|
-
>
|
|
57
|
-
<span
|
|
58
|
-
aria-hidden="true"
|
|
59
|
-
className={cn(
|
|
60
|
-
"flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors",
|
|
61
|
-
checked
|
|
62
|
-
? "border-primary bg-primary text-primary-contrast"
|
|
63
|
-
: "border-base-300 bg-base",
|
|
64
|
-
)}
|
|
65
|
-
>
|
|
66
|
-
{checked && <Check size={14} strokeWidth={3} />}
|
|
67
|
-
</span>
|
|
68
|
-
<span
|
|
69
|
-
className="h-3 w-3 shrink-0 rounded-full border border-base-200"
|
|
70
|
-
style={{ backgroundColor: a.color ?? "#9ca3af" }}
|
|
71
|
-
/>
|
|
72
|
-
<span className="font-medium text-base-contrast">{a.displayName}</span>
|
|
73
|
-
</PopoverItem>
|
|
74
|
-
</li>
|
|
75
|
-
);
|
|
76
|
-
})}
|
|
77
|
-
</ul>
|
|
78
|
-
)
|
|
91
|
+
<AudienceChecklist access={access} audiences={audiences} onToggle={toggle} />
|
|
79
92
|
}
|
|
80
93
|
/>
|
|
81
94
|
);
|
|
@@ -5,6 +5,7 @@ import { DragHandle } from "./DragHandle";
|
|
|
5
5
|
import { DeleteButton } from "./DeleteButton";
|
|
6
6
|
import { SettingsButton } from "./SettingsButton";
|
|
7
7
|
import { ColSpanControl } from "./ColSpanControl";
|
|
8
|
+
import { Tooltip, Kbd } from "../shared/Tooltip";
|
|
8
9
|
import { cn } from "../../lib/cn";
|
|
9
10
|
|
|
10
11
|
export interface ChildBlockWrapperProps {
|
|
@@ -70,17 +71,17 @@ export const ChildBlockWrapper = forwardRef<HTMLDivElement, ChildBlockWrapperPro
|
|
|
70
71
|
>
|
|
71
72
|
<span className="sr-only">{label}</span>
|
|
72
73
|
<div className="pointer-events-auto">
|
|
73
|
-
<DragHandle ref={dragHandleRef} />
|
|
74
|
+
<DragHandle ref={dragHandleRef} tooltipSide="right" />
|
|
74
75
|
</div>
|
|
75
76
|
<div className="pointer-events-auto">
|
|
76
77
|
<ColSpanControl colSpan={colSpan} columns={columns} onChange={onColSpanChange} />
|
|
77
78
|
</div>
|
|
78
79
|
{hasSettings && onOpenSettings && (
|
|
79
|
-
<
|
|
80
|
+
<Tooltip side="right" className="pointer-events-auto" content={<><Kbd>Edit</Kbd> settings</>}>
|
|
80
81
|
<SettingsButton onClick={onOpenSettings} />
|
|
81
|
-
</
|
|
82
|
+
</Tooltip>
|
|
82
83
|
)}
|
|
83
|
-
<
|
|
84
|
+
<Tooltip side="right" className="pointer-events-auto" content={<><Kbd>Duplicate</Kbd> block</>}>
|
|
84
85
|
<button
|
|
85
86
|
type="button"
|
|
86
87
|
aria-label="Duplicate block"
|
|
@@ -89,10 +90,10 @@ export const ChildBlockWrapper = forwardRef<HTMLDivElement, ChildBlockWrapperPro
|
|
|
89
90
|
>
|
|
90
91
|
<CopyPlus size={16} />
|
|
91
92
|
</button>
|
|
92
|
-
</
|
|
93
|
-
<
|
|
93
|
+
</Tooltip>
|
|
94
|
+
<Tooltip side="right" className="pointer-events-auto" content={<><Kbd>Delete</Kbd> block</>}>
|
|
94
95
|
<DeleteButton onDelete={onDelete} />
|
|
95
|
-
</
|
|
96
|
+
</Tooltip>
|
|
96
97
|
</div>
|
|
97
98
|
|
|
98
99
|
{/* Drop-edge indicators */}
|
|
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
|
|
|
2
2
|
import { Columns3 } from "lucide-react";
|
|
3
3
|
import { Popover } from "../shared/Popover";
|
|
4
4
|
import { PopoverItem } from "../shared/PopoverItem";
|
|
5
|
+
import { Tooltip, Kbd } from "../shared/Tooltip";
|
|
5
6
|
import { cn } from "../../lib/cn";
|
|
6
7
|
|
|
7
8
|
interface ColSpanControlProps {
|
|
@@ -21,17 +22,19 @@ export function ColSpanControl({ colSpan, columns, onChange }: ColSpanControlPro
|
|
|
21
22
|
|
|
22
23
|
return (
|
|
23
24
|
<div className="relative">
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
<Tooltip side="right" content={<><Kbd>Set</Kbd> column span</>}>
|
|
26
|
+
<button
|
|
27
|
+
ref={buttonRef}
|
|
28
|
+
type="button"
|
|
29
|
+
aria-label="Column span"
|
|
30
|
+
aria-haspopup="true"
|
|
31
|
+
aria-expanded={open}
|
|
32
|
+
onClick={() => setOpen((v) => !v)}
|
|
33
|
+
className="pointer-events-auto inline-flex cursor-pointer items-center gap-1 rounded p-1 text-xs text-base-contrast-light hover:text-primary"
|
|
34
|
+
>
|
|
35
|
+
<Columns3 size={14} />
|
|
36
|
+
</button>
|
|
37
|
+
</Tooltip>
|
|
35
38
|
|
|
36
39
|
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={buttonRef} className="min-w-28">
|
|
37
40
|
<ul role="list" className="py-1">
|
|
@@ -3,9 +3,12 @@ import { DragHandle as DragHandleIcon } from "../shared/icons";
|
|
|
3
3
|
import { IconButton } from "../shared/IconButton";
|
|
4
4
|
import { Tooltip, Kbd } from "../shared/Tooltip";
|
|
5
5
|
|
|
6
|
-
export const DragHandle = forwardRef<HTMLButtonElement>(function DragHandle(
|
|
6
|
+
export const DragHandle = forwardRef<HTMLButtonElement, { tooltipSide?: "bottom" | "right" }>(function DragHandle(
|
|
7
|
+
{ tooltipSide },
|
|
8
|
+
ref,
|
|
9
|
+
) {
|
|
7
10
|
return (
|
|
8
|
-
<Tooltip content={<><Kbd>Drag</Kbd> to move</>}>
|
|
11
|
+
<Tooltip side={tooltipSide} content={<><Kbd>Drag</Kbd> to move</>}>
|
|
9
12
|
<IconButton
|
|
10
13
|
ref={ref}
|
|
11
14
|
icon={<DragHandleIcon size={18} />}
|
|
@@ -205,15 +205,15 @@ function PageRow({
|
|
|
205
205
|
return (
|
|
206
206
|
<div
|
|
207
207
|
ref={rowRef}
|
|
208
|
-
className={cn("relative
|
|
208
|
+
className={cn("relative flex items-start gap-1", isDragging && "opacity-50")}
|
|
209
209
|
>
|
|
210
210
|
<DropEdgeIndicator edge={closestEdge} />
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
{draggable && (
|
|
212
|
+
<div className="flex shrink-0 items-center py-2 [&_[role=tooltip]]:hidden">
|
|
213
|
+
<DragHandle ref={handleRef} />
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
<div className={cn("min-w-0 flex-1 rounded-md border border-base-200 bg-base", isDragging && "outline outline-2 outline-primary/50")}>
|
|
217
217
|
<button
|
|
218
218
|
type="button"
|
|
219
219
|
aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
|
|
@@ -238,9 +238,8 @@ function PageRow({
|
|
|
238
238
|
className={cn("shrink-0 text-base-contrast/50 transition-transform", expanded && "rotate-180")}
|
|
239
239
|
/>
|
|
240
240
|
</button>
|
|
241
|
-
</div>
|
|
242
241
|
|
|
243
|
-
|
|
242
|
+
{expanded && (
|
|
244
243
|
<div className="flex flex-col gap-3 border-t border-base-200 px-3 py-3">
|
|
245
244
|
<Input
|
|
246
245
|
label="Title"
|
|
@@ -314,7 +313,8 @@ function PageRow({
|
|
|
314
313
|
</Button>
|
|
315
314
|
</div>
|
|
316
315
|
</div>
|
|
317
|
-
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
318
|
</div>
|
|
319
319
|
);
|
|
320
320
|
}
|
|
@@ -5,6 +5,8 @@ import { DragHandle } from "./DragHandle";
|
|
|
5
5
|
import { InsertButton } from "./InsertButton";
|
|
6
6
|
import { DeleteButton } from "./DeleteButton";
|
|
7
7
|
import { SettingsButton } from "./SettingsButton";
|
|
8
|
+
import { IconButton } from "../shared/IconButton";
|
|
9
|
+
import { Tooltip, Kbd } from "../shared/Tooltip";
|
|
8
10
|
import { StatusIndicator } from "./StatusIndicator";
|
|
9
11
|
import { AudienceIndicator } from "./AudienceIndicator";
|
|
10
12
|
import { LockBadge } from "./LockBadge";
|
|
@@ -298,7 +300,10 @@ export function SectionWrapper({
|
|
|
298
300
|
{/* Status + settings + delete — top-right, above content */}
|
|
299
301
|
<div
|
|
300
302
|
className={cn(
|
|
301
|
-
|
|
303
|
+
// When a selector inside this cluster is open (Popover renders role="dialog"),
|
|
304
|
+
// lift the whole cluster above sibling sections' chrome (z-30, hover:z-[60]) and
|
|
305
|
+
// the inline type picker (z-50) so the open dropdown isn't overlapped.
|
|
306
|
+
"pointer-events-none absolute right-0 bottom-full z-30 has-[[role=dialog]]:z-[70] mb-1 flex items-center gap-2 transition-opacity",
|
|
302
307
|
// A lock is important state — keep the cluster visible without hover.
|
|
303
308
|
alwaysShow || isLocked ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
|
304
309
|
)}
|
|
@@ -327,32 +332,38 @@ export function SectionWrapper({
|
|
|
327
332
|
</div>
|
|
328
333
|
|
|
329
334
|
{!isLocked && onAddChild && (
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
</
|
|
335
|
+
<Tooltip className="pointer-events-auto" content={<><Kbd>Add</Kbd> a block</>}>
|
|
336
|
+
<IconButton
|
|
337
|
+
icon={<Plus size={16} />}
|
|
338
|
+
label="Add block"
|
|
339
|
+
onClick={onAddChild}
|
|
340
|
+
className="pointer-events-auto hover:text-primary"
|
|
341
|
+
/>
|
|
342
|
+
</Tooltip>
|
|
338
343
|
)}
|
|
339
344
|
|
|
340
|
-
{!isLocked &&
|
|
341
|
-
<
|
|
345
|
+
{!isLocked && onMoveSection && (
|
|
346
|
+
<Tooltip className="pointer-events-auto" content={<><Kbd>Move</Kbd> to page</>}>
|
|
347
|
+
<IconButton
|
|
348
|
+
icon={<ArrowRightLeft size={16} />}
|
|
349
|
+
label="Move to page"
|
|
350
|
+
onClick={onMoveSection}
|
|
351
|
+
className="pointer-events-auto hover:text-primary"
|
|
352
|
+
/>
|
|
353
|
+
</Tooltip>
|
|
342
354
|
)}
|
|
343
355
|
|
|
344
|
-
{!isLocked &&
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
aria-label="Move to page"
|
|
349
|
-
className="pointer-events-auto cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
|
|
350
|
-
>
|
|
351
|
-
<ArrowRightLeft size={16} />
|
|
352
|
-
</button>
|
|
356
|
+
{!isLocked && hasSettings && (
|
|
357
|
+
<Tooltip className="pointer-events-auto" content={<><Kbd>Edit</Kbd> settings</>}>
|
|
358
|
+
<SettingsButton onClick={handleSettingsClick} />
|
|
359
|
+
</Tooltip>
|
|
353
360
|
)}
|
|
354
361
|
|
|
355
|
-
{!isLocked && onDelete &&
|
|
362
|
+
{!isLocked && onDelete && (
|
|
363
|
+
<Tooltip className="pointer-events-auto" content={<><Kbd>Delete</Kbd> section</>}>
|
|
364
|
+
<DeleteButton onDelete={onDelete} />
|
|
365
|
+
</Tooltip>
|
|
366
|
+
)}
|
|
356
367
|
</div>
|
|
357
368
|
|
|
358
369
|
{/* Drop indicator — centered in the section's own padding-top */}
|