privateboard 0.1.2 → 0.1.3
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/cli.js +3355 -940
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-profile.js +17 -7
- package/public/app.js +3775 -434
- package/public/index.html +2357 -265
- package/public/onboarding.js +36 -9
- package/public/quote-cta.css +49 -5
- package/public/quote-cta.js +215 -17
- package/public/report/spines/a16z-thesis.css +212 -97
- package/public/report/spines/anthropic-essay.css +564 -221
- package/public/report/spines/boardroom-dark.css +130 -72
- package/public/report/spines/gartner-note.css +83 -48
- package/public/report/spines/mckinsey-deck.css +81 -31
- package/public/report/spines/openai-paper.css +96 -35
- package/public/report.html +3576 -197
- package/public/room-settings.js +11 -8
- package/public/themes.css +15 -2
- package/public/user-settings.css +19 -8
- package/public/user-settings.js +37 -162
package/public/onboarding.js
CHANGED
|
@@ -112,9 +112,7 @@
|
|
|
112
112
|
// ── Provider catalogue ─────────────────────────────────
|
|
113
113
|
// Model providers shown on step 2. OpenRouter leads — it's the
|
|
114
114
|
// universal router that unlocks every model from a single key, so
|
|
115
|
-
// it's the lowest-friction first stop for new users.
|
|
116
|
-
// (Claude) is temporarily withheld; bring it back when the
|
|
117
|
-
// direct-Anthropic flow is ready.
|
|
115
|
+
// it's the lowest-friction first stop for new users.
|
|
118
116
|
// `slug` matches /api/keys/{slug} on the backend.
|
|
119
117
|
const KEY_PROVIDERS = [
|
|
120
118
|
{
|
|
@@ -125,6 +123,14 @@
|
|
|
125
123
|
help: "openrouter.ai/keys",
|
|
126
124
|
helpUrl: "https://openrouter.ai/keys",
|
|
127
125
|
},
|
|
126
|
+
{
|
|
127
|
+
slug: "anthropic",
|
|
128
|
+
label: "Claude",
|
|
129
|
+
sub: "Anthropic",
|
|
130
|
+
placeholder: "sk-ant-…",
|
|
131
|
+
help: "console.anthropic.com",
|
|
132
|
+
helpUrl: "https://console.anthropic.com/settings/keys",
|
|
133
|
+
},
|
|
128
134
|
{
|
|
129
135
|
slug: "openai",
|
|
130
136
|
label: "ChatGPT",
|
|
@@ -151,6 +157,7 @@
|
|
|
151
157
|
* and the Next-button enable state. */
|
|
152
158
|
let providerConfigured = {
|
|
153
159
|
openrouter: false,
|
|
160
|
+
anthropic: false,
|
|
154
161
|
openai: false,
|
|
155
162
|
google: false,
|
|
156
163
|
};
|
|
@@ -403,7 +410,7 @@
|
|
|
403
410
|
<div class="onb-field">
|
|
404
411
|
<div class="onb-field-label" data-onb-field-label>${escape(active.label)} API key</div>
|
|
405
412
|
<div class="onb-input-wrap">
|
|
406
|
-
<input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="
|
|
413
|
+
<input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="one-time-code" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellcheck="false" value="${escape(inputValue)}">
|
|
407
414
|
<button type="button" class="onb-input-reveal" data-onb-reveal aria-label="Show key" aria-pressed="false">show</button>
|
|
408
415
|
</div>
|
|
409
416
|
${status}
|
|
@@ -598,7 +605,23 @@
|
|
|
598
605
|
if (typeof window.boardroomModelsRefresh === "function") {
|
|
599
606
|
refreshes.push(Promise.resolve(window.boardroomModelsRefresh()).catch(() => {}));
|
|
600
607
|
}
|
|
601
|
-
Promise.all(refreshes).finally(() => {
|
|
608
|
+
Promise.all(refreshes).finally(() => {
|
|
609
|
+
if (continuation) {
|
|
610
|
+
continuation();
|
|
611
|
+
} else {
|
|
612
|
+
// Default skip path · the user dismissed onboarding without
|
|
613
|
+
// picking a starter or convene-your-own. Explicitly land
|
|
614
|
+
// them on the new-room composer. Without this, whatever
|
|
615
|
+
// composer mode the dashboard happened to settle into during
|
|
616
|
+
// boot stays put — and on first-run flows that's
|
|
617
|
+
// occasionally "agent" instead of "room", since the order
|
|
618
|
+
// of restore() / app.init() / refreshAgents isn't strictly
|
|
619
|
+
// guaranteed and the agents-tab restorer can win the race.
|
|
620
|
+
if (window.app && typeof window.app.setComposerMode === "function") {
|
|
621
|
+
window.app.setComposerMode("room");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
});
|
|
602
625
|
}
|
|
603
626
|
|
|
604
627
|
async function createDemoRoom(spec) {
|
|
@@ -630,11 +653,15 @@
|
|
|
630
653
|
function openConveneAfter() {
|
|
631
654
|
setTimeout(() => {
|
|
632
655
|
// Convene-overlay was retired in favour of the inline composer.
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
656
|
+
// Use setComposerMode("room") (not closeRoom) so we explicitly
|
|
657
|
+
// pin the new-room composer · closeRoom inherits whatever
|
|
658
|
+
// composerMode is set, and during a boot race that flag can be
|
|
659
|
+
// "agent", which would land the user on the new-agent composer
|
|
660
|
+
// instead of the new-room one.
|
|
636
661
|
try {
|
|
637
|
-
if (window.app && typeof window.app.
|
|
662
|
+
if (window.app && typeof window.app.setComposerMode === "function") {
|
|
663
|
+
window.app.setComposerMode("room");
|
|
664
|
+
} else if (window.app && typeof window.app.closeRoom === "function") {
|
|
638
665
|
window.app.closeRoom();
|
|
639
666
|
} else if (typeof window.openConveneOverlay === "function") {
|
|
640
667
|
window.openConveneOverlay();
|
package/public/quote-cta.css
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
QUOTE CTA · selection-driven follow-up
|
|
3
3
|
═══════════════════════════════════════════
|
|
4
4
|
When the user selects text inside a director's message bubble,
|
|
5
|
-
a small floating bar appears above the selection with
|
|
6
|
-
actions:
|
|
7
|
-
|
|
8
|
-
that quotes the
|
|
5
|
+
a small floating bar appears above the selection with three
|
|
6
|
+
actions: Probe (opens an overlay), Second (one-click), or Save
|
|
7
|
+
(bookmark to chairman's notes). Probe / Second produce a user
|
|
8
|
+
message that quotes the snippet via markdown blockquote; Save
|
|
9
|
+
is a personal bookmark with no room interaction.
|
|
9
10
|
|
|
10
11
|
Visual vocabulary mirrors the rest of the app: panel-2 surface,
|
|
11
12
|
lime accent, mono micro-type for the action labels. Per the
|
|
@@ -78,9 +79,52 @@
|
|
|
78
79
|
padding: 8px 13px;
|
|
79
80
|
white-space: nowrap;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
/* Adjourned-room state · Probe / Second hide (they post to a
|
|
83
|
+
closed room), but Save stays — bookmarking from a finished
|
|
84
|
+
session is a primary use case. */
|
|
85
|
+
.qcta.qcta-readonly .qcta-btn:not(.qcta-btn-save) { display: none; }
|
|
82
86
|
.qcta.qcta-readonly .qcta-hint { display: inline-flex; align-items: center; }
|
|
83
87
|
|
|
88
|
+
/* ─── Save toast ─────────────────────────────────────────────
|
|
89
|
+
Lightweight feedback after a successful POST /api/notes (or a
|
|
90
|
+
failure). Bottom-center anchored, fades in/out. Lime for ok,
|
|
91
|
+
red-tinted for error. Click to dismiss early. */
|
|
92
|
+
.qcta-toast {
|
|
93
|
+
position: fixed;
|
|
94
|
+
bottom: 24px;
|
|
95
|
+
left: 50%;
|
|
96
|
+
transform: translate(-50%, 8px);
|
|
97
|
+
z-index: 1600;
|
|
98
|
+
background: var(--panel, #131312);
|
|
99
|
+
border: 0.5px solid var(--lime, #6FB572);
|
|
100
|
+
color: var(--text, #C8C5BE);
|
|
101
|
+
font-family: var(--mono, "Inter", system-ui, sans-serif);
|
|
102
|
+
font-size: 11.5px;
|
|
103
|
+
font-weight: 500;
|
|
104
|
+
letter-spacing: 0.04em;
|
|
105
|
+
padding: 9px 16px;
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
opacity: 0;
|
|
108
|
+
transition: opacity 0.16s ease-out, transform 0.16s ease-out;
|
|
109
|
+
white-space: nowrap;
|
|
110
|
+
box-shadow: 0 14px 30px -14px rgba(0, 0, 0, 0.55);
|
|
111
|
+
}
|
|
112
|
+
.qcta-toast.open {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
transform: translate(-50%, 0);
|
|
115
|
+
pointer-events: auto;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
}
|
|
118
|
+
.qcta-toast.kind-error {
|
|
119
|
+
border-color: #d36a6a;
|
|
120
|
+
color: #f0bdbd;
|
|
121
|
+
}
|
|
122
|
+
.qcta-toast.kind-ok::before {
|
|
123
|
+
content: "✓ ";
|
|
124
|
+
color: var(--lime, #6FB572);
|
|
125
|
+
font-weight: 700;
|
|
126
|
+
}
|
|
127
|
+
|
|
84
128
|
/* Ask-follow-up overlay · backdrop + modal. Same chrome family
|
|
85
129
|
as openSendChoiceModal (.pc-overlay) so the family stays coherent. */
|
|
86
130
|
.qask-overlay {
|
package/public/quote-cta.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
QUOTE CTA · selection-driven follow-up
|
|
3
3
|
═══════════════════════════════════════════
|
|
4
4
|
When the user selects text inside a director's message bubble,
|
|
5
|
-
a small floating bar appears above the selection with
|
|
5
|
+
a small floating bar appears above the selection with three
|
|
6
6
|
actions:
|
|
7
7
|
|
|
8
8
|
✎ Probe / 追问 → opens an overlay; user types a question;
|
|
@@ -18,11 +18,22 @@
|
|
|
18
18
|
the quote, signalling the user co-signs the
|
|
19
19
|
director's point. Same routing.
|
|
20
20
|
|
|
21
|
+
⌖ Save / 收藏 → one-click; bookmarks the selection to the
|
|
22
|
+
chairman's notes (POST /api/notes). No room
|
|
23
|
+
message is created — this is a personal
|
|
24
|
+
collection, not a room interaction. Works
|
|
25
|
+
even in adjourned rooms (re-reading a
|
|
26
|
+
finished session is a primary use-case).
|
|
27
|
+
Keyboard shortcut: `S` (when a director
|
|
28
|
+
selection is live).
|
|
29
|
+
|
|
21
30
|
Director scope · selection only counts when both ends sit inside
|
|
22
31
|
the same `article.msg` whose class is neither `user` nor `chair`.
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
Probe / Second ride existing /api/rooms/:id/messages. Save POSTs
|
|
34
|
+
to /api/notes with quote + sentence-based context + char offsets
|
|
35
|
+
(computed against the bubble's textContent so the in-room overlay
|
|
36
|
+
can re-wrap the same span on next render).
|
|
26
37
|
*/
|
|
27
38
|
(function () {
|
|
28
39
|
let cta = null; // floating button bar
|
|
@@ -56,15 +67,81 @@
|
|
|
56
67
|
// director bubbles (added in app.js messageHtml). Name comes from
|
|
57
68
|
// the visible .msg-name span in the same message header.
|
|
58
69
|
const directorId = article.dataset.authorId || "";
|
|
70
|
+
const messageId = article.dataset.messageId || "";
|
|
59
71
|
const nameEl = article.querySelector(".msg-name");
|
|
60
72
|
const directorName = nameEl ? nameEl.textContent.trim() : "";
|
|
61
|
-
// Adjourned rooms ·
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// detected" and explains why probe / second aren't available.
|
|
73
|
+
// Adjourned rooms · the room is closed for new replies (Probe /
|
|
74
|
+
// Second are disabled), but the user can still save notes from
|
|
75
|
+
// it — re-reading a finished session is a primary use case.
|
|
65
76
|
const app = window.app;
|
|
66
77
|
const adjourned = !!(app && app.currentRoom && app.currentRoom.status === "adjourned");
|
|
67
|
-
|
|
78
|
+
|
|
79
|
+
// Char offsets relative to bubble.textContent · let the in-room
|
|
80
|
+
// overlay (Step 5) wrap the same span on next render. Computed
|
|
81
|
+
// once here so save can fire on either the button click or the
|
|
82
|
+
// `S` keyboard shortcut without re-walking the DOM.
|
|
83
|
+
const offsets = computeOffsets(bubble, range);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
article, bubble, range, text, messageId,
|
|
87
|
+
directorId, directorName, adjourned,
|
|
88
|
+
charOffsetStart: offsets.start,
|
|
89
|
+
charOffsetEnd: offsets.end,
|
|
90
|
+
bubbleText: offsets.bubbleText,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compute the char offset of a Range's start / end relative to a
|
|
95
|
+
// container's textContent. Uses Range.toString().length on a
|
|
96
|
+
// synthetic range that spans [container start → selection point],
|
|
97
|
+
// which honours rendered text the same way textContent does (skips
|
|
98
|
+
// markup, preserves visible characters). Returns -1 / -1 if the
|
|
99
|
+
// walk fails (renderer falls back to no overlay).
|
|
100
|
+
function computeOffsets(container, range) {
|
|
101
|
+
const bubbleText = container.textContent || "";
|
|
102
|
+
try {
|
|
103
|
+
const before = document.createRange();
|
|
104
|
+
before.setStart(container, 0);
|
|
105
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
106
|
+
const start = before.toString().length;
|
|
107
|
+
const inner = document.createRange();
|
|
108
|
+
inner.setStart(range.startContainer, range.startOffset);
|
|
109
|
+
inner.setEnd(range.endContainer, range.endOffset);
|
|
110
|
+
const end = start + inner.toString().length;
|
|
111
|
+
return { start, end, bubbleText };
|
|
112
|
+
} catch {
|
|
113
|
+
return { start: -1, end: -1, bubbleText };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sentence-based context expansion · grabs ~1–2 sentences on each
|
|
118
|
+
// side of the quote (capped at MAX_CHARS). Honours both ASCII
|
|
119
|
+
// (.!?) and CJK (。!?) sentence terminators. Falls back to the
|
|
120
|
+
// char cap if no boundary is found within the cap window.
|
|
121
|
+
function expandContext(fullText, quoteStart, quoteEnd) {
|
|
122
|
+
if (!fullText || quoteStart < 0 || quoteEnd < quoteStart) {
|
|
123
|
+
return { before: "", after: "" };
|
|
124
|
+
}
|
|
125
|
+
const MAX_CHARS = 200;
|
|
126
|
+
const SENTENCE_END = /[.!?。!?]/;
|
|
127
|
+
let beforeStart = Math.max(0, quoteStart - MAX_CHARS);
|
|
128
|
+
for (let i = quoteStart - 1; i >= beforeStart; i--) {
|
|
129
|
+
if (SENTENCE_END.test(fullText[i])) {
|
|
130
|
+
beforeStart = Math.min(i + 1, quoteStart);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let afterEnd = Math.min(fullText.length, quoteEnd + MAX_CHARS);
|
|
135
|
+
for (let i = quoteEnd; i < afterEnd; i++) {
|
|
136
|
+
if (SENTENCE_END.test(fullText[i])) {
|
|
137
|
+
afterEnd = Math.min(i + 1, fullText.length);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
before: fullText.slice(beforeStart, quoteStart),
|
|
143
|
+
after: fullText.slice(quoteEnd, afterEnd),
|
|
144
|
+
};
|
|
68
145
|
}
|
|
69
146
|
|
|
70
147
|
function lang() {
|
|
@@ -83,8 +160,8 @@
|
|
|
83
160
|
cta.className = "qcta";
|
|
84
161
|
cta.setAttribute("role", "toolbar");
|
|
85
162
|
const t = lang() === "zh"
|
|
86
|
-
? { ask: "追问", love: "附议" }
|
|
87
|
-
: { ask: "Probe", love: "Second" };
|
|
163
|
+
? { ask: "追问", love: "附议", save: "收藏" }
|
|
164
|
+
: { ask: "Probe", love: "Second", save: "Save" };
|
|
88
165
|
// Inline chat-bubble SVG · uses currentColor so it inherits the
|
|
89
166
|
// hover lime / base text colour like the ★ glyph does.
|
|
90
167
|
const askIcon = `
|
|
@@ -92,6 +169,13 @@
|
|
|
92
169
|
<path d="M2 3 H12 V9 H6.5 L4 11 L4 9 H2 Z"/>
|
|
93
170
|
</svg>
|
|
94
171
|
`;
|
|
172
|
+
// Bookmark glyph · matches the All Notes sidebar entry's icon
|
|
173
|
+
// semantics (this action lands in that view).
|
|
174
|
+
const saveIcon = `
|
|
175
|
+
<svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
|
|
176
|
+
<path d="M3.5 1.5 H10.5 V12.5 L7 9.5 L3.5 12.5 Z"/>
|
|
177
|
+
</svg>
|
|
178
|
+
`;
|
|
95
179
|
cta.innerHTML = `
|
|
96
180
|
<button type="button" class="qcta-btn" data-qcta="ask">
|
|
97
181
|
<span class="ico">${askIcon}</span><span>${t.ask}</span>
|
|
@@ -99,6 +183,9 @@
|
|
|
99
183
|
<button type="button" class="qcta-btn" data-qcta="second">
|
|
100
184
|
<span class="ico">★</span><span>${t.love}</span>
|
|
101
185
|
</button>
|
|
186
|
+
<button type="button" class="qcta-btn qcta-btn-save" data-qcta="save" title="Save to Notes · S">
|
|
187
|
+
<span class="ico">${saveIcon}</span><span>${t.save}</span>
|
|
188
|
+
</button>
|
|
102
189
|
<span class="qcta-hint" data-qcta-hint></span>
|
|
103
190
|
`;
|
|
104
191
|
// Prevent the bar's mousedown from collapsing the selection BEFORE
|
|
@@ -108,16 +195,17 @@
|
|
|
108
195
|
cta.addEventListener("click", (e) => {
|
|
109
196
|
const btn = e.target.closest("[data-qcta]");
|
|
110
197
|
if (!btn) return;
|
|
111
|
-
// Read-only state · the bar shows a hint about why instead of
|
|
112
|
-
// doing anything. Bail before hideCTA so the user can keep
|
|
113
|
-
// reading the hint while their selection stays.
|
|
114
|
-
if (cta.classList.contains("qcta-readonly")) return;
|
|
115
198
|
const action = btn.getAttribute("data-qcta");
|
|
199
|
+
// Read-only state (adjourned rooms) blocks Probe / Second since
|
|
200
|
+
// they post messages to a closed room. Save is exempt — the
|
|
201
|
+
// user is bookmarking for personal review, not interacting.
|
|
202
|
+
if (cta.classList.contains("qcta-readonly") && action !== "save") return;
|
|
116
203
|
const sel = lastSelection;
|
|
117
204
|
hideCTA();
|
|
118
205
|
if (!sel || !sel.text) return;
|
|
119
206
|
if (action === "ask") openAskOverlay(sel);
|
|
120
207
|
else if (action === "second") submitSecond(sel);
|
|
208
|
+
else if (action === "save") submitSave(sel);
|
|
121
209
|
});
|
|
122
210
|
document.body.appendChild(cta);
|
|
123
211
|
return cta;
|
|
@@ -129,10 +217,14 @@
|
|
|
129
217
|
text: ctx.text,
|
|
130
218
|
directorId: ctx.directorId,
|
|
131
219
|
directorName: ctx.directorName,
|
|
220
|
+
messageId: ctx.messageId,
|
|
221
|
+
charOffsetStart: ctx.charOffsetStart,
|
|
222
|
+
charOffsetEnd: ctx.charOffsetEnd,
|
|
223
|
+
bubbleText: ctx.bubbleText,
|
|
132
224
|
};
|
|
133
|
-
// Read-only state · adjourned room. Hide
|
|
134
|
-
//
|
|
135
|
-
//
|
|
225
|
+
// Read-only state · adjourned room. Hide Probe / Second (they
|
|
226
|
+
// post to a closed room); Save stays available — review-mode
|
|
227
|
+
// bookmarking is a primary use case for adjourned sessions.
|
|
136
228
|
bar.classList.toggle("qcta-readonly", !!ctx.adjourned);
|
|
137
229
|
const hint = bar.querySelector("[data-qcta-hint]");
|
|
138
230
|
if (hint) {
|
|
@@ -190,8 +282,35 @@
|
|
|
190
282
|
window.addEventListener("scroll", hideCTA, true);
|
|
191
283
|
document.addEventListener("keydown", (e) => {
|
|
192
284
|
if (e.key === "Escape") hideCTA();
|
|
285
|
+
|
|
286
|
+
// `S` shortcut · save current selection to Notes. Only fires
|
|
287
|
+
// when (a) a director-scoped selection is live, (b) no modifier
|
|
288
|
+
// keys are pressed (Cmd/Ctrl/Alt would clobber browser
|
|
289
|
+
// shortcuts), (c) the user isn't typing into an input. Skipping
|
|
290
|
+
// when an input/textarea is focused avoids hijacking the `s`
|
|
291
|
+
// key during composer typing — the qcta bar wouldn't have
|
|
292
|
+
// shown for a non-director selection anyway.
|
|
293
|
+
if ((e.key === "s" || e.key === "S")
|
|
294
|
+
&& !e.metaKey && !e.ctrlKey && !e.altKey
|
|
295
|
+
&& !isEditableTarget(e.target)
|
|
296
|
+
&& lastSelection
|
|
297
|
+
&& lastSelection.text
|
|
298
|
+
&& cta && cta.classList.contains("open")) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
const sel = lastSelection;
|
|
301
|
+
hideCTA();
|
|
302
|
+
submitSave(sel);
|
|
303
|
+
}
|
|
193
304
|
});
|
|
194
305
|
|
|
306
|
+
function isEditableTarget(node) {
|
|
307
|
+
if (!node) return false;
|
|
308
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
309
|
+
if (tag === "input" || tag === "textarea") return true;
|
|
310
|
+
if (node.isContentEditable) return true;
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
195
314
|
// ── Ask-follow-up overlay ────────────────────────────────────
|
|
196
315
|
function openAskOverlay(sel) {
|
|
197
316
|
closeAskOverlay();
|
|
@@ -315,6 +434,85 @@
|
|
|
315
434
|
routeSend(body, mentions);
|
|
316
435
|
}
|
|
317
436
|
|
|
437
|
+
// ── Save to Notes ─────────────────────────────────────────────
|
|
438
|
+
// POST /api/notes with quote + sentence-based context + char
|
|
439
|
+
// offsets. No room interaction — this is a personal bookmark.
|
|
440
|
+
async function submitSave(sel) {
|
|
441
|
+
const app = window.app;
|
|
442
|
+
const room = app && app.currentRoom;
|
|
443
|
+
if (!room || !room.id) {
|
|
444
|
+
toast(lang() === "zh" ? "无法保存:未打开房间" : "Can't save: no room open", "error");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!sel.messageId) {
|
|
448
|
+
toast(lang() === "zh" ? "无法保存:未识别原文位置" : "Can't save: source not identified", "error");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const ctx = expandContext(
|
|
452
|
+
sel.bubbleText || "",
|
|
453
|
+
typeof sel.charOffsetStart === "number" ? sel.charOffsetStart : -1,
|
|
454
|
+
typeof sel.charOffsetEnd === "number" ? sel.charOffsetEnd : -1,
|
|
455
|
+
);
|
|
456
|
+
const payload = {
|
|
457
|
+
roomId: room.id,
|
|
458
|
+
messageId: sel.messageId,
|
|
459
|
+
quoteText: sel.text,
|
|
460
|
+
contextBefore: ctx.before,
|
|
461
|
+
contextAfter: ctx.after,
|
|
462
|
+
charOffsetStart: sel.charOffsetStart,
|
|
463
|
+
charOffsetEnd: sel.charOffsetEnd,
|
|
464
|
+
authorName: sel.directorName,
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
const res = await fetch("/api/notes", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "Content-Type": "application/json" },
|
|
470
|
+
body: JSON.stringify(payload),
|
|
471
|
+
});
|
|
472
|
+
if (!res.ok) {
|
|
473
|
+
const j = await res.json().catch(() => ({}));
|
|
474
|
+
throw new Error(j.error || ("HTTP " + res.status));
|
|
475
|
+
}
|
|
476
|
+
const note = await res.json();
|
|
477
|
+
toast(lang() === "zh" ? "已收藏到笔记" : "Saved to Notes", "ok");
|
|
478
|
+
|
|
479
|
+
// Tell the rest of the app a note was created · sidebar badge
|
|
480
|
+
// refreshes its count, in-room overlay (Step 5) wraps the
|
|
481
|
+
// saved span. Listeners that don't exist yet are no-ops.
|
|
482
|
+
try {
|
|
483
|
+
document.dispatchEvent(new CustomEvent("note:created", { detail: { note } }));
|
|
484
|
+
} catch { /* */ }
|
|
485
|
+
} catch (err) {
|
|
486
|
+
toast(
|
|
487
|
+
(lang() === "zh" ? "保存失败:" : "Save failed: ") + (err && err.message ? err.message : err),
|
|
488
|
+
"error",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Lightweight toast · the app already has `app.notify(...)` in
|
|
494
|
+
// some paths but not all; using a self-contained one keeps this
|
|
495
|
+
// module independent. Lime for ok, red-tinted for error. Auto-
|
|
496
|
+
// dismisses after 1.8s; click to dismiss early.
|
|
497
|
+
let toastEl = null;
|
|
498
|
+
let toastTimer = null;
|
|
499
|
+
function toast(msg, kind) {
|
|
500
|
+
if (!toastEl) {
|
|
501
|
+
toastEl = document.createElement("div");
|
|
502
|
+
toastEl.className = "qcta-toast";
|
|
503
|
+
toastEl.addEventListener("click", () => toastEl.classList.remove("open"));
|
|
504
|
+
document.body.appendChild(toastEl);
|
|
505
|
+
}
|
|
506
|
+
toastEl.classList.remove("kind-ok", "kind-error");
|
|
507
|
+
toastEl.classList.add("kind-" + (kind === "error" ? "error" : "ok"));
|
|
508
|
+
toastEl.textContent = msg;
|
|
509
|
+
toastEl.classList.add("open");
|
|
510
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
511
|
+
toastTimer = setTimeout(() => {
|
|
512
|
+
if (toastEl) toastEl.classList.remove("open");
|
|
513
|
+
}, 1800);
|
|
514
|
+
}
|
|
515
|
+
|
|
318
516
|
/** Routing matrix:
|
|
319
517
|
* paused → auto-resume the room first, then send
|
|
320
518
|
* live + agent mid-turn → open the interrupt-or-queue modal
|