@drawnagency/primitives 0.1.87 → 0.1.89
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-PTVY3Q7P.js → chunk-2QXJ2KGB.js} +1 -1
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-NEDOJHHR.js → chunk-J75XGXYD.js} +2 -2
- package/dist/{chunk-6VOIZP2O.js → chunk-L2MAJKKC.js} +220 -207
- package/dist/{chunk-VY5V4HBV.js → chunk-MVX4ULYE.js} +6 -6
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-QFV5TSPO.js} +1 -1
- package/dist/{chunk-OEL57NYV.js → chunk-VK5KNH73.js} +25 -1
- package/dist/{chunk-XPUBUR64.js → chunk-WCBYXNAG.js} +27 -2
- package/dist/{chunk-WA3HFN54.js → chunk-Y24T75HT.js} +1 -1
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsEditable.d.ts +20 -0
- package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -0
- package/dist/components/brandguide/ColorsView.d.ts +9 -0
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -0
- package/dist/components/brandguide/colors-shared.d.ts +4 -0
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -0
- 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/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/SectionIsland.d.ts +8 -0
- package/dist/components/sections/SectionIsland.d.ts.map +1 -0
- 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/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.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/useCollabSession.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 +14 -4
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- 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 +10 -2
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +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 +2 -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 +12 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/brandguide/Colors.tsx +2 -275
- package/src/components/brandguide/ColorsEditable.tsx +155 -0
- package/src/components/brandguide/ColorsView.tsx +100 -0
- package/src/components/brandguide/colors-shared.ts +17 -0
- 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/sections/Colors/index.tsx +1 -0
- package/src/components/sections/SectionIsland.tsx +21 -0
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shell/EditorShell.tsx +71 -1
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +47 -0
- package/src/hooks/useCollabSession.ts +27 -0
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/registry.ts +7 -0
- 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 +26 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ensureSectionsRegistered } from "./register";
|
|
2
|
+
import { getSection } from "../../lib/registry";
|
|
3
|
+
|
|
4
|
+
// Registering on import means the registry is populated both during SSR of the
|
|
5
|
+
// island and after it hydrates client-side (register runs again in the browser
|
|
6
|
+
// bundle; the Symbol.for-keyed singleton dedupes).
|
|
7
|
+
ensureSectionsRegistered();
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
type: string;
|
|
11
|
+
content: unknown;
|
|
12
|
+
options?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function SectionIsland({ type, content, options }: Props) {
|
|
16
|
+
const def = getSection(type);
|
|
17
|
+
if (!def) return null;
|
|
18
|
+
const Component = def.component;
|
|
19
|
+
// Viewer mode: no onChange / openModal callbacks -> the view (not editable) path.
|
|
20
|
+
return <Component content={content} options={options} isEditMode={false} />;
|
|
21
|
+
}
|
|
@@ -18,9 +18,21 @@ interface Props {
|
|
|
18
18
|
// Optional same-island handler (tests). In production the editor nav is a
|
|
19
19
|
// separate island from EditorShell, so a click dispatches pageSelectEvent.
|
|
20
20
|
onPageSelect?: (pageId: string) => void;
|
|
21
|
+
/** Viewport width where the drawer becomes a fixed sidebar. Pages that dock
|
|
22
|
+
* the chat sidebar pass "xl" so tablet widths keep the hamburger and give
|
|
23
|
+
* the freed column to chat. Static per page render — deliberately keyed to
|
|
24
|
+
* chat being ENABLED, not open, so the nav never flaps when chat toggles. */
|
|
25
|
+
desktopBreakpoint?: "lg" | "xl";
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
// Both branches are literal class strings so Tailwind's scanner sees them.
|
|
29
|
+
const BP_CLASSES = {
|
|
30
|
+
lg: { hidden: "lg:hidden", leftAuto: "lg:left-auto", translate: "lg:translate-x-0", block: "lg:block" },
|
|
31
|
+
xl: { hidden: "xl:hidden", leftAuto: "xl:left-auto", translate: "xl:translate-x-0", block: "xl:block" },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect, desktopBreakpoint = "lg" }: Props) {
|
|
35
|
+
const bp = BP_CLASSES[desktopBreakpoint];
|
|
24
36
|
const [isOpen, setIsOpen] = useState(false);
|
|
25
37
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
26
38
|
const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
|
|
@@ -205,7 +217,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
205
217
|
|
|
206
218
|
return (
|
|
207
219
|
<>
|
|
208
|
-
<header className="fixed top-0 left-0 right-0 z-50 bg-base
|
|
220
|
+
<header className={cn("fixed top-0 left-0 right-0 z-50 bg-base", bp.hidden)}>
|
|
209
221
|
<div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
|
|
210
222
|
<div className="flex flex-col items-start gap-1">
|
|
211
223
|
{logoMark}
|
|
@@ -221,11 +233,12 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
221
233
|
</div>
|
|
222
234
|
</header>
|
|
223
235
|
|
|
224
|
-
{isOpen && <div className="fixed inset-0 z-40 bg-black/50
|
|
236
|
+
{isOpen && <div className={cn("fixed inset-0 z-40 bg-black/50", bp.hidden)} onClick={() => setIsOpen(false)} />}
|
|
225
237
|
|
|
226
|
-
<nav className={cn("fixed top-0 left-0
|
|
238
|
+
<nav className={cn("fixed top-0 left-0 z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform nav-sidebar",
|
|
239
|
+
bp.leftAuto, bp.translate,
|
|
227
240
|
isOpen ? "translate-x-0" : "-translate-x-full")}>
|
|
228
|
-
<div className="hidden px-4 py-4
|
|
241
|
+
<div className={cn("hidden px-4 py-4", bp.block)}>
|
|
229
242
|
{logoMark}
|
|
230
243
|
<span className="text-lg font-bold text-primary">{currentSiteName}</span>
|
|
231
244
|
</div>
|
|
@@ -10,12 +10,17 @@ 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";
|
|
14
|
+
import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
|
|
15
|
+
import { resolveMedia } from "../../media/resolve";
|
|
16
|
+
import { getMediaProvider } from "../../media";
|
|
13
17
|
// Sanctioned outside->inside import: document/ is a sealed subtree, but the
|
|
14
18
|
// Document mode toggle wiring needs the patch shape it edits (mirrors the same
|
|
15
19
|
// sanctioned import in schemas/site-config.ts and lib/pages.ts).
|
|
16
20
|
import type { DocumentOptions } from "../../document/engine/settings";
|
|
17
21
|
import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
|
|
18
22
|
import { ensureSanitizer } from "../../lib/sanitize";
|
|
23
|
+
import { resolveChatbotName } from "../../lib/chatbot";
|
|
19
24
|
import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
|
|
20
25
|
import { makeBlockMoveDispatch } from "./blockMoveDispatch";
|
|
21
26
|
import { ROOT_CONTAINER_ID, resolveDropIndex } from "../../lib/block-dnd";
|
|
@@ -240,6 +245,15 @@ export default function EditorShell({
|
|
|
240
245
|
useEffect(() => { sectionsRef.current = sections; }, [sections]);
|
|
241
246
|
useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
|
|
242
247
|
useEffect(() => { void ensureSanitizer(); }, []);
|
|
248
|
+
// Mirrors siteConfig so the async preview-image upload callback (below)
|
|
249
|
+
// writes onto the latest config instead of a stale closure.
|
|
250
|
+
const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
|
|
251
|
+
useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
|
|
252
|
+
// Guards the debounced preview-image composition effect (below): skips
|
|
253
|
+
// recomposing on the very first run when a preview already exists, and
|
|
254
|
+
// dedupes concurrent runs against the same composition signature.
|
|
255
|
+
const previewInitializedRef = useRef(false);
|
|
256
|
+
const lastPreviewSigRef = useRef<string | null>(null);
|
|
243
257
|
|
|
244
258
|
// --- Collaboration refs ---
|
|
245
259
|
// Freshest dirty-section set for the inbound remote-section handler (never
|
|
@@ -1332,6 +1346,60 @@ export default function EditorShell({
|
|
|
1332
1346
|
setLocalChangesExist(true);
|
|
1333
1347
|
}, [applySiteConfigPreview, persistence]);
|
|
1334
1348
|
|
|
1349
|
+
// Recompose the social-preview raster (siteConfig.previewImage) when brand
|
|
1350
|
+
// inputs change. Debounced so text edits don't compose per keystroke. The
|
|
1351
|
+
// new previewImage rides the next normal save like any other config change.
|
|
1352
|
+
useEffect(() => {
|
|
1353
|
+
if (!siteConfig) return;
|
|
1354
|
+
const resolveLogoUrl = (imageId: string): string | null => {
|
|
1355
|
+
const pending = mediaPipeline.pendingLocalUrls[imageId];
|
|
1356
|
+
if (pending) return pending;
|
|
1357
|
+
const resolved = resolveMedia({ imageId }, mediaManifest, [1200], getMediaProvider());
|
|
1358
|
+
return resolved?.src ?? null;
|
|
1359
|
+
};
|
|
1360
|
+
const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
|
|
1361
|
+
|
|
1362
|
+
// On first run: if a preview already exists, seed the signature and skip
|
|
1363
|
+
// (don't recompose/re-upload on every editor open). If none exists yet,
|
|
1364
|
+
// fall through to compose one. Subsequent runs recompose only on change.
|
|
1365
|
+
if (!previewInitializedRef.current) {
|
|
1366
|
+
previewInitializedRef.current = true;
|
|
1367
|
+
if (siteConfig.previewImage) {
|
|
1368
|
+
lastPreviewSigRef.current = signature;
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (signature === lastPreviewSigRef.current) return;
|
|
1373
|
+
|
|
1374
|
+
const timer = setTimeout(() => {
|
|
1375
|
+
lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
|
|
1376
|
+
(async () => {
|
|
1377
|
+
try {
|
|
1378
|
+
const blob = await composePreviewImage(inputs);
|
|
1379
|
+
const file = new File([blob], "site-preview.webp", { type: "image/webp" });
|
|
1380
|
+
const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
|
|
1381
|
+
mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
|
|
1382
|
+
handleSiteConfigChange({ ...siteConfigRef.current!, previewImage: { imageId, width, height } });
|
|
1383
|
+
}, { hidden: true });
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
console.error("[EditorShell] preview-image composition failed:", err);
|
|
1386
|
+
// Composition/upload failed — un-mark the signature so the next effect
|
|
1387
|
+
// run (next brand-input change, or this same one on remount) retries
|
|
1388
|
+
// instead of being permanently skipped as "already done".
|
|
1389
|
+
lastPreviewSigRef.current = null;
|
|
1390
|
+
}
|
|
1391
|
+
})();
|
|
1392
|
+
}, 900);
|
|
1393
|
+
return () => clearTimeout(timer);
|
|
1394
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1395
|
+
}, [
|
|
1396
|
+
siteConfig?.logo,
|
|
1397
|
+
siteConfig?.primaryColor,
|
|
1398
|
+
siteConfig?.siteName,
|
|
1399
|
+
siteConfig?.headingFont,
|
|
1400
|
+
siteConfig?.darkMode,
|
|
1401
|
+
]);
|
|
1402
|
+
|
|
1335
1403
|
const handleHistoryRestore = useCallback(async (historyContent: {
|
|
1336
1404
|
sections: LoadedSection[];
|
|
1337
1405
|
index: SiteIndex;
|
|
@@ -1512,6 +1580,8 @@ export default function EditorShell({
|
|
|
1512
1580
|
{chatbotPlatformEnabled && siteConfig && (
|
|
1513
1581
|
<ChatWidget
|
|
1514
1582
|
siteName={siteConfig.siteName}
|
|
1583
|
+
botName={resolveChatbotName(siteConfig.chatbot, null)}
|
|
1584
|
+
thinkingPhrases={siteConfig.chatbot.thinkingPhrases}
|
|
1515
1585
|
tasks={siteConfig.chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
|
|
1516
1586
|
isEditor
|
|
1517
1587
|
draftChatbot={siteConfig.chatbot}
|
|
@@ -1628,7 +1698,7 @@ export default function EditorShell({
|
|
|
1628
1698
|
items={[
|
|
1629
1699
|
...Object.values(mediaManifest.images),
|
|
1630
1700
|
...mediaPipeline.pendingMediaItems,
|
|
1631
|
-
].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id))}
|
|
1701
|
+
].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
|
|
1632
1702
|
onSelect={(id) => {
|
|
1633
1703
|
if (mediaSelectCallback) {
|
|
1634
1704
|
mediaSelectCallback(id);
|
|
@@ -5,7 +5,7 @@ import type { SiteConfig } from "../../schemas/site-config";
|
|
|
5
5
|
export interface MediaLibraryContextValue {
|
|
6
6
|
openSelectModal: (onSelect: (imageId: string) => void) => void;
|
|
7
7
|
uploadFile: (file: File) => void;
|
|
8
|
-
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => string;
|
|
8
|
+
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => string;
|
|
9
9
|
reportRejectedUploads: (files: { name: string; reason: "size" | "type" }[]) => void;
|
|
10
10
|
manifest: MediaManifest;
|
|
11
11
|
pendingItems: MediaItem[];
|
|
@@ -5,6 +5,17 @@ import ChatPanel from "../chat/ChatPanel";
|
|
|
5
5
|
import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
|
|
6
6
|
import type { Audience } from "../../auth/types";
|
|
7
7
|
import { audiencePasses } from "../../lib/pages";
|
|
8
|
+
import { resolveChatbotName, DEFAULT_CHATBOT_NAME } from "../../lib/chatbot";
|
|
9
|
+
|
|
10
|
+
/** Comma-separated textarea → schema list: trimmed, blanks dropped, clamped
|
|
11
|
+
* to the schema caps (30 phrases × 80 chars). */
|
|
12
|
+
function parseThinkingPhrases(text: string): string[] {
|
|
13
|
+
return text
|
|
14
|
+
.split(",")
|
|
15
|
+
.map((s) => s.trim().slice(0, 80))
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.slice(0, 30);
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
interface Props {
|
|
10
21
|
siteConfig: SiteConfig;
|
|
@@ -38,6 +49,9 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
38
49
|
const [newTaskLabel, setNewTaskLabel] = useState("");
|
|
39
50
|
const [showTest, setShowTest] = useState(false);
|
|
40
51
|
const [previewAudience, setPreviewAudience] = useState("");
|
|
52
|
+
// Local text mirror of thinkingPhrases: parsing on every keystroke while
|
|
53
|
+
// round-tripping through the array would fight trailing commas/spaces.
|
|
54
|
+
const [thinkingText, setThinkingText] = useState(() => (chatbot.thinkingPhrases ?? []).join(", "));
|
|
41
55
|
|
|
42
56
|
// A deselected audience can no longer chat — never keep simulating it.
|
|
43
57
|
useEffect(() => {
|
|
@@ -77,6 +91,10 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
77
91
|
const v = chatbot.audiencePrompts[name];
|
|
78
92
|
return typeof v === "string" ? v : "";
|
|
79
93
|
};
|
|
94
|
+
const audienceNameValue = (name: string) => {
|
|
95
|
+
const v = chatbot.audienceNames?.[name];
|
|
96
|
+
return typeof v === "string" ? v : "";
|
|
97
|
+
};
|
|
80
98
|
|
|
81
99
|
return (
|
|
82
100
|
<div className="flex flex-col gap-4">
|
|
@@ -94,12 +112,39 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
94
112
|
Show the chat widget to viewers
|
|
95
113
|
</label>
|
|
96
114
|
|
|
115
|
+
<div>
|
|
116
|
+
<label htmlFor="chatbot-name" className="mb-1 block text-sm font-medium text-base-contrast">Assistant name</label>
|
|
117
|
+
<input id="chatbot-name" aria-label="Assistant name" maxLength={60} value={chatbot.name}
|
|
118
|
+
onChange={(e) => patch({ name: e.target.value })}
|
|
119
|
+
placeholder={DEFAULT_CHATBOT_NAME}
|
|
120
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
121
|
+
<p className="mt-1 text-xs text-base-contrast-light">Shown in the widget header. Leave blank for “{DEFAULT_CHATBOT_NAME}”. Audiences can override it below.</p>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div>
|
|
125
|
+
<label htmlFor="chatbot-thinking-phrases" className="mb-1 block text-sm font-medium text-base-contrast">Thinking phrases</label>
|
|
126
|
+
<textarea id="chatbot-thinking-phrases" aria-label="Thinking phrases" rows={2}
|
|
127
|
+
value={thinkingText}
|
|
128
|
+
onChange={(e) => { setThinkingText(e.target.value); patch({ thinkingPhrases: parseThinkingPhrases(e.target.value) }); }}
|
|
129
|
+
placeholder="Mixing the palette, Checking the guide, Consulting the brand book"
|
|
130
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
131
|
+
<p className="mt-1 text-xs text-base-contrast-light">Comma-separated; one is shown at random while the assistant thinks, with “…” added automatically. Leave blank for “Thinking…”.</p>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
97
134
|
<div>
|
|
98
135
|
<p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
|
|
99
136
|
<p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
|
|
100
137
|
<AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
|
|
101
138
|
{chatbot.audiences.map((name) => (
|
|
102
139
|
<div key={name} className="mt-2">
|
|
140
|
+
<label htmlFor={`audience-name-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
141
|
+
Assistant name for {name}
|
|
142
|
+
</label>
|
|
143
|
+
<input id={`audience-name-${name}`} aria-label={`Assistant name for ${name}`} maxLength={60}
|
|
144
|
+
value={audienceNameValue(name)}
|
|
145
|
+
onChange={(e) => patch({ audienceNames: { ...chatbot.audienceNames, [name]: e.target.value } })}
|
|
146
|
+
placeholder={resolveChatbotName(chatbot, null)}
|
|
147
|
+
className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
103
148
|
<label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
104
149
|
Additional guidance for {name}
|
|
105
150
|
</label>
|
|
@@ -175,6 +220,8 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
175
220
|
{showTest && platformEnabled && (
|
|
176
221
|
<ChatPanel
|
|
177
222
|
open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
|
|
223
|
+
botName={resolveChatbotName(chatbot, previewAudience || null)}
|
|
224
|
+
thinkingPhrases={chatbot.thinkingPhrases}
|
|
178
225
|
tasks={chatbot.tasks
|
|
179
226
|
.filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
|
|
180
227
|
.map((t) => ({ id: t.id, label: t.label }))}
|
|
@@ -5,6 +5,13 @@ import type {
|
|
|
5
5
|
} from "../collab/types";
|
|
6
6
|
|
|
7
7
|
const REJOIN_DELAYS_MS = [5_000, 15_000, 30_000, 60_000];
|
|
8
|
+
// Circuit breaker: after this many consecutive failed rejoins (~3.5 min of
|
|
9
|
+
// backoff), stop the auto-rejoin loop and stay in solo mode rather than retry
|
|
10
|
+
// forever — an unbounded reconnect loop is itself a way to hammer the backend.
|
|
11
|
+
// Refocusing the tab (visibilitychange → visible) resets the breaker and
|
|
12
|
+
// retries, so a user returning to a long-idle/offline tab reconnects without a
|
|
13
|
+
// manual reload.
|
|
14
|
+
const MAX_REJOIN_ATTEMPTS = 6;
|
|
8
15
|
|
|
9
16
|
export interface UseCollabSessionArgs {
|
|
10
17
|
provider: CollabProvider | null;
|
|
@@ -129,14 +136,34 @@ export function useCollabSession(args: UseCollabSessionArgs): UseCollabSessionRe
|
|
|
129
136
|
// race two concurrent joins into two sessions, and the orphaned timer
|
|
130
137
|
// would survive unmount (the cleanup only clears the tracked one).
|
|
131
138
|
if (timer) clearTimeout(timer);
|
|
139
|
+
// Circuit breaker: give up after a capped number of consecutive failures
|
|
140
|
+
// rather than rejoin forever. onVisible() below rearms it on refocus.
|
|
141
|
+
if (attempt >= MAX_REJOIN_ATTEMPTS) {
|
|
142
|
+
console.warn(`Collab: stopped auto-rejoining after ${attempt} consecutive failures; staying solo until the tab is refocused.`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
132
145
|
const delay = REJOIN_DELAYS_MS[Math.min(attempt, REJOIN_DELAYS_MS.length - 1)];
|
|
133
146
|
attempt += 1;
|
|
134
147
|
timer = setTimeout(() => { timer = null; void join(); }, delay);
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
// Rearm the breaker (and reconnect immediately) when the user tabs back to
|
|
151
|
+
// a disconnected editor — the common recovery from a laptop-sleep / network
|
|
152
|
+
// blip that exhausted the rejoin budget. No-op while already live/connecting.
|
|
153
|
+
function onVisible() {
|
|
154
|
+
if (cancelled) return;
|
|
155
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
156
|
+
if (sessionRef.current || joining) return;
|
|
157
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
158
|
+
attempt = 0;
|
|
159
|
+
void join();
|
|
160
|
+
}
|
|
161
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
|
162
|
+
|
|
137
163
|
void join();
|
|
138
164
|
return () => {
|
|
139
165
|
cancelled = true;
|
|
166
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
140
167
|
if (timer) clearTimeout(timer);
|
|
141
168
|
void sessionRef.current?.disconnect().catch(() => {});
|
|
142
169
|
sessionRef.current = null;
|
|
@@ -76,6 +76,7 @@ export function useMediaPipeline({
|
|
|
76
76
|
// Display-folder target per in-flight upload, keyed by content hash. Set at
|
|
77
77
|
// enqueue time, consumed when the processing queue finalizes the item.
|
|
78
78
|
const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
|
|
79
|
+
const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
|
|
79
80
|
const destroyedRef = useRef(false);
|
|
80
81
|
|
|
81
82
|
// Shared persistence path for every pending media item — the processing
|
|
@@ -171,10 +172,12 @@ export function useMediaPipeline({
|
|
|
171
172
|
alt: "",
|
|
172
173
|
storage: "github",
|
|
173
174
|
...(targetFolderId ? { folderId: targetFolderId } : {}),
|
|
175
|
+
...(hiddenUploadsRef.current.get(event.item.hash) ? { hidden: true } : {}),
|
|
174
176
|
};
|
|
175
177
|
|
|
176
178
|
const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
|
|
177
179
|
uploadTargetsRef.current.delete(event.item.hash);
|
|
180
|
+
hiddenUploadsRef.current.delete(event.item.hash);
|
|
178
181
|
if (!persisted) return;
|
|
179
182
|
|
|
180
183
|
const callback = uploadCallbacksRef.current.get(event.item.hash);
|
|
@@ -603,7 +606,7 @@ export function useMediaPipeline({
|
|
|
603
606
|
|
|
604
607
|
// --- Unified enqueue ---
|
|
605
608
|
|
|
606
|
-
const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void) => {
|
|
609
|
+
const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
|
|
607
610
|
if (!siteConfig) return;
|
|
608
611
|
const mediaConfig = siteConfig.media;
|
|
609
612
|
if (file.size > mediaConfig.maxFileSize) return;
|
|
@@ -627,6 +630,7 @@ export function useMediaPipeline({
|
|
|
627
630
|
if (onComplete) {
|
|
628
631
|
uploadCallbacksRef.current.set(hash, onComplete);
|
|
629
632
|
}
|
|
633
|
+
if (opts?.hidden) hiddenUploadsRef.current.set(hash, true);
|
|
630
634
|
|
|
631
635
|
let kind: "image" | "animated" | "video" = "image";
|
|
632
636
|
if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
|
|
@@ -646,12 +650,12 @@ export function useMediaPipeline({
|
|
|
646
650
|
// Placeholder — EditorShell overrides this
|
|
647
651
|
},
|
|
648
652
|
uploadFile: (file: File) => handleMediaUpload([file]),
|
|
649
|
-
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => {
|
|
653
|
+
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => {
|
|
650
654
|
const blobUrl = URL.createObjectURL(file);
|
|
651
655
|
enqueueFile(file, (imageId) => {
|
|
652
656
|
URL.revokeObjectURL(blobUrl);
|
|
653
657
|
onComplete(imageId);
|
|
654
|
-
});
|
|
658
|
+
}, opts);
|
|
655
659
|
return blobUrl;
|
|
656
660
|
},
|
|
657
661
|
reportRejectedUploads: () => {
|