@drawnagency/primitives 0.1.111 → 0.1.112
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-3GAG6ZXB.js → chunk-3SZNUCXI.js} +8 -4
- package/dist/chunk-4I5OO5UB.js +785 -0
- package/dist/{chunk-ALMEC3XN.js → chunk-5KNXTMUC.js} +22 -18
- package/dist/{chunk-OUYB2GCW.js → chunk-BR4MIPTM.js} +1 -57
- package/dist/chunk-CXZ5YJG4.js +85 -0
- package/dist/{chunk-RA5IOFPY.js → chunk-DNY72JZN.js} +16 -38
- package/dist/{chunk-7MI3HCOF.js → chunk-EPWBPIV6.js} +10 -6
- package/dist/chunk-FV7JQRCN.js +94 -0
- package/dist/{chunk-UVIYI4J6.js → chunk-HSXMOS5Q.js} +15 -2
- package/dist/chunk-IIDKW7AV.js +41 -0
- package/dist/chunk-IINIZBQB.js +204 -0
- package/dist/chunk-MM7KZS5C.js +30 -0
- package/dist/{chunk-VCCNKTFD.js → chunk-OGGZ5AXT.js} +3 -31
- package/dist/{chunk-XQMDWXD6.js → chunk-P3ZNCHW7.js} +1 -1
- package/dist/chunk-QQ3S3P5P.js +38 -0
- package/dist/{chunk-ELQ4C677.js → chunk-TALRVOGO.js} +9 -44
- package/dist/chunk-VAYZJKVR.js +209 -0
- package/dist/{chunk-OTDA6KWL.js → chunk-VLPELTPI.js} +1 -1
- package/dist/{chunk-7WQJ57XG.js → chunk-VT3PYGVW.js} +68 -260
- package/dist/chunk-WUJ3XNFI.js +62 -0
- package/dist/{chunk-NXGIHSL5.js → chunk-Z2LO4PSQ.js} +11 -91
- package/dist/collab/rebase.js +1 -1
- package/dist/components/chat/ChatComposer.d.ts +25 -0
- package/dist/components/chat/ChatComposer.d.ts.map +1 -0
- package/dist/components/chat/ChatPanel.d.ts +5 -4
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/markdown.d.ts +14 -0
- package/dist/components/chat/markdown.d.ts.map +1 -0
- package/dist/components/editor/index.js +16 -11
- package/dist/components/primitives/EditablePlainText.d.ts.map +1 -1
- package/dist/components/primitives/index.js +15 -10
- package/dist/components/sections/brand-guide.js +11 -9
- package/dist/components/sections/register-schemas.js +19 -14
- package/dist/components/sections/register.js +19 -14
- package/dist/components/shell/AudienceChips.d.ts +10 -0
- package/dist/components/shell/AudienceChips.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDecks.d.ts +13 -0
- package/dist/components/shell/SiteSettingsDecks.d.ts.map +1 -0
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/deck/DeckComponentView.d.ts +13 -0
- package/dist/deck/DeckComponentView.d.ts.map +1 -0
- package/dist/deck/DeckPlayer.d.ts +26 -0
- package/dist/deck/DeckPlayer.d.ts.map +1 -0
- package/dist/deck/DeckSlideView.d.ts +17 -0
- package/dist/deck/DeckSlideView.d.ts.map +1 -0
- package/dist/deck/DeckStage.d.ts +16 -0
- package/dist/deck/DeckStage.d.ts.map +1 -0
- package/dist/deck/components/DeckImage.d.ts +14 -0
- package/dist/deck/components/DeckImage.d.ts.map +1 -0
- package/dist/deck/components/DeckImageGrid.d.ts +27 -0
- package/dist/deck/components/DeckImageGrid.d.ts.map +1 -0
- package/dist/deck/components/DeckRichText.d.ts +22 -0
- package/dist/deck/components/DeckRichText.d.ts.map +1 -0
- package/dist/deck/components/DeckVideo.d.ts +15 -0
- package/dist/deck/components/DeckVideo.d.ts.map +1 -0
- package/dist/deck/editing.d.ts +50 -0
- package/dist/deck/editing.d.ts.map +1 -0
- package/dist/deck/flip.d.ts +11 -0
- package/dist/deck/flip.d.ts.map +1 -0
- package/dist/deck/index.d.ts +24 -0
- package/dist/deck/index.d.ts.map +1 -0
- package/dist/deck/index.js +53 -0
- package/dist/deck/layouts/CenteredTextLayout.d.ts +12 -0
- package/dist/deck/layouts/CenteredTextLayout.d.ts.map +1 -0
- package/dist/deck/layouts/ColumnSplitLayout.d.ts +14 -0
- package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +1 -0
- package/dist/deck/layouts/FullBleedImageLayout.d.ts +14 -0
- package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +1 -0
- package/dist/deck/media.d.ts +33 -0
- package/dist/deck/media.d.ts.map +1 -0
- package/dist/deck/tokens.d.ts +22 -0
- package/dist/deck/tokens.d.ts.map +1 -0
- package/dist/deck/useStageScale.d.ts +10 -0
- package/dist/deck/useStageScale.d.ts.map +1 -0
- package/dist/deck-builder/DeckBuilderApp.d.ts +30 -0
- package/dist/deck-builder/DeckBuilderApp.d.ts.map +1 -0
- package/dist/deck-builder/DeckBuilderTopBar.d.ts +24 -0
- package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +1 -0
- package/dist/deck-builder/DeckChatPanel.d.ts +45 -0
- package/dist/deck-builder/DeckChatPanel.d.ts.map +1 -0
- package/dist/deck-builder/DeckEditor.d.ts +37 -0
- package/dist/deck-builder/DeckEditor.d.ts.map +1 -0
- package/dist/deck-builder/DeckLibrary.d.ts +22 -0
- package/dist/deck-builder/DeckLibrary.d.ts.map +1 -0
- package/dist/deck-builder/DeckSlideSurface.d.ts +31 -0
- package/dist/deck-builder/DeckSlideSurface.d.ts.map +1 -0
- package/dist/deck-builder/deck-api.d.ts +76 -0
- package/dist/deck-builder/deck-api.d.ts.map +1 -0
- package/dist/deck-builder/deck-chat-client.d.ts +44 -0
- package/dist/deck-builder/deck-chat-client.d.ts.map +1 -0
- package/dist/deck-builder/deck-chat-history.d.ts +10 -0
- package/dist/deck-builder/deck-chat-history.d.ts.map +1 -0
- package/dist/deck-builder/index.d.ts +13 -0
- package/dist/deck-builder/index.d.ts.map +1 -0
- package/dist/deck-builder/index.js +1505 -0
- package/dist/deck-builder/starter-deck.d.ts +23 -0
- package/dist/deck-builder/starter-deck.d.ts.map +1 -0
- package/dist/deck-builder/types.d.ts +29 -0
- package/dist/deck-builder/types.d.ts.map +1 -0
- package/dist/deck-builder/useSortableSlide.d.ts +29 -0
- package/dist/deck-builder/useSortableSlide.d.ts.map +1 -0
- package/dist/index.js +70 -63
- package/dist/lib/deck-access.d.ts +86 -0
- package/dist/lib/deck-access.d.ts.map +1 -0
- package/dist/lib/deck-access.js +58 -0
- package/dist/lib/deck-dexie.d.ts +88 -0
- package/dist/lib/deck-dexie.d.ts.map +1 -0
- package/dist/lib/deck-dexie.js +22 -0
- package/dist/lib/deck-edit.d.ts +67 -0
- package/dist/lib/deck-edit.d.ts.map +1 -0
- package/dist/lib/deck-media-assets.d.ts +27 -0
- package/dist/lib/deck-media-assets.d.ts.map +1 -0
- package/dist/lib/deck-media-assets.js +25 -0
- package/dist/lib/deck-media-refs.d.ts +35 -0
- package/dist/lib/deck-media-refs.d.ts.map +1 -0
- package/dist/lib/deck-media-refs.js +36 -0
- package/dist/lib/deck-tool.d.ts +25 -0
- package/dist/lib/deck-tool.d.ts.map +1 -0
- package/dist/lib/deck-tool.js +58 -0
- package/dist/lib/index-ops.js +2 -2
- package/dist/lib/index.js +17 -12
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/media/index.js +3 -3
- package/dist/schemas/deck-meta.d.ts +85 -0
- package/dist/schemas/deck-meta.d.ts.map +1 -0
- package/dist/schemas/deck-meta.js +64 -0
- package/dist/schemas/deck.d.ts +954 -0
- package/dist/schemas/deck.d.ts.map +1 -0
- package/dist/schemas/deck.js +65 -0
- package/dist/schemas/index.js +39 -37
- package/dist/schemas/site-config.d.ts +13 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +37 -1
- package/src/components/chat/ChatComposer.tsx +100 -0
- package/src/components/chat/ChatPanel.tsx +20 -69
- package/src/components/chat/markdown.ts +32 -0
- package/src/components/primitives/EditablePlainText.tsx +11 -1
- package/src/components/shell/AudienceChips.tsx +30 -0
- package/src/components/shell/EditorShell.tsx +12 -0
- package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
- package/src/components/shell/SiteSettingsDecks.tsx +41 -0
- package/src/components/shell/SiteSettingsModal.tsx +11 -0
- package/src/deck/DeckComponentView.tsx +28 -0
- package/src/deck/DeckPlayer.tsx +166 -0
- package/src/deck/DeckSlideView.tsx +38 -0
- package/src/deck/DeckStage.tsx +39 -0
- package/src/deck/components/DeckImage.tsx +38 -0
- package/src/deck/components/DeckImageGrid.tsx +254 -0
- package/src/deck/components/DeckRichText.tsx +111 -0
- package/src/deck/components/DeckVideo.tsx +81 -0
- package/src/deck/editing.tsx +201 -0
- package/src/deck/flip.ts +70 -0
- package/src/deck/index.ts +30 -0
- package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
- package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
- package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
- package/src/deck/media.tsx +46 -0
- package/src/deck/tokens.ts +57 -0
- package/src/deck/useStageScale.ts +44 -0
- package/src/deck-builder/DeckBuilderApp.tsx +572 -0
- package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
- package/src/deck-builder/DeckChatPanel.tsx +334 -0
- package/src/deck-builder/DeckEditor.tsx +258 -0
- package/src/deck-builder/DeckLibrary.tsx +195 -0
- package/src/deck-builder/DeckSlideSurface.tsx +55 -0
- package/src/deck-builder/deck-api.ts +177 -0
- package/src/deck-builder/deck-chat-client.ts +129 -0
- package/src/deck-builder/deck-chat-history.ts +62 -0
- package/src/deck-builder/index.ts +12 -0
- package/src/deck-builder/starter-deck.ts +86 -0
- package/src/deck-builder/types.ts +24 -0
- package/src/deck-builder/useSortableSlide.ts +102 -0
- package/src/lib/deck-access.ts +132 -0
- package/src/lib/deck-dexie.ts +190 -0
- package/src/lib/deck-edit.ts +196 -0
- package/src/lib/deck-media-assets.ts +57 -0
- package/src/lib/deck-media-refs.ts +67 -0
- package/src/lib/deck-tool.ts +95 -0
- package/src/lib/nav.ts +21 -1
- package/src/schemas/deck-meta.ts +88 -0
- package/src/schemas/deck.ts +478 -0
- package/src/schemas/index.ts +4 -0
- package/src/schemas/site-config.ts +24 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { ChevronRight, SquarePen } from "lucide-react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { ChatComposer } from "../components/chat/ChatComposer";
|
|
4
|
+
import { IconButton } from "../components/shared/IconButton";
|
|
5
|
+
import { MD_STYLES, USER_BUBBLE_CLASS, assistantHtml } from "../components/chat/markdown";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { ensureSanitizer } from "../lib/sanitize";
|
|
8
|
+
import type { Deck } from "../schemas/deck";
|
|
9
|
+
import {
|
|
10
|
+
DECK_CHAT_ERROR_COPY,
|
|
11
|
+
deckChatCanRetry,
|
|
12
|
+
streamDeckChat,
|
|
13
|
+
type DeckChatMessage,
|
|
14
|
+
} from "./deck-chat-client";
|
|
15
|
+
import { clearDeckChat, loadDeckChat, saveDeckChat } from "./deck-chat-history";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The Deck Builder's docked assistant (#35) — the surface that makes the
|
|
19
|
+
* builder an authoring tool rather than a viewer.
|
|
20
|
+
*
|
|
21
|
+
* It is the ChatPanel IDIOM (h-16 header, `p-3` log, the shared composer and
|
|
22
|
+
* markdown treatment) rendered as an in-flow column rather than as
|
|
23
|
+
* ChatPanel itself: that component is bound to `/api/chat`'s contract, to the
|
|
24
|
+
* viewer/editor chrome it positions against, and to a task selector and history
|
|
25
|
+
* archive a deck conversation has no use for. What the two genuinely share —
|
|
26
|
+
* the composer, the assistant-markdown rules, the bubble treatment — is
|
|
27
|
+
* imported, not copied.
|
|
28
|
+
*
|
|
29
|
+
* "Chat authors, direct manipulation adjusts": this pane is the ~80% path, and
|
|
30
|
+
* `busy` is what #39's inline editing will lock against.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
deck: Deck;
|
|
35
|
+
/** The identity digest the Deck Draft is keyed by — the transcript is scoped
|
|
36
|
+
* the same way, so two people sharing a browser never read each other's. */
|
|
37
|
+
scope: string;
|
|
38
|
+
/**
|
|
39
|
+
* The deck as of RIGHT NOW, for send time. The `deck` prop above comes from
|
|
40
|
+
* React state, which lags the Deck Draft by one round-trip — and pressing Send
|
|
41
|
+
* blurs an open inline field, so the author's last edit is routinely in that
|
|
42
|
+
* gap. Grounding a turn on the prop would post a deck they had just changed.
|
|
43
|
+
*/
|
|
44
|
+
getDeck?: () => Deck | null;
|
|
45
|
+
/** A deck that PARSED server-side. The caller stages it; this pane never writes. */
|
|
46
|
+
onDeck: (deck: Deck) => void;
|
|
47
|
+
/** Streaming state, lifted so the shell can lock publish and (later) editing. */
|
|
48
|
+
onBusyChange?: (busy: boolean) => void;
|
|
49
|
+
/**
|
|
50
|
+
* The OTHER half of that lock. The shell sets this while it is doing
|
|
51
|
+
* something that reads or rewrites the draft row (publish, reopen, delete):
|
|
52
|
+
* a turn landing inside a publish's round-trip would have its `putDeckDraft`
|
|
53
|
+
* overwritten by the baseline that publish writes on the way out, and the
|
|
54
|
+
* author would lose the turn with no error anywhere.
|
|
55
|
+
*/
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function DeckChatPanel({ deck, getDeck, scope, onDeck, onBusyChange, disabled = false, onClose }: Props) {
|
|
61
|
+
const [messages, setMessages] = useState<DeckChatMessage[]>([]);
|
|
62
|
+
const [input, setInput] = useState("");
|
|
63
|
+
const [busy, setBusy] = useState(false);
|
|
64
|
+
const [updating, setUpdating] = useState(false);
|
|
65
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
66
|
+
const [canRetry, setCanRetry] = useState(false);
|
|
67
|
+
const [mdReady, setMdReady] = useState(false);
|
|
68
|
+
const streamingRef = useRef("");
|
|
69
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
70
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
72
|
+
// Bumped by every run and by anything that replaces the transcript under one.
|
|
73
|
+
// Every post-await write is keyed to the generation it started in, so a turn
|
|
74
|
+
// that finishes after "New conversation" cannot resurrect the old transcript.
|
|
75
|
+
const runGenRef = useRef(0);
|
|
76
|
+
// The deck as of THIS render, read at send time: a turn always posts what the
|
|
77
|
+
// author can currently see, including a change the previous turn just made.
|
|
78
|
+
const deckRef = useRef(deck);
|
|
79
|
+
deckRef.current = deck;
|
|
80
|
+
const getDeckRef = useRef(getDeck);
|
|
81
|
+
getDeckRef.current = getDeck;
|
|
82
|
+
|
|
83
|
+
const deckId = deck.id;
|
|
84
|
+
|
|
85
|
+
const freshDeck = useCallback((): Deck => {
|
|
86
|
+
const fresh = getDeckRef.current?.();
|
|
87
|
+
return fresh !== null && fresh !== undefined && fresh.id === deckId ? fresh : deckRef.current;
|
|
88
|
+
}, [deckId]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
// Invalidate any in-flight turn: it was asked about the PREVIOUS deck, and
|
|
92
|
+
// its tail would otherwise write that answer into this deck's transcript
|
|
93
|
+
// (and hand `onDeck` a deck under the wrong id). Reached only by a deckId
|
|
94
|
+
// swap on a LIVE instance: navigating to the grid unmounts this panel, which
|
|
95
|
+
// does not abort the stream — the shell's `editBase` is id-tagged for
|
|
96
|
+
// exactly that reason, since this guard cannot see it —
|
|
97
|
+
// but the props allow it, and the guard is one line.
|
|
98
|
+
runGenRef.current++;
|
|
99
|
+
setMessages(loadDeckChat(scope, deckId));
|
|
100
|
+
setNotice(null);
|
|
101
|
+
setCanRetry(false);
|
|
102
|
+
}, [scope, deckId]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let alive = true;
|
|
106
|
+
// Failure just leaves mdReady false forever (plain-text messages) — never
|
|
107
|
+
// let it surface as an unhandled rejection.
|
|
108
|
+
void ensureSanitizer()
|
|
109
|
+
.then(() => {
|
|
110
|
+
if (alive) setMdReady(true);
|
|
111
|
+
})
|
|
112
|
+
.catch(() => {});
|
|
113
|
+
return () => {
|
|
114
|
+
alive = false;
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
// Stick to the newest tokens. Simpler than ChatPanel's pinned/jump-pill
|
|
119
|
+
// machinery on purpose: this column is short, and a deck turn is a sentence.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const el = listRef.current;
|
|
122
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
123
|
+
}, [messages, updating]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
onBusyChange?.(busy);
|
|
127
|
+
}, [busy, onBusyChange]);
|
|
128
|
+
|
|
129
|
+
const run = useCallback(
|
|
130
|
+
async (history: DeckChatMessage[]) => {
|
|
131
|
+
const gen = ++runGenRef.current;
|
|
132
|
+
const superseded = () => runGenRef.current !== gen;
|
|
133
|
+
setNotice(null);
|
|
134
|
+
setCanRetry(false);
|
|
135
|
+
setBusy(true);
|
|
136
|
+
setUpdating(false);
|
|
137
|
+
setMessages([...history, { role: "assistant", content: "" }]);
|
|
138
|
+
streamingRef.current = "";
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
abortRef.current = controller;
|
|
141
|
+
|
|
142
|
+
const result = await streamDeckChat(
|
|
143
|
+
// The getter wins when it answers for THIS deck; a mismatch means the
|
|
144
|
+
// shell has moved on and the render-time prop is the honest answer.
|
|
145
|
+
{ deck: freshDeck(), messages: history },
|
|
146
|
+
{
|
|
147
|
+
onText: (delta) => {
|
|
148
|
+
if (superseded()) return;
|
|
149
|
+
streamingRef.current += delta;
|
|
150
|
+
setMessages([...history, { role: "assistant", content: streamingRef.current }]);
|
|
151
|
+
},
|
|
152
|
+
onRetry: () => {
|
|
153
|
+
if (superseded()) return;
|
|
154
|
+
// The failed attempt's prose is not an answer to anything — drop it
|
|
155
|
+
// and say what is actually happening.
|
|
156
|
+
streamingRef.current = "";
|
|
157
|
+
setUpdating(true);
|
|
158
|
+
setMessages([...history, { role: "assistant", content: "" }]);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
controller.signal,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
abortRef.current = null;
|
|
165
|
+
setBusy(false);
|
|
166
|
+
setUpdating(false);
|
|
167
|
+
if (superseded()) return;
|
|
168
|
+
|
|
169
|
+
if (!result.ok) {
|
|
170
|
+
// The deck is untouched — that is the whole point of validating server
|
|
171
|
+
// side — so the transcript keeps the user's line and drops the stub.
|
|
172
|
+
setMessages(history);
|
|
173
|
+
// Persisted too. Without this the line is on screen but not in storage,
|
|
174
|
+
// so a reload silently swallows what the author just typed — and the
|
|
175
|
+
// Try again path is exactly when someone is most likely to reload.
|
|
176
|
+
saveDeckChat(scope, deckId, history);
|
|
177
|
+
setNotice(DECK_CHAT_ERROR_COPY[result.error] ?? DECK_CHAT_ERROR_COPY.upstream_error);
|
|
178
|
+
setCanRetry(deckChatCanRetry(result.error));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// A turn is allowed to be nothing but a tool call, and observed models do
|
|
182
|
+
// exactly that (the prompt asks for a line first; it is not a guarantee).
|
|
183
|
+
// Without this the transcript ends on the user's message while the preview
|
|
184
|
+
// silently changes underneath it.
|
|
185
|
+
const reply = streamingRef.current || (result.deck ? "Updated the deck." : "");
|
|
186
|
+
const finished: DeckChatMessage[] = reply ? [...history, { role: "assistant", content: reply }] : history;
|
|
187
|
+
setMessages(finished);
|
|
188
|
+
saveDeckChat(scope, deckId, finished);
|
|
189
|
+
if (result.deck) onDeck(result.deck);
|
|
190
|
+
if (result.truncated) setNotice("Some brand content didn't fit — the assistant may not know everything on this site.");
|
|
191
|
+
},
|
|
192
|
+
[deckId, freshDeck, onDeck, scope],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const send = useCallback(
|
|
196
|
+
(override?: string) => {
|
|
197
|
+
const text = (override ?? input).trim();
|
|
198
|
+
if (!text || busy || disabled) return;
|
|
199
|
+
if (override === undefined) setInput("");
|
|
200
|
+
void run([...messages, { role: "user", content: text }]);
|
|
201
|
+
},
|
|
202
|
+
[busy, disabled, input, messages, run],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
function retry() {
|
|
206
|
+
if (busy || messages.length === 0 || messages[messages.length - 1].role !== "user") return;
|
|
207
|
+
void run(messages);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function newConversation() {
|
|
211
|
+
if (busy) abortRef.current?.abort();
|
|
212
|
+
runGenRef.current++;
|
|
213
|
+
setMessages([]);
|
|
214
|
+
setNotice(null);
|
|
215
|
+
setCanRetry(false);
|
|
216
|
+
clearDeckChat(scope, deckId);
|
|
217
|
+
composerRef.current?.focus();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const lastIndex = messages.length - 1;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<aside
|
|
224
|
+
data-deck-chat=""
|
|
225
|
+
aria-label="Deck assistant"
|
|
226
|
+
className="flex w-[var(--chat-w)] shrink-0 flex-col border-l border-base-200 bg-base"
|
|
227
|
+
>
|
|
228
|
+
<div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-base-200 px-3">
|
|
229
|
+
<span className="text-sm font-medium text-base-contrast">Deck assistant</span>
|
|
230
|
+
<div className="flex items-center gap-1">
|
|
231
|
+
{messages.length > 0 && (
|
|
232
|
+
<IconButton
|
|
233
|
+
icon={<SquarePen size={16} />}
|
|
234
|
+
label="New conversation"
|
|
235
|
+
onClick={newConversation}
|
|
236
|
+
className="rounded-inner hover:bg-base-accent"
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
<IconButton
|
|
240
|
+
icon={<ChevronRight size={18} />}
|
|
241
|
+
label="Hide deck assistant"
|
|
242
|
+
onClick={onClose}
|
|
243
|
+
className="rounded-inner hover:bg-base-accent"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div ref={listRef} role="log" aria-label="Conversation" className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
|
249
|
+
{messages.length === 0 && (
|
|
250
|
+
<div className="flex h-full flex-col justify-end gap-2 pb-1">
|
|
251
|
+
<p className="text-sm text-base-contrast-light">
|
|
252
|
+
Describe the deck you need and I'll build it from this portal's brand content.
|
|
253
|
+
</p>
|
|
254
|
+
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
255
|
+
{["Build a five-slide brand overview", "Add a slide about our color palette"].map((suggestion) => (
|
|
256
|
+
<button
|
|
257
|
+
key={suggestion}
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={() => send(suggestion)}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary disabled:cursor-default disabled:opacity-50"
|
|
262
|
+
>
|
|
263
|
+
{suggestion}
|
|
264
|
+
</button>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
{messages.map((m, i) =>
|
|
270
|
+
m.role === "user" ? (
|
|
271
|
+
<div key={i} className={USER_BUBBLE_CLASS}>
|
|
272
|
+
{m.content}
|
|
273
|
+
</div>
|
|
274
|
+
) : m.content === "" ? (
|
|
275
|
+
busy && i === lastIndex ? (
|
|
276
|
+
<div key={i} className="text-sm text-base-contrast-light motion-safe:animate-pulse">
|
|
277
|
+
{updating ? "Fixing the deck…" : "Working on your deck…"}
|
|
278
|
+
</div>
|
|
279
|
+
) : null
|
|
280
|
+
) : mdReady ? (
|
|
281
|
+
<div
|
|
282
|
+
key={i}
|
|
283
|
+
className={cn("text-sm leading-relaxed text-base-contrast", MD_STYLES)}
|
|
284
|
+
dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
|
|
285
|
+
/>
|
|
286
|
+
) : (
|
|
287
|
+
<div key={i} className="text-sm leading-relaxed whitespace-pre-wrap text-base-contrast">
|
|
288
|
+
{m.content}
|
|
289
|
+
</div>
|
|
290
|
+
),
|
|
291
|
+
)}
|
|
292
|
+
{notice && (
|
|
293
|
+
<p className="text-xs text-base-contrast-light">
|
|
294
|
+
{notice}
|
|
295
|
+
{canRetry && !busy && (
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={retry}
|
|
299
|
+
className="ml-2 cursor-pointer text-primary underline underline-offset-2"
|
|
300
|
+
>
|
|
301
|
+
Try again
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
</p>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* `aria-live` rather than role="status": the shell already owns a status
|
|
309
|
+
region for publish/notice, and a second one would give the builder two
|
|
310
|
+
places a screen reader (and a test) has to disambiguate between. This
|
|
311
|
+
is a transient announcement, not a landmark. */}
|
|
312
|
+
<p aria-live="polite" className="sr-only">
|
|
313
|
+
{busy ? (updating ? "Fixing the deck" : "Updating the deck") : ""}
|
|
314
|
+
</p>
|
|
315
|
+
|
|
316
|
+
<ChatComposer
|
|
317
|
+
value={input}
|
|
318
|
+
onChange={setInput}
|
|
319
|
+
onSend={() => send()}
|
|
320
|
+
onStop={() => abortRef.current?.abort()}
|
|
321
|
+
busy={busy}
|
|
322
|
+
// The placeholder deliberately does NOT change: the shell's `busy`
|
|
323
|
+
// covers publish and reload-published, so any wording here would be
|
|
324
|
+
// wrong half the time, and the window is sub-second.
|
|
325
|
+
disabled={disabled}
|
|
326
|
+
placeholder="Describe the change…"
|
|
327
|
+
textareaRef={composerRef}
|
|
328
|
+
/>
|
|
329
|
+
<p className="shrink-0 border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">
|
|
330
|
+
Deck drafts stage on this device until you publish.
|
|
331
|
+
</p>
|
|
332
|
+
</aside>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { ChevronLeftIcon, CopyIcon, MoveLeftIcon, MoveRightIcon, Trash2Icon } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Button } from "../components/shared/Button";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import type { DeckTextPath } from "../lib/deck-edit";
|
|
6
|
+
import type { Deck, DeckMediaAsset, DeckSlide } from "../schemas/deck";
|
|
7
|
+
import { DeckSlideSurface, type DeckTextEditor } from "./DeckSlideSurface";
|
|
8
|
+
import { useSortableSlide } from "./useSortableSlide";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The deck-editing view (prototype variant A's arrangement): a back-link to the
|
|
12
|
+
* grid, the deck's own head row, a 16:9 stage, and a horizontal filmstrip of
|
|
13
|
+
* slide thumbnails BELOW it.
|
|
14
|
+
*
|
|
15
|
+
* Direct manipulation is #39's scope, and its boundary is the product decision
|
|
16
|
+
* recorded in CONTEXT.md — "chat authors, direct manipulation adjusts". What is
|
|
17
|
+
* here: editing a text leaf in place on the stage, and reorder / duplicate /
|
|
18
|
+
* delete on the strip. What is deliberately NOT here, and must not be added
|
|
19
|
+
* without revisiting that decision: layout switching, adding or removing a Deck
|
|
20
|
+
* Component, and grid composition. Those belong to the assistant.
|
|
21
|
+
*/
|
|
22
|
+
interface Props {
|
|
23
|
+
deck: Deck;
|
|
24
|
+
mediaAssets: Record<string, DeckMediaAsset>;
|
|
25
|
+
currentSlide: number;
|
|
26
|
+
onSelectSlide: (index: number) => void;
|
|
27
|
+
onBack: () => void;
|
|
28
|
+
/** Draft-vs-published state, spelled out under the title. */
|
|
29
|
+
statusLine: string;
|
|
30
|
+
onEditText: (path: DeckTextPath, text: string) => void;
|
|
31
|
+
/** `to` is the destination index in the post-removal list. */
|
|
32
|
+
onMoveSlide: (from: number, to: number) => void;
|
|
33
|
+
onDuplicateSlide: (index: number) => void;
|
|
34
|
+
onDeleteSlide: (index: number) => void;
|
|
35
|
+
/**
|
|
36
|
+
* A chat turn is in flight. Every manual affordance goes inert: a turn
|
|
37
|
+
* rewrites the whole deck, so an edit landing mid-stream would be overwritten
|
|
38
|
+
* with nothing anywhere saying so.
|
|
39
|
+
*/
|
|
40
|
+
locked: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function DeckEditor({
|
|
44
|
+
deck,
|
|
45
|
+
mediaAssets,
|
|
46
|
+
currentSlide,
|
|
47
|
+
onSelectSlide,
|
|
48
|
+
onBack,
|
|
49
|
+
statusLine,
|
|
50
|
+
onEditText,
|
|
51
|
+
onMoveSlide,
|
|
52
|
+
onDuplicateSlide,
|
|
53
|
+
onDeleteSlide,
|
|
54
|
+
locked,
|
|
55
|
+
}: Props) {
|
|
56
|
+
const index = Math.min(Math.max(currentSlide, 0), deck.slides.length - 1);
|
|
57
|
+
const slide = deck.slides[index];
|
|
58
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
59
|
+
|
|
60
|
+
// A pending confirmation belongs to the slide it was opened on — moving away
|
|
61
|
+
// (or a turn rewriting the deck under it) must not leave it armed.
|
|
62
|
+
//
|
|
63
|
+
// Keyed to the slide's IDENTITY, not to the index: a drag that moves a
|
|
64
|
+
// DIFFERENT slide into the selected position changes neither the index nor
|
|
65
|
+
// the slide count, so an index-keyed reset left "Delete slide 2?" armed and
|
|
66
|
+
// pointing at a slide the author never named.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
setConfirmingDelete(false);
|
|
69
|
+
}, [slide.id, index, deck.slides.length, locked]);
|
|
70
|
+
|
|
71
|
+
// Memoised: this is a context value, so a fresh object each render would
|
|
72
|
+
// re-render every text leaf on the stage on every keystroke elsewhere.
|
|
73
|
+
const editor = useMemo<DeckTextEditor>(() => ({ onEditText, disabled: locked }), [onEditText, locked]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="mx-auto grid w-full max-w-screen-xl gap-3.5 px-4 pt-8 pb-16">
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={onBack}
|
|
80
|
+
className="inline-flex cursor-pointer items-center gap-1 self-start text-xs font-medium text-base-contrast-light transition-colors hover:text-base-contrast"
|
|
81
|
+
>
|
|
82
|
+
<ChevronLeftIcon size={14} aria-hidden="true" />
|
|
83
|
+
All decks
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
<div className="flex flex-wrap items-center gap-2.5">
|
|
87
|
+
<h1 className="font-body text-lg font-semibold text-base-contrast">{deck.title}</h1>
|
|
88
|
+
<p className="text-xs text-base-contrast-light">{statusLine}</p>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* The stage never grows past 52vh tall, so the filmstrip under it is on
|
|
92
|
+
screen without scrolling on a laptop. The strip matches its width so
|
|
93
|
+
the two read as one object. */}
|
|
94
|
+
<div className="aspect-video w-[min(100%,calc(52vh*16/9))] overflow-hidden rounded-[var(--radius-outer)] border border-base-200 shadow-md">
|
|
95
|
+
<DeckSlideSurface slide={slide} mediaAssets={mediaAssets} className="h-full" editor={editor} />
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Announced, not merely styled: the lock arrives while the author's
|
|
99
|
+
attention is on the chat, and a field that silently stops responding
|
|
100
|
+
is the failure this line exists to prevent. `aria-live` rather than
|
|
101
|
+
role="status" deliberately — the shell's notice owns that role, and two
|
|
102
|
+
status regions in one view is one too many for a screen reader and for
|
|
103
|
+
`getByRole("status")`. */}
|
|
104
|
+
<p aria-live="polite" data-deck-edit-hint="" className="min-h-4 text-xs text-base-contrast-light">
|
|
105
|
+
{locked
|
|
106
|
+
? "Editing is paused while the assistant works on this deck."
|
|
107
|
+
: "Click any text on the slide to edit it. Drag a thumbnail to reorder."}
|
|
108
|
+
</p>
|
|
109
|
+
|
|
110
|
+
<ul
|
|
111
|
+
aria-label={`${deck.title} slides`}
|
|
112
|
+
className="flex w-[min(100%,calc(52vh*16/9))] gap-2 overflow-x-auto p-0.5"
|
|
113
|
+
>
|
|
114
|
+
{deck.slides.map((s, i) => (
|
|
115
|
+
<SlideThumb
|
|
116
|
+
key={s.id}
|
|
117
|
+
slide={s}
|
|
118
|
+
index={i}
|
|
119
|
+
mediaAssets={mediaAssets}
|
|
120
|
+
selected={i === index}
|
|
121
|
+
enabled={!locked}
|
|
122
|
+
onSelect={onSelectSlide}
|
|
123
|
+
onReorder={onMoveSlide}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
|
|
128
|
+
{/* Slide-level operations, on the SELECTED slide. Deliberately not a
|
|
129
|
+
hover menu on each thumbnail: at filmstrip size a per-tile control
|
|
130
|
+
strip covers the thing it is describing, and the selected slide is
|
|
131
|
+
already the one on the stage. */}
|
|
132
|
+
<div className="flex w-[min(100%,calc(52vh*16/9))] flex-wrap items-center gap-2">
|
|
133
|
+
<span className="text-xs text-base-contrast-light">
|
|
134
|
+
Slide {index + 1} of {deck.slides.length}
|
|
135
|
+
</span>
|
|
136
|
+
<Button
|
|
137
|
+
variant="ghost"
|
|
138
|
+
onClick={() => onMoveSlide(index, index - 1)}
|
|
139
|
+
disabled={locked || index === 0}
|
|
140
|
+
aria-label="Move slide earlier"
|
|
141
|
+
>
|
|
142
|
+
<MoveLeftIcon size={14} className="mr-1.5" aria-hidden="true" />
|
|
143
|
+
Earlier
|
|
144
|
+
</Button>
|
|
145
|
+
<Button
|
|
146
|
+
variant="ghost"
|
|
147
|
+
onClick={() => onMoveSlide(index, index + 1)}
|
|
148
|
+
disabled={locked || index === deck.slides.length - 1}
|
|
149
|
+
aria-label="Move slide later"
|
|
150
|
+
>
|
|
151
|
+
<MoveRightIcon size={14} className="mr-1.5" aria-hidden="true" />
|
|
152
|
+
Later
|
|
153
|
+
</Button>
|
|
154
|
+
<Button variant="ghost" onClick={() => onDuplicateSlide(index)} disabled={locked} aria-label="Duplicate slide">
|
|
155
|
+
<CopyIcon size={14} className="mr-1.5" aria-hidden="true" />
|
|
156
|
+
Duplicate
|
|
157
|
+
</Button>
|
|
158
|
+
|
|
159
|
+
{confirmingDelete ? (
|
|
160
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
161
|
+
<span className="text-xs text-base-contrast">Delete slide {index + 1}?</span>
|
|
162
|
+
<Button
|
|
163
|
+
variant="destructive"
|
|
164
|
+
onClick={() => {
|
|
165
|
+
setConfirmingDelete(false);
|
|
166
|
+
onDeleteSlide(index);
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
Delete
|
|
170
|
+
</Button>
|
|
171
|
+
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
|
|
172
|
+
Cancel
|
|
173
|
+
</Button>
|
|
174
|
+
</span>
|
|
175
|
+
) : (
|
|
176
|
+
<Button
|
|
177
|
+
variant="ghost"
|
|
178
|
+
onClick={() => setConfirmingDelete(true)}
|
|
179
|
+
disabled={locked || deck.slides.length === 1}
|
|
180
|
+
aria-label="Delete slide"
|
|
181
|
+
>
|
|
182
|
+
<Trash2Icon size={14} className="mr-1.5" aria-hidden="true" />
|
|
183
|
+
Delete
|
|
184
|
+
</Button>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function SlideThumb({
|
|
192
|
+
slide,
|
|
193
|
+
index,
|
|
194
|
+
mediaAssets,
|
|
195
|
+
selected,
|
|
196
|
+
enabled,
|
|
197
|
+
onSelect,
|
|
198
|
+
onReorder,
|
|
199
|
+
}: {
|
|
200
|
+
slide: DeckSlide;
|
|
201
|
+
index: number;
|
|
202
|
+
mediaAssets: Record<string, DeckMediaAsset>;
|
|
203
|
+
selected: boolean;
|
|
204
|
+
enabled: boolean;
|
|
205
|
+
onSelect: (index: number) => void;
|
|
206
|
+
onReorder: (from: number, to: number) => void;
|
|
207
|
+
}) {
|
|
208
|
+
const { ref, isDragging, closestEdge } = useSortableSlide<HTMLLIElement>({ index, onReorder, enabled });
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<li ref={ref} className="relative shrink-0">
|
|
212
|
+
{closestEdge && (
|
|
213
|
+
<span
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
className={cn(
|
|
216
|
+
"absolute inset-y-0 z-[3] w-0.5 bg-primary",
|
|
217
|
+
closestEdge === "left" ? "-left-1" : "-right-1",
|
|
218
|
+
)}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
{/* role="button" rather than a <button>: a rendered slide can carry
|
|
222
|
+
real buttons (the image grid's tiles), and nesting them is
|
|
223
|
+
invalid. Same reason the deck cards' thumbnails are divs. */}
|
|
224
|
+
<div
|
|
225
|
+
role="button"
|
|
226
|
+
tabIndex={0}
|
|
227
|
+
aria-current={selected ? "true" : undefined}
|
|
228
|
+
aria-label={slide.title ? `Slide ${index + 1}: ${slide.title}` : `Slide ${index + 1}`}
|
|
229
|
+
onClick={() => onSelect(index)}
|
|
230
|
+
onKeyDown={(event) => {
|
|
231
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
onSelect(index);
|
|
234
|
+
}}
|
|
235
|
+
className={cn(
|
|
236
|
+
"relative aspect-video w-30 cursor-pointer overflow-hidden rounded border bg-base-accent transition-colors",
|
|
237
|
+
selected ? "border-primary shadow-[0_0_0_1px_var(--color-primary)]" : "border-base-200 hover:border-base-300",
|
|
238
|
+
isDragging && "opacity-40",
|
|
239
|
+
)}
|
|
240
|
+
>
|
|
241
|
+
{/* The thumbnail carries NO edit seam — `editor` is omitted, so the
|
|
242
|
+
same renderer produces the read-only markup here. */}
|
|
243
|
+
<div className="pointer-events-none h-full">
|
|
244
|
+
<DeckSlideSurface slide={slide} mediaAssets={mediaAssets} className="h-full" />
|
|
245
|
+
</div>
|
|
246
|
+
<span
|
|
247
|
+
aria-hidden="true"
|
|
248
|
+
className={cn(
|
|
249
|
+
"absolute bottom-0 left-0 z-[2] rounded-tr bg-base px-1 py-px text-[0.625rem] font-semibold",
|
|
250
|
+
selected ? "text-primary" : "text-base-contrast-light",
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
{String(index + 1).padStart(2, "0")}
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
</li>
|
|
257
|
+
);
|
|
258
|
+
}
|