1// routes/app.js
2import express from "express";
3import User from "../../../db/models/user.js";
4import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
5import authenticateLite from "../../../middleware/authenticateLite.js";
6import { notFoundPage } from "../../../middleware/notFoundPage.js";
7import {
8 dashboardCSS,
9 dashboardHTML,
10 dashboardJS,
11} from "./sessionManagerPartial.js";
12import { getLandUrl } from "../../../canopy/identity.js";
13
14const router = express.Router();
15
16/**
17 * GET /dashboard
18 * Authenticated iframe shell with integrated chat
19 */
20router.get("/dashboard", authenticateLite, async (req, res) => {
21 try {
22 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
23 return res.status(404).json({
24 error: "Server-rendered HTML is disabled. Use the SPA frontend.",
25 });
26 }
27 if (!req.userId) {
28 return res.redirect("/login");
29 }
30
31 const user = await User.findById(req.userId).select(
32 "username roots metadata llmDefault",
33 );
34
35 if (!user) {
36 return notFoundPage(req, res, "This user doesn't exist.");
37 }
38
39 // Redirect to setup if user needs LLM or first tree (unless they skipped recently)
40 const setupSkipped = req.cookies?.setupSkipped === "1";
41 if (!setupSkipped) {
42 const hasMainLlm = !!user.llmDefault;
43 const hasTree = user.roots && user.roots.length > 0;
44 if (!hasMainLlm || !hasTree) {
45 const connCount = hasMainLlm
46 ? 1
47 : await CustomLlmConnection.countDocuments({ userId: req.userId });
48 if (connCount === 0 || !hasTree) {
49 return res.redirect("/setup");
50 }
51 }
52 }
53
54 const { getUserMeta } = await import("../../../core/tree/userMetadata.js");
55 const htmlShareToken = getUserMeta(user, "html")?.shareToken || "";
56 const { username } = user;
57 const hasLlm =
58 !!user.llmDefault ||
59 (await CustomLlmConnection.countDocuments({ userId: req.userId })) > 0;
60
61 return res.send(`<!DOCTYPE html>
62<html lang="en">
63<head>
64 <meta charset="UTF-8" />
65 <title>Dashboard - TreeOS</title>
66 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
67 <meta name="theme-color" content="#667eea" />
68 <link rel="icon" href="/tree.png" />
69 <link rel="canonical" href="${getLandUrl()}/app" />
70 <meta name="robots" content="noindex, nofollow" />
71 <meta name="description" content="TreeOS dashboard with tree visualization, chat, and knowledge management tools." />
72 <meta property="og:title" content="Dashboard - TreeOS" />
73 <meta property="og:description" content="TreeOS dashboard with tree visualization, chat, and knowledge management tools." />
74 <meta property="og:url" content="${getLandUrl()}/app" />
75 <meta property="og:type" content="website" />
76 <meta property="og:site_name" content="TreeOS" />
77 <meta property="og:image" content="${getLandUrl()}/tree.png" />
78 <link rel="preconnect" href="https://fonts.googleapis.com">
79 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
80 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
81 <style>
82 :root {
83 --glass-rgb: 115, 111, 230;
84 --glass-alpha: 0.28;
85 --glass-blur: 22px;
86 --glass-border: rgba(255, 255, 255, 0.28);
87 --glass-border-light: rgba(255, 255, 255, 0.15);
88 --glass-highlight: rgba(255, 255, 255, 0.25);
89 --text-primary: #ffffff;
90 --text-secondary: rgba(255, 255, 255, 0.9);
91 --text-muted: rgba(255, 255, 255, 0.6);
92 --accent: #10b981;
93 --accent-glow: rgba(16, 185, 129, 0.6);
94 --error: #ef4444;
95 --header-height: 56px;
96 --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
97 --mobile-input-height: 70px;
98 --min-panel-width: 280px;
99 }
100
101
102 * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
103 html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: #736fe6; }
104
105 .app-bg { position: fixed; inset: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: -2; }
106 .app-bg::before, .app-bg::after { content: ''; position: fixed; border-radius: 50%; opacity: 0.08; animation: float 20s infinite ease-in-out; pointer-events: none; }
107 .app-bg::before { width: 600px; height: 600px; top: -300px; right: -200px; animation-delay: -5s; }
108 .app-bg::after { width: 400px; height: 400px; bottom: -200px; left: -100px; animation-delay: -10s; }
109 @keyframes float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-30px) rotate(5deg); } }
110
111 .app-container { display: flex; height: 100%; width: 100%; padding: 0px; gap: 0px; }
112 .glass-panel {
113 background: rgba(var(--glass-rgb), var(--glass-alpha));
114 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
115 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
116 border-radius: 0;
117 border: none;
118 box-shadow: none;
119 }
120 .glass-panel::before { content: ""; position: absolute; inset: -40%; background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.2), transparent 60%); pointer-events: none; z-index: 0; }
121
122 .chat-panel { width: 400px; min-width: 0; height: 100%; display: flex; flex-direction: column; z-index: 10; flex-shrink: 0; position: relative; }
123 .chat-header { height: var(--header-height); padding: 0 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0; position: relative; z-index: 1; }
124 .chat-header a { text-decoration: none; color: inherit; }
125 .chat-title { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
126 .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
127 @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
128 .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
129
130 .chat-header-controls { display: flex; align-items: center; gap: 0; margin-left: auto; }
131 .chat-header-buttons { display: flex; align-items: center; gap: 6px; margin-right: 12px; }
132 .chat-header-right { display: flex; align-items: center; gap: 0; }
133
134 .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
135 .status-badge .status-text { display: inline; }
136 .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
137 .status-dot.connected { background: var(--accent); }
138 .status-dot.disconnected { background: var(--error); animation: none; }
139 .status-dot.connecting { background: #f59e0b; }
140 @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
141
142 /* Compact mode for narrow panels */
143 .chat-panel:not(.collapsed) { container-type: inline-size; }
144 @container (max-width: 420px) {
145 #desktopOpenTabBtn { display: none; }
146 }
147 @container (max-width: 360px) {
148 .status-badge .status-text { display: none; }
149 .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
150 }
151 @container (max-width: 320px) {
152 .chat-title h1 { display: none; }
153 }
154
155 /* Wide panel mode - constrain content when panel is very wide */
156 @container (min-width: 750px) {
157 .chat-messages {
158 width: 100%;
159 max-width: 720px;
160 margin-left: auto;
161 margin-right: auto;
162 padding-left: 24px;
163 padding-right: 24px;
164 }
165 .chat-input-area {
166 width: 100%;
167 max-width: 760px;
168 margin-left: auto;
169 margin-right: auto;
170 }
171 .mode-bar {
172 width: 100%;
173 max-width: 760px;
174 margin-left: auto;
175 margin-right: auto;
176 }
177 }
178 @container (min-width: 950px) {
179 .chat-messages {
180 max-width: 840px;
181 }
182 .chat-input-area {
183 max-width: 880px;
184 }
185 .mode-bar {
186 max-width: 880px;
187 }
188 }
189 @container (min-width: 1150px) {
190 .chat-messages {
191 max-width: 920px;
192 padding-left: 32px;
193 padding-right: 32px;
194 }
195 .chat-input-area {
196 max-width: 960px;
197 }
198 .mode-bar {
199 max-width: 960px;
200 }
201 }
202/* Orchestrator step messages */
203.message.orchestrator-step .message-content {
204 background: rgba(255, 255, 255, 0.06);
205 border: 1px dashed rgba(255, 255, 255, 0.15);
206 border-radius: 12px;
207 font-size: 12px;
208 color: var(--text-muted);
209 padding: 10px 14px;
210 font-family: 'JetBrains Mono', monospace;
211 max-width: 95%;
212}
213.message.orchestrator-step .message-avatar {
214 width: 28px;
215 height: 28px;
216 font-size: 12px;
217 border-radius: 8px;
218 background: rgba(255, 255, 255, 0.06);
219 border-color: rgba(255, 255, 255, 0.1);
220}
221.message.orchestrator-step .step-mode {
222 color: var(--accent);
223 font-weight: 600;
224 font-size: 11px;
225 text-transform: uppercase;
226 letter-spacing: 0.5px;
227 margin-bottom: 4px;
228 display: block;
229}
230.message.orchestrator-step .step-body {
231 white-space: pre-wrap;
232 word-break: break-word;
233 max-height: 200px;
234 overflow-y: auto;
235 display: block;
236}
237.message.orchestrator-step .step-body::-webkit-scrollbar { width: 4px; }
238.message.orchestrator-step .step-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
239 /* Clear chat button in header */
240 .clear-chat-btn {
241 width: 30px;
242 height: 30px;
243 display: flex;
244 align-items: center;
245 justify-content: center;
246 background: rgba(255, 255, 255, 0.1);
247 border: 1px solid var(--glass-border-light);
248 border-radius: 8px;
249 color: var(--text-muted);
250 cursor: pointer;
251 transition: all var(--transition-fast);
252 flex-shrink: 0;
253 }
254 #clearChatBtn {
255 margin-left: 8px;
256 }
257 .clear-chat-btn:hover {
258 background: rgba(255, 255, 255, 0.2);
259 color: var(--text-primary);
260 }
261 .clear-chat-btn:active {
262 transform: scale(0.93);
263 }
264 .clear-chat-btn svg { width: 14px; height: 14px; }
265 .clear-chat-btn.llm-glow {
266 animation: llmGlow 0.6s ease-in-out 3;
267 border-color: var(--accent);
268 will-change: opacity;
269 }
270 @keyframes llmGlow {
271 0%, 100% { opacity: 1; }
272 50% { opacity: 0.4; background: rgba(16, 185, 129, 0.25); }
273 }
274
275 /* Active root name - inline after Tree in header */
276 .root-name-inline {
277 font-size: 13px;
278 font-weight: 400;
279 color: var(--text-muted);
280 white-space: nowrap;
281 overflow: hidden;
282 text-overflow: ellipsis;
283 flex: 1;
284 min-width: 0;
285 opacity: 0;
286 cursor: default;
287 transition: opacity 0.3s ease;
288 }
289 .root-name-inline.visible {
290 opacity: 1;
291 }
292 .root-name-inline::before {
293 content: ' / ';
294 color: var(--glass-border-light);
295 }
296 .root-name-inline.fade-in {
297 animation: rootNameFade 0.5s ease;
298 }
299 @keyframes rootNameFade {
300 0% { opacity: 0; transform: translateY(-4px); }
301 100% { opacity: 1; transform: translateY(0); }
302 }
303
304 /* Mobile root path - no prefix slash, styled as path */
305 .mobile-root-path {
306 font-size: 15px;
307 font-weight: 500;
308 }
309 .mobile-root-path::before {
310 content: '/';
311 color: var(--text-muted);
312 }
313
314 /* ================================================================
315 RECENT ROOTS DROPDOWN (Top-left overlay, doesn't push content)
316 ================================================================ */
317 .recent-roots-dropdown {
318 position: absolute;
319 top: calc(var(--header-height) + 6px);
320 left: 16px;
321 z-index: 50;
322 }
323 .recent-roots-dropdown.hidden {
324 display: none;
325 }
326
327 .recent-roots-trigger {
328 width: 28px;
329 height: 28px;
330 display: flex;
331 align-items: center;
332 justify-content: center;
333 background: rgba(255, 255, 255, 0.12);
334 border: 1px solid var(--glass-border-light);
335 border-radius: 8px;
336 cursor: pointer;
337 transition: all var(--transition-fast);
338 color: var(--text-muted);
339 }
340 .recent-roots-trigger:hover {
341 background: rgba(255, 255, 255, 0.2);
342 color: var(--text-primary);
343 }
344 .recent-roots-trigger:active {
345 transform: scale(0.94);
346 }
347 .recent-roots-trigger svg {
348 width: 14px;
349 height: 14px;
350 transition: transform 0.2s ease;
351 }
352 .recent-roots-dropdown.open .recent-roots-trigger svg {
353 transform: rotate(180deg);
354 }
355 .recent-roots-dropdown.open .recent-roots-trigger {
356 background: rgba(255, 255, 255, 0.2);
357 color: var(--text-primary);
358 }
359
360 .recent-roots-menu {
361 display: none;
362 position: absolute;
363 top: calc(100% + 6px);
364 left: 0;
365 min-width: 160px;
366 max-width: 200px;
367 background: rgba(var(--glass-rgb), 0.92);
368 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
369 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
370 border: 1px solid var(--glass-border);
371 border-radius: 12px;
372 padding: 6px;
373 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
374 animation: recentMenuIn 0.15s ease-out;
375 }
376 @keyframes recentMenuIn {
377 from { opacity: 0; transform: translateY(-4px); }
378 to { opacity: 1; transform: translateY(0); }
379 }
380 .recent-roots-dropdown.open .recent-roots-menu {
381 display: block;
382 }
383
384 .recent-roots-menu-header {
385 padding: 6px 10px 8px;
386 font-size: 10px;
387 font-weight: 600;
388 text-transform: uppercase;
389 letter-spacing: 0.5px;
390 color: var(--text-muted);
391 border-bottom: 1px solid var(--glass-border-light);
392 margin-bottom: 4px;
393 }
394
395 .recent-root-item {
396 display: flex;
397 align-items: center;
398 gap: 8px;
399 padding: 8px 10px;
400 border-radius: 8px;
401 cursor: pointer;
402 transition: all var(--transition-fast);
403 font-size: 12px;
404 font-weight: 500;
405 color: var(--text-secondary);
406 border: none;
407 background: none;
408 width: 100%;
409 text-align: left;
410 }
411 .recent-root-item:hover {
412 background: rgba(255, 255, 255, 0.12);
413 color: var(--text-primary);
414 }
415 .recent-root-item:active {
416 background: rgba(255, 255, 255, 0.18);
417 transform: scale(0.98);
418 }
419 .recent-root-item.active {
420 background: rgba(16, 185, 129, 0.15);
421 color: var(--text-primary);
422 border-left: 2px solid var(--accent);
423 padding-left: 8px;
424 }
425 .recent-root-name {
426 flex: 1;
427 min-width: 0;
428 overflow: hidden;
429 text-overflow: ellipsis;
430 white-space: nowrap;
431 }
432
433 .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; position: relative; z-index: 1; }
434 .chat-messages::-webkit-scrollbar { width: 6px; }
435 .chat-messages::-webkit-scrollbar-track { background: transparent; }
436 .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; }
437
438 .welcome-message { text-align: center; padding: 40px 20px; }
439 .welcome-message.disconnected { opacity: 0.7; }
440 .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
441 .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
442 @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
443 .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
444 .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 8px; }
445
446 .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
447 @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
448 .message.user { flex-direction: row-reverse; }
449 .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
450 .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
451 .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
452 .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
453 .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
454 .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
455
456 /* Carried messages from previous mode - dimmed */
457 .message.carried { opacity: 0.4; pointer-events: none; }
458 .message.carried .message-content { border-style: dashed; }
459
460 /* Mode bar locked while AI is responding */
461 .mode-bar.locked .mode-current {
462 opacity: 0.4;
463 pointer-events: none;
464 cursor: not-allowed;
465 }
466 .mobile-mode-btn.locked {
467 opacity: 0.4;
468 pointer-events: none;
469 }
470
471 /* Send button in stop mode */
472 .send-btn.stop-mode {
473 background: rgba(239, 68, 68, 0.7);
474 box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
475 }
476 .send-btn.stop-mode:hover:not(:disabled) {
477 background: rgba(239, 68, 68, 0.9);
478 box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
479 }
480
481 /* Message content formatting */
482 .message-content p { margin: 0 0 10px 0; word-break: break-word; }
483 .message-content p:last-child { margin-bottom: 0; }
484 .message-content h1, .message-content h2, .message-content h3, .message-content h4 {
485 margin: 14px 0 8px 0;
486 font-weight: 600;
487 line-height: 1.3;
488 }
489 .message-content h1:first-child, .message-content h2:first-child,
490 .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
491 .message-content h1 { font-size: 17px; }
492 .message-content h2 { font-size: 16px; }
493 .message-content h3 { font-size: 15px; }
494 .message-content h4 { font-size: 14px; color: var(--text-secondary); }
495 .message-content ul, .message-content ol {
496 margin: 8px 0;
497 padding-left: 0;
498 list-style: none;
499 }
500 .message-content li {
501 margin: 4px 0;
502 padding: 6px 10px;
503 background: rgba(255, 255, 255, 0.06);
504 border-radius: 8px;
505 line-height: 1.4;
506 word-break: break-word;
507 }
508 .message-content li .list-num {
509 color: var(--accent);
510 font-weight: 600;
511 margin-right: 6px;
512 }
513 .message-content strong, .message-content b {
514 font-weight: 600;
515 color: #fff;
516 }
517 .message-content em, .message-content i {
518 font-style: italic;
519 color: var(--text-secondary);
520 }
521 .message-content code {
522 background: rgba(0, 0, 0, 0.3);
523 padding: 2px 6px;
524 border-radius: 4px;
525 font-family: 'JetBrains Mono', monospace;
526 font-size: 11px;
527 word-break: break-all;
528 }
529 .message-content pre {
530 background: rgba(0, 0, 0, 0.3);
531 padding: 12px;
532 border-radius: 8px;
533 overflow-x: auto;
534 margin: 10px 0;
535 max-width: 100%;
536 }
537 .message-content pre code {
538 background: none;
539 padding: 0;
540 word-break: normal;
541 white-space: pre-wrap;
542 }
543 .message-content blockquote {
544 border-left: 3px solid var(--accent);
545 padding-left: 12px;
546 margin: 10px 0;
547 color: var(--text-secondary);
548 font-style: italic;
549 }
550 .message-content hr {
551 border: none;
552 border-top: 1px solid var(--glass-border-light);
553 margin: 14px 0;
554 }
555 .message-content a {
556 color: var(--accent);
557 text-decoration: underline;
558 text-underline-offset: 2px;
559 }
560 .message-content a:hover {
561 text-decoration: none;
562 }
563
564 /* Menu items - numbered/bulleted options */
565 .message-content .menu-item {
566 display: flex;
567 align-items: flex-start;
568 gap: 10px;
569 padding: 10px 12px;
570 margin: 6px 0;
571 background: rgba(255, 255, 255, 0.08);
572 border-radius: 10px;
573 border: 1px solid rgba(255, 255, 255, 0.06);
574 transition: all 0.15s ease;
575 }
576 .message-content .menu-item.clickable {
577 cursor: pointer;
578 user-select: none;
579 }
580 .message-content .menu-item.clickable:hover {
581 background: rgba(255, 255, 255, 0.15);
582 border-color: rgba(16, 185, 129, 0.3);
583 transform: translateX(4px);
584 }
585 .message-content .menu-item.clickable:active {
586 transform: translateX(4px) scale(0.98);
587 background: rgba(16, 185, 129, 0.2);
588 }
589 .message-content .menu-item:first-of-type {
590 margin-top: 8px;
591 }
592 .message-content .menu-number {
593 display: flex;
594 align-items: center;
595 justify-content: center;
596 min-width: 26px;
597 max-width: 26px;
598 height: 26px;
599 background: linear-gradient(135deg, var(--accent) 0%, #059669 100%);
600 border-radius: 8px;
601 font-size: 12px;
602 font-weight: 600;
603 flex-shrink: 0;
604 box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
605 transition: all 0.15s ease;
606 }
607 .message-content .menu-item.clickable:hover .menu-number {
608 transform: scale(1.1);
609 box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5);
610 }
611 .message-content .menu-text {
612 flex: 1;
613 min-width: 0;
614 padding-top: 2px;
615 word-break: break-word;
616 overflow-wrap: break-word;
617 }
618 .message-content .menu-text strong {
619 display: block;
620 margin-bottom: 2px;
621 word-break: break-word;
622 }
623 .message-content .menu-item.clicking {
624 animation: menuClick 0.3s ease;
625 }
626 @keyframes menuClick {
627 0% { background: rgba(16, 185, 129, 0.3); }
628 100% { background: rgba(255, 255, 255, 0.08); }
629 }
630
631 .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
632 .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
633 .typing-dot:nth-child(2) { animation-delay: 0.2s; }
634 .typing-dot:nth-child(3) { animation-delay: 0.4s; }
635 @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
636
637 .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); position: relative; z-index: 1; }
638 .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
639 .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
640 .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; }
641 .chat-input::placeholder { color: var(--text-muted); }
642 .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
643 .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
644 .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
645 .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
646 .send-btn svg { width: 20px; height: 20px; }
647
648 .viewport-panel { flex: 1; height: 100%; display: flex; flex-direction: column; min-width: 0; position: relative; }
649
650 .iframe-container {
651 flex: 1;
652 position: relative;
653 overflow: hidden;
654 border-radius: 0;
655 margin: 0;
656 }
657
658 iframe {
659 width: 100%;
660 height: 100%;
661 border: none;
662 display: block;
663 background: transparent;
664 border-radius: 0;
665 }
666
667 .loading-overlay { position: absolute; inset: 0; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); z-index: 5; border-radius: 0; }
668 .loading-overlay.visible { opacity: 1; pointer-events: auto; }
669 .spinner-ring { width: 44px; height: 44px; border: 3px solid rgba(255, 255, 255, 0.2); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
670 @keyframes spin { to { transform: rotate(360deg); } }
671 .loading-text { font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-top: 16px; }
672
673 .navigator-indicator {
674 display: none;
675 align-items: center;
676 justify-content: flex-end;
677 gap: 4px;
678 padding: 2px 8px 4px;
679 }
680 .navigator-indicator.active { display: flex; }
681 .navigator-indicator.desktop-only { }
682 .navigator-indicator.mobile-only {
683 position: fixed;
684 top: 8px;
685 right: 8px;
686 z-index: 200;
687 padding: 0;
688 }
689 @media (max-width: 768px) {
690 .navigator-indicator.desktop-only { display: none !important; }
691 }
692 @media (min-width: 769px) {
693 .navigator-indicator.mobile-only { display: none !important; }
694 }
695 /* ── Dashboard toggle (chat panel) ─────────────────────────────── */
696 .chat-dashboard-btn {
697 display: flex;
698 align-items: center;
699 justify-content: flex-end;
700 gap: 4px;
701 padding: 2px 8px 4px;
702 }
703 .chat-dashboard-badge {
704 display: flex;
705 align-items: center;
706 gap: 5px;
707 padding: 3px 8px;
708 background: rgba(59, 130, 246, 0.12);
709 border: 1px solid rgba(59, 130, 246, 0.25);
710 border-radius: 6px;
711 cursor: pointer;
712 transition: all 0.15s;
713 font-size: 10px;
714 color: rgba(147, 197, 253, 0.9);
715 white-space: nowrap;
716 }
717 .chat-dashboard-badge:hover { background: rgba(59, 130, 246, 0.22); color: #93c5fd; }
718 .chat-dashboard-badge.active { background: rgba(16,185,129,0.15); border-color: rgba(16,185,129,0.35); color: var(--accent); }
719 .chat-dashboard-badge svg { width: 12px; height: 12px; flex-shrink: 0; }
720 .chat-dashboard-badge .dash-btn-label {
721 max-width: 0;
722 overflow: hidden;
723 transition: max-width 0.25s ease, opacity 0.2s;
724 opacity: 0;
725 }
726 .chat-dashboard-badge:hover .dash-btn-label { max-width: 80px; opacity: 1; }
727 @media (max-width: 768px) {
728 .chat-dashboard-btn { display: none; }
729 }
730 .navigator-badge {
731 display: flex;
732 flex-direction: row-reverse;
733 align-items: center;
734 gap: 4px;
735 padding: 3px 6px;
736 background: rgba(var(--glass-rgb), 0.4);
737 border: 1px solid var(--glass-border);
738 border-radius: 6px;
739 cursor: default;
740 transition: background 0.2s;
741 }
742 .navigator-badge:hover { background: rgba(var(--glass-rgb), 0.7); }
743 .navigator-badge .nav-icon {
744 width: 14px;
745 height: 14px;
746 color: var(--accent);
747 flex-shrink: 0;
748 }
749 .navigator-badge .nav-label {
750 font-size: 10px;
751 color: var(--text-secondary);
752 white-space: nowrap;
753 max-width: 0;
754 overflow: hidden;
755 transition: max-width 0.3s ease, opacity 0.2s;
756 opacity: 0;
757 }
758 .navigator-badge .nav-close-icon {
759 width: 12px;
760 height: 12px;
761 max-width: 0;
762 overflow: hidden;
763 opacity: 0;
764 flex-shrink: 0;
765 transition: max-width 0.3s ease, opacity 0.2s;
766 }
767 /* Reveal: just expand to show label (no red, no X) */
768 .navigator-badge.reveal .nav-label { max-width: 160px; opacity: 1; }
769 /* Hover: expand all, turn red, show X */
770 .navigator-badge:hover .nav-label { max-width: 160px; opacity: 1; }
771 .navigator-badge:hover .nav-close-icon { max-width: 16px; opacity: 1; }
772 .navigator-badge:hover {
773 background: rgba(239, 68, 68, 0.15);
774 border-color: rgba(239, 68, 68, 0.3);
775 cursor: pointer;
776 }
777 .navigator-badge:hover .nav-icon { color: #ef4444; }
778 .navigator-badge:hover .nav-label { color: #ef4444; }
779 .navigator-badge:hover .nav-close-icon { color: #ef4444; }
780
781 .panel-divider { width: 16px; height: 100%; display: flex; align-items: center; justify-content: center; cursor: col-resize; position: relative; z-index: 20; flex-shrink: 0; }
782 .divider-handle { width: 6px; height: 80px; background: rgba(var(--glass-rgb), 0.5); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 4px; transition: all var(--transition-fast); }
783 .panel-divider:hover .divider-handle { background: rgba(var(--glass-rgb), 0.7); width: 8px; }
784 .chat-header,
785 .chat-input-area {
786 border-bottom: none;
787 border-top: none;
788 }
789 .expand-buttons { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: 8px; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); }
790 .panel-divider:hover .expand-buttons { opacity: 1; pointer-events: auto; }
791 .panel-divider:hover .divider-handle { opacity: 0; }
792 .expand-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast); }
793 .expand-btn:hover { background: rgba(255, 255, 255, 0.25); color: var(--text-primary); transform: scale(1.1); }
794 .expand-btn svg { width: 16px; height: 16px; }
795
796 /* Collapsed chat tab - visible by default on mobile */
797 .mobile-chat-tab {
798 display: none;
799 position: fixed;
800 bottom: calc(100px + env(safe-area-inset-bottom, 0px));
801 right: 0;
802 z-index: 150;
803 width: 32px;
804 height: 56px;
805 background: rgba(255, 255, 255, 0.12);
806 backdrop-filter: blur(12px);
807 -webkit-backdrop-filter: blur(12px);
808 border: 1px solid rgba(255, 255, 255, 0.15);
809 border-right: none;
810 border-radius: 12px 0 0 12px;
811 align-items: center;
812 justify-content: center;
813 cursor: pointer;
814 transition: all 0.2s ease;
815 box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
816 font-size: 18px;
817 }
818 .mobile-chat-tab:active {
819 width: 38px;
820 background: rgba(255, 255, 255, 0.2);
821 }
822
823 /* Mobile connection status indicator - inside sheet header */
824 .mobile-status-indicator {
825 display: none;
826 width: 6px;
827 height: 6px;
828 border-radius: 50%;
829 flex-shrink: 0;
830 }
831 .mobile-status-indicator.connected {
832 background: var(--accent);
833 box-shadow: 0 0 6px var(--accent-glow);
834 animation: pulse 2s ease-in-out infinite;
835 }
836 .mobile-status-indicator.disconnected {
837 background: var(--error);
838 animation: none;
839 }
840 .mobile-status-indicator.connecting {
841 background: #f59e0b;
842 animation: pulse 1s ease-in-out infinite;
843 }
844
845 /* Tree icon with status dot wrapper */
846 .mobile-tree-icon-wrapper {
847 position: relative;
848 display: flex;
849 align-items: center;
850 justify-content: center;
851 text-decoration: none;
852 transition: transform 0.15s ease;
853 }
854 .mobile-tree-icon-wrapper:active {
855 transform: scale(0.92);
856 }
857 .mobile-tree-icon-wrapper .mobile-status-indicator {
858 display: block;
859 position: absolute;
860 top: 0;
861 left: 0;
862 }
863 .mobile-tree-icon-wrapper .tree-icon {
864 font-size: 24px;
865 text-shadow: none;
866 filter: none;
867 }
868
869 /* Mobile header action buttons */
870 .mobile-header-actions {
871 display: flex;
872 align-items: center;
873 gap: 2px;
874 }
875 .mobile-header-actions .mobile-close-btn {
876 margin-left: 10px;
877 }
878 .mobile-header-actions .clear-chat-btn {
879 background: rgba(255, 255, 255, 0.08);
880 border-color: rgba(255, 255, 255, 0.12);
881 }
882 .mobile-header-actions .clear-chat-btn:active {
883 background: rgba(255, 255, 255, 0.15);
884 }
885 .mobile-header-actions .mobile-dash-btn {
886 background: rgba(59, 130, 246, 0.15);
887 border-color: rgba(59, 130, 246, 0.3);
888 color: rgba(147, 197, 253, 0.9);
889 }
890 .mobile-header-actions .mobile-dash-btn:active {
891 background: rgba(59, 130, 246, 0.28);
892 }
893 .mobile-header-actions .mobile-dash-btn.active {
894 background: rgba(16, 185, 129, 0.2);
895 border-color: rgba(16, 185, 129, 0.35);
896 color: var(--accent);
897 }
898
899 .mobile-chat-sheet {
900 display: none;
901 position: fixed;
902 bottom: 0;
903 left: 0;
904 right: 0;
905 height: 85vh;
906 max-height: calc(100vh - 40px);
907 z-index: 200;
908 background: rgba(var(--glass-rgb), 0.22);
909 backdrop-filter: blur(12px);
910 -webkit-backdrop-filter: blur(12px);
911 border-top-left-radius: 24px;
912 border-top-right-radius: 24px;
913 border: 1px solid rgba(255, 255, 255, 0.18);
914 border-bottom: none;
915 box-shadow: 0 -15px 50px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.25);
916 transform: translateY(100%);
917 flex-direction: column;
918 will-change: transform;
919 }
920 .mobile-chat-sheet.open {
921 transform: translateY(0);
922 transition: transform 0.4s cubic-bezier(0.32, 0.72, 0, 1);
923 }
924 .mobile-chat-sheet.peeked {
925 transform: translateY(calc(100% - 90px));
926 transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
927 }
928 .mobile-chat-sheet.peeked .mobile-chat-messages,
929 .mobile-chat-sheet.peeked .mobile-chat-input-area,
930 .mobile-chat-sheet.peeked .mobile-recent-roots,
931 .mobile-chat-sheet.peeked .mobile-mode-bar {
932 display: none;
933 }
934 .mobile-chat-sheet.closing {
935 transform: translateY(100%);
936 transition: transform 0.3s cubic-bezier(0.4, 0, 1, 1);
937 }
938 .mobile-chat-sheet.dragging {
939 transition: none !important;
940 }
941
942 .mobile-sheet-header {
943 padding: 12px 16px;
944 display: flex;
945 flex-direction: column;
946 align-items: center;
947 border-bottom: 1px solid rgba(255, 255, 255, 0.1);
948 flex-shrink: 0;
949 cursor: grab;
950 touch-action: none;
951 background: rgba(255, 255, 255, 0.03);
952 user-select: none;
953 position: relative;
954 }
955 .mobile-sheet-header h1,
956 .mobile-sheet-header .root-name-inline {
957 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
958 }
959 .mobile-sheet-header:active { cursor: grabbing; }
960 .mobile-sheet-header .drag-handle {
961 width: 36px;
962 height: 4px;
963 background: rgba(255, 255, 255, 0.2);
964 border-radius: 2px;
965 margin-bottom: 10px;
966 }
967 .mobile-sheet-title-row { width: 100%; display: flex; align-items: center; justify-content: space-between; }
968 .mobile-sheet-title { display: flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; }
969 .mobile-sheet-title .tree-icon { font-size: 24px; }
970 .mobile-sheet-title h1 { font-size: 17px; font-weight: 600; }
971 .mobile-close-btn {
972 width: 32px;
973 height: 32px;
974 display: flex;
975 align-items: center;
976 justify-content: center;
977 background: rgba(255, 255, 255, 0.08);
978 border: 1px solid rgba(255, 255, 255, 0.12);
979 border-radius: 50%;
980 color: var(--text-primary);
981 cursor: pointer;
982 transition: all 0.15s ease;
983 }
984 .mobile-close-btn:active {
985 background: rgba(255, 255, 255, 0.15);
986 transform: scale(0.95);
987 }
988 .mobile-close-btn svg { width: 16px; height: 16px; }
989
990 .mobile-chat-messages {
991 flex: 1;
992 overflow-y: auto;
993 padding: 16px;
994 display: flex;
995 flex-direction: column;
996 gap: 12px;
997 background: transparent;
998 -webkit-overflow-scrolling: touch;
999 }
1000
1001 /* Glass-printed text style for mobile */
1002 .mobile-chat-messages .message-content {
1003 background: rgba(255, 255, 255, 0.1);
1004 border: 1px solid rgba(255, 255, 255, 0.12);
1005 color: #fff;
1006 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
1007 }
1008 .mobile-chat-messages .message.user .message-content {
1009 background: rgba(255, 255, 255, 0.14);
1010 border-color: rgba(255, 255, 255, 0.18);
1011 }
1012 .mobile-chat-messages .message-avatar {
1013 background: rgba(255, 255, 255, 0.1);
1014 border-color: rgba(255, 255, 255, 0.12);
1015 }
1016 .mobile-chat-messages .welcome-message {
1017 text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
1018 }
1019
1020 .mobile-chat-input-area {
1021 padding: 14px 16px;
1022 padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
1023 border-top: 1px solid rgba(255, 255, 255, 0.1);
1024 flex-shrink: 0;
1025 background: transparent;
1026 }
1027 .mobile-chat-input-area .input-container {
1028 padding: 14px 18px;
1029 border-radius: 26px;
1030 background: rgba(255, 255, 255, 0.12);
1031 backdrop-filter: blur(6px);
1032 border: 1px solid rgba(255, 255, 255, 0.12);
1033 min-height: 52px;
1034 }
1035 .mobile-chat-input-area .input-container:focus-within {
1036 background: rgba(255, 255, 255, 0.16);
1037 border-color: rgba(255, 255, 255, 0.25);
1038 }
1039 .mobile-chat-input-area .chat-input { font-size: 16px; }
1040 .mobile-chat-input-area .send-btn {
1041 width: 42px;
1042 height: 42px;
1043 border-radius: 14px;
1044 box-shadow: none;
1045 }
1046 .mobile-chat-input-area .send-btn:hover:not(:disabled) {
1047 box-shadow: none;
1048 }
1049
1050 .mobile-backdrop {
1051 display: none;
1052 position: fixed;
1053 inset: 0;
1054 background: rgba(0, 0, 0, 0.1);
1055 z-index: 190;
1056 opacity: 0;
1057 transition: opacity 0.3s ease;
1058 pointer-events: none;
1059 }
1060 .mobile-backdrop.visible { opacity: 1; pointer-events: auto; }
1061
1062 /* Mobile recent roots dropdown */
1063 .mobile-recent-roots {
1064 display: none;
1065 width: 100%;
1066 margin-top: 6px;
1067 }
1068 .mobile-recent-roots.visible {
1069 display: block;
1070 }
1071 .mobile-recent-roots-toggle {
1072 display: flex;
1073 align-items: center;
1074 justify-content: center;
1075 gap: 4px;
1076 padding: 4px 10px;
1077 background: rgba(255, 255, 255, 0.08);
1078 border: 1px solid rgba(255, 255, 255, 0.1);
1079 border-radius: 14px;
1080 font-size: 11px;
1081 font-weight: 500;
1082 color: var(--text-muted);
1083 cursor: pointer;
1084 margin: 0 auto;
1085 transition: all var(--transition-fast);
1086 }
1087 .mobile-recent-roots-toggle:active {
1088 background: rgba(255, 255, 255, 0.15);
1089 transform: scale(0.97);
1090 }
1091 .mobile-recent-roots-toggle svg {
1092 width: 10px;
1093 height: 10px;
1094 transition: transform 0.2s ease;
1095 }
1096 .mobile-recent-roots.expanded .mobile-recent-roots-toggle svg {
1097 transform: rotate(180deg);
1098 }
1099 .mobile-recent-roots-list {
1100 display: none;
1101 flex-direction: row;
1102 flex-wrap: wrap;
1103 gap: 4px;
1104 margin-top: 6px;
1105 padding: 0 4px;
1106 justify-content: center;
1107 }
1108 .mobile-recent-roots.expanded .mobile-recent-roots-list {
1109 display: flex;
1110 }
1111 .mobile-recent-roots-list .recent-root-item {
1112 background: rgba(255, 255, 255, 0.06);
1113 font-size: 11px;
1114 padding: 5px 10px;
1115 border-radius: 12px;
1116 width: auto;
1117 flex: 0 0 auto;
1118 }
1119
1120 @media (max-width: 768px) {
1121 .app-container { padding: 0; gap: 0; flex-direction: column; }
1122 .chat-panel { display: none !important; }
1123 .viewport-panel { width: 100% !important; height: 100%; }
1124 .viewport-panel.glass-panel { border-radius: 0; }
1125 .iframe-container { border-radius: 0; margin: 0; flex: 1; }
1126 iframe, .loading-overlay { border-radius: 0; }
1127 .panel-divider { display: none; }
1128 .mobile-chat-sheet, .mobile-backdrop { display: block; }
1129 .mobile-chat-sheet { display: flex; }
1130 .mobile-chat-tab { display: flex; }
1131 .mobile-chat-tab.hidden { display: none; }
1132
1133 .message-content {
1134 max-width: 90%;
1135 padding: 12px 14px;
1136 font-size: 14px;
1137 }
1138 .message-content .menu-item {
1139 padding: 8px 10px;
1140 gap: 8px;
1141 }
1142 .message-content .menu-number {
1143 min-width: 24px;
1144 max-width: 24px;
1145 height: 24px;
1146 font-size: 11px;
1147 }
1148 .message-content .menu-text {
1149 font-size: 13px;
1150 }
1151 .message-content code {
1152 font-size: 10px;
1153 }
1154 .message-content pre {
1155 padding: 10px;
1156 font-size: 11px;
1157 }
1158 }
1159
1160 .app-container.dragging { user-select: none; cursor: col-resize; }
1161 .app-container.dragging iframe { pointer-events: none; }
1162 .chat-panel.collapsed, .viewport-panel.collapsed { width: 0 !important; min-width: 0 !important; opacity: 0; pointer-events: none; padding: 0; border: none; overflow: hidden; }
1163 .chat-panel, .viewport-panel { transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
1164 .app-container.dragging .chat-panel, .app-container.dragging .viewport-panel { transition: none; }
1165
1166 /* ================================================================
1167 Mode bar styles
1168 ================================================================ */
1169 .mode-bar {
1170 display: flex;
1171 align-items: center;
1172 gap: 6px;
1173 padding: 6px 12px;
1174 border-top: 1px solid var(--glass-border-light);
1175 flex-shrink: 0;
1176 position: relative;
1177 z-index: 12;
1178 min-height: 40px;
1179 }
1180
1181 .mode-current {
1182 display: flex;
1183 align-items: center;
1184 gap: 8px;
1185 padding: 5px 12px 5px 8px;
1186 background: rgba(255, 255, 255, 0.15);
1187 backdrop-filter: blur(10px);
1188 border: 1px solid var(--glass-border-light);
1189 border-radius: 10px;
1190 cursor: pointer;
1191 user-select: none;
1192 transition: all var(--transition-fast);
1193 position: relative;
1194 font-size: 13px;
1195 font-weight: 600;
1196 color: var(--text-primary);
1197 }
1198 .mode-current:hover {
1199 background: rgba(255, 255, 255, 0.22);
1200 border-color: rgba(255, 255, 255, 0.3);
1201 }
1202 .mode-current:active {
1203 transform: scale(0.97);
1204 }
1205 .mode-current-emoji {
1206 font-size: 16px;
1207 line-height: 1;
1208 }
1209 .mode-current-label {
1210 white-space: nowrap;
1211 }
1212 .mode-current-chevron {
1213 width: 12px;
1214 height: 12px;
1215 color: var(--text-muted);
1216 transition: transform var(--transition-fast);
1217 flex-shrink: 0;
1218 }
1219 .mode-bar.open .mode-current-chevron {
1220 transform: rotate(180deg);
1221 }
1222
1223 .mode-dropdown {
1224 display: none;
1225 position: absolute;
1226 bottom: calc(100% + 8px);
1227 left: 0;
1228 min-width: 180px;
1229 max-width: 280px;
1230 background: rgba(var(--glass-rgb), 0.85);
1231 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1232 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1233 border: 1px solid var(--glass-border);
1234 border-radius: 14px;
1235 padding: 6px;
1236 z-index: 100;
1237 box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.3), inset 0 1px 0 var(--glass-highlight);
1238 animation: dropdownIn 0.15s ease-out;
1239 }
1240 @keyframes dropdownIn {
1241 from { opacity: 0; transform: translateY(6px); }
1242 to { opacity: 1; transform: translateY(0); }
1243 }
1244 .mode-bar.open .mode-dropdown {
1245 display: block;
1246 }
1247
1248 .mode-option {
1249 display: flex;
1250 align-items: center;
1251 gap: 10px;
1252 padding: 8px 12px;
1253 border-radius: 10px;
1254 cursor: pointer;
1255 transition: all var(--transition-fast);
1256 font-size: 13px;
1257 font-weight: 500;
1258 color: var(--text-secondary);
1259 border: none;
1260 background: none;
1261 width: 100%;
1262 text-align: left;
1263 }
1264 .mode-option:hover {
1265 background: rgba(255, 255, 255, 0.15);
1266 color: var(--text-primary);
1267 }
1268 .mode-option:active {
1269 background: rgba(255, 255, 255, 0.2);
1270 transform: scale(0.97);
1271 }
1272 .mode-option.active {
1273 background: rgba(16, 185, 129, 0.2);
1274 color: var(--text-primary);
1275 font-weight: 600;
1276 border: 1px solid rgba(16, 185, 129, 0.3);
1277 }
1278 .mode-option-emoji {
1279 font-size: 16px;
1280 width: 22px;
1281 text-align: center;
1282 flex-shrink: 0;
1283 }
1284
1285 /* Mode alert toast */
1286 .mode-alert {
1287 position: fixed;
1288 bottom: 165px;
1289 left: 10px;
1290 z-index: 9999;
1291 display: flex;
1292 align-items: center;
1293 gap: 6px;
1294 padding: 7px 14px;
1295 background: rgba(var(--glass-rgb), 0.85);
1296 backdrop-filter: blur(var(--glass-blur));
1297 -webkit-backdrop-filter: blur(var(--glass-blur));
1298 border: 1px solid var(--glass-border);
1299 border-radius: 10px;
1300 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
1301 font-size: 12px;
1302 font-weight: 600;
1303 color: var(--text-primary);
1304 pointer-events: none;
1305 opacity: 0;
1306 transform: translateY(10px);
1307 transition: opacity 0.25s ease, transform 0.25s ease;
1308 }
1309 .mode-alert.visible {
1310 opacity: 1;
1311 transform: translateY(0);
1312 }
1313 .mode-alert-emoji {
1314 font-size: 14px;
1315 }
1316
1317 /* Mobile mode bar (inside sheet header) */
1318 .mobile-mode-bar {
1319 display: flex;
1320 gap: 4px;
1321 margin-top: 10px;
1322 width: 100%;
1323 overflow-x: auto;
1324 -webkit-overflow-scrolling: touch;
1325 scrollbar-width: none;
1326 padding-bottom: 2px;
1327 scroll-behavior: smooth;
1328 }
1329 .mobile-mode-bar::-webkit-scrollbar { display: none; }
1330
1331 .mobile-mode-btn {
1332 display: flex;
1333 align-items: center;
1334 gap: 6px;
1335 padding: 6px 12px;
1336 background: rgba(255, 255, 255, 0.08);
1337 border: 1px solid rgba(255, 255, 255, 0.1);
1338 border-radius: 20px;
1339 font-size: 12px;
1340 font-weight: 600;
1341 color: var(--text-primary);
1342 cursor: pointer;
1343 white-space: nowrap;
1344 flex-shrink: 0;
1345 transition: all var(--transition-fast);
1346 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1347 }
1348 .mobile-mode-btn:active {
1349 transform: scale(0.95);
1350 }
1351 .mobile-mode-btn.active {
1352 background: rgba(16, 185, 129, 0.2);
1353 border-color: rgba(16, 185, 129, 0.3);
1354 color: var(--text-primary);
1355 }
1356 .mobile-mode-btn-emoji {
1357 font-size: 14px;
1358 }
1359
1360 @media (max-width: 768px) {
1361 .mode-alert {
1362 top: 10px;
1363 bottom: auto;
1364 left: 50%;
1365 transform: translateX(-50%) translateY(-10px);
1366 }
1367 .mode-alert.visible {
1368 transform: translateX(-50%) translateY(0);
1369 }
1370 }
1371 ${dashboardCSS()}
1372 </style>
1373</head>
1374<body>
1375 <div class="app-bg"></div>
1376
1377 <!-- Mode alert toast -->
1378 <div class="mode-alert" id="modeAlert">
1379 <span class="mode-alert-emoji" id="modeAlertEmoji"></span>
1380 <span id="modeAlertText"></span>
1381 </div>
1382
1383 <!-- Navigator indicator (mobile: fixed top-right) -->
1384 <div class="navigator-indicator mobile-only" id="navigatorIndicatorMobile">
1385 <div class="navigator-badge" id="navigatorBadgeMobile" title="Detach session navigator">
1386 <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1387 <span class="nav-label" id="navigatorLabelMobile">session</span>
1388 <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1389 </div>
1390 </div>
1391
1392 <div class="app-container">
1393 <!-- Chat Panel -->
1394 <div class="chat-panel glass-panel" id="chatPanel">
1395 <div class="chat-header">
1396 <a href="/app" class="tree-home-link">
1397 <div class="chat-title">
1398 <span class="tree-icon">🌳</span>
1399 <h1>Tree</h1>
1400 </div>
1401 </a>
1402 <span class="root-name-inline" id="rootNameLabel" title=""></span>
1403
1404 <div class="chat-header-controls">
1405 <div class="chat-header-buttons">
1406 <button class="clear-chat-btn" id="desktopHomeBtn" title="Home">
1407 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1408 </button>
1409 <button class="clear-chat-btn" id="desktopRefreshBtn" title="Refresh">
1410 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1411 </button>
1412 <button class="clear-chat-btn" id="desktopOpenTabBtn" title="Open in new tab">
1413 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
1414 </button>
1415 <button class="clear-chat-btn" id="desktopCustomAiBtn" title="LLM Connections">🤖</button>
1416 </div>
1417 <div class="chat-header-right">
1418 <div class="status-badge">
1419 <span class="status-dot connecting" id="statusDot"></span>
1420 <span class="status-text" id="statusText">Connecting...</span>
1421 </div>
1422 <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
1423 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1424 </button>
1425 </div>
1426 </div>
1427 </div>
1428
1429 <!-- Session manager toggle (desktop: row above navigator) -->
1430 <div class="chat-dashboard-btn" id="desktopDashboardRow">
1431 <div class="chat-dashboard-badge" id="desktopDashboardBtn" title="Session Manager">
1432 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1433 <span class="dash-btn-label">Sessions</span>
1434 </div>
1435 </div>
1436
1437 <!-- Navigator indicator (desktop: row below session manager) -->
1438 <div class="navigator-indicator desktop-only" id="navigatorIndicatorDesktop">
1439 <div class="navigator-badge" id="navigatorBadgeDesktop" title="Detach session navigator">
1440 <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1441 <span class="nav-label" id="navigatorLabelDesktop">session</span>
1442 <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1443 </div>
1444 </div>
1445
1446 <!-- Recent Roots Dropdown (absolute positioned, top-left overlay) -->
1447 <div class="recent-roots-dropdown hidden" id="recentRootsDropdown">
1448 <div class="recent-roots-trigger" id="recentRootsTrigger">
1449 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1450 </div>
1451 <div class="recent-roots-menu" id="recentRootsMenu">
1452 <div class="recent-roots-menu-header">Recent Trees</div>
1453 <div id="recentRootsList"></div>
1454 </div>
1455 </div>
1456
1457 <div class="chat-messages" id="chatMessages">
1458 <div class="welcome-message">
1459 <div class="welcome-icon">🌳</div>
1460 <h2>Welcome to Tree</h2>
1461 <p>Your intelligent workspace is ready</p>
1462 </div>
1463 </div>
1464
1465 <!-- Desktop mode bar (above input) -->
1466 <div class="mode-bar" id="modeBar">
1467 <div class="mode-current" id="modeCurrent">
1468 <span class="mode-current-emoji" id="modeCurrentEmoji">🏠</span>
1469 <span class="mode-current-label" id="modeCurrentLabel">Home</span>
1470 <svg class="mode-current-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 15l6-6 6 6"/></svg>
1471 <div class="mode-dropdown" id="modeDropdown"></div>
1472 </div>
1473 </div>
1474
1475 <div class="chat-input-area">
1476 <div class="input-container">
1477 <textarea class="chat-input" id="chatInput" placeholder="Message Tree..." rows="1"></textarea>
1478 <button class="send-btn" id="sendBtn" disabled>
1479 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1480 <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
1481 </svg>
1482 </button>
1483 </div>
1484 </div>
1485 </div>
1486
1487 <!-- Divider -->
1488 <div class="panel-divider" id="panelDivider">
1489 <div class="divider-handle"></div>
1490 <div class="expand-buttons">
1491 <button class="expand-btn" id="expandChatBtn" title="Expand chat">
1492 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 5l7 7-7 7"/><path d="M5 5l7 7-7 7"/></svg>
1493 </button>
1494 <button class="expand-btn" id="resetPanelsBtn" title="Reset">
1495 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
1496 </button>
1497 <button class="expand-btn" id="expandViewportBtn" title="Expand viewport">
1498 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7"/><path d="M19 19l-7-7 7-7"/></svg>
1499 </button>
1500 </div>
1501 </div>
1502
1503 <!-- Viewport Panel -->
1504 <div class="viewport-panel glass-panel" id="viewportPanel">
1505 ${dashboardHTML()}
1506 <div class="iframe-container" id="iframeContainer">
1507 <div class="loading-overlay" id="loadingOverlay">
1508 <div class="loading-spinner">
1509 <div class="spinner-ring"></div>
1510 <span class="loading-text">Loading...</span>
1511 </div>
1512 </div>
1513 <iframe id="viewport" src="${req.query.rootId ? `/api/v1/root/${req.query.rootId}?html&token=${htmlShareToken}&inApp=1` : `/api/v1/user/${req.userId}?html&token=${htmlShareToken}&inApp=1`}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads allow-top-navigation-by-user-activation allow-top-navigation"></iframe>
1514 </div>
1515 </div>
1516 </div>
1517
1518 <!-- Collapsed chat tab -->
1519 <div class="mobile-chat-tab" id="mobileChatTab">🌳</div>
1520
1521 <div class="mobile-backdrop" id="mobileBackdrop"></div>
1522
1523 <div class="mobile-chat-sheet" id="mobileChatSheet">
1524 <div class="mobile-sheet-header" id="mobileSheetHeader">
1525 <div class="drag-handle"></div>
1526 <div class="mobile-sheet-title-row">
1527 <div class="mobile-sheet-title">
1528 <a href="/app" class="mobile-tree-icon-wrapper" title="Back to TreeOS">
1529 <div class="mobile-status-indicator connecting" id="mobileStatusIndicator"></div>
1530 <span class="tree-icon">🌳</span>
1531 </a>
1532 <span class="root-name-inline mobile-root-path" id="mobileRootNameLabel" title=""></span>
1533 </div>
1534 <div class="mobile-header-actions">
1535 <button class="clear-chat-btn mobile-dash-btn" id="mobileDashboardBtn" title="Session Manager">
1536 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1537 </button>
1538 <button class="clear-chat-btn" id="mobileHomeBtn" title="Home">
1539 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1540 </button>
1541 <button class="clear-chat-btn" id="mobileRefreshBtn" title="Refresh">
1542 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1543 </button>
1544 <button class="clear-chat-btn" id="mobileClearChatBtn" title="Clear conversation">
1545 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1546 </button>
1547 <button class="clear-chat-btn" id="mobileCustomAiBtn" title="LLM Connections">🤖</button>
1548 <button class="mobile-close-btn" id="mobileCloseBtn">
1549 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
1550 </button>
1551 </div>
1552 </div>
1553 <!-- Mobile mode bar (horizontal pill row) -->
1554 <div class="mobile-mode-bar" id="mobileModeBar"></div>
1555 <!-- Mobile recent roots dropdown -->
1556 <div class="mobile-recent-roots" id="mobileRecentRoots">
1557 <div class="mobile-recent-roots-toggle" id="mobileRecentRootsToggle">
1558 <span>Recent Trees</span>
1559 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1560 </div>
1561 <div class="mobile-recent-roots-list" id="mobileRecentRootsList"></div>
1562 </div>
1563 </div>
1564 <div class="mobile-chat-messages" id="mobileChatMessages">
1565 <div class="welcome-message">
1566 <div class="welcome-icon">🌳</div>
1567 <h2>Welcome to Tree</h2>
1568 <p>Your intelligent workspace is ready.</p>
1569 </div>
1570 </div>
1571 <div class="mobile-chat-input-area">
1572 <div class="input-container">
1573 <textarea class="chat-input" id="mobileSheetInput" placeholder="Message Tree..." rows="1"></textarea>
1574 <button class="send-btn" id="mobileSheetSendBtn" disabled>
1575 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
1576 </button>
1577 </div>
1578 </div>
1579 </div>
1580
1581 <script src="/socket.io/socket.io.js"></script>
1582 <script>
1583 // Config from server
1584 const CONFIG = {
1585 userId: "${req.userId}",
1586 username: "${username || req.userId}",
1587 htmlShareToken: "${htmlShareToken}",
1588 homeUrl: "/api/v1/user/${req.userId}?html&token=${htmlShareToken}&inApp=1",
1589 hasLlm: ${hasLlm}
1590 };
1591
1592 // Elements
1593 const $ = (id) => document.getElementById(id);
1594 const chatMessages = $("chatMessages");
1595 const chatInput = $("chatInput");
1596 const sendBtn = $("sendBtn");
1597 const statusDot = $("statusDot");
1598 const statusText = $("statusText");
1599 const mobileStatusIndicator = $("mobileStatusIndicator");
1600 const iframe = $("viewport");
1601 const loadingOverlay = $("loadingOverlay");
1602 const mobileChatMessages = $("mobileChatMessages");
1603 const mobileSheetInput = $("mobileSheetInput");
1604 const mobileSheetSendBtn = $("mobileSheetSendBtn");
1605 const mobileChatSheet = $("mobileChatSheet");
1606 const mobileBackdrop = $("mobileBackdrop");
1607 const mobileSheetHeader = $("mobileSheetHeader");
1608 const mobileChatTab = $("mobileChatTab");
1609
1610 // Recent roots elements
1611 const recentRootsDropdown = $("recentRootsDropdown");
1612 const recentRootsTrigger = $("recentRootsTrigger");
1613 const recentRootsList = $("recentRootsList");
1614 const mobileRecentRoots = $("mobileRecentRoots");
1615 const mobileRecentRootsToggle = $("mobileRecentRootsToggle");
1616 const mobileRecentRootsList = $("mobileRecentRootsList");
1617
1618 // State
1619 let isConnected = false;
1620 let isRegistered = false;
1621 let isSending = false;
1622 let currentIframeUrl = CONFIG.homeUrl;
1623
1624 // Mobile sheet state: 'closed' | 'peeked' | 'open'
1625 let mobileSheetState = 'closed';
1626
1627 // Mode state
1628 let currentModeKey = null;
1629 let availableModes = [];
1630 let modeBarOpen = false;
1631 let requestGeneration = 0;
1632
1633 // Recent roots state
1634 let recentRoots = [];
1635 let recentRootsOpen = false;
1636 let mobileRecentRootsExpanded = false;
1637// Build iframe URL — always injects inApp, token, and rootId when available
1638function buildIframeUrl(raw) {
1639 try {
1640 const base = raw.startsWith('http') ? raw : new URL(raw, window.location.origin).href;
1641 const u = new URL(base);
1642 if (!u.searchParams.has('inApp')) u.searchParams.set('inApp', '1');
1643 if (!u.searchParams.has('token')) u.searchParams.set('token', CONFIG.htmlShareToken);
1644 const rootId = getCurrentRootId();
1645 if (rootId && !u.pathname.includes('/root/')) {
1646 u.searchParams.set('rootId', rootId);
1647 } else {
1648 u.searchParams.delete('rootId');
1649 }
1650 return u.pathname + u.search;
1651 } catch (e) {
1652 return raw;
1653 }
1654}
1655 // Socket setup
1656 const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
1657
1658 socket.on("connect", () => {
1659 console.log("[socket] connected:", socket.id);
1660 isConnected = true;
1661 socket.emit("ready");
1662 updateStatus("connecting");
1663 socket.emit("register", { username: CONFIG.username });
1664 });
1665
1666 socket.on("registered", ({ success, error }) => {
1667 if (success) {
1668 isRegistered = true;
1669 updateStatus("connected");
1670 console.log("[socket] registered for chat");
1671 let currentUrl = "";
1672 try { currentUrl = iframe.contentWindow?.location?.pathname + iframe.contentWindow?.location?.search; } catch(e) {}
1673 if (!currentUrl) {
1674 try { const u = new URL(iframe.src); currentUrl = u.pathname + u.search; } catch(e) {}
1675 }
1676 if (!currentUrl) {
1677 currentUrl = currentIframeUrl || "";
1678 }
1679 socket.emit("getAvailableModes", { url: currentUrl });
1680 socket.emit("getRecentRoots");
1681 if (currentUrl) detectIframeUrlChange();
1682 } else {
1683 console.error("[socket] registration failed:", error);
1684 updateStatus("connected");
1685 addMessage("Chat registration failed: " + (error || "Unknown error") + ". You can still browse your tree.", "error");
1686 }
1687 });
1688
1689 socket.on("chatResponse", ({ answer, generation }) => {
1690 if (generation !== undefined && generation < requestGeneration) {
1691 console.log("[socket] dropping stale response, gen:", generation, "current:", requestGeneration);
1692 return;
1693 }
1694 removeTypingIndicator();
1695 addMessage(answer, "assistant");
1696 isSending = false;
1697 updateSendButtons();
1698 lockModeBar(false);
1699 });
1700
1701 socket.on("chatError", ({ error, generation }) => {
1702 if (generation !== undefined && generation < requestGeneration) return;
1703 removeTypingIndicator();
1704 addMessage("Error: " + error, "error");
1705 isSending = false;
1706 updateSendButtons();
1707 lockModeBar(false);
1708
1709 // Glow the LLM button after a delay so they read the error first
1710 if (error && (error.includes("/setup") || error.includes("LLM connection"))) {
1711 setTimeout(function() {
1712 var glowBtns = [$("desktopCustomAiBtn"), $("mobileCustomAiBtn")];
1713 glowBtns.forEach(function(btn) {
1714 if (!btn) return;
1715 btn.classList.remove("llm-glow");
1716 void btn.offsetWidth;
1717 btn.classList.add("llm-glow");
1718 btn.addEventListener("animationend", function() { btn.classList.remove("llm-glow"); }, { once: true });
1719 });
1720 }, 2500);
1721 }
1722 });
1723
1724 // Session killed from session manager while chat was in-flight
1725 socket.on("chatCancelled", () => {
1726 if (isSending) {
1727 removeTypingIndicator();
1728 isSending = false;
1729 lockModeBar(false);
1730 updateSendButtons();
1731 }
1732 });
1733
1734socket.on("navigate", ({ url, replace }) => {
1735 console.log("[socket] navigate:", url);
1736 loadingOverlay.classList.add("visible");
1737 currentIframeUrl = url;
1738 let navUrl = buildIframeUrl(url);
1739 if (replace) {
1740 iframe.contentWindow?.location.replace(navUrl);
1741 } else {
1742 iframe.src = navUrl;
1743 }
1744 });
1745
1746 // ── Navigator session indicator ──────────────────────────────────
1747 const navIndicators = [
1748 document.getElementById("navigatorIndicatorDesktop"),
1749 document.getElementById("navigatorIndicatorMobile"),
1750 ];
1751 const navBadges = [
1752 document.getElementById("navigatorBadgeDesktop"),
1753 document.getElementById("navigatorBadgeMobile"),
1754 ];
1755 const navLabels = [
1756 document.getElementById("navigatorLabelDesktop"),
1757 document.getElementById("navigatorLabelMobile"),
1758 ];
1759
1760 const sessionTypeLabels = {
1761 "websocket-chat": "chat",
1762 "api-tree-chat": "api chat",
1763 "api-tree-place": "api place",
1764 "raw-idea-orchestrate": "raw idea",
1765 "raw-idea-chat": "raw idea chat",
1766 "understanding-orchestrate": "understand",
1767 "scheduled-raw-idea": "scheduled",
1768 };
1769
1770 let navFlashTimer = null;
1771 let currentNavSessionId = null;
1772 socket.on("navigatorSession", (data) => {
1773 if (data && data.sessionId) {
1774 const label = sessionTypeLabels[data.type] || data.type || "session";
1775 navLabels.forEach(el => { if (el) el.textContent = label; });
1776 navIndicators.forEach(el => { if (el) el.classList.add("active"); });
1777 // Only reveal when navigator actually changes (new session or added from nothing)
1778 if (data.sessionId !== currentNavSessionId) {
1779 currentNavSessionId = data.sessionId;
1780 navBadges.forEach(el => { if (el) el.classList.add("reveal"); });
1781 if (navFlashTimer) clearTimeout(navFlashTimer);
1782 navFlashTimer = setTimeout(() => {
1783 navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1784 }, 3000);
1785 }
1786 } else {
1787 currentNavSessionId = null;
1788 navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1789 navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1790 if (navFlashTimer) clearTimeout(navFlashTimer);
1791 }
1792 });
1793
1794 document.getElementById("navigatorBadgeDesktop").addEventListener("click", () => {
1795 socket.emit("detachNavigator");
1796 });
1797
1798 // Mobile: first tap expands, second tap (when expanded) detaches
1799 let mobileNavExpanded = false;
1800 let mobileNavCollapseTimer = null;
1801 document.getElementById("navigatorBadgeMobile").addEventListener("click", () => {
1802 const badge = document.getElementById("navigatorBadgeMobile");
1803 if (mobileNavExpanded) {
1804 // Already expanded — detach
1805 mobileNavExpanded = false;
1806 if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1807 badge.classList.remove("reveal");
1808 socket.emit("detachNavigator");
1809 } else {
1810 // First tap — expand to show session name
1811 mobileNavExpanded = true;
1812 badge.classList.add("reveal");
1813 if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1814 mobileNavCollapseTimer = setTimeout(() => {
1815 mobileNavExpanded = false;
1816 badge.classList.remove("reveal");
1817 }, 4000);
1818 }
1819 });
1820
1821 socket.on("reload", () => {
1822 loadingOverlay.classList.add("visible");
1823 iframe.contentWindow?.location.reload();
1824 });
1825
1826 socket.on("disconnect", () => {
1827 isConnected = false;
1828 isRegistered = false;
1829 updateStatus("disconnected");
1830 navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1831
1832 [chatMessages, mobileChatMessages].forEach(container => {
1833 container.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the whole website to reconnect.</p></div>';
1834 });
1835 });
1836
1837 // ================================================================
1838 // Recent Roots
1839 // ================================================================
1840
1841 socket.on("recentRoots", ({ roots }) => {
1842 console.log("[socket] recent roots:", roots);
1843 recentRoots = roots || [];
1844 renderRecentRoots();
1845 });
1846var _initParams = new URLSearchParams(window.location.search);
1847let activeRootId = _initParams.get("rootId") || null;
1848if (activeRootId) window.history.replaceState({}, "", "/dashboard");
1849
1850 function getCurrentRootId() {
1851 if (activeRootId) return activeRootId;
1852 // Fallback: try to extract from URL
1853 const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
1854 const rootMatch = currentIframeUrl.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')', 'i'));
1855 return rootMatch ? rootMatch[1] : null;
1856}
1857
1858 function truncateName(str, maxLen = 18) {
1859 if (!str) return '';
1860 return str.length > maxLen ? str.slice(0, maxLen) + '…' : str;
1861 }
1862
1863 function renderRecentRoots() {
1864 const currentRootId = getCurrentRootId();
1865
1866 // Hide if no roots
1867 if (recentRoots.length === 0) {
1868 recentRootsDropdown.classList.add("hidden");
1869 mobileRecentRoots.classList.remove("visible");
1870 return;
1871 }
1872
1873 // Show dropdown trigger
1874 recentRootsDropdown.classList.remove("hidden");
1875 mobileRecentRoots.classList.add("visible");
1876 mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
1877
1878 // Render list HTML (truncated names, no emoji)
1879 const listHtml = recentRoots.map(root => {
1880 const isActive = root.rootId === currentRootId;
1881 return \`
1882 <button class="recent-root-item\${isActive ? ' active' : ''}" data-root-id="\${root.rootId}">
1883 <span class="recent-root-name">\${escapeHtml(truncateName(root.name))}</span>
1884 </button>
1885 \`;
1886 }).join('');
1887
1888 recentRootsList.innerHTML = listHtml;
1889 mobileRecentRootsList.innerHTML = listHtml;
1890
1891 // Add click handlers
1892 [recentRootsList, mobileRecentRootsList].forEach(list => {
1893 list.querySelectorAll('.recent-root-item').forEach(item => {
1894 item.addEventListener('click', (e) => {
1895 e.preventDefault();
1896 e.stopPropagation();
1897 const rootId = item.dataset.rootId;
1898 if (rootId) {
1899 navigateToRoot(rootId);
1900 closeRecentRoots();
1901 // On mobile, just collapse recent trees, not the whole sheet
1902 if (window.innerWidth <= 768) {
1903 mobileRecentRootsExpanded = false;
1904 mobileRecentRoots.classList.remove("expanded");
1905 }
1906 }
1907 });
1908 });
1909 });
1910 }
1911
1912 function navigateToRoot(rootId) {
1913 activeRootId = rootId;
1914
1915 const url = '/api/v1/root/' + rootId + '?html&token=' + CONFIG.htmlShareToken + '&inApp=1';
1916 loadingOverlay.classList.add("visible");
1917 iframe.src = url;
1918 currentIframeUrl = '/api/v1/root/' + rootId;
1919}
1920
1921 function closeRecentRoots() {
1922 recentRootsOpen = false;
1923 recentRootsDropdown.classList.remove("open");
1924 }
1925
1926 function toggleRecentRoots() {
1927 recentRootsOpen = !recentRootsOpen;
1928 recentRootsDropdown.classList.toggle("open", recentRootsOpen);
1929 }
1930
1931 // Desktop trigger click
1932 recentRootsTrigger.addEventListener("click", (e) => {
1933 e.stopPropagation();
1934 toggleRecentRoots();
1935 });
1936
1937 // Mobile toggle
1938 mobileRecentRootsToggle.addEventListener("click", (e) => {
1939 e.preventDefault();
1940 e.stopPropagation();
1941 e.stopImmediatePropagation();
1942 mobileRecentRootsExpanded = !mobileRecentRootsExpanded;
1943 mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
1944 });
1945
1946 // Close recent roots when clicking outside
1947 document.addEventListener("click", (e) => {
1948 if (recentRootsOpen && !recentRootsDropdown.contains(e.target)) {
1949 closeRecentRoots();
1950 }
1951 if (modeBarOpen && !$("modeBar").contains(e.target)) {
1952 closeModeBar();
1953 }
1954 });
1955
1956 // Close recent roots when focusing chat input
1957 chatInput.addEventListener("focus", () => {
1958 closeRecentRoots();
1959 });
1960
1961 // Close mobile recent roots when focusing input
1962 mobileSheetInput.addEventListener("focus", () => {
1963 mobileRecentRootsExpanded = false;
1964 mobileRecentRoots.classList.remove("expanded");
1965 });
1966
1967 // ================================================================
1968 // Mode switching socket events
1969 // ================================================================
1970
1971 socket.on("modeSwitched", ({ modeKey, emoji, label, alert, carriedMessages, silent }) => {
1972 console.log("[mode] switched to:", modeKey, silent ? "(silent)" : "", "carried:", carriedMessages?.length || 0);
1973 currentModeKey = modeKey;
1974 $("modeCurrentEmoji").textContent = emoji;
1975 $("modeCurrentLabel").textContent = label;
1976 const bigMode = modeKey.split(":")[0];
1977 if (availableModes.length && availableModes[0].key.startsWith(bigMode + ":")) {
1978 renderModeDropdown();
1979 renderMobileModeBar();
1980 }
1981 if (!silent) {
1982 if (isSending) {
1983 isSending = false;
1984 removeTypingIndicator();
1985 lockModeBar(false);
1986 updateSendButtons();
1987 }
1988 clearChatUI(carriedMessages || [], modeKey, emoji);
1989 showModeAlert(emoji, label);
1990 }
1991 });
1992
1993 socket.on("availableModes", ({ bigMode, modes, currentMode, rootName, rootId }) => {
1994 console.log("[mode] available:", bigMode, modes, "root:", rootName, rootId);
1995 availableModes = modes || [];
1996 if (currentMode) currentModeKey = currentMode;
1997
1998 // Sync activeRootId from server — this is the source of truth
1999 if (rootId) {
2000 activeRootId = rootId;
2001 } else if (bigMode === 'home') {
2002 activeRootId = null;
2003 }
2004
2005 const active = availableModes.find(m => m.key === currentModeKey);
2006 if (active) {
2007 $("modeCurrentEmoji").textContent = active.emoji;
2008 $("modeCurrentLabel").textContent = active.label;
2009 }
2010 renderModeDropdown();
2011 renderMobileModeBar();
2012
2013 const isTree = bigMode === 'tree';
2014 $("modeBar").style.display = isTree ? 'none' : '';
2015 $("mobileModeBar").style.display = isTree ? 'none' : '';
2016 updateRootName(rootName);
2017});
2018
2019 socket.on("conversationCleared", () => {
2020 console.log("[socket] conversation manually cleared");
2021 clearChatUI([], currentModeKey);
2022 });
2023
2024 // ================================================================
2025 // Mode bar logic (desktop)
2026 // ================================================================
2027
2028 function renderModeDropdown() {
2029 const dropdown = $("modeDropdown");
2030 dropdown.innerHTML = "";
2031 availableModes.forEach(mode => {
2032 const btn = document.createElement("button");
2033 btn.className = "mode-option" + (mode.key === currentModeKey ? " active" : "");
2034 btn.innerHTML = '<span class="mode-option-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2035 btn.addEventListener("click", (e) => {
2036 e.stopPropagation();
2037 if (mode.key !== currentModeKey) {
2038 socket.emit("switchMode", { modeKey: mode.key });
2039 }
2040 closeModeBar();
2041 });
2042 dropdown.appendChild(btn);
2043 });
2044 }
2045
2046 function toggleModeBar() {
2047 modeBarOpen = !modeBarOpen;
2048 $("modeBar").classList.toggle("open", modeBarOpen);
2049 }
2050
2051 function closeModeBar() {
2052 modeBarOpen = false;
2053 $("modeBar").classList.remove("open");
2054 }
2055
2056 $("modeCurrent").addEventListener("click", (e) => {
2057 e.stopPropagation();
2058 toggleModeBar();
2059 });
2060
2061 // Prevent clicks inside dropdown from toggling mode bar
2062 $("modeDropdown").addEventListener("click", (e) => {
2063 e.stopPropagation();
2064 });
2065
2066 // ================================================================
2067 // Lock/unlock mode bar while AI is responding
2068 // ================================================================
2069
2070 const SEND_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>';
2071 const STOP_SVG = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
2072
2073 function lockModeBar(locked) {
2074 $("modeBar").classList.toggle("locked", locked);
2075 document.querySelectorAll(".mobile-mode-btn").forEach(btn => {
2076 btn.classList.toggle("locked", locked);
2077 });
2078 [sendBtn, mobileSheetSendBtn].forEach(btn => {
2079 btn.classList.toggle("stop-mode", locked);
2080 btn.innerHTML = locked ? STOP_SVG : SEND_SVG;
2081 if (locked) btn.disabled = false;
2082 });
2083 }
2084
2085 function cancelRequest() {
2086 if (!isSending) return;
2087 requestGeneration++;
2088 isSending = false;
2089 removeTypingIndicator();
2090 lockModeBar(false);
2091 updateSendButtons();
2092 socket.emit("cancelRequest");
2093 }
2094
2095 function updateRootName(name) {
2096 ["rootNameLabel", "mobileRootNameLabel"].forEach(id => {
2097 const el = $(id);
2098 if (name) {
2099 const changed = el.textContent !== name;
2100 el.textContent = name;
2101 el.title = name;
2102 el.classList.add("visible");
2103 if (changed) {
2104 el.classList.remove("fade-in");
2105 void el.offsetWidth;
2106 el.classList.add("fade-in");
2107 }
2108 } else {
2109 el.classList.remove("visible", "fade-in");
2110 el.textContent = "";
2111 el.title = "";
2112 }
2113 });
2114 }
2115
2116 // ================================================================
2117 // Mobile mode bar (horizontal pills in sheet header)
2118 // ================================================================
2119
2120 function renderMobileModeBar() {
2121 const bar = $("mobileModeBar");
2122 bar.innerHTML = "";
2123 availableModes.forEach(mode => {
2124 const btn = document.createElement("button");
2125 btn.className = "mobile-mode-btn" + (mode.key === currentModeKey ? " active" : "");
2126 btn.dataset.modeKey = mode.key;
2127 btn.innerHTML = '<span class="mobile-mode-btn-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2128 btn.addEventListener("click", (e) => {
2129 e.stopPropagation();
2130 if (mode.key !== currentModeKey) {
2131 socket.emit("switchMode", { modeKey: mode.key });
2132 }
2133 });
2134 bar.appendChild(btn);
2135 });
2136 scrollToActiveMode();
2137 }
2138
2139 function scrollToActiveMode() {
2140 const bar = $("mobileModeBar");
2141 const activeBtn = bar.querySelector(".mobile-mode-btn.active");
2142 if (activeBtn) {
2143 const barRect = bar.getBoundingClientRect();
2144 const btnRect = activeBtn.getBoundingClientRect();
2145 const scrollLeft = activeBtn.offsetLeft - (barRect.width / 2) + (btnRect.width / 2);
2146 bar.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
2147 }
2148 }
2149
2150 // ================================================================
2151 // Mode alert toast
2152 // ================================================================
2153
2154 let modeAlertTimer = null;
2155 function showModeAlert(emoji, label) {
2156 //handled behind scenes
2157 }
2158
2159 // ================================================================
2160 // Clear chat UI helper
2161 // ================================================================
2162
2163 const MODE_WELCOMES = {
2164 "home:default": { icon: "🌳", title: "Welcome to TreeOS", desc: "Your intelligent workspace is ready — build, explore, and reflect" },
2165 "home:raw-idea-placement": { icon: "💡", title: "Raw Ideas", desc: "Capture unstructured thoughts and gradually grow them into trees (work in progress)" },
2166 "home:reflect": { icon: "🔮", title: "Reflect", desc: "Review your notes, tags, and contributions across all your trees" },
2167 "tree:structure": { icon: "🏗️", title: "Structure Mode", desc: "Create, reorganize, and grow the overall shape of your tree" },
2168 "tree:be": { icon: "🎯", title: "Be Mode", desc: "Focus on one active leaf at a time and work through it step by step" },
2169 "tree:reflect": { icon: "🔮", title: "Reflect Mode", desc: "Look at your tree as a whole to spot gaps, patterns, and opportunities" },
2170 "tree:edit": { icon: "✏️", title: "Edit Mode", desc: "Refine names, values, notes, and details within your tree" }
2171 };
2172
2173 function clearChatUI(carriedMessages, modeKey, emoji) {
2174 const valid = (carriedMessages || []).filter(m => m.content && m.content.trim());
2175 const welcome = MODE_WELCOMES[modeKey] || { icon: "🌳" || "🌳", title: "Ready?", desc: "Let's grow." };
2176
2177 [chatMessages, mobileChatMessages].forEach(container => {
2178 container.innerHTML = '';
2179
2180 if (valid.length > 0) {
2181 valid.forEach(msg => {
2182 const el = document.createElement("div");
2183 el.className = "message " + msg.role + " carried";
2184 const formattedContent = msg.role === "assistant" ? formatMessageContent(msg.content) : escapeHtml(msg.content);
2185 el.innerHTML =
2186 '<div class="message-avatar">' + (msg.role === "user" ? "👤" : "🌳") + '</div>' +
2187 '<div class="message-content">' + formattedContent + '</div>';
2188 container.appendChild(el);
2189 });
2190 container.scrollTop = container.scrollHeight;
2191 } else {
2192 container.innerHTML = '<div class="welcome-message"><div class="welcome-icon">' + welcome.icon + '</div><h2>' + welcome.title + '</h2><p>' + welcome.desc + '</p></div>';
2193 }
2194 });
2195 }
2196
2197 // ================================================================
2198 // iframe URL change detection
2199 // ================================================================
2200
2201 let lastEmittedUrl = "";
2202 function detectIframeUrlChange() {
2203 let path = "";
2204
2205 try {
2206 const loc = iframe.contentWindow?.location;
2207 if (loc) path = loc.pathname + loc.search;
2208 } catch (e) {}
2209
2210 if (!path) {
2211 try { const u = new URL(iframe.src); path = u.pathname + u.search; } catch(e) {}
2212 }
2213
2214 if (!path) {
2215 path = currentIframeUrl || "";
2216 }
2217
2218 if (path && path !== lastEmittedUrl) {
2219 lastEmittedUrl = path;
2220 currentIframeUrl = path;
2221 const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
2222 let rootId = null;
2223 let nodeId = null;
2224 const rootMatch = path.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')', 'i'));
2225 const bareMatch = path.match(new RegExp('(?:/api/v1)?/(' + ID + ')(?:[?/]|$)', 'i'));
2226 if (rootMatch) rootId = rootMatch[1];
2227 else if (bareMatch) nodeId = bareMatch[1];
2228
2229 if (isRegistered) {
2230 socket.emit("urlChanged", { url: path, rootId, nodeId });
2231 }
2232if (rootMatch) {
2233 rootId = rootMatch[1];
2234 activeRootId = rootId; // <-- add this
2235}
2236 // Re-render recent roots to update active state
2237 renderRecentRoots();
2238 }
2239 }
2240
2241 // Status
2242 function updateStatus(status) {
2243 statusDot.className = "status-dot " + status;
2244 mobileStatusIndicator.className = "mobile-status-indicator " + status;
2245 statusText.textContent = status === "connected" ? "Connected" : status === "connecting" ? "Connecting..." : "Disconnected";
2246 isConnected = status === "connected";
2247 }
2248
2249 // Format message content with markdown-like parsing
2250 function formatMessageContent(text) {
2251 if (!text) return '';
2252
2253 let html = text;
2254
2255 html = html.replace(/ /g, ' ');
2256 html = html.replace(/&/g, '&');
2257 html = html.replace(/</g, '<');
2258 html = html.replace(/>/g, '>');
2259 html = html.replace(/\\u00A0/g, ' ');
2260 html = html.replace(/–/g, '-');
2261 html = html.replace(/—/g, '--');
2262
2263 html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
2264
2265 const tableRegex = /^\\|(.+)\\|\\s*\\n\\|[-:\\s|]+\\|\\s*\\n((?:\\|.+\\|\\s*\\n?)+)/gm;
2266 html = html.replace(tableRegex, (match, headerRow, bodyRows) => {
2267 const rows = bodyRows.trim().split('\\n').map(row =>
2268 row.split('|').map(cell => cell.trim()).filter(cell => cell)
2269 );
2270 let items = '';
2271 rows.forEach(row => {
2272 if (row.length >= 2) {
2273 const num = row[0];
2274 const name = row[1];
2275 if (/^\\d{1,2}$/.test(num)) {
2276 items += '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '"') + '">' +
2277 '<span class="menu-number">' + num + '</span>' +
2278 '<span class="menu-text">' + name + '</span></div>';
2279 } else {
2280 items += '<div class="menu-item">' +
2281 '<span class="menu-number">•</span>' +
2282 '<span class="menu-text">' + name + '</span></div>';
2283 }
2284 }
2285 });
2286 return items;
2287 });
2288
2289 html = html.replace(/^\\|\\s*(\\d{1,2})\\s*\\|\\s*(.+?)\\s*\\|\\s*$/gm, (match, num, name) => {
2290 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '"') + '">' +
2291 '<span class="menu-number">' + num + '</span>' +
2292 '<span class="menu-text">' + name + '</span></div>';
2293 });
2294
2295 html = html.replace(/^\\|\\s*#\\s*\\|.*\\|\\s*$/gm, '');
2296 html = html.replace(/^\\|[-:\\s|]+\\|\\s*$/gm, '');
2297
2298 html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
2299 html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
2300
2301 html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
2302 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
2303
2304 html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
2305
2306 html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
2307 html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
2308 html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
2309 html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
2310
2311 html = html.replace(/^-{3,}$/gm, '<hr>');
2312 html = html.replace(/^\\*{3,}$/gm, '<hr>');
2313
2314 html = html.replace(/^>\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
2315
2316 html = html.replace(/^([1-9]️⃣)\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, emoji, title, rest) => {
2317 const num = emoji.match(/[1-9]/)?.[0] || '1';
2318 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '"') + '">' +
2319 '<span class="menu-number">' + num + '</span>' +
2320 '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2321 });
2322 html = html.replace(/^([1-9]️⃣)\\s*(.+)$/gm, (m, emoji, text) => {
2323 const num = emoji.match(/[1-9]/)?.[0] || '1';
2324 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + text.replace(/"/g, '"') + '">' +
2325 '<span class="menu-number">' + num + '</span>' +
2326 '<span class="menu-text">' + text + '</span></div>';
2327 });
2328
2329 html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, num, title, rest) => {
2330 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '"') + '">' +
2331 '<span class="menu-number">' + num + '</span>' +
2332 '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2333 });
2334
2335 html = html.replace(/^[-–•]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
2336 '<div class="menu-item"><span class="menu-number">•</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
2337
2338 html = html.replace(/^[-–•]\\s+([^<].*)$/gm, '<li>$1</li>');
2339
2340 html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
2341
2342 let inList = false;
2343 const lines = html.split('\\n');
2344 const processed = [];
2345 for (let i = 0; i < lines.length; i++) {
2346 const line = lines[i];
2347 const isListItem = line.trim().startsWith('<li>');
2348 if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
2349 else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
2350 processed.push(line);
2351 }
2352 if (inList) processed.push('</ul>');
2353 html = processed.join('\\n');
2354
2355 html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
2356
2357 const blocks = html.split(/\\n\\n+/);
2358 html = blocks.map(block => {
2359 const trimmed = block.trim();
2360 if (!trimmed) return '';
2361 if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
2362 const withBreaks = trimmed.split('\\n').map(l => l.trim()).filter(l => l).join('<br>');
2363 return '<p>' + withBreaks + '</p>';
2364 }).filter(b => b).join('');
2365
2366 html = html.replace(/<p><\\/p>/g, '');
2367 html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote|<table)/g, '$1');
2368 html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>|<\\/table>)<\\/p>/g, '$1');
2369 html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
2370 html = html.replace(/(<div[^>]*>)<br>/g, '$1');
2371
2372 return html;
2373 }
2374
2375 // Messages
2376 function addMessage(content, role) {
2377 [chatMessages, mobileChatMessages].forEach(container => {
2378 const welcome = container.querySelector(".welcome-message");
2379 if (welcome) welcome.remove();
2380
2381 const msg = document.createElement("div");
2382 msg.className = "message " + role;
2383
2384 const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
2385
2386 msg.innerHTML = \`
2387 <div class="message-avatar">\${role === "user" ? "👤" : "🌳"}</div>
2388 <div class="message-content">\${formattedContent}</div>
2389 \`;
2390
2391 if (role === "assistant") {
2392 msg.querySelectorAll('.menu-item.clickable').forEach(item => {
2393 item.addEventListener('click', () => handleMenuItemClick(item));
2394 });
2395 }
2396
2397 container.appendChild(msg);
2398 container.scrollTop = container.scrollHeight;
2399 });
2400 }
2401
2402 function handleMenuItemClick(item) {
2403 const action = item.dataset.action;
2404 const name = item.dataset.name;
2405
2406 if (!action || isSending) return;
2407
2408 item.classList.add('clicking');
2409 setTimeout(() => item.classList.remove('clicking'), 300);
2410
2411 sendChatMessage(action);
2412 }
2413
2414 function addTypingIndicator() {
2415 [chatMessages, mobileChatMessages].forEach(container => {
2416 if (container.querySelector(".typing-indicator")) return;
2417 const typing = document.createElement("div");
2418 typing.className = "message assistant";
2419 typing.innerHTML = \`
2420 <div class="message-avatar">🌳</div>
2421 <div class="typing-indicator">
2422 <div class="typing-dot"></div>
2423 <div class="typing-dot"></div>
2424 <div class="typing-dot"></div>
2425 </div>
2426 \`;
2427 container.appendChild(typing);
2428 container.scrollTop = container.scrollHeight;
2429 });
2430 }
2431
2432 function removeTypingIndicator() {
2433 document.querySelectorAll(".typing-indicator").forEach(el => el.closest(".message")?.remove());
2434 }
2435
2436 function escapeHtml(text) {
2437 const div = document.createElement("div");
2438 div.textContent = text;
2439 return div.innerHTML;
2440 }
2441
2442 // Send message
2443 function sendChatMessage(message) {
2444 if (!message.trim() || isSending || !isRegistered) return;
2445
2446 addMessage(message, "user");
2447 addTypingIndicator();
2448 isSending = true;
2449 requestGeneration++;
2450 const thisGen = requestGeneration;
2451 updateSendButtons();
2452 lockModeBar(true);
2453
2454 socket.emit("chat", { message, username: CONFIG.username, generation: thisGen });
2455 }
2456
2457 function updateSendButtons() {
2458 const desktopText = chatInput.value.trim();
2459 const mobileSheetText = mobileSheetInput.value.trim();
2460
2461 sendBtn.disabled = isSending ? false : !(desktopText && isRegistered);
2462 mobileSheetSendBtn.disabled = isSending ? false : !(mobileSheetText && isRegistered);
2463 chatInput.disabled = isSending;
2464 mobileSheetInput.disabled = isSending;
2465 }
2466
2467 // Input handlers - Desktop
2468 chatInput.addEventListener("input", () => {
2469 updateSendButtons();
2470 chatInput.style.height = "auto";
2471 chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + "px";
2472 });
2473
2474 chatInput.addEventListener("keydown", (e) => {
2475 if (e.key === "Enter" && !e.shiftKey) {
2476 e.preventDefault();
2477 const msg = chatInput.value.trim();
2478 if (msg && isRegistered && !isSending) {
2479 sendChatMessage(msg);
2480 chatInput.value = "";
2481 chatInput.style.height = "auto";
2482 updateSendButtons();
2483 }
2484 }
2485 });
2486
2487 sendBtn.addEventListener("click", () => {
2488 if (isSending) {
2489 cancelRequest();
2490 return;
2491 }
2492 const msg = chatInput.value.trim();
2493 if (msg && isRegistered && !isSending) {
2494 sendChatMessage(msg);
2495 chatInput.value = "";
2496 chatInput.style.height = "auto";
2497 updateSendButtons();
2498 }
2499 });
2500
2501 // Mobile handlers
2502 let sheetDragStartY = 0;
2503 let isDraggingSheet = false;
2504 let sheetHeight = 0;
2505 let currentDragY = 0;
2506 let dragStartState = 'closed';
2507
2508 function setMobileSheetState(newState, force = false) {
2509 // Don't re-run if already in this state (unless forced)
2510 if (mobileSheetState === newState && !force) return;
2511
2512 mobileSheetState = newState;
2513 mobileChatSheet.classList.remove("open", "peeked", "closing");
2514
2515 if (newState === 'open') {
2516 mobileChatSheet.classList.add("open");
2517 mobileBackdrop.classList.add("visible");
2518 mobileChatTab.classList.add("hidden");
2519 setTimeout(() => {
2520 mobileSheetInput.focus();
2521 updateSendButtons();
2522 scrollToActiveMode();
2523 }, 350);
2524 } else if (newState === 'peeked') {
2525 mobileChatSheet.classList.add("peeked");
2526 mobileBackdrop.classList.remove("visible");
2527 mobileChatTab.classList.add("hidden");
2528 mobileSheetInput.blur();
2529 } else {
2530 // closed - go to side tab
2531 mobileChatSheet.classList.add("closing");
2532 mobileBackdrop.classList.remove("visible");
2533 mobileSheetInput.blur();
2534 setTimeout(() => {
2535 mobileChatSheet.classList.remove("closing");
2536 mobileChatTab.classList.remove("hidden");
2537 }, 300);
2538 }
2539 }
2540
2541 function openMobileSheetFull() {
2542 setMobileSheetState('open');
2543 }
2544
2545 function peekMobileSheet() {
2546 setMobileSheetState('peeked');
2547 }
2548
2549 function closeMobileSheet() {
2550 setMobileSheetState('closed');
2551 }
2552
2553 // Side tab opens to full
2554 mobileChatTab.addEventListener("click", (e) => {
2555 e.preventDefault();
2556 e.stopPropagation();
2557 openMobileSheetFull();
2558 });
2559
2560 // Header tap in peeked state opens full (ignore button clicks)
2561 mobileSheetHeader.addEventListener("click", (e) => {
2562 if (mobileSheetState === 'peeked' && !isDraggingSheet && !e.target.closest("button")) {
2563 e.preventDefault();
2564 e.stopPropagation();
2565 openMobileSheetFull();
2566 }
2567 });
2568
2569 mobileSheetInput.addEventListener("input", () => {
2570 updateSendButtons();
2571 mobileSheetInput.style.height = "auto";
2572 mobileSheetInput.style.height = Math.min(mobileSheetInput.scrollHeight, 120) + "px";
2573 });
2574
2575 mobileSheetInput.addEventListener("keydown", (e) => {
2576 if (e.key === "Enter" && !e.shiftKey) {
2577 e.preventDefault();
2578 const msg = mobileSheetInput.value.trim();
2579 if (msg && isRegistered && !isSending) {
2580 sendChatMessage(msg);
2581 mobileSheetInput.value = "";
2582 mobileSheetInput.style.height = "auto";
2583 updateSendButtons();
2584 }
2585 }
2586 });
2587
2588 mobileSheetSendBtn.addEventListener("click", () => {
2589 if (isSending) {
2590 cancelRequest();
2591 return;
2592 }
2593 const msg = mobileSheetInput.value.trim();
2594 if (msg && isRegistered && !isSending) {
2595 sendChatMessage(msg);
2596 mobileSheetInput.value = "";
2597 mobileSheetInput.style.height = "auto";
2598 updateSendButtons();
2599 }
2600 });
2601
2602 // X button always closes to side tab
2603 $("mobileCloseBtn").addEventListener("click", (e) => {
2604 e.stopPropagation();
2605 e.preventDefault();
2606 closeMobileSheet();
2607 });
2608 // Backdrop click closes to side tab
2609 mobileBackdrop.addEventListener("click", closeMobileSheet);
2610
2611 // Sheet drag handling
2612 function handleSheetDragStart(e) {
2613 if (mobileSheetState === 'closed') return;
2614
2615 const touch = e.touches ? e.touches[0] : e;
2616 sheetDragStartY = touch.clientY;
2617 sheetHeight = mobileChatSheet.offsetHeight;
2618 currentDragY = 0;
2619 isDraggingSheet = true;
2620 dragStartState = mobileSheetState;
2621 mobileChatSheet.classList.add("dragging");
2622 }
2623
2624 function handleSheetDragMove(e) {
2625 if (!isDraggingSheet) return;
2626
2627 const touch = e.touches ? e.touches[0] : e;
2628 const deltaY = touch.clientY - sheetDragStartY;
2629
2630 // Calculate base offset based on current state
2631 let baseOffset = 0;
2632 if (dragStartState === 'peeked') {
2633 baseOffset = sheetHeight - 90; // peeked position
2634 }
2635
2636 if (dragStartState === 'open') {
2637 // Dragging down from open
2638 if (deltaY > 0) {
2639 currentDragY = deltaY;
2640 mobileChatSheet.style.transform = \`translateY(\${deltaY}px)\`;
2641 const progress = Math.min(deltaY / (sheetHeight * 0.5), 1);
2642 mobileBackdrop.style.opacity = String(1 - progress);
2643 } else if (deltaY < 0) {
2644 // Allow slight overdrag up
2645 currentDragY = deltaY;
2646 mobileChatSheet.style.transform = \`translateY(\${Math.max(deltaY, -20)}px)\`;
2647 }
2648 } else if (dragStartState === 'peeked') {
2649 // Dragging from peeked state
2650 currentDragY = deltaY;
2651 const newOffset = Math.max(0, Math.min(baseOffset + deltaY, sheetHeight));
2652 mobileChatSheet.style.transform = \`translateY(\${newOffset}px)\`;
2653
2654 // Show backdrop when dragging up
2655 if (deltaY < 0) {
2656 const progress = Math.min(Math.abs(deltaY) / (sheetHeight - 90), 1);
2657 mobileBackdrop.style.opacity = String(progress);
2658 mobileBackdrop.classList.add("visible");
2659 }
2660 }
2661 }
2662
2663 function handleSheetDragEnd(e) {
2664 if (!isDraggingSheet) return;
2665
2666 isDraggingSheet = false;
2667 mobileChatSheet.classList.remove("dragging");
2668 mobileChatSheet.style.transform = "";
2669 mobileBackdrop.style.opacity = "";
2670
2671 const peekThreshold = sheetHeight - 200; // pixels from top to trigger peek
2672
2673 if (dragStartState === 'open') {
2674 // From open: drag down far = peek, drag down very far = still peek (not close)
2675 if (currentDragY > peekThreshold) {
2676 peekMobileSheet();
2677 } else if (currentDragY > 50) {
2678 peekMobileSheet();
2679 } else {
2680 setMobileSheetState('open');
2681 }
2682 } else if (dragStartState === 'peeked') {
2683 // From peeked: drag up = open, drag down = stay peeked
2684 if (currentDragY < -50) {
2685 openMobileSheetFull();
2686 } else {
2687 setMobileSheetState('peeked');
2688 }
2689 }
2690
2691 currentDragY = 0;
2692 }
2693
2694 mobileSheetHeader.addEventListener("touchstart", handleSheetDragStart, { passive: true });
2695 mobileSheetHeader.addEventListener("touchmove", handleSheetDragMove, { passive: true });
2696 mobileSheetHeader.addEventListener("touchend", handleSheetDragEnd, { passive: true });
2697 mobileSheetHeader.addEventListener("touchcancel", handleSheetDragEnd, { passive: true });
2698
2699 let mouseIsDown = false;
2700 mobileSheetHeader.addEventListener("mousedown", (e) => {
2701 mouseIsDown = true;
2702 handleSheetDragStart(e);
2703 });
2704 document.addEventListener("mousemove", (e) => {
2705 if (mouseIsDown && isDraggingSheet) handleSheetDragMove(e);
2706 });
2707 document.addEventListener("mouseup", (e) => {
2708 if (mouseIsDown) {
2709 mouseIsDown = false;
2710 if (isDraggingSheet) handleSheetDragEnd(e);
2711 }
2712 });
2713
2714 // Start in peeked state on mobile
2715 if (window.innerWidth <= 768) {
2716 setTimeout(() => setMobileSheetState('peeked'), 100);
2717 }
2718
2719 // Panel resizing (desktop)
2720 const appContainer = document.querySelector(".app-container");
2721 const chatPanel = $("chatPanel");
2722 const viewportPanel = $("viewportPanel");
2723 const panelDivider = $("panelDivider");
2724 let isDragging = false, dragStartX = 0, dragStartWidth = 0, currentChatWidth = 0;
2725 const MIN_PANEL = 280, DIVIDER = 16, PADDING = 32;
2726
2727 function getAvailable() { return appContainer.clientWidth - PADDING - DIVIDER; }
2728
2729 function setChatWidth(w) {
2730 const avail = getAvailable();
2731 let clamped = Math.max(0, Math.min(w, avail));
2732 if (clamped > 0 && clamped < MIN_PANEL) clamped = 0;
2733 if (avail - clamped > 0 && avail - clamped < MIN_PANEL) clamped = avail;
2734 currentChatWidth = clamped;
2735 chatPanel.style.width = clamped + "px";
2736 chatPanel.classList.toggle("collapsed", clamped === 0);
2737 viewportPanel.classList.toggle("collapsed", avail - clamped === 0);
2738 }
2739
2740 setChatWidth(getAvailable() / 2.5);
2741 window.addEventListener("resize", () => setChatWidth(currentChatWidth));
2742
2743 panelDivider.addEventListener("mousedown", (e) => {
2744 isDragging = true;
2745 dragStartX = e.clientX;
2746 dragStartWidth = currentChatWidth;
2747 appContainer.classList.add("dragging");
2748 e.preventDefault();
2749 });
2750
2751 document.addEventListener("mousemove", (e) => {
2752 if (!isDragging) return;
2753 setChatWidth(dragStartWidth + (e.clientX - dragStartX));
2754 });
2755
2756 document.addEventListener("mouseup", () => {
2757 if (isDragging) {
2758 isDragging = false;
2759 appContainer.classList.remove("dragging");
2760 }
2761 });
2762
2763 $("expandChatBtn").addEventListener("click", () => setChatWidth(getAvailable()));
2764 $("expandViewportBtn").addEventListener("click", () => setChatWidth(0));
2765 $("resetPanelsBtn").addEventListener("click", () => setChatWidth(getAvailable() / 2));
2766
2767 // Clear chat buttons
2768 function handleClearChat() {
2769 if (!isRegistered) return;
2770 if (isSending) cancelRequest();
2771 socket.emit("clearConversation");
2772 clearChatUI([], currentModeKey);
2773 // Navigate iframe back to tree root
2774 const rootId = getCurrentRootId();
2775 if (rootId) {
2776 navigateToRoot(rootId);
2777 } else {
2778 goHome();
2779 }
2780 }
2781
2782 $("clearChatBtn").addEventListener("click", handleClearChat);
2783 $("mobileClearChatBtn").addEventListener("click", (e) => {
2784 e.stopPropagation();
2785 e.preventDefault();
2786 handleClearChat();
2787 });
2788
2789 function getCurrentIframeUrl() {
2790 let url = "";
2791 try {
2792 url = iframe.contentWindow?.location?.href;
2793 } catch (e) {}
2794 if (!url) {
2795 try { url = iframe.src; } catch(e) {}
2796 }
2797 if (!url) {
2798 url = window.location.origin + currentIframeUrl;
2799 }
2800 try {
2801 const u = new URL(url, window.location.origin);
2802 u.searchParams.delete('inApp');
2803 return u.href;
2804 } catch(e) {
2805 return url.replace(/[&?]inApp=1/g, '');
2806 }
2807 }
2808
2809function goHome() {
2810 activeRootId = null;
2811
2812 // Close dashboard if open
2813 if (window.TreeApp && window.TreeApp.closeDashboard) window.TreeApp.closeDashboard();
2814
2815 loadingOverlay.classList.add("visible");
2816 currentIframeUrl = CONFIG.homeUrl;
2817 iframe.src = CONFIG.homeUrl; // home doesn't need rootId
2818}
2819
2820 $("desktopHomeBtn").addEventListener("click", goHome);
2821 $("mobileHomeBtn").addEventListener("click", (e) => {
2822 e.stopPropagation();
2823 e.preventDefault();
2824 goHome();
2825 // Always reset to peeked/dragged-down mode on mobile (use timeout to ensure it happens last)
2826 setTimeout(() => setMobileSheetState('peeked', true), 10);
2827 });
2828
2829 function doRefresh() {
2830 loadingOverlay.classList.add("visible");
2831 iframe.contentWindow?.location.reload();
2832 }
2833
2834 $("desktopRefreshBtn").addEventListener("click", doRefresh);
2835 $("mobileRefreshBtn").addEventListener("click", (e) => {
2836 e.stopPropagation();
2837 e.preventDefault();
2838 doRefresh();
2839 });
2840
2841 function openInNewTab() {
2842 const url = getCurrentIframeUrl();
2843 window.open(url, '_blank');
2844 }
2845
2846 $("desktopOpenTabBtn").addEventListener("click", openInNewTab);
2847
2848 // LLM Connections button — go to /setup if no LLM, else energy page
2849 function goCustomAi() {
2850 if (!CONFIG.hasLlm) {
2851 window.location.href = "/setup";
2852 return;
2853 }
2854 const url = buildIframeUrl('/api/v1/user/' + CONFIG.userId + '/energy?html');
2855 loadingOverlay.classList.add("visible");
2856 currentIframeUrl = url;
2857 iframe.src = url;
2858 // Scroll iframe to bottom once loaded
2859 const onLoad = () => {
2860 iframe.removeEventListener('load', onLoad);
2861 try { iframe.contentWindow.scrollTo(0, iframe.contentDocument.body.scrollHeight); } catch(e) {}
2862 };
2863 iframe.addEventListener('load', onLoad);
2864 }
2865
2866 $("desktopCustomAiBtn").addEventListener("click", goCustomAi);
2867 $("mobileCustomAiBtn").addEventListener("click", (e) => {
2868 e.stopPropagation();
2869 e.preventDefault();
2870 if (mobileSheetState === 'open') {
2871 // In full chat mode on mobile — slide it down to peeked
2872 setTimeout(() => setMobileSheetState('peeked', true), 10);
2873 }
2874 // If already peeked/closed, keep it down (don't open)
2875 goCustomAi();
2876 });
2877
2878 // Iframe
2879 iframe.addEventListener("load", () => {
2880 loadingOverlay.classList.remove("visible");
2881 try {
2882 const loc = iframe.contentWindow?.location;
2883 if (loc) {
2884 currentIframeUrl = loc.pathname + loc.search;
2885 }
2886 } catch (e) {}
2887 detectIframeUrlChange();
2888 injectIframeParamForwarding();
2889});
2890
2891function injectIframeParamForwarding() {
2892 try {
2893 const doc = iframe.contentDocument || iframe.contentWindow?.document;
2894 if (!doc) return;
2895
2896 // Skip if already injected
2897 if (doc._paramForwardingInjected) return;
2898 doc._paramForwardingInjected = true;
2899
2900 // Intercept all clicks on links
2901 doc.addEventListener('click', (e) => {
2902 const anchor = e.target.closest('a');
2903 if (!anchor || !anchor.href) return;
2904
2905 try {
2906 const url = new URL(anchor.href);
2907
2908 // Only rewrite same-origin links
2909 if (url.origin !== window.location.origin) return;
2910
2911 // Add inApp
2912 if (!url.searchParams.has('inApp')) {
2913 url.searchParams.set('inApp', '1');
2914 }
2915
2916 // Add token
2917 if (!url.searchParams.has('token')) {
2918 url.searchParams.set('token', CONFIG.htmlShareToken);
2919 }
2920
2921 // Add rootId if we have one and it's not already a /root/ URL
2922 const rootId = getCurrentRootId();
2923 if (rootId && !url.pathname.includes('/root/')) {
2924 url.searchParams.set('rootId', rootId);
2925 }
2926
2927 anchor.href = url.pathname + url.search;
2928 } catch (err) {
2929 // ignore malformed URLs
2930 }
2931 }, true); // capture phase to run before default
2932
2933 // Also intercept form submissions
2934 doc.addEventListener('submit', (e) => {
2935 const form = e.target;
2936 if (!form || !form.action) return;
2937 try {
2938 const url = new URL(form.action, window.location.origin);
2939 if (url.origin !== window.location.origin) return;
2940
2941 // Inject hidden fields
2942 ['inApp', 'token', 'rootId'].forEach(key => {
2943 if (form.querySelector('input[name="' + key + '"]')) return;
2944 let val;
2945 if (key === 'inApp') val = '1';
2946 else if (key === 'token') val = CONFIG.htmlShareToken;
2947 else if (key === 'rootId') val = getCurrentRootId();
2948 if (!val) return;
2949 const input = doc.createElement('input');
2950 input.type = 'hidden';
2951 input.name = key;
2952 input.value = val;
2953 form.appendChild(input);
2954 });
2955 } catch (err) {}
2956 }, true);
2957
2958 // Intercept programmatic navigation (window.location assignments)
2959 const iframeWindow = iframe.contentWindow;
2960 if (iframeWindow) {
2961 const origPushState = iframeWindow.history.pushState?.bind(iframeWindow.history);
2962 const origReplaceState = iframeWindow.history.replaceState?.bind(iframeWindow.history);
2963
2964 function patchUrl(urlArg) {
2965 if (!urlArg || typeof urlArg !== 'string') return urlArg;
2966 try {
2967 const u = new URL(urlArg, window.location.origin);
2968 if (u.origin !== window.location.origin) return urlArg;
2969 if (!u.searchParams.has('inApp')) u.searchParams.set('inApp', '1');
2970 if (!u.searchParams.has('token')) u.searchParams.set('token', CONFIG.htmlShareToken);
2971 const rootId = getCurrentRootId();
2972 if (rootId && !u.pathname.includes('/root/')) u.searchParams.set('rootId', rootId);
2973 return u.pathname + u.search;
2974 } catch (e) { return urlArg; }
2975 }
2976
2977 if (origPushState) {
2978 iframeWindow.history.pushState = function(state, title, url) {
2979 return origPushState(state, title, patchUrl(url));
2980 };
2981 }
2982 if (origReplaceState) {
2983 iframeWindow.history.replaceState = function(state, title, url) {
2984 return origReplaceState(state, title, patchUrl(url));
2985 };
2986 }
2987 }
2988 } catch (e) {
2989 // Cross-origin or sandbox restriction — can't inject
2990 console.warn('[iframe] param forwarding injection failed:', e.message);
2991 }
2992}
2993
2994 // Socket events
2995 socket.on("treeChanged", ({ nodeId, changeType, details }) => {
2996 console.log("[socket] tree changed:", changeType, nodeId);
2997 loadingOverlay.classList.add("visible");
2998 iframe.contentWindow?.location.reload();
2999 });
3000
3001 socket.on("toolResult", ({ tool, args, success, error }) => {
3002 console.log("[socket] tool:", tool, success ? "✓" : "✗", error || "");
3003 });
3004socket.on("executionStatus", ({ phase, text }) => {
3005 if (!text || phase === "done") return;
3006 console.log("[status]", phase, text);
3007 // Optionally show as a subtle inline status
3008 addOrchestratorStep("status:" + phase, text);
3009});
3010socket.on("orchestratorStep", ({ modeKey, result, timestamp }) => {
3011 console.log("[orchestrator]", modeKey, result);
3012 addOrchestratorStep(modeKey, result);
3013});
3014
3015function addOrchestratorStep(modeKey, result) {
3016 // Truncate long results for display
3017 let displayResult = result;
3018 // if (displayResult.length > 500) {
3019 // displayResult = displayResult.slice(0, 500) + "\\n… (truncated)";
3020 //}
3021
3022 const MODE_EMOJIS = {
3023 "intent": "🎯",
3024 "tree:navigate": "🧭",
3025 "tree:getContext": "📖",
3026 "tree:structure": "🏗️",
3027 "tree:edit": "✏️",
3028 "tree:notes": "📝",
3029 "tree:respond": "💬",
3030 };
3031
3032 const emoji = MODE_EMOJIS[modeKey] || "⚙️";
3033 const label = modeKey.replace("tree:", "");
3034
3035 [chatMessages, mobileChatMessages].forEach(container => {
3036 // Remove welcome if present
3037 const welcome = container.querySelector(".welcome-message");
3038 if (welcome) welcome.remove();
3039
3040 // Insert before the typing indicator if it exists
3041 const typing = container.querySelector(".typing-indicator")?.closest(".message");
3042
3043 const msg = document.createElement("div");
3044 msg.className = "message orchestrator-step";
3045 msg.innerHTML =
3046 '<div class="message-avatar">' + emoji + '</div>' +
3047 '<div class="message-content">' +
3048 '<span class="step-mode">' + escapeHtml(label) + '</span>' +
3049 '<span class="step-body">' + escapeHtml(displayResult) + '</span>' +
3050 '</div>';
3051
3052 if (typing) {
3053 container.insertBefore(msg, typing);
3054 } else {
3055 container.appendChild(msg);
3056 }
3057 container.scrollTop = container.scrollHeight;
3058 });
3059}
3060 // API
3061 window.TreeApp = {
3062 sendMessage: sendChatMessage,
3063 addMessage,
3064 navigate: (url) => {
3065 loadingOverlay.classList.add("visible");
3066 currentIframeUrl = url;
3067 iframe.src = buildIframeUrl(url);
3068},
3069 goHome: () => { loadingOverlay.classList.add("visible"); iframe.src = CONFIG.homeUrl; currentIframeUrl = CONFIG.homeUrl; },
3070 isConnected: () => isConnected,
3071 isRegistered: () => isRegistered,
3072 notifyNodeUpdated: (nodeId, changes) => { if (isRegistered) socket.emit("nodeUpdated", { nodeId, changes }); },
3073 notifyNodeNavigated: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeNavigated", { nodeId, nodeName }); },
3074 notifyNodeSelected: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeSelected", { nodeId, nodeName }); },
3075 notifyNodeCreated: (nodeId, nodeName, parentId) => { if (isRegistered) socket.emit("nodeCreated", { nodeId, nodeName, parentId }); },
3076 notifyNodeDeleted: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeDeleted", { nodeId, nodeName }); },
3077 notifyNoteCreated: (nodeId, noteContent) => { if (isRegistered) socket.emit("noteCreated", { nodeId, noteContent }); },
3078 clearConversation: () => { if (isRegistered) socket.emit("clearConversation"); },
3079 switchMode: (modeKey) => { if (isRegistered) socket.emit("switchMode", { modeKey }); },
3080 getCurrentMode: () => currentModeKey,
3081 getAvailableModes: () => availableModes,
3082 getRecentRoots: () => recentRoots,
3083 navigateToRoot: navigateToRoot
3084 };
3085
3086 ${dashboardJS()}
3087 </script>
3088</body>
3089</html>`);
3090 } catch (err) {
3091 console.error(err);
3092 res.status(500).send("Failed to load app");
3093 }
3094});
3095
3096export default router;
3097
1// routesURL/chat.js
2// Simple chat-only interface for tree conversations.
3// No iframe, no tree view — just pick a tree and talk.
4
5import express from "express";
6import User from "../../../db/models/user.js";
7import Node from "../../../db/models/node.js";
8import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
9import authenticateLite from "../../../middleware/authenticateLite.js";
10import { getNotifications } from "../../../core/tree/notifications.js";
11import {
12 getPendingInvitesForUser,
13 respondToInvite,
14} from "../../../core/tree/invites.js";
15import { notFoundPage } from "../../../middleware/notFoundPage.js";
16import { getLandUrl } from "../../../canopy/identity.js";
17
18const router = express.Router();
19
20function escapeHtml(str) {
21 return str
22 .replace(/&/g, "&")
23 .replace(/</g, "<")
24 .replace(/>/g, ">")
25 .replace(/"/g, """)
26 .replace(/'/g, "'");
27}
28
29router.get("/chat", authenticateLite, async (req, res) => {
30 try {
31 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
32 return res.status(404).json({
33 error: "Server-rendered HTML is disabled. Use the SPA frontend.",
34 });
35 }
36 if (!req.userId) {
37 return res.redirect("/login");
38 }
39
40 const user = await User.findById(req.userId).select(
41 "username roots metadata",
42 );
43 if (!user) {
44 return notFoundPage(req, res, "This user doesn't exist.");
45 }
46
47 // Redirect to setup if user needs LLM or first tree (unless they skipped recently)
48 const setupSkipped = req.cookies?.setupSkipped === "1";
49 if (!setupSkipped) {
50 const hasMainLlm = !!user.llmDefault;
51 const hasTree = user.roots && user.roots.length > 0;
52 if (!hasMainLlm || !hasTree) {
53 const connCount = hasMainLlm
54 ? 1
55 : await CustomLlmConnection.countDocuments({ userId: req.userId });
56 if (connCount === 0 || !hasTree) {
57 return res.redirect("/setup");
58 }
59 }
60 }
61
62 const { username } = user;
63
64 // Load user's trees
65 const rootIds = (user.roots || []).map(String);
66 let trees = [];
67 if (rootIds.length > 0) {
68 trees = await Node.find({ _id: { $in: rootIds } })
69 .select("_id name children")
70 .lean();
71 }
72
73 const treesJSON = JSON.stringify(
74 trees.map((t) => ({
75 id: t._id,
76 name: t.name,
77 childCount: t.children?.length || 0,
78 })),
79 );
80
81 return res.send(`<!DOCTYPE html>
82<html lang="en">
83<head>
84 <meta charset="UTF-8" />
85 <title>Chat - TreeOS</title>
86 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
87 <meta name="theme-color" content="#736fe6" />
88 <link rel="icon" href="/tree.png" />
89 <link rel="canonical" href="${getLandUrl()}/chat" />
90 <meta name="robots" content="noindex, nofollow" />
91 <meta name="description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
92 <meta property="og:title" content="Chat - TreeOS" />
93 <meta property="og:description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
94 <meta property="og:url" content="${getLandUrl()}/chat" />
95 <meta property="og:type" content="website" />
96 <meta property="og:site_name" content="TreeOS" />
97 <meta property="og:image" content="${getLandUrl()}/tree.png" />
98 <link rel="preconnect" href="https://fonts.googleapis.com">
99 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
100 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
101 <style>
102 :root {
103 --glass-rgb: 115, 111, 230;
104 --glass-alpha: 0.28;
105 --glass-blur: 22px;
106 --glass-border: rgba(255, 255, 255, 0.28);
107 --glass-border-light: rgba(255, 255, 255, 0.15);
108 --glass-highlight: rgba(255, 255, 255, 0.25);
109 --text-primary: #ffffff;
110 --text-secondary: rgba(255, 255, 255, 0.9);
111 --text-muted: rgba(255, 255, 255, 0.6);
112 --accent: #10b981;
113 --accent-glow: rgba(16, 185, 129, 0.6);
114 --error: #ef4444;
115 --header-height: 56px;
116 --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
117 }
118
119 * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
120 html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); }
121 html { background: #736fe6; }
122 body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-attachment: fixed; }
123
124 /* Layout */
125 .container {
126 height: 100%; width: 100%;
127 display: flex; flex-direction: column;
128 max-width: 800px; margin: 0 auto;
129 }
130
131 /* Header */
132 .chat-header {
133 height: var(--header-height); padding: 0 20px;
134 display: flex; align-items: center; justify-content: space-between;
135 border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0;
136 }
137 .chat-title { display: flex; align-items: center; gap: 12px; }
138 .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
139 @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
140 .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
141
142 .header-right { display: flex; align-items: center; gap: 10px; }
143 .back-row {
144 display: none; padding: 8px 20px 0;
145 border-bottom: none; flex-shrink: 0;
146 }
147 .back-row.visible { display: flex; }
148 .back-btn {
149 display: flex; align-items: center; gap: 6px;
150 font-size: 12px; color: var(--text-muted);
151 background: rgba(255,255,255,0.1); border-radius: 8px;
152 padding: 6px 12px; border: 1px solid var(--glass-border-light);
153 cursor: pointer; transition: all var(--transition-fast);
154 font-family: inherit;
155 }
156 .back-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
157 .back-btn svg { width: 12px; height: 12px; }
158
159 .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
160 .status-badge .status-text { display: inline; }
161 .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
162 .status-dot.connected { background: var(--accent); }
163 .status-dot.disconnected { background: var(--error); animation: none; }
164 .status-dot.connecting { background: #f59e0b; }
165 @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
166
167 .advanced-btn {
168 font-size: 12px; color: var(--text-muted);
169 background: rgba(255,255,255,0.1); border-radius: 8px;
170 padding: 6px 14px; border: 1px solid var(--glass-border-light);
171 cursor: pointer; text-decoration: none; transition: all var(--transition-fast);
172 font-family: inherit;
173 }
174 .advanced-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
175
176 /* Root name inline */
177 .root-name-inline {
178 font-size: 13px; font-weight: 400; color: var(--text-muted);
179 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
180 max-width: 200px; opacity: 0; transition: opacity 0.3s ease;
181 }
182 .root-name-inline.visible { opacity: 1; }
183 .root-name-inline::before { content: ' / '; color: var(--glass-border-light); }
184
185 /* Tree picker */
186 .tree-picker {
187 flex: 1; display: flex; flex-direction: column;
188 align-items: center;
189 padding: 32px 20px 40px; gap: 24px;
190 overflow-y: auto; min-height: 0;
191 }
192 .tree-picker-title { font-size: 24px; font-weight: 600; margin-bottom: 4px; flex-shrink: 0; }
193 .tree-picker-sub { color: var(--text-muted); font-size: 15px; text-align: center; flex-shrink: 0; }
194 .tree-list { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 420px; }
195 .tree-item {
196 background: rgba(var(--glass-rgb), var(--glass-alpha));
197 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
198 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
199 border: 1px solid var(--glass-border-light);
200 border-radius: 16px; padding: 18px 22px;
201 cursor: pointer; transition: all var(--transition-fast);
202 display: flex; align-items: center; justify-content: space-between;
203 animation: fadeInUp 0.3s ease-out backwards;
204 }
205 .tree-item:hover { background: rgba(var(--glass-rgb), 0.42); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
206 .tree-item:active { transform: translateY(0) scale(0.98); }
207 .tree-item-left { display: flex; align-items: center; gap: 14px; }
208 .tree-item-icon { font-size: 22px; }
209 .tree-item-name { font-size: 15px; font-weight: 500; }
210 .tree-item-meta { font-size: 12px; color: var(--text-muted); }
211 @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } }
212 ${trees.map((_, i) => `.tree-item:nth-child(${i + 1}) { animation-delay: ${i * 0.06}s; }`).join("\n ")}
213
214 .empty-state {
215 background: rgba(var(--glass-rgb), var(--glass-alpha));
216 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
217 border: 1px solid var(--glass-border-light);
218 border-radius: 20px; padding: 48px 32px;
219 text-align: center; max-width: 400px;
220 }
221 .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; display: block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); }
222 .empty-state h2 { font-size: 20px; margin-bottom: 8px; }
223 .empty-state p { color: var(--text-muted); font-size: 14px; margin-bottom: 20px; line-height: 1.5; }
224 /* Create tree form */
225 .create-tree-form {
226 display: flex; gap: 8px; width: 100%; max-width: 420px; margin-top: 8px;
227 flex-shrink: 0; padding-bottom: 8px;
228 }
229 .create-tree-form input {
230 flex: 1; padding: 14px 18px; font-size: 15px;
231 background: rgba(var(--glass-rgb), 0.25);
232 border: 1px solid var(--glass-border-light);
233 border-radius: 14px; color: var(--text-primary);
234 transition: all 0.2s; outline: none;
235 }
236 .create-tree-form input::placeholder { color: var(--text-muted); }
237 .create-tree-form input:focus {
238 border-color: var(--accent); background: rgba(var(--glass-rgb), 0.35);
239 box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
240 }
241 .create-tree-form button {
242 padding: 14px 18px; font-size: 20px; line-height: 1;
243 background: rgba(var(--glass-rgb), 0.3);
244 border: 1px solid var(--glass-border-light);
245 border-radius: 14px; color: var(--text-primary);
246 cursor: pointer; transition: all 0.2s;
247 }
248 .create-tree-form button:hover {
249 background: var(--accent); border-color: var(--accent);
250 box-shadow: 0 4px 15px var(--accent-glow);
251 }
252 .create-tree-form button:disabled { opacity: 0.4; cursor: not-allowed; }
253
254 /* Chat area */
255 .chat-area { flex: 1; display: none; flex-direction: column; overflow: hidden; }
256 .chat-area.active { display: flex; }
257
258 /* Messages — matches app.js */
259 .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
260 .chat-messages::-webkit-scrollbar { width: 4px; }
261 .chat-messages::-webkit-scrollbar-track { background: transparent; margin: 8px 0; }
262 .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 4px; }
263 .chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
264 .chat-messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.12) transparent; }
265
266 .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
267 @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
268 .message.user { flex-direction: row-reverse; }
269 .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
270 .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
271 .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
272 .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
273 .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
274 .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
275
276 /* Message content formatting — matches app.js */
277 .message-content p { margin: 0 0 10px 0; word-break: break-word; }
278 .message-content p:last-child { margin-bottom: 0; }
279 .message-content h1, .message-content h2, .message-content h3, .message-content h4 { margin: 14px 0 8px 0; font-weight: 600; line-height: 1.3; }
280 .message-content h1:first-child, .message-content h2:first-child, .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
281 .message-content h1 { font-size: 17px; }
282 .message-content h2 { font-size: 16px; }
283 .message-content h3 { font-size: 15px; }
284 .message-content h4 { font-size: 14px; color: var(--text-secondary); }
285 .message-content ul, .message-content ol { margin: 8px 0; padding-left: 0; list-style: none; }
286 .message-content li { margin: 4px 0; padding: 6px 10px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; line-height: 1.4; word-break: break-word; }
287 .message-content li .list-num { color: var(--accent); font-weight: 600; margin-right: 6px; }
288 .message-content strong, .message-content b { font-weight: 600; color: #fff; }
289 .message-content em, .message-content i { font-style: italic; color: var(--text-secondary); }
290 .message-content code { background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }
291 .message-content pre { background: rgba(0, 0, 0, 0.3); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; max-width: 100%; }
292 .message-content pre code { background: none; padding: 0; word-break: normal; white-space: pre-wrap; }
293 .message-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 10px 0; color: var(--text-secondary); font-style: italic; }
294 .message-content hr { border: none; border-top: 1px solid var(--glass-border-light); margin: 14px 0; }
295 .message-content a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
296 .message-content a:hover { text-decoration: none; }
297
298 /* Menu items */
299 .message-content .menu-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin: 6px 0; background: rgba(255, 255, 255, 0.08); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.06); transition: all 0.15s ease; }
300 .message-content .menu-item.clickable { cursor: pointer; user-select: none; }
301 .message-content .menu-item.clickable:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(16, 185, 129, 0.3); transform: translateX(4px); }
302 .message-content .menu-item.clickable:active { transform: translateX(4px) scale(0.98); background: rgba(16, 185, 129, 0.2); }
303 .message-content .menu-item:first-of-type { margin-top: 8px; }
304 .message-content .menu-number { display: flex; align-items: center; justify-content: center; min-width: 26px; max-width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); border-radius: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); transition: all 0.15s ease; }
305 .message-content .menu-item.clickable:hover .menu-number { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5); }
306 .message-content .menu-text { flex: 1; min-width: 0; padding-top: 2px; word-break: break-word; overflow-wrap: break-word; }
307 .message-content .menu-text strong { display: block; margin-bottom: 2px; word-break: break-word; }
308
309 /* Typing indicator — matches app.js */
310 .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
311 .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
312 .typing-dot:nth-child(2) { animation-delay: 0.2s; }
313 .typing-dot:nth-child(3) { animation-delay: 0.4s; }
314 @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
315
316 /* Input — matches app.js */
317 .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); }
318 .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
319 .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
320 .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto; }
321 .chat-input::-webkit-scrollbar { width: 4px; }
322 .chat-input::-webkit-scrollbar-track { background: transparent; }
323 .chat-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
324 .chat-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
325 .chat-area.empty .chat-input { max-height: 40vh; }
326 .chat-input::placeholder { color: var(--text-muted); }
327 .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
328 .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
329 .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
330 .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
331 .send-btn svg { width: 20px; height: 20px; }
332 .send-btn.stop-mode { background: rgba(239, 68, 68, 0.7); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
333 .send-btn.stop-mode:hover:not(:disabled) { background: rgba(239, 68, 68, 0.9); box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); }
334
335 /* Mode toggle */
336 .mode-toggle { display: flex; gap: 4px; padding: 0 2px 10px; }
337 .mode-btn { padding: 4px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text-muted); font-size: 12px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
338 .mode-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-secondary); }
339 .mode-btn.active { background: rgba(255,255,255,0.18); color: var(--text-primary); border-color: rgba(255,255,255,0.25); }
340 .mode-btn.active[data-mode="chat"] { background: var(--accent); border-color: var(--accent); color: #fff; }
341 .mode-btn.active[data-mode="place"] { background: rgba(72,187,120,0.4); border-color: rgba(72,187,120,0.5); color: #fff; }
342 .mode-btn.active[data-mode="query"] { background: rgba(115,111,230,0.4); border-color: rgba(115,111,230,0.5); color: #fff; }
343 .mode-hint { font-size: 11px; color: var(--text-muted); padding: 0 4px 6px; opacity: 0.7; }
344
345 /* Place result message */
346 .place-result { font-size: 13px; color: var(--text-muted); padding: 8px 14px; background: rgba(72,187,120,0.08); border-radius: 12px; border: 1px solid rgba(72,187,120,0.15); margin: 4px 0; }
347
348 /* Empty state — input pinned to vertical center, welcome above it */
349 .chat-area.empty { position: relative; }
350 .chat-area.empty .chat-input-area { position: absolute; top: 40%; left: 50%; transform: translate(-50%, 0); border-top: none; max-width: 600px; width: calc(100% - 40px); }
351 .chat-area.empty .chat-messages { position: absolute; top: 40%; left: 0; right: 0; transform: translateY(-100%); display: flex; flex-direction: column; align-items: center; overflow: visible; flex: none; }
352
353 /* Welcome message */
354 .welcome-message { text-align: center; padding: 40px 20px; }
355 .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
356 @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
357 .chat-area.empty .welcome-message { padding: 8px 20px; }
358 .chat-area.empty .welcome-icon { font-size: 48px; margin-bottom: 12px; }
359 .chat-area.empty .welcome-message h2 { font-size: 18px; margin-bottom: 6px; }
360 .chat-area.empty .welcome-message p { font-size: 13px; }
361 .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
362 .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; }
363 .welcome-message.disconnected { opacity: 0.7; }
364 .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
365
366 /* Notifications panel */
367 .clear-chat-btn {
368 background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
369 border-radius: 8px; padding: 6px 8px; cursor: pointer;
370 color: var(--text-muted); transition: all var(--transition-fast);
371 display: none; align-items: center; justify-content: center;
372 }
373 .clear-chat-btn.visible { display: flex; }
374 .clear-chat-btn:hover { background: rgba(255,255,255,0.2); color: var(--text-primary); }
375 .clear-chat-btn:active { transform: scale(0.93); }
376 .clear-chat-btn svg { width: 14px; height: 14px; }
377 .notif-btn {
378 font-size: 12px; color: var(--text-muted);
379 background: rgba(255,255,255,0.1); border-radius: 8px;
380 padding: 6px 14px; border: 1px solid var(--glass-border-light);
381 cursor: pointer; transition: all var(--transition-fast);
382 font-family: inherit; position: relative;
383 display: flex; align-items: center; gap: 6px;
384 }
385 .notif-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
386 .notif-btn-icon { display: none; font-size: 14px; line-height: 1; }
387 .notif-btn .notif-dot {
388 position: absolute; top: -3px; right: -3px;
389 width: 8px; height: 8px; border-radius: 50%;
390 background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
391 display: none;
392 }
393 .notif-btn .notif-dot.has-notifs { display: block; }
394
395 .notif-overlay {
396 position: fixed; inset: 0; background: rgba(0,0,0,0.4);
397 z-index: 9998; display: none;
398 }
399 .notif-overlay.open { display: block; }
400
401 .notif-panel {
402 position: fixed; top: 0; right: -400px; bottom: 0;
403 width: 380px; max-width: 90vw;
404 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
405 z-index: 9999;
406 display: flex; flex-direction: column;
407 transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
408 box-shadow: -8px 0 32px rgba(0,0,0,0.3);
409 }
410 .notif-panel.open { right: 0; }
411
412 .notif-panel-header {
413 padding: 20px; display: flex; align-items: center;
414 justify-content: space-between; flex-shrink: 0;
415 border-bottom: 1px solid var(--glass-border-light);
416 }
417 .notif-panel-header h2 { font-size: 18px; font-weight: 600; color: white; }
418 .notif-close {
419 width: 32px; height: 32px; border-radius: 8px;
420 background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
421 color: white; cursor: pointer; font-size: 16px; display: flex;
422 align-items: center; justify-content: center; transition: all var(--transition-fast);
423 }
424 .notif-close:hover { background: rgba(255,255,255,0.2); }
425
426 .notif-list {
427 flex: 1; overflow-y: auto; padding: 16px;
428 display: flex; flex-direction: column; gap: 12px;
429 }
430 .notif-list::-webkit-scrollbar { width: 6px; }
431 .notif-list::-webkit-scrollbar-track { background: transparent; }
432 .notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
433
434 .notif-item {
435 background: rgba(var(--glass-rgb), var(--glass-alpha));
436 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
437 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
438 border: 1px solid var(--glass-border-light);
439 border-radius: 14px; padding: 16px;
440 animation: fadeInUp 0.3s ease-out backwards;
441 transition: all var(--transition-fast);
442 }
443 .notif-item:hover {
444 background: rgba(var(--glass-rgb), 0.42);
445 transform: translateY(-1px);
446 }
447 .notif-item.type-thought { border-left: 3px solid #9b64dc; }
448 .notif-item.type-summary { border-left: 3px solid #6464d2; }
449
450 .notif-item-header {
451 display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
452 }
453 .notif-item-icon { font-size: 18px; flex-shrink: 0; }
454 .notif-item-title {
455 font-size: 14px; font-weight: 600; color: white;
456 flex: 1; line-height: 1.3;
457 }
458 .notif-item-badge {
459 font-size: 9px; font-weight: 700; text-transform: uppercase;
460 letter-spacing: 0.5px; padding: 2px 7px; border-radius: 6px;
461 background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
462 border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
463 }
464 .notif-item-content {
465 font-size: 13px; color: rgba(255,255,255,0.85);
466 line-height: 1.55; white-space: pre-wrap; word-break: break-word;
467 }
468 .notif-item-time {
469 font-size: 11px; color: rgba(255,255,255,0.45);
470 margin-top: 8px;
471 }
472 .notif-empty {
473 text-align: center; color: rgba(255,255,255,0.5);
474 font-size: 14px; padding: 40px 20px;
475 }
476 .notif-empty-icon { font-size: 40px; margin-bottom: 12px; display: block; }
477 .notif-loading { text-align: center; color: rgba(255,255,255,0.5); padding: 40px 20px; font-size: 14px; }
478
479 /* Notification tabs */
480 .notif-tabs {
481 display: flex; gap: 0; flex-shrink: 0;
482 border-bottom: 1px solid var(--glass-border-light);
483 }
484 .notif-tab {
485 flex: 1; padding: 10px 0; text-align: center;
486 font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.5);
487 background: none; border: none; border-bottom: 2px solid transparent;
488 cursor: pointer; font-family: inherit; transition: all var(--transition-fast);
489 position: relative;
490 }
491 .notif-tab:hover { color: rgba(255,255,255,0.8); }
492 .notif-tab.active { color: white; border-bottom-color: white; }
493 .notif-tab .tab-dot {
494 display: none; width: 6px; height: 6px; border-radius: 50%;
495 background: var(--accent); position: absolute; top: 8px; right: calc(50% - 30px);
496 }
497 .notif-tab .tab-dot.visible { display: block; }
498
499 /* Invite items */
500 .invite-item {
501 background: rgba(var(--glass-rgb), var(--glass-alpha));
502 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
503 border: 1px solid var(--glass-border-light);
504 border-radius: 14px; padding: 16px;
505 border-left: 3px solid #48bb78;
506 animation: fadeInUp 0.3s ease-out backwards;
507 }
508 .invite-item-text { font-size: 13px; color: rgba(255,255,255,0.9); line-height: 1.5; margin-bottom: 10px; }
509 .invite-item-text strong { color: white; }
510 .invite-item-actions { display: flex; gap: 8px; }
511 .invite-item-actions button {
512 flex: 1; padding: 8px; border-radius: 8px; border: none;
513 font-family: inherit; font-size: 12px; font-weight: 600;
514 cursor: pointer; transition: all var(--transition-fast);
515 }
516 .invite-accept { background: var(--accent); color: white; }
517 .invite-accept:hover { filter: brightness(1.1); }
518 .invite-decline { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid var(--glass-border-light) !important; }
519 .invite-decline:hover { background: rgba(255,255,255,0.18); color: white; }
520
521 /* Members section */
522 .members-section { margin-top: 4px; }
523 .members-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); margin-bottom: 8px; }
524 .member-item {
525 display: flex; align-items: center; justify-content: space-between;
526 padding: 10px 14px; border-radius: 10px;
527 background: rgba(var(--glass-rgb), var(--glass-alpha));
528 border: 1px solid var(--glass-border-light); margin-bottom: 8px;
529 }
530 .member-name { font-size: 13px; color: white; font-weight: 500; }
531 .member-role { font-size: 11px; color: rgba(255,255,255,0.45); margin-left: 6px; }
532 .member-actions { display: flex; gap: 6px; }
533 .member-actions button {
534 padding: 4px 10px; border-radius: 6px; border: 1px solid var(--glass-border-light);
535 background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6);
536 font-family: inherit; font-size: 11px; cursor: pointer;
537 transition: all var(--transition-fast);
538 }
539 .member-actions button:hover { background: rgba(255,255,255,0.15); color: white; }
540 .member-actions .btn-danger:hover { background: rgba(239,68,68,0.3); color: #fca5a5; border-color: rgba(239,68,68,0.4); }
541
542 .invite-form {
543 display: flex; gap: 8px; margin-top: 12px;
544 }
545 .invite-form input {
546 flex: 1; padding: 8px 12px; border-radius: 8px;
547 background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
548 color: white; font-family: inherit; font-size: 13px; outline: none;
549 }
550 .invite-form input::placeholder { color: rgba(255,255,255,0.35); }
551 .invite-form input:focus { border-color: var(--glass-border); }
552 .invite-form button {
553 padding: 8px 16px; border-radius: 8px; border: none;
554 background: var(--accent); color: white; font-family: inherit;
555 font-size: 12px; font-weight: 600; cursor: pointer;
556 transition: all var(--transition-fast);
557 }
558 .invite-form button:hover { filter: brightness(1.1); }
559 .invite-form button:disabled { opacity: 0.5; cursor: not-allowed; }
560
561 .invite-status {
562 font-size: 12px; margin-top: 6px; padding: 6px 10px;
563 border-radius: 6px; display: none;
564 }
565 .invite-status.error { display: block; background: rgba(239,68,68,0.15); color: #fca5a5; }
566 .invite-status.success { display: block; background: rgba(16,185,129,0.15); color: #6ee7b7; }
567
568 /* Dream time config */
569 .dream-config {
570 background: rgba(var(--glass-rgb), var(--glass-alpha));
571 border: 1px solid var(--glass-border-light);
572 border-radius: 14px; padding: 14px 16px; margin-bottom: 12px;
573 }
574 .dream-config-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
575 .dream-config-row { display: flex; align-items: center; gap: 8px; }
576 .dream-config-row input[type="time"] {
577 padding: 6px 10px; border-radius: 8px;
578 background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
579 color: white; font-family: inherit; font-size: 13px; outline: none;
580 color-scheme: dark;
581 }
582 .dream-config-row input[type="time"]:focus { border-color: var(--glass-border); }
583 .dream-config-row button {
584 padding: 6px 12px; border-radius: 8px; border: none;
585 font-family: inherit; font-size: 12px; font-weight: 600;
586 cursor: pointer; transition: all var(--transition-fast);
587 }
588 .dream-config-save { background: var(--accent); color: white; }
589 .dream-config-save:hover { filter: brightness(1.1); }
590 .dream-config-off {
591 background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
592 border: 1px solid var(--glass-border-light) !important;
593 }
594 .dream-config-off:hover { background: rgba(255,255,255,0.18); color: white; }
595 .dream-config-status { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 6px; }
596 .dream-config-hint { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.4; }
597
598 .notif-panel-footer {
599 padding: 16px; border-top: 1px solid var(--glass-border-light); flex-shrink: 0;
600 }
601 .logout-btn {
602 width: 100%; padding: 10px; border-radius: 10px;
603 background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
604 color: #fca5a5; font-family: inherit; font-size: 13px;
605 cursor: pointer; transition: all var(--transition-fast);
606 }
607 .logout-btn:hover { background: rgba(239,68,68,0.3); color: #fecaca; }
608
609 @media (max-width: 600px) {
610 .container { max-width: 100%; }
611 .chat-header { padding: 0 12px; }
612 .header-right { gap: 6px; }
613 .chat-input-area { padding: 12px 16px 16px; }
614
615 /* Collapse status badge to dot only */
616 .status-badge .status-text { display: none; }
617 .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
618
619 /* Collapse notifications button to icon only */
620 .notif-btn-label { display: none; }
621 .notif-btn-icon { display: inline; }
622 .notif-btn { padding: 6px 8px; }
623
624 /* Shrink other buttons */
625 .advanced-btn { padding: 6px 10px; font-size: 11px; }
626 .back-btn { padding: 4px 8px; font-size: 11px; }
627 .back-btn svg { width: 10px; height: 10px; }
628
629 /* Hide title text, keep icon */
630 .chat-title h1 { display: none; }
631
632 .notif-panel { width: 100%; max-width: 100%; right: -100%; }
633 .notif-panel.open { right: 0; }
634
635 /* Empty state — mobile: push to top */
636 .chat-area.empty { overflow: visible; }
637 .chat-area.empty .chat-messages { position: static; flex: 0; padding-top: 0; transform: none; overflow: visible; }
638 .chat-area.empty .chat-input-area { position: static; transform: none; width: 100%; max-width: 100%; margin: 0; }
639 }
640 </style>
641</head>
642<body>
643 <div class="container">
644 <div class="chat-header">
645 <div class="chat-title">
646 <a href="/app" style="text-decoration:none;display:flex;align-items:center;gap:12px;color:inherit;">
647 <span class="tree-icon">🌳</span>
648 <h1>Tree</h1>
649 </a>
650 <span class="root-name-inline" id="rootName"></span>
651 </div>
652 <div class="header-right">
653 <div class="status-badge">
654 <div class="status-dot connecting" id="statusDot"></div>
655 <span class="status-text" id="statusText">Connecting</span>
656 </div>
657 <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
658 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
659 </button>
660 <button class="notif-btn" id="notifBtn" onclick="toggleNotifs()">
661 <span class="notif-dot" id="notifDot"></span>
662 <span class="notif-btn-icon">☰</span>
663 <span class="notif-btn-label">Menu</span>
664 </button>
665 <a href="/dashboard" class="advanced-btn" id="advancedLink">Advanced</a>
666 </div>
667 </div>
668 <div class="back-row" id="backRow">
669 <button class="back-btn" onclick="backToTrees()">
670 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
671 Back
672 </button>
673 </div>
674
675 <!-- Menu panel -->
676 <div class="notif-overlay" id="notifOverlay" onclick="toggleNotifs()"></div>
677 <div class="notif-panel" id="notifPanel">
678 <div class="notif-panel-header">
679 <h2>Menu</h2>
680 <button class="notif-close" onclick="toggleNotifs()">✕</button>
681 </div>
682 <div class="notif-tabs">
683 <button class="notif-tab active" id="tabDreams" onclick="switchTab('dreams')">Dreams<span class="tab-dot" id="dreamsDot"></span></button>
684 <button class="notif-tab" id="tabInvites" onclick="switchTab('invites')">Invites<span class="tab-dot" id="invitesDot"></span></button>
685 </div>
686 <div class="notif-list" id="notifList">
687 <div class="notif-loading">Loading...</div>
688 </div>
689 <div class="notif-list" id="invitesList" style="display:none">
690 <div class="notif-loading">Loading...</div>
691 </div>
692 <div class="notif-panel-footer">
693 <button class="logout-btn" onclick="doLogout()">Log out</button>
694 </div>
695 </div>
696
697 <div class="tree-picker" id="treePicker">
698 ${
699 trees.length === 0
700 ? `<div class="empty-state">
701 <span class="empty-icon">🌱</span>
702 <h2>Plant your first tree</h2>
703 <p>A tree starts with a single root, a broad topic that everything else branches out from. Think big categories like <strong>My Life</strong>, <strong>Career</strong>, or <strong>Health</strong>.</p>
704 <p style="margin-top:8px;">Name it, and you can start chatting with it right away.</p>
705 <form class="create-tree-form" style="margin-top:16px;" onsubmit="createTree(event)">
706 <input type="text" id="newTreeNameEmpty" placeholder="e.g. My Life" autocomplete="off" />
707 <button type="submit" title="Create tree">+</button>
708 </form>
709 </div>`
710 : `<h2 class="tree-picker-title">Your Trees</h2>
711 <p class="tree-picker-sub">Pick a tree to start chatting</p>
712 <div class="tree-list" id="treeList">
713 ${trees
714 .map(
715 (t) => `
716 <div class="tree-item" onclick="selectTree('${t._id}', '${escapeHtml(t.name)}')">
717 <span class="tree-item-icon">🌳</span>
718 <span class="tree-item-name">${escapeHtml(t.name)}</span>
719 </div>`,
720 )
721 .join("")}
722 </div>`
723 }
724 ${
725 trees.length > 0
726 ? `
727 <form class="create-tree-form" id="createTreeForm" onsubmit="createTree(event)">
728 <input type="text" id="newTreeName" placeholder="New tree name..." autocomplete="off" />
729 <button type="submit" title="Create tree">+</button>
730 </form>`
731 : ""
732 }
733 </div>
734
735 <div class="chat-area empty" id="chatArea">
736 <div class="chat-messages" id="messages">
737 <div class="welcome-message" id="welcomeMsg">
738 <div class="welcome-icon">🌳</div>
739 <h2>Start chatting</h2>
740 <p>Ask anything about your tree or tell it something new.</p>
741 </div>
742 </div>
743 <div class="chat-input-area">
744 <div class="mode-toggle" id="modeToggle">
745 <button class="mode-btn active" data-mode="chat">Chat</button>
746 <button class="mode-btn" data-mode="place">Place</button>
747 <button class="mode-btn" data-mode="query">Query</button>
748 </div>
749 <div class="input-container">
750 <textarea class="chat-input" id="chatInput" placeholder="Say something..." rows="1"></textarea>
751 <button class="send-btn" id="sendBtn" disabled>
752 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
753 </button>
754 </div>
755 </div>
756 </div>
757 </div>
758
759 <script src="/socket.io/socket.io.js"></script>
760 <script>
761 const CONFIG = {
762 username: "${escapeHtml(username)}",
763 userId: "${req.userId}",
764 trees: ${treesJSON},
765 };
766
767 // State
768 let activeRootId = null;
769 let isConnected = false;
770 let isRegistered = false;
771 let isSending = false;
772 let requestGeneration = 0;
773 let chatMode = "chat";
774
775 // Mode toggle
776 const modeToggle = document.getElementById("modeToggle");
777 const modePlaceholders = { chat: "Full conversation. Places content and responds.", place: "Places content onto your tree but doesn't respond.", query: "Talk to your tree without it making any changes." };
778 modeToggle.addEventListener("click", function(e) {
779 var btn = e.target.closest(".mode-btn");
780 if (!btn || isSending) return;
781 chatMode = btn.dataset.mode;
782 modeToggle.querySelectorAll(".mode-btn").forEach(function(b) { b.classList.remove("active"); });
783 btn.classList.add("active");
784 document.getElementById("chatInput").placeholder = modePlaceholders[chatMode] || "Say something...";
785 });
786
787 // Elements
788 const statusDot = document.getElementById("statusDot");
789 const statusText = document.getElementById("statusText");
790 const treePicker = document.getElementById("treePicker");
791 const chatArea = document.getElementById("chatArea");
792 const chatMessages = document.getElementById("messages");
793 const chatInput = document.getElementById("chatInput");
794 const sendBtn = document.getElementById("sendBtn");
795 const backRow = document.getElementById("backRow");
796 const rootName = document.getElementById("rootName");
797 const advancedLink = document.getElementById("advancedLink");
798
799 function escapeHtml(s) {
800 const d = document.createElement("div");
801 d.textContent = s;
802 return d.innerHTML;
803 }
804
805 // ── Markdown formatting — matches app.js ──────────────────────────
806 function formatMessageContent(text) {
807 if (!text) return '';
808 let html = text;
809
810 html = html.replace(/ /g, ' ');
811 html = html.replace(/&/g, '&');
812 html = html.replace(/</g, '<');
813 html = html.replace(/>/g, '>');
814 html = html.replace(/\\u00A0/g, ' ');
815
816 html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
817
818 // Code blocks
819 html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
820 html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
821
822 // Bold / italic
823 html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
824 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
825 html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
826
827 // Headings
828 html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
829 html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
830 html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
831 html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
832
833 // HR
834 html = html.replace(/^-{3,}$/gm, '<hr>');
835 html = html.replace(/^\\*{3,}$/gm, '<hr>');
836
837 // Blockquote
838 html = html.replace(/^>\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
839
840 // Numbered menu items with bold title
841 html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, function(m, num, title, rest) {
842 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '"') + '">' +
843 '<span class="menu-number">' + num + '</span>' +
844 '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
845 });
846
847 // Bullet items with bold title
848 html = html.replace(/^[-\\u2013\\u2022]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
849 '<div class="menu-item"><span class="menu-number">\\u2022</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
850
851 // Plain bullet items
852 html = html.replace(/^[-\\u2013\\u2022]\\s+([^<].*)$/gm, '<li>$1</li>');
853
854 // Numbered list items
855 html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
856
857 // Wrap consecutive li in ul
858 let inList = false;
859 const lines = html.split('\\n');
860 const processed = [];
861 for (let i = 0; i < lines.length; i++) {
862 const line = lines[i];
863 const isListItem = line.trim().startsWith('<li>');
864 if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
865 else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
866 processed.push(line);
867 }
868 if (inList) processed.push('</ul>');
869 html = processed.join('\\n');
870
871 // Links
872 html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
873
874 // Paragraphs
875 const blocks = html.split(/\\n\\n+/);
876 html = blocks.map(function(block) {
877 const trimmed = block.trim();
878 if (!trimmed) return '';
879 if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
880 const withBreaks = trimmed.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l; }).join('<br>');
881 return '<p>' + withBreaks + '</p>';
882 }).filter(function(b) { return b; }).join('');
883
884 // Clean up
885 html = html.replace(/<p><\\/p>/g, '');
886 html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote)/g, '$1');
887 html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>)<\\/p>/g, '$1');
888 html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
889 html = html.replace(/(<div[^>]*>)<br>/g, '$1');
890
891 return html;
892 }
893
894 // ── Socket ────────────────────────────────────────────────────────
895 const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
896
897 socket.on("connect", () => {
898 isConnected = true;
899 statusDot.className = "status-dot connecting";
900 statusText.textContent = "Connecting";
901 socket.emit("ready");
902 socket.emit("register", { username: CONFIG.username });
903 });
904
905 socket.on("registered", ({ success }) => {
906 if (success) {
907 isRegistered = true;
908 statusDot.className = "status-dot connected";
909 statusText.textContent = "Connected";
910
911 // Clear disconnected message on reconnect
912 const disc = chatMessages.querySelector(".welcome-message.disconnected");
913 if (disc) {
914 disc.remove();
915 chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
916 chatArea.classList.add("empty");
917 }
918
919 updateSendBtn();
920 if (activeRootId) {
921 socket.emit("setActiveRoot", { rootId: activeRootId });
922 socket.emit("urlChanged", { url: "/api/v1/root/" + activeRootId, rootId: activeRootId });
923 }
924 }
925 });
926
927 socket.on("chatResponse", ({ answer, generation }) => {
928 if (generation !== undefined && generation < requestGeneration) return;
929 removeTyping();
930 addMessage(answer, "assistant");
931 isSending = false;
932 updateSendBtn();
933 });
934
935 socket.on("placeResult", ({ stepSummaries, targetPath, generation }) => {
936 if (generation !== undefined && generation < requestGeneration) return;
937 var el = document.getElementById("placeStatus");
938 var summary = (stepSummaries && stepSummaries.length > 0)
939 ? "Placed on: " + (targetPath || stepSummaries.map(function(s) { return s.summary || s; }).join(", "))
940 : "Nothing to place for that message.";
941 if (el) {
942 el.querySelector(".place-result").textContent = summary;
943 } else {
944 addMessage(summary, "place-status");
945 }
946 isSending = false;
947 updateSendBtn();
948 });
949
950 socket.on("chatError", ({ error, generation }) => {
951 if (generation !== undefined && generation < requestGeneration) return;
952 removeTyping();
953 addMessage("Error: " + error, "error");
954 isSending = false;
955 updateSendBtn();
956 });
957
958 socket.on("chatCancelled", () => {
959 if (isSending) {
960 removeTyping();
961 isSending = false;
962 updateSendBtn();
963 }
964 });
965
966 socket.on("disconnect", () => {
967 isConnected = false;
968 isRegistered = false;
969 isSending = false;
970 statusDot.className = "status-dot disconnected";
971 statusText.textContent = "Disconnected";
972 updateSendBtn();
973
974 chatMessages.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the page to reconnect.</p></div>';
975 });
976
977 // Ignore navigate events — no iframe
978 socket.on("navigate", () => {});
979
980 // ── Create tree ─────────────────────────────────────────────────
981 async function createTree(e) {
982 e.preventDefault();
983 const input = e.target.querySelector("input[type=text]");
984 const name = input.value.trim();
985 if (!name) return;
986
987 const btn = e.target.querySelector("button");
988 btn.disabled = true;
989
990 try {
991 const res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
992 method: "POST",
993 headers: { "Content-Type": "application/json" },
994 credentials: "include",
995 body: JSON.stringify({ name }),
996 });
997 const data = await res.json();
998 if (!data.success) throw new Error(data.error || "Failed");
999
1000 // Add to tree list (create it if empty state)
1001 let treeList = document.getElementById("treeList");
1002 if (!treeList) {
1003 // Was empty state — rebuild picker content
1004 const emptyState = treePicker.querySelector(".empty-state");
1005 if (emptyState) emptyState.remove();
1006
1007 const title = document.createElement("h2");
1008 title.className = "tree-picker-title";
1009 title.textContent = "Your Trees";
1010
1011 const sub = document.createElement("p");
1012 sub.className = "tree-picker-sub";
1013 sub.textContent = "Pick a tree to start chatting";
1014
1015 treeList = document.createElement("div");
1016 treeList.className = "tree-list";
1017 treeList.id = "treeList";
1018
1019 const form = document.getElementById("createTreeForm");
1020 treePicker.insertBefore(treeList, form);
1021 treePicker.insertBefore(sub, treeList);
1022 treePicker.insertBefore(title, sub);
1023 }
1024
1025 const item = document.createElement("div");
1026 item.className = "tree-item";
1027 item.onclick = () => selectTree(data.rootId, name);
1028 item.innerHTML = \`
1029 <span class="tree-item-icon">🌳</span>
1030 <span class="tree-item-name">\${escapeHtml(name)}</span>\`;
1031 item.style.animation = "fadeInUp 0.3s ease-out";
1032 treeList.appendChild(item);
1033
1034 input.value = "";
1035 } catch (err) {
1036 console.error("Create tree error:", err);
1037 alert("Failed to create tree: " + err.message);
1038 } finally {
1039 btn.disabled = false;
1040 }
1041 }
1042
1043 // ── Tree selection ────────────────────────────────────────────────
1044 function selectTree(rootId, name) {
1045 activeRootId = rootId;
1046 advancedLink.href = "/dashboard?rootId=" + rootId;
1047 treePicker.style.display = "none";
1048 chatArea.classList.add("active");
1049 rootName.textContent = name;
1050 rootName.classList.add("visible");
1051 backRow.classList.add("visible");
1052
1053 // Reset chat
1054 const welcome = chatMessages.querySelector(".welcome-message");
1055 if (welcome) welcome.style.display = "";
1056 chatMessages.querySelectorAll(".message, .typing-indicator").forEach(el => el.remove());
1057 chatArea.classList.add("empty");
1058
1059 // Tell server about this root
1060 socket.emit("setActiveRoot", { rootId });
1061 socket.emit("urlChanged", { url: "/api/v1/root/" + rootId, rootId });
1062
1063 // Refresh menu panel for this tree
1064 dreamsLoaded = false;
1065 invitesLoaded = false;
1066 if (notifOpen) {
1067 if (activeTab === "dreams") fetchDreams();
1068 if (activeTab === "invites") fetchInvites();
1069 }
1070
1071 updateSendBtn();
1072 }
1073
1074 function backToTrees() {
1075 // Cancel any in-flight request
1076 if (isSending) {
1077 requestGeneration++;
1078 socket.emit("cancelRequest");
1079 removeTyping();
1080 }
1081
1082 activeRootId = null;
1083 advancedLink.href = "/dashboard";
1084 treePicker.style.display = "";
1085 chatArea.classList.remove("active");
1086 rootName.classList.remove("visible");
1087 backRow.classList.remove("visible");
1088 document.getElementById("clearChatBtn").classList.remove("visible");
1089 isSending = false;
1090 updateSendBtn();
1091
1092 // Tell server we're going home so it properly exits tree mode
1093 socket.emit("urlChanged", { url: "/api/v1/user/" + CONFIG.userId });
1094 socket.emit("clearConversation");
1095 dreamsLoaded = false;
1096 invitesLoaded = false;
1097 if (notifOpen) {
1098 if (activeTab === "dreams") fetchDreams();
1099 if (activeTab === "invites") fetchInvites();
1100 }
1101 }
1102
1103 // ── Messages ──────────────────────────────────────────────────────
1104 function addMessage(content, role) {
1105 const welcome = chatMessages.querySelector(".welcome-message");
1106 if (welcome) {
1107 welcome.remove();
1108 document.getElementById("chatArea").classList.remove("empty");
1109 document.getElementById("clearChatBtn").classList.add("visible");
1110 }
1111
1112 const msg = document.createElement("div");
1113 if (role === "place-status") {
1114 msg.className = "message assistant";
1115 msg.id = "placeStatus";
1116 msg.innerHTML = '<div class="message-avatar">\\ud83c\\udf33</div><div class="message-content"><div class="place-result">' + escapeHtml(content) + '</div></div>';
1117 chatMessages.appendChild(msg);
1118 chatMessages.scrollTop = chatMessages.scrollHeight;
1119 return;
1120 }
1121
1122 msg.className = "message " + role;
1123
1124 const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
1125
1126 msg.innerHTML =
1127 '<div class="message-avatar">' + (role === "user" ? "\\ud83d\\udc64" : "\\ud83c\\udf33") + '</div>' +
1128 '<div class="message-content">' + formattedContent + '</div>';
1129
1130 // Clickable menu items
1131 if (role === "assistant") {
1132 msg.querySelectorAll(".menu-item.clickable").forEach(function(item) {
1133 item.addEventListener("click", function() {
1134 const name = item.dataset.name;
1135 if (name && !isSending) {
1136 chatInput.value = name;
1137 sendMessage();
1138 }
1139 });
1140 });
1141 }
1142
1143 chatMessages.appendChild(msg);
1144 chatMessages.scrollTop = chatMessages.scrollHeight;
1145 }
1146
1147 function addTyping() {
1148 removeTyping();
1149 const msg = document.createElement("div");
1150 msg.className = "message assistant";
1151 msg.id = "typingIndicator";
1152 msg.innerHTML =
1153 '<div class="message-avatar">\\ud83c\\udf33</div>' +
1154 '<div class="message-content typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
1155 chatMessages.appendChild(msg);
1156 chatMessages.scrollTop = chatMessages.scrollHeight;
1157 }
1158
1159 function removeTyping() {
1160 const el = document.getElementById("typingIndicator");
1161 if (el) el.remove();
1162 }
1163
1164 // ── Send ──────────────────────────────────────────────────────────
1165 function sendMessage() {
1166 if (isSending) {
1167 requestGeneration++;
1168 socket.emit("cancelRequest");
1169 removeTyping();
1170 addMessage("Stopped", "error");
1171 isSending = false;
1172 updateSendBtn();
1173 return;
1174 }
1175
1176 const text = chatInput.value.trim();
1177 if (!text || !isRegistered || !activeRootId) return;
1178
1179 chatInput.value = "";
1180 chatInput.style.height = "auto";
1181 addMessage(text, "user");
1182 if (chatMode === "place") {
1183 addMessage("Placing...", "place-status");
1184 } else {
1185 addTyping();
1186 }
1187 isSending = true;
1188 requestGeneration++;
1189 updateSendBtn();
1190 socket.emit("chat", { message: text, username: CONFIG.username, generation: requestGeneration, mode: chatMode });
1191 }
1192
1193 function updateSendBtn() {
1194 const hasText = chatInput.value.trim().length > 0;
1195 if (isSending) {
1196 sendBtn.classList.add("stop-mode");
1197 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
1198 sendBtn.disabled = !(isConnected && isRegistered);
1199 chatInput.disabled = true;
1200 } else {
1201 sendBtn.classList.remove("stop-mode");
1202 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
1203 sendBtn.disabled = !(hasText && isRegistered && activeRootId);
1204 chatInput.disabled = false;
1205 }
1206 }
1207
1208 // ── Input handlers ────────────────────────────────────────────────
1209 chatInput.addEventListener("input", () => {
1210 const maxH = chatArea.classList.contains("empty") ? window.innerHeight * 0.4 : 120;
1211 chatInput.style.height = "auto";
1212 chatInput.style.height = Math.min(chatInput.scrollHeight, maxH) + "px";
1213 updateSendBtn();
1214 });
1215
1216 chatInput.addEventListener("keydown", (e) => {
1217 if (e.key === "Enter" && !e.shiftKey) {
1218 e.preventDefault();
1219 sendMessage();
1220 // On mobile, blur to dismiss keyboard so user can see the response
1221 if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
1222 chatInput.blur();
1223 }
1224 }
1225 });
1226
1227 sendBtn.addEventListener("click", sendMessage);
1228
1229 document.getElementById("clearChatBtn").addEventListener("click", () => {
1230 if (!isRegistered) return;
1231 if (isSending) {
1232 socket.emit("cancelRequest");
1233 removeTyping();
1234 isSending = false;
1235 }
1236 socket.emit("clearConversation");
1237 chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
1238 chatArea.classList.add("empty");
1239 document.getElementById("clearChatBtn").classList.remove("visible");
1240 updateSendBtn();
1241 });
1242
1243 // ── Notifications + Invites ────────────────────────────────────────
1244 const notifPanel = document.getElementById("notifPanel");
1245 const notifOverlay = document.getElementById("notifOverlay");
1246 const notifList = document.getElementById("notifList");
1247 const invitesList = document.getElementById("invitesList");
1248 const notifDot = document.getElementById("notifDot");
1249 let notifOpen = false;
1250 let dreamsLoaded = false;
1251 let invitesLoaded = false;
1252 let activeTab = "dreams";
1253
1254 async function doLogout() {
1255 try {
1256 await fetch("/api/v1/logout", { method: "POST", credentials: "include" });
1257 window.location.href = "/login";
1258 } catch(e) {
1259 alert("Logout failed");
1260 }
1261 }
1262
1263 function toggleNotifs() {
1264 notifOpen = !notifOpen;
1265 notifPanel.classList.toggle("open", notifOpen);
1266 notifOverlay.classList.toggle("open", notifOpen);
1267 if (notifOpen) {
1268 if (activeTab === "dreams" && !dreamsLoaded) fetchDreams();
1269 if (activeTab === "invites" && !invitesLoaded) fetchInvites();
1270 }
1271 }
1272
1273 function switchTab(tab) {
1274 activeTab = tab;
1275 document.getElementById("tabDreams").classList.toggle("active", tab === "dreams");
1276 document.getElementById("tabInvites").classList.toggle("active", tab === "invites");
1277 notifList.style.display = tab === "dreams" ? "" : "none";
1278 invitesList.style.display = tab === "invites" ? "" : "none";
1279 if (tab === "dreams" && !dreamsLoaded) fetchDreams();
1280 if (tab === "invites" && !invitesLoaded) fetchInvites();
1281 }
1282
1283 async function fetchDreams() {
1284 notifList.innerHTML = '<div class="notif-loading">Loading...</div>';
1285 dreamsLoaded = false;
1286 try {
1287 var dreamUrl = "/chat/notifications" + (activeRootId ? "?rootId=" + activeRootId : "");
1288 var res = await fetch(dreamUrl, { credentials: "include" });
1289 var data = await res.json();
1290 if (!res.ok) throw new Error(data.error || "Failed");
1291
1292 dreamsLoaded = true;
1293 var notifs = data.notifications || [];
1294 var html = "";
1295
1296 // Dream time config (only when inside a tree and user is owner)
1297 if (activeRootId && data.isOwner) {
1298 if (data.metadata?.dreams?.dreamTime) {
1299 html += '<div class="dream-config">' +
1300 '<div class="dream-config-label">Dream schedule</div>' +
1301 '<div class="dream-config-row">' +
1302 '<input type="time" id="dreamTimeInput" value="' + escapeHtml(data.metadata?.dreams?.dreamTime) + '" />' +
1303 '<button class="dream-config-save" onclick="saveDreamTime()">Save</button>' +
1304 '<button class="dream-config-off" onclick="disableDreamTime()">Turn Off</button>' +
1305 '</div>' +
1306 '<div class="dream-config-status" id="dreamStatus"></div>' +
1307 '</div>';
1308 } else {
1309 html += '<div class="dream-config">' +
1310 '<div class="dream-config-hint">Dreams are off for this tree. Set a time to enable nightly dreams. Your tree will reflect, reorganize, and share thoughts with you.</div>' +
1311 '<div class="dream-config-row" style="margin-top:8px">' +
1312 '<input type="time" id="dreamTimeInput" value="" />' +
1313 '<button class="dream-config-save" onclick="saveDreamTime()">Enable</button>' +
1314 '</div>' +
1315 '<div class="dream-config-status" id="dreamStatus"></div>' +
1316 '</div>';
1317 }
1318 }
1319
1320 if (notifs.length === 0) {
1321 html += '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udd14</span>' +
1322 (activeRootId ? 'No dreams from this tree yet' : 'No dream notifications from the last 7 days') +
1323 '</div>';
1324 notifList.innerHTML = html;
1325 return;
1326 }
1327
1328 document.getElementById("dreamsDot").classList.add("visible");
1329 notifDot.classList.add("has-notifs");
1330 html += notifs.map(function(n, i) {
1331 var isThought = n.type === "dream-thought";
1332 var icon = isThought ? "\\ud83d\\udcad" : "\\ud83d\\udccb";
1333 var badge = isThought ? "Thought" : "Summary";
1334 var cls = isThought ? "type-thought" : "type-summary";
1335 var date = new Date(n.createdAt).toLocaleDateString(undefined, {
1336 month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
1337 });
1338 return '<div class="notif-item ' + cls + '" style="animation-delay:' + (i * 0.04) + 's">' +
1339 '<div class="notif-item-header">' +
1340 '<span class="notif-item-icon">' + icon + '</span>' +
1341 '<span class="notif-item-title">' + escapeHtml(n.title) + '</span>' +
1342 '<span class="notif-item-badge">' + badge + '</span>' +
1343 '</div>' +
1344 '<div class="notif-item-content">' + escapeHtml(n.content) + '</div>' +
1345 '<div class="notif-item-time">' + date + '</div>' +
1346 '</div>';
1347 }).join("");
1348 notifList.innerHTML = html;
1349 } catch (err) {
1350 console.error("Dreams error:", err);
1351 notifList.innerHTML = '<div class="notif-empty">Failed to load notifications</div>';
1352 }
1353 }
1354
1355 async function saveDreamTime() {
1356 var input = document.getElementById("dreamTimeInput");
1357 var status = document.getElementById("dreamStatus");
1358 if (!input.value) { status.textContent = "Pick a time first"; return; }
1359 try {
1360 var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1361 method: "POST",
1362 headers: { "Content-Type": "application/json" },
1363 credentials: "include",
1364 body: JSON.stringify({ dreamTime: input.value }),
1365 });
1366 var data = await res.json();
1367 if (!res.ok) throw new Error(data.error || "Failed");
1368 status.textContent = "Dreams set for " + input.value;
1369 dreamsLoaded = false;
1370 fetchDreams();
1371 } catch (err) {
1372 status.textContent = err.message;
1373 }
1374 }
1375
1376 async function disableDreamTime() {
1377 var status = document.getElementById("dreamStatus");
1378 try {
1379 var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1380 method: "POST",
1381 headers: { "Content-Type": "application/json" },
1382 credentials: "include",
1383 body: JSON.stringify({ dreamTime: null }),
1384 });
1385 var data = await res.json();
1386 if (!res.ok) throw new Error(data.error || "Failed");
1387 status.textContent = "Dreams disabled";
1388 dreamsLoaded = false;
1389 fetchDreams();
1390 } catch (err) {
1391 status.textContent = err.message;
1392 }
1393 }
1394
1395 async function fetchInvites() {
1396 invitesList.innerHTML = '<div class="notif-loading">Loading...</div>';
1397 invitesLoaded = false;
1398 try {
1399 var invUrl = "/chat/invites" + (activeRootId ? "?rootId=" + activeRootId : "");
1400 var res = await fetch(invUrl, { credentials: "include" });
1401 var data = await res.json();
1402 if (!res.ok) throw new Error(data.error || "Failed");
1403
1404 invitesLoaded = true;
1405 var html = "";
1406
1407 // Pending invites section
1408 var invites = data.invites || [];
1409 if (invites.length > 0) {
1410 document.getElementById("invitesDot").classList.add("visible");
1411 notifDot.classList.add("has-notifs");
1412 html += invites.map(function(inv, i) {
1413 return '<div class="invite-item" style="animation-delay:' + (i * 0.04) + 's">' +
1414 '<div class="invite-item-text"><strong>' + escapeHtml(inv.from) + '</strong> invited you to <strong>' + escapeHtml(inv.treeName) + (inv.isRemote && inv.homeLand ? ' on ' + escapeHtml(inv.homeLand) : '') + '</strong></div>' +
1415 '<div class="invite-item-actions">' +
1416 '<button class="invite-accept" onclick="respondInvite(\\'' + inv.id + '\\', true, this)">Accept</button>' +
1417 '<button class="invite-decline" onclick="respondInvite(\\'' + inv.id + '\\', false, this)">Decline</button>' +
1418 '</div>' +
1419 '</div>';
1420 }).join("");
1421 }
1422
1423 // Members section (only when inside a tree)
1424 if (activeRootId && data.members) {
1425 var members = data.members;
1426 html += '<div class="members-section">';
1427 html += '<div class="members-section-title">Members</div>';
1428
1429 // Owner
1430 if (members.owner) {
1431 html += '<div class="member-item">' +
1432 '<div><span class="member-name">' + escapeHtml(members.owner.username) + '</span><span class="member-role">Owner</span></div>' +
1433 '</div>';
1434 }
1435
1436 // Contributors
1437 (members.contributors || []).forEach(function(c) {
1438 var isSelf = c._id === CONFIG.userId;
1439 var isOwner = members.isOwner;
1440 var actions = '';
1441 if (isOwner || isSelf) {
1442 var label = isSelf ? "Leave" : "Remove";
1443 var cls = isSelf ? "btn-danger" : "btn-danger";
1444 actions = '<div class="member-actions">';
1445 if (isOwner && !isSelf) {
1446 actions += '<button onclick="transferOwner(\\'' + c._id + '\\', this)">Transfer</button>';
1447 }
1448 actions += '<button class="' + cls + '" onclick="removeMember(\\'' + c._id + '\\', \\'' + label + '\\', this)">' + label + '</button>';
1449 actions += '</div>';
1450 }
1451 html += '<div class="member-item">' +
1452 '<div><span class="member-name">' + escapeHtml(c.username) + '</span></div>' +
1453 actions +
1454 '</div>';
1455 });
1456
1457 // Invite form (owner or contributor)
1458 if (members.isOwner || members.contributors.some(function(c) { return c._id === userId; })) {
1459 html += '<form class="invite-form" onsubmit="sendInvite(event)">' +
1460 '<input type="text" id="inviteUsername" placeholder="username or user@other.land.com" />' +
1461 '<button type="submit">Invite</button>' +
1462 '</form>' +
1463 '<div class="invite-status" id="inviteStatus"></div>';
1464 }
1465 html += '</div>';
1466 }
1467
1468 if (!html) {
1469 html = '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udcec</span>No pending invites</div>';
1470 }
1471
1472 invitesList.innerHTML = html;
1473 } catch (err) {
1474 console.error("Invites error:", err);
1475 invitesList.innerHTML = '<div class="notif-empty">Failed to load invites</div>';
1476 }
1477 }
1478
1479 async function respondInvite(inviteId, accept, btn) {
1480 var item = btn.closest(".invite-item");
1481 item.style.opacity = "0.5";
1482 item.style.pointerEvents = "none";
1483 try {
1484 var res = await fetch("/chat/invites/" + inviteId, {
1485 method: "POST",
1486 headers: { "Content-Type": "application/json" },
1487 credentials: "include",
1488 body: JSON.stringify({ accept: accept }),
1489 });
1490 var data = await res.json();
1491 if (!res.ok) throw new Error(data.error || "Failed");
1492 item.remove();
1493 // Refresh tree list if accepted
1494 if (accept) {
1495 location.reload();
1496 }
1497 } catch (err) {
1498 item.style.opacity = "1";
1499 item.style.pointerEvents = "";
1500 alert(err.message);
1501 }
1502 }
1503
1504 async function sendInvite(e) {
1505 e.preventDefault();
1506 var input = document.getElementById("inviteUsername");
1507 var status = document.getElementById("inviteStatus");
1508 var username = input.value.trim();
1509 if (!username) return;
1510
1511 status.className = "invite-status";
1512 status.textContent = "";
1513
1514 try {
1515 var res = await fetch("/api/v1/root/" + activeRootId + "/invite", {
1516 method: "POST",
1517 headers: { "Content-Type": "application/json" },
1518 credentials: "include",
1519 body: JSON.stringify({ userReceiving: username }),
1520 });
1521 var data = await res.json();
1522 if (!res.ok) throw new Error(data.error || "Failed");
1523
1524 status.textContent = "Invite sent!";
1525 status.className = "invite-status success";
1526 input.value = "";
1527 } catch (err) {
1528 status.textContent = err.message;
1529 status.className = "invite-status error";
1530 }
1531 }
1532
1533 async function removeMember(userId, label, btn) {
1534 if (!confirm("Are you sure you want to " + label.toLowerCase() + "?")) return;
1535 btn.disabled = true;
1536 try {
1537 var res = await fetch("/api/v1/root/" + activeRootId + "/remove-user", {
1538 method: "POST",
1539 headers: { "Content-Type": "application/json" },
1540 credentials: "include",
1541 body: JSON.stringify({ userReceiving: userId }),
1542 });
1543 var data = await res.json();
1544 if (!res.ok) throw new Error(data.error || "Failed");
1545 if (userId === CONFIG.userId) {
1546 location.reload();
1547 } else {
1548 invitesLoaded = false;
1549 fetchInvites();
1550 }
1551 } catch (err) {
1552 btn.disabled = false;
1553 alert(err.message);
1554 }
1555 }
1556
1557 async function transferOwner(userId, btn) {
1558 if (!confirm("Transfer ownership? This cannot be undone.")) return;
1559 btn.disabled = true;
1560 try {
1561 var res = await fetch("/api/v1/root/" + activeRootId + "/transfer-owner", {
1562 method: "POST",
1563 headers: { "Content-Type": "application/json" },
1564 credentials: "include",
1565 body: JSON.stringify({ userReceiving: userId }),
1566 });
1567 var data = await res.json();
1568 if (!res.ok) throw new Error(data.error || "Failed");
1569 invitesLoaded = false;
1570 fetchInvites();
1571 } catch (err) {
1572 btn.disabled = false;
1573 alert(err.message);
1574 }
1575 }
1576
1577 // Check for notifications + invites on load
1578 fetch("/chat/notifications", { credentials: "include" })
1579 .then(function(r) { return r.json(); })
1580 .then(function(d) {
1581 if (d.notifications && d.notifications.length > 0) {
1582 notifDot.classList.add("has-notifs");
1583 document.getElementById("dreamsDot").classList.add("visible");
1584 }
1585 })
1586 .catch(function() {});
1587
1588 fetch("/chat/invites", { credentials: "include" })
1589 .then(function(r) { return r.json(); })
1590 .then(function(d) {
1591 if (d.invites && d.invites.length > 0) {
1592 notifDot.classList.add("has-notifs");
1593 document.getElementById("invitesDot").classList.add("visible");
1594 }
1595 })
1596 .catch(function() {});
1597 </script>
1598</body>
1599</html>`);
1600 } catch (err) {
1601 console.error("Error rendering /chat:", err);
1602 return res.status(500).send("Internal server error");
1603 }
1604});
1605
1606router.get("/chat/notifications", authenticateLite, async (req, res) => {
1607 try {
1608 if (!req.userId)
1609 return res.status(401).json({ error: "Not authenticated" });
1610 const rootId = req.query.rootId;
1611 const { notifications, total } = await getNotifications({
1612 userId: req.userId,
1613 rootId,
1614 limit: 50,
1615 sinceDays: 7,
1616 });
1617
1618 // Include dream config when viewing a specific tree
1619 let dreamTime = null;
1620 let isOwner = false;
1621 if (rootId) {
1622 const rootNode = await Node.findById(rootId)
1623 .select("metadata rootOwner")
1624 .lean();
1625 if (rootNode) {
1626 dreamTime = rootNode.metadata?.dreams?.dreamTime || null;
1627 isOwner = rootNode.rootOwner?.toString() === req.userId.toString();
1628 }
1629 }
1630
1631 return res.json({ notifications, total, dreamTime, isOwner });
1632 } catch (err) {
1633 console.error("Chat notifications error:", err);
1634 return res.status(500).json({ error: err.message });
1635 }
1636});
1637
1638// ── Invites + Members API for chat panel ──────────────────────────────
1639router.get("/chat/invites", authenticateLite, async (req, res) => {
1640 try {
1641 if (!req.userId)
1642 return res.status(401).json({ error: "Not authenticated" });
1643
1644 // Pending invites for this user
1645 const invites = await getPendingInvitesForUser(req.userId);
1646 const inviteList = invites.map((inv) => ({
1647 id: inv._id,
1648 from: inv.userInviting?.username
1649 ? (inv.userInviting.isRemote && inv.userInviting.homeLand
1650 ? inv.userInviting.username + "@" + inv.userInviting.homeLand
1651 : inv.userInviting.username)
1652 : "Unknown",
1653 isRemote: inv.userInviting?.isRemote || false,
1654 homeLand: inv.userInviting?.homeLand || null,
1655 treeName: inv.rootId?.name || "Unknown tree",
1656 rootId: inv.rootId?._id || inv.rootId,
1657 }));
1658
1659 // Members (only if rootId query param provided = user is in a tree)
1660 let members = null;
1661 const rootId = req.query.rootId;
1662 if (rootId) {
1663 const rootNode = await Node.findById(rootId)
1664 .populate("rootOwner", "username _id")
1665 .populate("contributors", "username _id")
1666 .select("rootOwner contributors")
1667 .lean();
1668 if (rootNode) {
1669 members = {
1670 owner: rootNode.rootOwner || null,
1671 contributors: rootNode.contributors || [],
1672 isOwner:
1673 rootNode.rootOwner?._id?.toString() === req.userId.toString(),
1674 };
1675 }
1676 }
1677
1678 return res.json({ invites: inviteList, members });
1679 } catch (err) {
1680 console.error("Chat invites error:", err);
1681 return res.status(500).json({ error: err.message });
1682 }
1683});
1684
1685router.post("/chat/invites/:inviteId", authenticateLite, async (req, res) => {
1686 try {
1687 if (!req.userId)
1688 return res.status(401).json({ error: "Not authenticated" });
1689 const { accept } = req.body;
1690 await respondToInvite({
1691 inviteId: req.params.inviteId,
1692 userId: req.userId,
1693 acceptInvite: accept === true || accept === "true",
1694 });
1695 return res.json({ success: true });
1696 } catch (err) {
1697 return res.status(400).json({ error: err.message });
1698 }
1699});
1700
1701export default router;
1702
1// routesURL/sessionManagerPartial.js
2// Exports CSS, HTML, and JS strings for the session manager view
3// embedded inside app.js viewport panel.
4
5export function dashboardCSS() {
6 return `
7 /* ── Dashboard view ─────────────────────────────────────────────── */
8 .dashboard-view {
9 display: none;
10 width: 100%;
11 height: 100%;
12 flex-direction: column;
13 overflow: hidden;
14 }
15 .dashboard-view.active { display: flex; }
16 .dashboard-view.disconnected { position: relative; pointer-events: none; }
17 .dashboard-view.disconnected::after {
18 content: "Disconnected";
19 position: absolute;
20 inset: 0;
21 display: flex;
22 align-items: center;
23 justify-content: center;
24 background: rgba(0, 0, 0, 0.5);
25 color: var(--text-muted);
26 font-size: 14px;
27 font-weight: 500;
28 letter-spacing: 0.5px;
29 z-index: 100;
30 }
31 .iframe-container.hidden { display: none; }
32
33 .dashboard-layout {
34 display: flex;
35 flex: 1;
36 overflow: hidden;
37 }
38
39 /* ── Main area ───────────────────────────────────────────────────── */
40 .dash-tree-view {
41 flex: 1;
42 display: flex;
43 flex-direction: column;
44 padding: 16px;
45 position: relative;
46 min-height: 0;
47 overflow: hidden;
48 }
49 #dashForestView {
50 flex: 1;
51 overflow: auto;
52 min-height: 0;
53 }
54 #dashTreeContent {
55 flex: 1;
56 display: flex;
57 flex-direction: column;
58 min-height: 0;
59 }
60 #dashTreeCanvas {
61 flex: 1;
62 min-height: 0;
63 }
64 .dash-tree-header {
65 display: flex;
66 align-items: center;
67 gap: 10px;
68 margin-bottom: 12px;
69 padding-bottom: 8px;
70 border-bottom: 1px solid rgba(255,255,255,0.06);
71 }
72 .dash-tree-title {
73 font-size: 14px;
74 font-weight: 600;
75 color: var(--text-secondary);
76 }
77 .dash-back-btn {
78 padding: 4px 10px;
79 border-radius: 6px;
80 border: 1px solid rgba(255,255,255,0.1);
81 background: rgba(255,255,255,0.06);
82 color: var(--text-secondary);
83 font-size: 11px;
84 cursor: pointer;
85 transition: all 0.15s;
86 white-space: nowrap;
87 }
88 .dash-back-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
89
90 .dash-close-btn {
91 margin-left: auto;
92 width: 28px; height: 28px;
93 display: flex; align-items: center; justify-content: center;
94 border-radius: 8px;
95 border: 1px solid rgba(255,255,255,0.1);
96 background: rgba(255,255,255,0.06);
97 color: var(--text-muted);
98 font-size: 16px; line-height: 1;
99 cursor: pointer;
100 transition: all 0.15s;
101 flex-shrink: 0;
102 }
103 .dash-close-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
104 .dash-close-btn:active { transform: scale(0.93); }
105
106 /* ── Raw idea processing strip ──────────────────────────────────── */
107 .raw-idea-space {
108 margin-bottom: 14px;
109 flex-shrink: 0;
110 }
111 .raw-idea-label {
112 font-size: 10px;
113 font-weight: 600;
114 text-transform: uppercase;
115 color: var(--text-muted);
116 margin-bottom: 6px;
117 letter-spacing: 0.5px;
118 }
119 .raw-idea-list {
120 display: flex;
121 gap: 8px;
122 overflow-x: auto;
123 padding-bottom: 4px;
124 }
125 .raw-idea-card {
126 display: flex;
127 align-items: center;
128 gap: 8px;
129 padding: 6px 12px;
130 border-radius: 8px;
131 background: rgba(251,191,36,0.1);
132 border: 1px solid rgba(251,191,36,0.2);
133 flex-shrink: 0;
134 cursor: pointer;
135 transition: all 0.15s;
136 max-width: 200px;
137 }
138 .raw-idea-card:hover { background: rgba(251,191,36,0.18); }
139 .raw-idea-pulse {
140 width: 8px;
141 height: 8px;
142 border-radius: 50%;
143 background: rgba(251,191,36,0.8);
144 flex-shrink: 0;
145 animation: rawPulse 1.5s ease-in-out infinite;
146 }
147 @keyframes rawPulse {
148 0%, 100% { opacity: 0.4; transform: scale(0.9); }
149 50% { opacity: 1; transform: scale(1.1); }
150 }
151 .raw-idea-desc {
152 font-size: 11px;
153 color: var(--text-secondary);
154 overflow: hidden;
155 text-overflow: ellipsis;
156 white-space: nowrap;
157 }
158
159 /* ── Forest view (grid of root trees) ──────────────────────────── */
160 .dash-forest {
161 display: grid;
162 grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
163 gap: 12px;
164 padding: 4px 0;
165 }
166 .dash-root-card {
167 display: flex;
168 flex-direction: column;
169 align-items: center;
170 gap: 6px;
171 padding: 16px 10px 12px;
172 border-radius: 10px;
173 background: rgba(255,255,255,0.05);
174 border: 1px solid rgba(255,255,255,0.08);
175 cursor: pointer;
176 transition: all 0.15s;
177 position: relative;
178 text-align: center;
179 }
180 .dash-root-card:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); }
181 .dash-root-card.has-sessions { border-color: rgba(16,185,129,0.3); }
182 .dash-root-icon {
183 font-size: 28px;
184 line-height: 1;
185 }
186 .dash-root-name {
187 font-size: 12px;
188 font-weight: 500;
189 color: var(--text-secondary);
190 overflow: hidden;
191 text-overflow: ellipsis;
192 white-space: nowrap;
193 max-width: 100%;
194 }
195 .dash-root-info {
196 font-size: 9px;
197 color: var(--text-muted);
198 }
199 .dash-root-badge {
200 position: absolute;
201 top: 6px;
202 right: 6px;
203 background: var(--accent);
204 color: #000;
205 font-size: 9px;
206 font-weight: 700;
207 width: 18px;
208 height: 18px;
209 border-radius: 50%;
210 display: flex;
211 align-items: center;
212 justify-content: center;
213 }
214 .dash-forest-empty {
215 grid-column: 1 / -1;
216 text-align: center;
217 padding: 40px 16px;
218 color: var(--text-muted);
219 font-size: 13px;
220 }
221 .dash-forest-empty-icon { font-size: 40px; opacity: 0.4; margin-bottom: 8px; }
222
223 /* ── Visual tree (SVG) ───────────────────────────────────────────── */
224 .vtree-container {
225 width: 100%;
226 height: 100%;
227 display: flex;
228 align-items: center;
229 justify-content: center;
230 overflow: auto;
231 }
232 .vtree-svg {
233 width: 100%;
234 height: 100%;
235 max-width: 100%;
236 max-height: 100%;
237 }
238 .vtree-node { cursor: pointer; }
239 .vtree-node:hover circle.vtree-main { filter: brightness(1.4); }
240 .vtree-highlight-ring.active {
241 stroke: var(--accent) !important;
242 stroke-width: 2.5;
243 filter: drop-shadow(0 0 8px rgba(16,185,129,0.6));
244 }
245 .vtree-tooltip {
246 position: absolute;
247 background: rgba(0,0,0,0.88);
248 color: #fff;
249 padding: 6px 10px;
250 border-radius: 6px;
251 font-size: 11px;
252 pointer-events: none;
253 z-index: 40;
254 max-width: 220px;
255 white-space: nowrap;
256 display: none;
257 box-shadow: 0 2px 8px rgba(0,0,0,0.4);
258 }
259 .vtree-tooltip.visible { display: block; }
260 .vtree-tooltip-name { font-weight: 600; }
261 .vtree-tooltip-status { opacity: 0.65; margin-left: 6px; font-size: 10px; }
262 .vtree-badge-dot {
263 stroke: none;
264 filter: drop-shadow(0 0 3px rgba(16,185,129,0.5));
265 }
266
267 /* ── Session sidebar ────────────────────────────────────────────── */
268 .session-sidebar {
269 width: 280px;
270 flex-shrink: 0;
271 border-left: 1px solid var(--glass-border-light);
272 display: flex;
273 flex-direction: column;
274 overflow: hidden;
275 }
276 .session-sidebar-header {
277 padding: 12px 16px;
278 border-bottom: 1px solid var(--glass-border-light);
279 display: flex;
280 align-items: center;
281 justify-content: space-between;
282 flex-shrink: 0;
283 }
284 .session-sidebar-header h3 {
285 font-size: 14px;
286 font-weight: 600;
287 }
288 .session-count-badge {
289 background: rgba(255,255,255,0.15);
290 padding: 2px 8px;
291 border-radius: 100px;
292 font-size: 11px;
293 font-weight: 600;
294 }
295 .session-list {
296 flex: 1;
297 overflow-y: auto;
298 padding: 8px;
299 }
300
301 /* ── Session cards ──────────────────────────────────────────────── */
302 .session-card {
303 padding: 10px 12px;
304 border-radius: 8px;
305 background: rgba(255,255,255,0.06);
306 border: 1px solid rgba(255,255,255,0.08);
307 margin-bottom: 6px;
308 transition: all 0.15s;
309 cursor: pointer;
310 }
311 .session-card:hover { background: rgba(255,255,255,0.1); }
312 .session-card.tracked {
313 border-color: var(--accent);
314 background: rgba(16, 185, 129, 0.1);
315 }
316 .session-card-header {
317 display: flex;
318 align-items: center;
319 gap: 8px;
320 margin-bottom: 6px;
321 }
322 .session-type-icon { font-size: 16px; }
323 .session-desc {
324 font-size: 12px;
325 font-weight: 500;
326 color: var(--text-secondary);
327 flex: 1;
328 overflow: hidden;
329 text-overflow: ellipsis;
330 white-space: nowrap;
331 }
332 .session-stop-btn {
333 background: none;
334 border: 1px solid rgba(239, 68, 68, 0.3);
335 color: #ef4444;
336 font-size: 10px;
337 font-weight: 600;
338 line-height: 1;
339 cursor: pointer;
340 padding: 3px 8px;
341 border-radius: 4px;
342 transition: all 0.15s;
343 flex-shrink: 0;
344 opacity: 0;
345 }
346 .session-card:hover .session-stop-btn { opacity: 1; }
347 .session-stop-btn:hover {
348 color: #fff;
349 background: rgba(239, 68, 68, 0.4);
350 }
351 .session-meta-info {
352 font-size: 10px;
353 color: var(--text-muted);
354 margin-bottom: 6px;
355 }
356 .session-actions {
357 display: flex;
358 gap: 4px;
359 }
360 .session-btn {
361 padding: 3px 8px;
362 border-radius: 4px;
363 border: 1px solid rgba(255,255,255,0.1);
364 background: rgba(255,255,255,0.06);
365 color: var(--text-secondary);
366 font-size: 10px;
367 cursor: pointer;
368 transition: all 0.15s;
369 }
370 .session-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
371 .session-btn.active { background: rgba(16,185,129,0.2); border-color: var(--accent); color: var(--accent); }
372
373 /* ── Chat slide-out panel ───────────────────────────────────────── */
374 .dashboard-chat-panel {
375 position: absolute;
376 top: 0;
377 right: 0;
378 width: 360px;
379 height: 100%;
380 background: rgba(var(--glass-rgb), 0.95);
381 backdrop-filter: blur(var(--glass-blur));
382 border-left: 1px solid var(--glass-border);
383 transform: translateX(100%);
384 transition: transform 0.25s ease;
385 z-index: 30;
386 display: flex;
387 flex-direction: column;
388 overflow: hidden;
389 }
390 .dashboard-chat-panel.open { transform: translateX(0); }
391 .dash-chat-header {
392 padding: 12px 16px;
393 border-bottom: 1px solid var(--glass-border-light);
394 display: flex;
395 flex-direction: column;
396 align-items: flex-start;
397 flex-shrink: 0;
398 }
399 .dash-chat-close {
400 background: none;
401 border: none;
402 color: var(--text-muted);
403 cursor: pointer;
404 padding: 4px;
405 font-size: 18px;
406 }
407 .dash-chat-close:hover { color: var(--text-primary); }
408 .dash-chat-kill {
409 color: #ef4444;
410 font-size: 11px;
411 font-weight: 600;
412 }
413 .dash-chat-kill:hover { color: #f87171 !important; }
414 .dash-chat-body {
415 flex: 1;
416 overflow-y: auto;
417 padding: 12px 16px;
418 }
419 .chat-message-item {
420 margin-bottom: 12px;
421 padding: 8px 10px;
422 border-radius: 8px;
423 background: rgba(255,255,255,0.05);
424 font-size: 12px;
425 color: var(--text-secondary);
426 line-height: 1.5;
427 }
428 .chat-message-role {
429 font-size: 10px;
430 font-weight: 600;
431 color: var(--text-muted);
432 margin-bottom: 4px;
433 text-transform: uppercase;
434 }
435
436 /* (dashboard toggle buttons are now in app.js chat panel) */
437
438 /* ── Mobile sessions overlay ────────────────────────────────────── */
439 .mobile-sessions-pill {
440 display: none;
441 z-index: 25;
442 align-items: center;
443 align-self: flex-start;
444 flex-shrink: 0;
445 gap: 5px;
446 padding: 4px 10px;
447 margin-bottom: 8px;
448 border-radius: 20px;
449 background: rgba(var(--glass-rgb), 0.8);
450 backdrop-filter: blur(10px);
451 border: 1px solid var(--glass-border);
452 color: var(--text-secondary);
453 cursor: pointer;
454 font-size: 12px;
455 font-weight: 500;
456 transition: all 0.15s;
457 }
458 .mobile-sessions-pill:hover { background: rgba(var(--glass-rgb), 0.95); }
459 .mobile-sessions-pill.has-activity { border-color: rgba(16,185,129,0.4); }
460 .mobile-sessions-pill .pill-count {
461 background: var(--accent);
462 color: #000;
463 font-size: 9px;
464 font-weight: 700;
465 min-width: 16px;
466 height: 16px;
467 border-radius: 50%;
468 display: flex;
469 align-items: center;
470 justify-content: center;
471 padding: 0 3px;
472 }
473 .mobile-sessions-pill .pill-label {
474 font-size: 11px;
475 }
476 .mobile-sessions-overlay {
477 display: none;
478 position: absolute;
479 top: 52px;
480 right: 8px;
481 left: 8px;
482 max-height: 70%;
483 background: rgba(var(--glass-rgb), 0.95);
484 backdrop-filter: blur(var(--glass-blur));
485 border: 1px solid var(--glass-border);
486 border-radius: 12px;
487 z-index: 35;
488 flex-direction: column;
489 overflow: hidden;
490 box-shadow: 0 8px 32px rgba(0,0,0,0.4);
491 }
492 .mobile-sessions-overlay.open { display: flex; }
493 .mobile-overlay-header {
494 padding: 10px 14px;
495 border-bottom: 1px solid var(--glass-border-light);
496 display: flex;
497 align-items: center;
498 justify-content: space-between;
499 flex-shrink: 0;
500 }
501 .mobile-overlay-header h3 { font-size: 13px; font-weight: 600; margin: 0; }
502 .mobile-overlay-close {
503 background: none;
504 border: none;
505 color: var(--text-muted);
506 cursor: pointer;
507 font-size: 18px;
508 padding: 0 2px;
509 line-height: 1;
510 }
511 .mobile-overlay-close:hover { color: var(--text-primary); }
512 .mobile-overlay-body {
513 flex: 1;
514 overflow-y: auto;
515 padding: 8px 10px 12px;
516 }
517 .mobile-sessions-scrim {
518 display: none;
519 position: absolute;
520 inset: 0;
521 z-index: 34;
522 background: rgba(0,0,0,0.3);
523 }
524 .mobile-sessions-scrim.open { display: block; }
525
526 /* ── Mobile adjustments ─────────────────────────────────────────── */
527 @media (max-width: 768px) {
528 .dashboard-layout { flex-direction: column; }
529 .session-sidebar { display: none; }
530 .dashboard-chat-panel { width: 100%; }
531 .mobile-sessions-pill { display: flex; }
532 .dash-forest { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
533 }
534 `;
535}
536
537export function dashboardHTML() {
538 return `
539 <div class="dashboard-view" id="dashboardView">
540 <div class="dashboard-layout">
541 <div class="dash-tree-view" id="dashTreeView">
542
543 <!-- Mobile sessions (inline row, not floating) -->
544 <button class="mobile-sessions-pill" id="mobileSessionsPill">
545 <span class="pill-label">Sessions</span>
546 <span class="pill-count" id="mobileSessionsPillCount">0</span>
547 </button>
548 <div class="mobile-sessions-scrim" id="mobileSessionsScrim"></div>
549 <div class="mobile-sessions-overlay" id="mobileSessionsOverlay">
550 <div class="mobile-overlay-header">
551 <h3>Sessions <span class="session-count-badge" id="dashSessionCountMobile">0</span></h3>
552 <button class="mobile-overlay-close" id="mobileSessionsClose">×</button>
553 </div>
554 <div class="mobile-overlay-body" id="mobileSessionsList"></div>
555 </div>
556
557 <!-- Raw ideas being processed (visible when any exist) -->
558 <div class="raw-idea-space" id="rawIdeaSpace" style="display:none">
559 <div class="raw-idea-label">Processing</div>
560 <div class="raw-idea-list" id="rawIdeaList"></div>
561 </div>
562
563 <!-- Forest view — all root trees (default) -->
564 <div id="dashForestView">
565 <div class="dash-tree-header">
566 <span class="dash-tree-title">Your Trees</span>
567 <button class="dash-close-btn" id="dashCloseBtn1" title="Close dashboard">×</button>
568 </div>
569 <div class="dash-forest" id="dashForestGrid"></div>
570 </div>
571
572 <!-- Single tree view (shown when a root is selected) -->
573 <div id="dashTreeContent" style="display:none">
574 <div class="dash-tree-header">
575 <button class="dash-back-btn" id="dashBackBtn">← All Trees</button>
576 <span class="dash-tree-title" id="dashTreeTitle">Tree</span>
577 <button class="dash-close-btn" id="dashCloseBtn2" title="Close dashboard">×</button>
578 </div>
579 <div id="dashTreeCanvas"></div>
580 </div>
581
582 <div class="vtree-tooltip" id="vtreeTooltip">
583 <span class="vtree-tooltip-name" id="vtreeTooltipName"></span>
584 <span class="vtree-tooltip-status" id="vtreeTooltipStatus"></span>
585 </div>
586 </div>
587 <div class="session-sidebar" id="sessionSidebar">
588 <div class="session-sidebar-header">
589 <h3>Sessions</h3>
590 <span class="session-count-badge" id="dashSessionCount">0</span>
591 </div>
592 <div class="session-list" id="dashSessionList"></div>
593 </div>
594 </div>
595 <div class="dashboard-chat-panel" id="dashChatPanel">
596 <div class="dash-chat-header">
597 <span id="dashChatTitle" style="font-size:13px;font-weight:600">Messages</span>
598 <div class="dash-chat-controls" style="display:flex;gap:6px;align-items:center;margin-top:8px">
599 <button class="dash-chat-close" id="dashChatClose" title="Back">←</button>
600 <button class="dash-chat-close" id="dashChatRefresh" title="Refresh">↻</button>
601 <button class="dash-chat-close dash-chat-kill" id="dashChatKill" title="Kill session">Kill</button>
602 </div>
603 </div>
604 <div class="dash-chat-body" id="dashChatBody"></div>
605 </div>
606 </div>
607 `;
608}
609
610export function dashboardJS() {
611 return `
612 // ══════════════════════════════════════════════════════════════════
613 // SESSION DASHBOARD
614 // ══════════════════════════════════════════════════════════════════
615
616 (function() {
617 var dashboardActive = false;
618 var dashMode = "forest"; // "forest" or "tree"
619 var dashSessions = [];
620 var dashRoots = [];
621 var dashTrackedSessionId = null;
622 var dashTrackedNavRootId = null; // last rootId we auto-navigated to for tracked session
623 var dashCurrentRootId = null;
624 var dashTreeData = null;
625 var dashSelfSessionId = null;
626 var dashActiveNavigatorId = null;
627
628 var desktopDashboardBtn = document.getElementById("desktopDashboardBtn");
629 var iframeContainer = document.getElementById("iframeContainer");
630 var dashboardView = document.getElementById("dashboardView");
631 var dashForestView = document.getElementById("dashForestView");
632 var dashForestGrid = document.getElementById("dashForestGrid");
633 var dashTreeContent = document.getElementById("dashTreeContent");
634 var dashTreeCanvas = document.getElementById("dashTreeCanvas");
635 var dashTreeTitle = document.getElementById("dashTreeTitle");
636 var dashBackBtn = document.getElementById("dashBackBtn");
637 var rawIdeaSpace = document.getElementById("rawIdeaSpace");
638 var rawIdeaList = document.getElementById("rawIdeaList");
639 var dashSessionList = document.getElementById("dashSessionList");
640 var dashSessionCount = document.getElementById("dashSessionCount");
641 var dashChatPanel = document.getElementById("dashChatPanel");
642 var dashChatBody = document.getElementById("dashChatBody");
643 var dashChatTitle = document.getElementById("dashChatTitle");
644 var vtreeTooltip = document.getElementById("vtreeTooltip");
645 var vtreeTooltipName = document.getElementById("vtreeTooltipName");
646 var vtreeTooltipStatus = document.getElementById("vtreeTooltipStatus");
647 var dashTreeView = document.getElementById("dashTreeView");
648 var mobileSessionsPill = document.getElementById("mobileSessionsPill");
649 var mobileSessionsPillCount = document.getElementById("mobileSessionsPillCount");
650 var mobileSessionsOverlay = document.getElementById("mobileSessionsOverlay");
651 var mobileSessionsScrim = document.getElementById("mobileSessionsScrim");
652 var mobileSessionsClose = document.getElementById("mobileSessionsClose");
653 var mobileSessionsList = document.getElementById("mobileSessionsList");
654 var dashSessionCountMobile = document.getElementById("dashSessionCountMobile");
655 var mobileDashboardBtn = document.getElementById("mobileDashboardBtn");
656
657 // ── Disconnected state ──────────────────────────────────────
658 socket.on("disconnect", function() {
659 if (dashboardView) dashboardView.classList.add("disconnected");
660 });
661 socket.on("connect", function() {
662 if (dashboardView) dashboardView.classList.remove("disconnected");
663 });
664
665 var DASH_SESSION_ICONS = {
666 "websocket-chat": "\\u{1F4AC}",
667 "api-tree-chat": "\\u{1F333}",
668 "api-tree-place": "\\u{1F4CC}",
669 "raw-idea-orchestrate": "\\u{1F4A1}",
670 "raw-idea-chat": "\\u{1F4A1}",
671 "understanding-orchestrate": "\\u{1F9E0}",
672 "scheduled-raw-idea": "\\u{23F0}"
673 };
674
675 function dashEscape(str) {
676 return String(str || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
677 }
678 function dashTimeAgo(ts) {
679 if (!ts) return "";
680 var diff = Date.now() - ts;
681 if (diff < 60000) return "just now";
682 if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
683 return Math.floor(diff / 3600000) + "h ago";
684 }
685 function dashTruncate(str, len) {
686 if (!str) return "";
687 return str.length > len ? str.slice(0, len) + "..." : str;
688 }
689
690 // ── Toggle ────────────────────────────────────────────────────
691 function toggleDashboard() {
692 dashboardActive = !dashboardActive;
693 if (desktopDashboardBtn) desktopDashboardBtn.classList.toggle("active", dashboardActive);
694 if (mobileDashboardBtn) mobileDashboardBtn.classList.toggle("active", dashboardActive);
695 iframeContainer.classList.toggle("hidden", dashboardActive);
696 dashboardView.classList.toggle("active", dashboardActive);
697 if (dashboardActive) {
698 socket.emit("getDashboardSessions");
699 socket.emit("getDashboardRoots");
700 }
701 }
702
703 if (desktopDashboardBtn) desktopDashboardBtn.addEventListener("click", toggleDashboard);
704 if (mobileDashboardBtn) mobileDashboardBtn.addEventListener("click", function(e) {
705 e.stopPropagation();
706 toggleDashboard();
707 });
708
709 // Close buttons inside the dashboard view
710 var dashCloseBtn1 = document.getElementById("dashCloseBtn1");
711 var dashCloseBtn2 = document.getElementById("dashCloseBtn2");
712 if (dashCloseBtn1) dashCloseBtn1.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
713 if (dashCloseBtn2) dashCloseBtn2.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
714
715 // Expose closeDashboard so app.js goHome() can dismiss it
716 if (window.TreeApp) {
717 window.TreeApp.closeDashboard = function() {
718 if (dashboardActive) toggleDashboard();
719 };
720 }
721
722 // ── Mode switching ──────────────────────────────────────────
723 function enterTreeMode(rootId) {
724 dashMode = "tree";
725 dashCurrentRootId = rootId;
726 dashTreeData = null;
727 dashForestView.style.display = "none";
728 dashTreeContent.style.display = "";
729 dashTreeCanvas.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading tree...</div>';
730 dashTreeTitle.textContent = "Loading...";
731 socket.emit("getDashboardTree", { rootId: rootId });
732 renderDashSessions();
733 }
734
735 function exitTreeMode() {
736 dashMode = "forest";
737 dashCurrentRootId = null;
738 dashTreeData = null;
739 dashForestView.style.display = "";
740 dashTreeContent.style.display = "none";
741 renderForest();
742 renderDashSessions();
743 }
744
745 dashBackBtn.addEventListener("click", exitTreeMode);
746
747 // ── Roots (forest) ──────────────────────────────────────────
748 socket.on("dashboardRoots", function(data) {
749 if (!data) return;
750 dashRoots = data.roots || [];
751 if (dashMode === "forest") renderForest();
752 });
753
754 function renderForest() {
755 if (dashRoots.length === 0) {
756 dashForestGrid.innerHTML = '<div class="dash-forest-empty">'
757 + '<div class="dash-forest-empty-icon">\\u{1F331}</div>'
758 + '<p>No trees yet</p></div>';
759 return;
760 }
761
762 var html = "";
763 for (var i = 0; i < dashRoots.length; i++) {
764 var r = dashRoots[i];
765 // Count sessions on this root
766 var count = 0;
767 for (var j = 0; j < dashSessions.length; j++) {
768 if (dashSessions[j].meta && dashSessions[j].meta.rootId === r.id) count++;
769 }
770 var sizeLabel = r.childCount === 0 ? "seedling" : r.childCount <= 3 ? "sapling" : r.childCount <= 10 ? "growing" : "mature";
771 var treeIcon = r.childCount === 0 ? "\\u{1F331}" : r.childCount <= 3 ? "\\u{1F33F}" : r.childCount <= 10 ? "\\u{1F333}" : "\\u{1F332}";
772
773 html += '<div class="dash-root-card' + (count > 0 ? " has-sessions" : "") + '" data-root-id="' + r.id + '">'
774 + (count > 0 ? '<span class="dash-root-badge">' + count + '</span>' : '')
775 + '<div class="dash-root-icon">' + treeIcon + '</div>'
776 + '<div class="dash-root-name">' + dashEscape(r.name) + '</div>'
777 + '<div class="dash-root-info">' + sizeLabel + '</div>'
778 + '</div>';
779 }
780 dashForestGrid.innerHTML = html;
781 }
782
783 // Click root card → enter tree mode
784 dashForestGrid.addEventListener("click", function(e) {
785 var card = e.target.closest("[data-root-id]");
786 if (!card) return;
787 enterTreeMode(card.getAttribute("data-root-id"));
788 });
789
790 // ── Raw ideas ───────────────────────────────────────────────
791 function renderRawIdeas() {
792 var rawSessions = [];
793 for (var i = 0; i < dashSessions.length; i++) {
794 var s = dashSessions[i];
795 var isRaw = s.type === "raw-idea-orchestrate" || s.type === "raw-idea-chat" || s.type === "scheduled-raw-idea";
796 var noTree = !s.meta || !s.meta.rootId;
797 if (isRaw && noTree) rawSessions.push(s);
798 }
799
800 if (rawSessions.length === 0) {
801 rawIdeaSpace.style.display = "none";
802 return;
803 }
804 rawIdeaSpace.style.display = "";
805
806 var html = "";
807 for (var i = 0; i < rawSessions.length; i++) {
808 var s = rawSessions[i];
809 var desc = dashEscape(s.description || "Raw idea");
810 html += '<div class="raw-idea-card" data-raw-sid="' + s.sessionId + '">'
811 + '<span class="raw-idea-pulse"></span>'
812 + '<span class="raw-idea-desc">' + desc + '</span>'
813 + '</div>';
814 }
815 rawIdeaList.innerHTML = html;
816 }
817
818 // Click raw idea → track it (auto-follow when it gets placed)
819 rawIdeaList.addEventListener("click", function(e) {
820 var card = e.target.closest("[data-raw-sid]");
821 if (!card) return;
822 var sid = card.getAttribute("data-raw-sid");
823 dashTrackedSessionId = sid;
824 renderDashSessions();
825 });
826
827 // ── Sessions ────────────────────────────────────────────────
828 socket.on("dashboardSessions", function(data) {
829 if (!data) return;
830 dashSessions = data.sessions || [];
831 if (data.selfSessionId) dashSelfSessionId = data.selfSessionId;
832 dashActiveNavigatorId = data.activeNavigatorId || null;
833
834 // Sync tracked session with server navigator state
835 if (dashTrackedSessionId !== dashActiveNavigatorId) {
836 dashTrackedSessionId = dashActiveNavigatorId;
837 if (!dashTrackedSessionId) dashTrackedNavRootId = null;
838 }
839
840 renderRawIdeas();
841 renderDashSessions();
842
843 // Auto-follow tracked session — only navigate when rootId first appears or changes
844 if (dashTrackedSessionId) {
845 var tracked = null;
846 for (var i = 0; i < dashSessions.length; i++) {
847 if (dashSessions[i].sessionId === dashTrackedSessionId) { tracked = dashSessions[i]; break; }
848 }
849 if (!tracked) {
850 dashTrackedSessionId = null;
851 dashTrackedNavRootId = null;
852 renderDashSessions();
853 } else if (tracked.meta && tracked.meta.rootId && tracked.meta.rootId !== dashTrackedNavRootId) {
854 // Session got a NEW rootId — close dashboard; server handles navigation via emitNavigate
855 dashTrackedNavRootId = tracked.meta.rootId;
856 if (dashboardActive) toggleDashboard();
857 }
858 }
859
860 // Update forest badges if in forest mode
861 if (dashMode === "forest") renderForest();
862 // Update tree highlights if in tree mode
863 if (dashMode === "tree") updateDashHighlights();
864 });
865
866 function renderDashSessions() {
867 // Filter sessions based on mode
868 var filtered;
869 if (dashMode === "tree" && dashCurrentRootId) {
870 filtered = [];
871 for (var i = 0; i < dashSessions.length; i++) {
872 if (dashSessions[i].meta && dashSessions[i].meta.rootId === dashCurrentRootId) {
873 filtered.push(dashSessions[i]);
874 }
875 }
876 } else {
877 filtered = dashSessions;
878 }
879
880 dashSessionCount.textContent = filtered.length;
881
882 if (filtered.length === 0) {
883 var emptyMsg = dashMode === "tree"
884 ? "No sessions on this tree"
885 : "No active sessions";
886 dashSessionList.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">' + emptyMsg + '</div>';
887 syncMobileSessions("", 0);
888 return;
889 }
890
891 var html = "";
892 for (var i = 0; i < filtered.length; i++) {
893 var s = filtered[i];
894 var isTracked = s.sessionId === dashTrackedSessionId;
895 var icon = DASH_SESSION_ICONS[s.type] || "\\u{1F527}";
896 var desc = dashEscape(s.description || s.type);
897 var hasRoot = s.meta && s.meta.rootId;
898 var ago = dashTimeAgo(s.lastActivity);
899
900 // Resolve location label: tree name or "Home"
901 var locationLabel;
902 if (hasRoot) {
903 var rootName = null;
904 for (var k = 0; k < dashRoots.length; k++) {
905 if (dashRoots[k].id === s.meta.rootId) { rootName = dashRoots[k].name; break; }
906 }
907 locationLabel = "Tree: " + dashEscape(rootName || s.meta.rootId.slice(0, 8));
908 } else {
909 locationLabel = "Home";
910 }
911
912 // Follow button logic: follow sets navigator, detach clears it
913 var trackBtn = "";
914 if (isTracked) {
915 trackBtn = '<button class="session-btn active" data-action="track" data-sid="' + s.sessionId + '">\\u{1F4CD} Detach</button>';
916 } else {
917 trackBtn = '<button class="session-btn" data-action="track" data-sid="' + s.sessionId + '">\\u{1F3AF} Follow</button>';
918 }
919
920 html += '<div class="session-card ' + (isTracked ? "tracked" : "") + '" data-sid="' + s.sessionId + '"'
921 + (hasRoot ? ' data-root="' + s.meta.rootId + '"' : '') + '>'
922 + '<div class="session-card-header">'
923 + '<span class="session-type-icon">' + icon + '</span>'
924 + '<span class="session-desc">' + desc + '</span>'
925 + '<button class="session-stop-btn" data-action="stop" data-sid="' + s.sessionId + '" title="Kill session">Kill</button>'
926 + '</div>'
927 + '<div class="session-meta-info">' + locationLabel + ' \\u00B7 ' + ago + '</div>'
928 + '<div class="session-actions">'
929 + trackBtn
930 + '<button class="session-btn" data-action="chat" data-sid="' + s.sessionId + '">\\u{1F4AC} Messages</button>'
931 + '</div>'
932 + '</div>';
933 }
934 dashSessionList.innerHTML = html;
935 syncMobileSessions(html, filtered.length);
936 }
937
938 function syncMobileSessions(html, count) {
939 var n = count !== undefined ? count : dashSessions.length;
940 if (mobileSessionsList) mobileSessionsList.innerHTML = html || dashSessionList.innerHTML;
941 if (mobileSessionsPillCount) mobileSessionsPillCount.textContent = n;
942 if (dashSessionCountMobile) dashSessionCountMobile.textContent = n;
943 if (mobileSessionsPill) mobileSessionsPill.classList.toggle("has-activity", n > 1);
944 }
945
946 // Event delegation for session cards + buttons
947 function handleSessionClick(e) {
948 // Button actions first
949 var btn = e.target.closest("[data-action]");
950 if (btn) {
951 e.stopPropagation();
952 var action = btn.getAttribute("data-action");
953 var sid = btn.getAttribute("data-sid");
954 if (action === "track") toggleTrack(sid);
955 else if (action === "chat") dashViewChat(sid);
956 else if (action === "stop") stopSession(sid);
957 return;
958 }
959 // Click on card body → navigate to session's tree
960 var card = e.target.closest("[data-sid]");
961 if (!card) return;
962 var rootId = card.getAttribute("data-root");
963 if (rootId) {
964 enterTreeMode(rootId);
965 }
966 }
967
968 dashSessionList.addEventListener("click", handleSessionClick);
969
970 function toggleTrack(sessionId) {
971 if (dashTrackedSessionId === sessionId) {
972 dashTrackedSessionId = null;
973 dashTrackedNavRootId = null;
974 socket.emit("detachNavigator");
975 } else {
976 dashTrackedSessionId = sessionId;
977 dashTrackedNavRootId = null;
978 socket.emit("attachNavigator", { sessionId: sessionId });
979 var s = null;
980 for (var i = 0; i < dashSessions.length; i++) {
981 if (dashSessions[i].sessionId === sessionId) { s = dashSessions[i]; break; }
982 }
983 if (s && s.meta && s.meta.rootId) {
984 // Close dashboard; server handles navigation via emitNavigate
985 dashTrackedNavRootId = s.meta.rootId;
986 if (dashboardActive) toggleDashboard();
987 }
988 // If no rootId (Home), stay on dashboard
989 }
990 renderDashSessions();
991 }
992
993 function stopSession(sessionId) {
994 if (!confirm("Stop this session?")) return;
995 // If stopping the tracked session, detach first
996 if (dashTrackedSessionId === sessionId) {
997 dashTrackedSessionId = null;
998 dashTrackedNavRootId = null;
999 socket.emit("detachNavigator");
1000 }
1001 socket.emit("stopSession", { sessionId: sessionId });
1002 }
1003
1004 // ── Tree loading ──────────────────────────────────────────────
1005 socket.on("dashboardTreeData", function(data) {
1006 if (!data || data.rootId !== dashCurrentRootId) return;
1007 if (data.error) {
1008 dashTreeCanvas.innerHTML = '<div style="color:var(--error);padding:16px">' + dashEscape(data.error) + '</div>';
1009 return;
1010 }
1011 dashTreeData = data.tree;
1012 renderDashTree();
1013 updateDashHighlights();
1014 });
1015
1016 // ── Visual tree helpers ───────────────────────────────────────
1017 function vtreeCount(node) {
1018 var c = 1;
1019 if (node.children) for (var i = 0; i < node.children.length; i++) c += vtreeCount(node.children[i]);
1020 return c;
1021 }
1022 function vtreeMaxDepth(node, d) {
1023 d = d || 0;
1024 if (!node.children || !node.children.length) return d;
1025 var mx = d;
1026 for (var i = 0; i < node.children.length; i++) {
1027 var cd = vtreeMaxDepth(node.children[i], d + 1);
1028 if (cd > mx) mx = cd;
1029 }
1030 return mx;
1031 }
1032 function vtreeWidth(node) {
1033 if (!node.children || !node.children.length) return 1;
1034 var w = 0;
1035 for (var i = 0; i < node.children.length; i++) w += vtreeWidth(node.children[i]);
1036 return w;
1037 }
1038
1039 function buildVisualTree(treeData) {
1040 var total = vtreeCount(treeData);
1041 var maxD = vtreeMaxDepth(treeData);
1042
1043 var nodeR, fontSize, branchBase;
1044 if (total <= 5) { nodeR = 22; fontSize = 11; branchBase = 6; }
1045 else if (total <= 15) { nodeR = 15; fontSize = 10; branchBase = 4.5; }
1046 else if (total <= 40) { nodeR = 11; fontSize = 9; branchBase = 3; }
1047 else { nodeR = 7; fontSize = 0; branchBase = 2; }
1048
1049 var hSpace = total <= 5 ? 100 : total <= 15 ? 65 : total <= 40 ? 45 : 30;
1050 var vSpace = total <= 5 ? 110 : total <= 15 ? 80 : total <= 40 ? 58 : 44;
1051
1052 var nodes = [];
1053 function place(node, depth, xL, xR, pid) {
1054 var x = (xL + xR) / 2;
1055 var isLeaf = !node.children || !node.children.length;
1056 nodes.push({ id: node.id, name: node.name, status: node.status || "active", prestige: 0 || 0, x: x, depth: depth, pid: pid, isLeaf: isLeaf });
1057 if (!isLeaf) {
1058 var tw = vtreeWidth(node);
1059 var cur = xL;
1060 for (var i = 0; i < node.children.length; i++) {
1061 var cw = vtreeWidth(node.children[i]);
1062 var cR = cur + (xR - xL) * (cw / tw);
1063 place(node.children[i], depth + 1, cur, cR, node.id);
1064 cur = cR;
1065 }
1066 }
1067 }
1068 var treeW = vtreeWidth(treeData);
1069 place(treeData, 0, 0, treeW, null);
1070
1071 var pad = nodeR * 3 + 15;
1072 var trunkH = total <= 5 ? 40 : 25;
1073 var svgW = treeW * hSpace + pad * 2;
1074 var svgH = (maxD + 1) * vSpace + pad * 2 + trunkH;
1075
1076 for (var i = 0; i < nodes.length; i++) {
1077 nodes[i].sx = nodes[i].x * hSpace + pad;
1078 nodes[i].sy = pad + (maxD - nodes[i].depth) * vSpace;
1079 }
1080
1081 var rootN = nodes[0];
1082 var groundY = rootN.sy + trunkH + 8;
1083
1084 var s = '<svg class="vtree-svg" viewBox="0 0 ' + svgW + ' ' + svgH + '" preserveAspectRatio="xMidYMid meet">';
1085 s += '<ellipse cx="' + (svgW / 2) + '" cy="' + groundY + '" rx="' + Math.min(svgW * 0.35, 120) + '" ry="6" fill="rgba(139,90,43,0.12)"/>';
1086 s += '<line x1="' + rootN.sx + '" y1="' + rootN.sy + '" x2="' + rootN.sx + '" y2="' + (rootN.sy + trunkH) + '"'
1087 + ' stroke="rgba(139,90,43,0.55)" stroke-width="' + (branchBase * 1.8) + '" stroke-linecap="round"/>';
1088
1089 for (var i = 0; i < nodes.length; i++) {
1090 var n = nodes[i];
1091 if (n.pid === null) continue;
1092 var par = null;
1093 for (var j = 0; j < nodes.length; j++) { if (nodes[j].id === n.pid) { par = nodes[j]; break; } }
1094 if (!par) continue;
1095 var bw = Math.max(1, branchBase - n.depth * 0.6);
1096 var cpY = (par.sy + n.sy) / 2;
1097 s += '<path class="vtree-branch" d="M' + par.sx + ',' + par.sy + ' C' + par.sx + ',' + cpY + ' ' + n.sx + ',' + cpY + ' ' + n.sx + ',' + n.sy + '"'
1098 + ' fill="none" stroke="rgba(139,90,43,0.3)" stroke-width="' + bw + '" stroke-linecap="round"/>';
1099 }
1100
1101 for (var i = 0; i < nodes.length; i++) {
1102 var n = nodes[i];
1103 var fill;
1104 if (n.status === "trimmed") fill = "rgba(120,120,120,0.45)";
1105 else if (n.status === "completed") fill = "rgba(234,179,8,0.65)";
1106 else if (n.isLeaf) fill = "rgba(34,197,94,0.75)";
1107 else fill = "rgba(16,185,129,0.55)";
1108 var r = (n.pid === null) ? nodeR * 1.2 : nodeR;
1109
1110 s += '<g class="vtree-node" data-node-id="' + n.id + '" data-name="' + dashEscape(n.name) + '" data-status="' + n.status + '" data-prestige="' + n.prestige + '">';
1111 s += '<circle class="vtree-highlight-ring" cx="' + n.sx + '" cy="' + n.sy + '" r="' + (r + 5) + '" fill="none" stroke="transparent" stroke-width="2"/>';
1112 s += '<circle class="vtree-main" cx="' + n.sx + '" cy="' + n.sy + '" r="' + r + '" fill="' + fill + '" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>';
1113 if (fontSize > 0) {
1114 var lbl = n.name.length > 14 ? n.name.slice(0, 12) + ".." : n.name;
1115 s += '<text x="' + n.sx + '" y="' + (n.sy - r - 6) + '" text-anchor="middle" fill="var(--text-secondary)" font-size="' + fontSize + '" style="pointer-events:none">' + dashEscape(lbl) + '</text>';
1116 }
1117 s += '</g>';
1118 }
1119
1120 s += '</svg>';
1121 return s;
1122 }
1123
1124 function renderDashTree() {
1125 if (!dashTreeData) return;
1126 dashTreeTitle.textContent = dashEscape(dashTreeData.name);
1127 dashTreeCanvas.innerHTML = '<div class="vtree-container">' + buildVisualTree(dashTreeData) + '</div>';
1128 }
1129
1130 // ── Tooltip ─────────────────────────────────────────────────────
1131 dashTreeCanvas.addEventListener("mouseover", function(e) {
1132 var g = e.target.closest(".vtree-node");
1133 if (!g) return;
1134 vtreeTooltipName.textContent = g.getAttribute("data-name");
1135 vtreeTooltipStatus.textContent = g.getAttribute("data-status");
1136 vtreeTooltip.classList.add("visible");
1137 });
1138 dashTreeCanvas.addEventListener("mousemove", function(e) {
1139 if (!vtreeTooltip.classList.contains("visible")) return;
1140 var rect = dashTreeView.getBoundingClientRect();
1141 vtreeTooltip.style.left = (e.clientX - rect.left + 14) + "px";
1142 vtreeTooltip.style.top = (e.clientY - rect.top - 32) + "px";
1143 });
1144 dashTreeCanvas.addEventListener("mouseout", function(e) {
1145 if (e.target.closest(".vtree-node")) vtreeTooltip.classList.remove("visible");
1146 });
1147
1148 // ── Click tree node → navigate iframe ──────────────────────────
1149 dashTreeCanvas.addEventListener("click", function(e) {
1150 var g = e.target.closest(".vtree-node");
1151 if (!g) return;
1152 var nodeId = g.getAttribute("data-node-id");
1153 var prestige = g.getAttribute("data-prestige") || "0";
1154 if (!nodeId) return;
1155 // Switch back to iframe and navigate to this node
1156 if (dashboardActive) toggleDashboard();
1157 if (window.TreeApp && window.TreeApp.navigate) {
1158 window.TreeApp.navigate("/api/v1/node/" + nodeId + "/" + prestige + "?html");
1159 }
1160 });
1161
1162 // ── Node highlighting (SVG) ────────────────────────────────────
1163 function updateDashHighlights() {
1164 var rings = document.querySelectorAll(".vtree-highlight-ring.active");
1165 for (var i = 0; i < rings.length; i++) rings[i].classList.remove("active");
1166 var dots = document.querySelectorAll(".vtree-badge-dot");
1167 for (var i = 0; i < dots.length; i++) dots[i].remove();
1168
1169 for (var j = 0; j < dashSessions.length; j++) {
1170 var s = dashSessions[j];
1171 if (!s.meta || !s.meta.nodeId) continue;
1172 var g = document.querySelector('.vtree-node[data-node-id="' + s.meta.nodeId + '"]');
1173 if (!g) continue;
1174 var ring = g.querySelector(".vtree-highlight-ring");
1175 if (ring) ring.classList.add("active");
1176 var main = g.querySelector(".vtree-main");
1177 if (main) {
1178 var cx = parseFloat(main.getAttribute("cx"));
1179 var cy = parseFloat(main.getAttribute("cy"));
1180 var r = parseFloat(main.getAttribute("r"));
1181 var badge = document.createElementNS("http://www.w3.org/2000/svg", "circle");
1182 badge.setAttribute("class", "vtree-badge-dot");
1183 badge.setAttribute("cx", String(cx + r + 4));
1184 badge.setAttribute("cy", String(cy - r + 2));
1185 badge.setAttribute("r", "4");
1186 badge.setAttribute("fill", "rgba(16,185,129,0.9)");
1187 g.appendChild(badge);
1188 }
1189 }
1190 }
1191
1192 // ── Tree change live updates ──────────────────────────────────
1193 socket.on("dashboardTreeChanged", function(data) {
1194 if (dashCurrentRootId) {
1195 socket.emit("getDashboardTree", { rootId: dashCurrentRootId });
1196 }
1197 });
1198
1199 // ── Messages panel ─────────────────────────────────────────────
1200 var dashChatCurrentSid = null;
1201
1202 document.getElementById("dashChatClose").addEventListener("click", function() {
1203 dashChatPanel.classList.remove("open");
1204 dashChatCurrentSid = null;
1205 });
1206
1207 document.getElementById("dashChatRefresh").addEventListener("click", function() {
1208 if (!dashChatCurrentSid) return;
1209 dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1210 socket.emit("getDashboardChats", { sessionId: dashChatCurrentSid });
1211 });
1212
1213 document.getElementById("dashChatKill").addEventListener("click", function() {
1214 if (!dashChatCurrentSid) return;
1215 stopSession(dashChatCurrentSid);
1216 dashChatPanel.classList.remove("open");
1217 dashChatCurrentSid = null;
1218 });
1219
1220 function dashViewChat(sessionId) {
1221 dashChatCurrentSid = sessionId;
1222 dashChatPanel.classList.add("open");
1223 dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1224 dashChatTitle.textContent = "Messages \\u2014 " + sessionId.slice(0, 8);
1225 socket.emit("getDashboardChats", { sessionId: sessionId });
1226 }
1227
1228 socket.on("dashboardChats", function(data) {
1229 if (!data) return;
1230 if (data.error) {
1231 dashChatBody.innerHTML = '<div style="color:var(--error)">Error loading chats</div>';
1232 return;
1233 }
1234 var chats = data.chats;
1235 if (!chats || chats.length === 0) {
1236 dashChatBody.innerHTML = '<div style="color:var(--text-muted);padding:16px;text-align:center">No chat records for this session</div>';
1237 return;
1238 }
1239 var html = "";
1240 for (var i = 0; i < chats.length; i++) {
1241 var c = chats[i];
1242 var source = (c.startMessage && c.startMessage.source) || "user";
1243 var path = (c.aiContext && c.aiContext.path) || "?";
1244 var userMsg = dashEscape(dashTruncate((c.startMessage && c.startMessage.content) || "", 300));
1245 var aiMsg = (c.endMessage && c.endMessage.content)
1246 ? '<div style="margin-top:6px;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px">' + dashEscape(dashTruncate(c.endMessage.content, 500)) + '</div>'
1247 : '';
1248 html += '<div class="chat-message-item">'
1249 + '<div class="chat-message-role">' + dashEscape(source) + ' \\u2192 ' + dashEscape(path) + '</div>'
1250 + '<div>' + userMsg + '</div>'
1251 + aiMsg
1252 + '</div>';
1253 }
1254 dashChatBody.innerHTML = html;
1255 });
1256
1257 // ── Mobile sessions ──────────────────────────────────────────
1258 function closeMobileOverlay() {
1259 if (mobileSessionsOverlay) mobileSessionsOverlay.classList.remove("open");
1260 if (mobileSessionsScrim) mobileSessionsScrim.classList.remove("open");
1261 }
1262 function openMobileOverlay() {
1263 if (mobileSessionsOverlay) mobileSessionsOverlay.classList.add("open");
1264 if (mobileSessionsScrim) mobileSessionsScrim.classList.add("open");
1265 }
1266
1267 if (mobileSessionsPill) {
1268 mobileSessionsPill.addEventListener("click", function() {
1269 var isOpen = mobileSessionsOverlay && mobileSessionsOverlay.classList.contains("open");
1270 if (isOpen) closeMobileOverlay();
1271 else openMobileOverlay();
1272 });
1273 }
1274 if (mobileSessionsClose) {
1275 mobileSessionsClose.addEventListener("click", closeMobileOverlay);
1276 }
1277 if (mobileSessionsScrim) {
1278 mobileSessionsScrim.addEventListener("click", closeMobileOverlay);
1279 }
1280 if (mobileSessionsList) {
1281 mobileSessionsList.addEventListener("click", function(e) {
1282 var btn = e.target.closest("[data-action]");
1283 if (btn) {
1284 var action = btn.getAttribute("data-action");
1285 var sid = btn.getAttribute("data-sid");
1286 if (action === "track") toggleTrack(sid);
1287 else if (action === "chat") dashViewChat(sid);
1288 else if (action === "stop") { stopSession(sid); }
1289 closeMobileOverlay();
1290 return;
1291 }
1292 var card = e.target.closest("[data-sid]");
1293 if (card) {
1294 var rootId = card.getAttribute("data-root");
1295 if (rootId) enterTreeMode(rootId);
1296 closeMobileOverlay();
1297 }
1298 });
1299 }
1300
1301 })();
1302 `;
1303}
1304
1// routesURL/setup.js
2// First-time onboarding: connect LLM + create first tree.
3// Skips steps already completed, redirects to /chat when done.
4
5import log from "../../../core/log.js";
6import express from "express";
7import authenticateLite from "../../../middleware/authenticateLite.js";
8import User from "../../../db/models/user.js";
9import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
10import { renderSetup } from "./setupPage.js";
11
12const router = express.Router();
13
14router.get("/setup", authenticateLite, async (req, res) => {
15 try {
16 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
17 return res.status(404).json({ error: "Server-rendered HTML is disabled. Use the SPA frontend." });
18 }
19
20 if (!req.userId) {
21 return res.redirect("/login?redirect=/setup");
22 }
23
24 const user = await User.findById(req.userId)
25 .select("username roots metadata llmDefault")
26 .lean();
27 if (!user) {
28 return res.redirect("/login?redirect=/setup");
29 }
30
31 const connCount = await CustomLlmConnection.countDocuments({ userId: req.userId });
32 const hasMainLlm = !!(user.llmDefault);
33 const needsLlm = !hasMainLlm && connCount === 0;
34 const needsTree = !user.roots || user.roots.length === 0;
35
36 // Both done, go to chat
37 if (!needsLlm && !needsTree) {
38 return res.redirect("/chat");
39 }
40
41 const userId = req.userId;
42 const username = user.username;
43
44 return res.send(renderSetup({ userId, username, needsLlm, needsTree }));
45 } catch (err) {
46 log.error("HTML", "Setup page error:", err.message);
47 return res.status(500).send("Something went wrong");
48 }
49});
50
51export default router;
52
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for setup / onboarding page */
3/* ─────────────────────────────────────────────── */
4
5export function renderSetup({ userId, username, needsLlm, needsTree }) {
6 return `<!DOCTYPE html>
7<html lang="en">
8<head>
9 <meta charset="UTF-8" />
10 <title>Setup - TreeOS</title>
11 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12 <meta name="theme-color" content="#667eea" />
13 <link rel="preconnect" href="https://fonts.googleapis.com">
14 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
16 <style>
17 :root {
18 --glass-rgb: 115, 111, 230;
19 --glass-alpha: 0.28;
20 --glass-blur: 22px;
21 --glass-border: rgba(255, 255, 255, 0.28);
22 --glass-border-light: rgba(255, 255, 255, 0.15);
23 --glass-highlight: rgba(255, 255, 255, 0.25);
24 --text-primary: #ffffff;
25 --text-secondary: rgba(255, 255, 255, 0.9);
26 --text-muted: rgba(255, 255, 255, 0.6);
27 --accent: #10b981;
28 --accent-glow: rgba(16, 185, 129, 0.6);
29 --error: #ef4444;
30 --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
31 }
32
33 * { box-sizing: border-box; margin: 0; padding: 0; }
34 html { background: #667eea; }
35 body { min-height: 100vh; min-height: 100dvh; width: 100%; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-attachment: fixed; position: relative; overflow-x: hidden; overflow-y: auto; }
36 body::before, body::after {
37 content: ''; position: fixed; border-radius: 50%; background: white;
38 opacity: 0.08; animation: float 20s infinite ease-in-out; pointer-events: none;
39 }
40 body::before { width: 600px; height: 600px; top: -300px; right: -200px; animation-delay: -5s; }
41 body::after { width: 400px; height: 400px; bottom: -200px; left: -100px; animation-delay: -10s; }
42 @keyframes float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-30px) rotate(5deg); } }
43
44 .container {
45 max-width: 620px; margin: 0 auto; padding: 40px 20px 60px;
46 display: flex; flex-direction: column; gap: 24px;
47 min-height: 100vh;
48 }
49
50 .header {
51 text-align: center; padding: 20px 0 10px;
52 }
53 .header .tree-icon { font-size: 48px; display: block; margin-bottom: 12px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); }
54 .header h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
55 .header p { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
56
57 .glass-card {
58 background: rgba(var(--glass-rgb), var(--glass-alpha));
59 backdrop-filter: blur(var(--glass-blur));
60 -webkit-backdrop-filter: blur(var(--glass-blur));
61 border: 1px solid var(--glass-border);
62 border-radius: 16px;
63 padding: 24px;
64 animation: fadeUp 0.5s ease both;
65 }
66 @keyframes fadeUp {
67 from { opacity: 0; transform: translateY(16px); }
68 to { opacity: 1; transform: translateY(0); }
69 }
70 .glass-card h2 { font-size: 17px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
71 .glass-card .sub { font-size: 13px; color: var(--text-muted); line-height: 1.5; margin-bottom: 16px; }
72
73 .step-badge {
74 display: inline-flex; align-items: center; justify-content: center;
75 width: 24px; height: 24px; border-radius: 50%;
76 background: rgba(255,255,255,0.15); font-size: 12px; font-weight: 600;
77 flex-shrink: 0;
78 }
79
80 .field-row { margin-bottom: 16px; text-align: left; }
81 .field-label {
82 display: block; font-size: 14px; font-weight: 600; color: white;
83 margin-bottom: 8px; text-shadow: 0 1px 3px rgba(0,0,0,0.2); letter-spacing: -0.2px;
84 }
85 .field-input {
86 width: 100%; padding: 14px 18px;
87 background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.3);
88 border-radius: 12px; color: white; font-family: inherit; font-size: 16px; font-weight: 500;
89 outline: none; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
90 backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%);
91 box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
92 }
93 .field-input:focus {
94 border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.25);
95 backdrop-filter: blur(25px) saturate(160%); -webkit-backdrop-filter: blur(25px) saturate(160%);
96 box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
97 transform: translateY(-2px);
98 }
99 .field-input::placeholder { color: rgba(255,255,255,0.5); font-weight: 400; }
100
101 .btn-primary {
102 width: 100%; padding: 16px; margin-top: 8px;
103 border-radius: 980px; border: 1px solid rgba(255,255,255,0.3);
104 background: rgba(255,255,255,0.25); backdrop-filter: blur(10px);
105 color: white; font-family: inherit; font-size: 16px; font-weight: 600;
106 cursor: pointer; transition: all 0.3s; letter-spacing: -0.2px;
107 box-shadow: 0 4px 12px rgba(0,0,0,0.12);
108 position: relative; overflow: hidden;
109 }
110 .btn-primary::before {
111 content: ''; position: absolute; inset: -40%;
112 background: radial-gradient(120% 60% at 0% 0%, rgba(255,255,255,0.35), transparent 60%);
113 opacity: 0; transform: translateX(-30%) translateY(-10%);
114 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
115 pointer-events: none;
116 }
117 .btn-primary:hover::before { opacity: 1; transform: translateX(30%) translateY(10%); }
118 .btn-primary:hover { background: rgba(255,255,255,0.35); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
119 .btn-primary:active { transform: translateY(0); }
120 .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
121 .btn-primary:disabled::before { display: none; }
122
123 .btn-skip {
124 display: block; width: 100%; text-align: center; padding: 12px;
125 color: white; font-size: 15px; font-weight: 600; font-family: inherit;
126 background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
127 border-radius: 980px; cursor: pointer; transition: all 0.3s;
128 text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
129 }
130 .btn-skip:hover { background: rgba(255,255,255,0.25); color: white; }
131
132 .status-msg {
133 padding: 12px 16px; border-radius: 10px; font-size: 14px; font-weight: 600;
134 margin-top: 8px; display: none; text-align: left;
135 }
136 .status-msg.error { display: block; background: rgba(239,68,68,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(239,68,68,0.4); color: white; }
137 .status-msg.success { display: block; background: rgba(16,185,129,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(16,185,129,0.4); color: white; }
138
139 .video-wrap {
140 position: relative; width: 100%; padding-top: 56.25%;
141 border-radius: 12px; overflow: hidden; margin-bottom: 16px;
142 background: rgba(0,0,0,0.2);
143 }
144 .video-wrap iframe {
145 position: absolute; inset: 0; width: 100%; height: 100%; border: none;
146 }
147 .video-placeholder {
148 position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
149 color: var(--text-muted); font-size: 14px;
150 }
151
152 .done-card { text-align: center; padding: 40px 24px; }
153 .done-card .seed-anim {
154 font-size: 48px; display: inline-block; margin-bottom: 12px;
155 }
156 .done-card .seed-anim.shake {
157 animation: seedShake 0.4s ease-in-out 3;
158 }
159 .done-card .seed-anim.burst {
160 animation: seedBurst 0.4s ease-out forwards;
161 }
162 .done-card .seed-anim.tree {
163 animation: treeAppear 0.5s ease-out forwards;
164 }
165 @keyframes seedShake {
166 0%, 100% { transform: rotate(0deg) scale(1); }
167 25% { transform: rotate(-12deg) scale(1.05); }
168 75% { transform: rotate(12deg) scale(1.05); }
169 }
170 @keyframes seedBurst {
171 0% { transform: scale(1); opacity: 1; filter: brightness(1); }
172 50% { transform: scale(1.6); opacity: 0.8; filter: brightness(2.5); }
173 100% { transform: scale(0); opacity: 0; filter: brightness(3); }
174 }
175 @keyframes treeAppear {
176 0% { transform: scale(0); opacity: 0; }
177 60% { transform: scale(1.2); opacity: 1; }
178 100% { transform: scale(1); opacity: 1; }
179 }
180
181 .screen-flash {
182 position: fixed; inset: -50px; background: rgba(255,255,255,0.9); opacity: 0; z-index: 9999;
183 pointer-events: none; animation: screenFlash 0.6s ease-out forwards;
184 }
185 @keyframes screenFlash {
186 0% { opacity: 0; }
187 15% { opacity: 0.85; }
188 100% { opacity: 0; }
189 }
190
191 .skip-note { font-size: 12px; color: var(--text-muted); text-align: center; margin-top: 4px; line-height: 1.4; }
192
193 .hidden { display: none !important; }
194 </style>
195</head>
196<body>
197 <div class="container">
198
199 <div class="header">
200 <span class="tree-icon">🌳</span>
201 <h1>Welcome${username ? ", " + username : ""}!</h1>
202 <p>Let's get you set up to start growing your Tree.</p>
203 </div>
204
205 <!-- Step 1: Connect LLM -->
206 <div class="glass-card" id="stepLlm" ${needsLlm ? "" : 'style="display:none"'}>
207 <h2>Connect Your LLM</h2>
208 <div class="sub">
209 TreeOS uses AI to help you build and organize your knowledge. You'll need to connect your own LLM provider
210 using any OpenAI-compatible API endpoint. We recommend <strong>OpenRouter</strong> for the easiest setup.
211 It gives you access to hundreds of models with one API key.
212 </div>
213
214 <div class="video-wrap">
215 <iframe src="https://www.youtube-nocookie.com/embed/_cXGZXdiVgw" allowfullscreen></iframe>
216 </div>
217
218 <div class="field-row">
219 <label class="field-label">Label</label>
220 <input type="text" class="field-input" id="llmName" placeholder="e.g. OpenRouter, Groq" />
221 </div>
222 <div class="field-row">
223 <label class="field-label">Endpoint URL</label>
224 <input type="text" class="field-input" id="llmBaseUrl" placeholder="https://openrouter.ai/api/v1/chat/completions" />
225 </div>
226 <div class="field-row">
227 <label class="field-label">API Key <span style="opacity:0.5;font-weight:400;">- encrypted in our database</span></label>
228 <input type="password" class="field-input" id="llmApiKey" placeholder="sk-or-..." />
229 </div>
230 <div class="field-row">
231 <label class="field-label">Model</label>
232 <input type="text" class="field-input" id="llmModel" placeholder="e.g. openai/gpt-4o-mini" />
233 </div>
234 <div id="llmStatus" class="status-msg"></div>
235 <button class="btn-primary" id="llmSubmit" onclick="submitLlm()">Connect</button>
236 </div>
237
238 <!-- Step 2: Create First Tree -->
239 <div class="glass-card" id="stepTree" style="display:${!needsTree || needsLlm ? "none" : ""}">
240 <h2>Plant Your First Tree</h2>
241 <div class="sub">
242 Think broad. <strong>My Life</strong>, <strong>Career</strong>,
243 <strong>Health</strong>, <strong>Side Project</strong>.
244 You can always create more later.
245 </div>
246 <div class="field-row">
247 <label class="field-label">Tree Name</label>
248 <input type="text" class="field-input" id="treeName" placeholder="e.g. My Life, Career, Health & Fitness" />
249 </div>
250 <div id="treeStatus" class="status-msg"></div>
251 <button class="btn-primary" id="treeSubmit" onclick="submitTree()">Plant Tree</button>
252 </div>
253
254 <!-- Done state -->
255 <div class="glass-card done-card hidden" id="stepDone">
256 <div class="seed-anim" id="seedEmoji">🌱</div>
257 <h2 style="justify-content:center;" id="doneTitle" class="hidden">Your tree is being planted...</h2>
258 <div class="sub" style="text-align:center;" id="doneSub" class="hidden"></div>
259 </div>
260
261 <!-- Skip -->
262 <a class="btn-skip" href="#" id="skipBtn" onclick="skipSetup(); return false;">Skip for now</a>
263 <div class="skip-note" id="skipNote">You can still browse trees others have invited you to if they have their own LLM connected, but you won't be able to talk to your own trees or process raw ideas.</div>
264
265 </div>
266
267 <script>
268 var CONFIG = {
269 userId: "${userId}",
270 needsTree: ${needsTree},
271 };
272
273 function showStatus(id, msg, type) {
274 var el = document.getElementById(id);
275 el.textContent = msg;
276 el.className = "status-msg " + type;
277 }
278 function clearStatus(id) {
279 var el = document.getElementById(id);
280 el.className = "status-msg";
281 el.textContent = "";
282 }
283
284 async function submitLlm() {
285 var name = document.getElementById("llmName").value.trim();
286 var baseUrl = document.getElementById("llmBaseUrl").value.trim();
287 var apiKey = document.getElementById("llmApiKey").value.trim();
288 var model = document.getElementById("llmModel").value.trim();
289
290 if (!name || !baseUrl || !apiKey || !model) {
291 showStatus("llmStatus", "All fields are required.", "error");
292 return;
293 }
294
295 var btn = document.getElementById("llmSubmit");
296 btn.disabled = true;
297 btn.textContent = "Connecting...";
298 clearStatus("llmStatus");
299
300 try {
301 // Create connection
302 var createRes = await fetch("/api/v1/user/" + CONFIG.userId + "/custom-llm", {
303 method: "POST",
304 headers: { "Content-Type": "application/json" },
305 credentials: "include",
306 body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
307 });
308 var createData = await createRes.json();
309
310 if (!createRes.ok || !createData.success) {
311 throw new Error(createData.error || "Failed to create connection");
312 }
313
314 // Set as default
315 var connId = createData.connection._id;
316 var assignRes = await fetch("/api/v1/user/" + CONFIG.userId + "/llm-assign", {
317 method: "POST",
318 headers: { "Content-Type": "application/json" },
319 credentials: "include",
320 body: JSON.stringify({ slot: "main", connectionId: connId }),
321 });
322 var assignData = await assignRes.json();
323 if (!assignRes.ok || !assignData.success) {
324 throw new Error(assignData.error || "Failed to set as default");
325 }
326
327 showStatus("llmStatus", "Connected!", "success");
328
329 setTimeout(function() {
330 document.getElementById("stepLlm").style.display = "none";
331 if (CONFIG.needsTree) {
332 document.getElementById("stepTree").style.display = "";
333 } else {
334 finish();
335 }
336 }, 600);
337
338 } catch (err) {
339 showStatus("llmStatus", err.message, "error");
340 btn.disabled = false;
341 btn.textContent = "Connect";
342 }
343 }
344
345 async function submitTree() {
346 var name = document.getElementById("treeName").value.trim();
347 if (!name) {
348 showStatus("treeStatus", "Give your tree a name.", "error");
349 return;
350 }
351
352 var btn = document.getElementById("treeSubmit");
353 btn.disabled = true;
354 btn.textContent = "Planting...";
355 clearStatus("treeStatus");
356
357 try {
358 var res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
359 method: "POST",
360 headers: { "Content-Type": "application/json" },
361 credentials: "include",
362 body: JSON.stringify({ name: name }),
363 });
364 var data = await res.json();
365
366 if (!res.ok || !data.success) {
367 throw new Error(data.error || "Failed to create tree");
368 }
369
370 finish();
371 } catch (err) {
372 showStatus("treeStatus", err.message, "error");
373 btn.disabled = false;
374 btn.textContent = "Plant Tree";
375 }
376 }
377
378 function skipSetup() {
379 document.cookie = "setupSkipped=1;path=/;max-age=" + (12 * 60 * 60) + ";secure;samesite=none";
380 window.location.href = "/chat";
381 }
382
383 function finish() {
384 document.getElementById("stepLlm").style.display = "none";
385 document.getElementById("stepTree").style.display = "none";
386 document.getElementById("stepDone").classList.remove("hidden");
387 document.getElementById("skipBtn").style.display = "none";
388 document.getElementById("skipNote").style.display = "none";
389
390 var seed = document.getElementById("seedEmoji");
391 var title = document.getElementById("doneTitle");
392 var sub = document.getElementById("doneSub");
393 // Show "being planted" right away
394 title.classList.remove("hidden");
395 // Phase 1: seed shakes (1.2s = 0.4s x 3 iterations)
396 seed.classList.add("shake");
397 setTimeout(function() {
398 // Phase 2: burst flash
399 seed.classList.remove("shake");
400 seed.classList.add("burst");
401 setTimeout(function() {
402 // Phase 3: flash + swap to tree
403 var flash = document.createElement("div");
404 flash.className = "screen-flash";
405 document.body.appendChild(flash);
406 flash.addEventListener("animationend", function() { flash.remove(); });
407 seed.innerHTML = "🌳";
408 seed.classList.remove("burst");
409 seed.classList.add("tree");
410 // Swap to ready text
411 title.textContent = "Your tree is ready.";
412 sub.textContent = "Taking you there now...";
413 sub.classList.remove("hidden");
414 setTimeout(function() {
415 window.location.href = "/chat";
416 }, 1500);
417 }, 400);
418 }, 1200);
419 }
420 </script>
421</body>
422</html>`;
423}
424
1// ─────────────────────────────────────────────────
2// Shared CSS building blocks for HTML renderers
3//
4// Import the pieces you need:
5// import { baseStyles, backNavStyles } from ...
6// <style>${baseStyles}${backNavStyles}
7// ... page-specific CSS here ...
8// </style>
9// ─────────────────────────────────────────────────
10
11// ─── Core: variables, reset, gradient, orbs, keyframes, container ───
12
13export const baseStyles = `
14:root {
15 --glass-water-rgb: 115, 111, 230;
16 --glass-alpha: 0.28;
17 --glass-alpha-hover: 0.38;
18}
19
20* {
21 box-sizing: border-box;
22 margin: 0;
23 padding: 0;
24 -webkit-tap-highlight-color: transparent;
25}
26
27html, body {
28 background: #736fe6;
29 margin: 0;
30 padding: 0;
31}
32
33body {
34 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
35 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
36 min-height: 100vh;
37 min-height: 100dvh;
38 padding: 20px;
39 color: #1a1a1a;
40 position: relative;
41 overflow-x: hidden;
42 touch-action: manipulation;
43}
44
45body::before,
46body::after {
47 content: '';
48 position: fixed;
49 border-radius: 50%;
50 opacity: 0.08;
51 animation: float 20s infinite ease-in-out;
52 pointer-events: none;
53}
54
55body::before {
56 width: 600px; height: 600px;
57 background: white;
58 top: -300px; right: -200px;
59 animation-delay: -5s;
60}
61
62body::after {
63 width: 400px; height: 400px;
64 background: white;
65 bottom: -200px; left: -100px;
66 animation-delay: -10s;
67}
68
69@keyframes float {
70 0%, 100% { transform: translateY(0) rotate(0deg); }
71 50% { transform: translateY(-30px) rotate(5deg); }
72}
73
74@keyframes fadeInUp {
75 from { opacity: 0; transform: translateY(30px); }
76 to { opacity: 1; transform: translateY(0); }
77}
78
79.container {
80 max-width: 900px;
81 margin: 0 auto;
82 position: relative;
83 z-index: 1;
84}
85`;
86
87// ─── Pill-shaped back navigation buttons ───
88
89export const backNavStyles = `
90.back-nav {
91 display: flex;
92 gap: 12px;
93 margin-bottom: 20px;
94 flex-wrap: wrap;
95 animation: fadeInUp 0.5s ease-out both;
96}
97
98.back-link {
99 display: inline-flex;
100 align-items: center;
101 gap: 6px;
102 padding: 10px 20px;
103 background: rgba(115, 111, 230, var(--glass-alpha));
104 backdrop-filter: blur(22px) saturate(140%);
105 -webkit-backdrop-filter: blur(22px) saturate(140%);
106 color: white;
107 text-decoration: none;
108 border-radius: 980px;
109 font-weight: 600;
110 font-size: 14px;
111 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
112 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
113 inset 0 1px 0 rgba(255, 255, 255, 0.25);
114 border: 1px solid rgba(255, 255, 255, 0.28);
115 position: relative;
116 overflow: hidden;
117}
118
119.back-link::before {
120 content: "";
121 position: absolute;
122 inset: -40%;
123 background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
124 opacity: 0;
125 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
126 pointer-events: none;
127}
128
129.back-link:hover {
130 background: rgba(115, 111, 230, var(--glass-alpha-hover));
131 transform: translateY(-1px);
132}
133
134.back-link:hover::before {
135 opacity: 1;
136 transform: translateX(30%) translateY(10%);
137}
138`;
139
140// ─── Glass header panel (title bar with h1) ───
141
142export const glassHeaderStyles = `
143.header {
144 position: relative;
145 overflow: hidden;
146 background: rgba(115, 111, 230, var(--glass-alpha));
147 backdrop-filter: blur(22px) saturate(140%);
148 -webkit-backdrop-filter: blur(22px) saturate(140%);
149 border-radius: 16px;
150 padding: 32px;
151 margin-bottom: 24px;
152 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
153 inset 0 1px 0 rgba(255, 255, 255, 0.25);
154 border: 1px solid rgba(255, 255, 255, 0.28);
155 color: white;
156 animation: fadeInUp 0.6s ease-out 0.1s both;
157}
158
159.header::before {
160 content: "";
161 position: absolute;
162 inset: -40%;
163 background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
164 opacity: 0;
165 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
166 pointer-events: none;
167}
168
169.header:hover::before {
170 opacity: 1;
171 transform: translateX(30%) translateY(10%);
172}
173
174.header h1 {
175 font-size: 28px;
176 font-weight: 600;
177 color: white;
178 margin-bottom: 8px;
179 line-height: 1.3;
180 letter-spacing: -0.5px;
181 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
182}
183
184.header h1 a {
185 color: white;
186 text-decoration: none;
187 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
188 transition: all 0.2s;
189}
190
191.header h1 a:hover {
192 border-bottom-color: white;
193 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
194}
195
196.message-count {
197 display: inline-block;
198 padding: 6px 14px;
199 background: rgba(255, 255, 255, 0.25);
200 color: white;
201 border-radius: 980px;
202 font-size: 14px;
203 font-weight: 600;
204 margin-left: 12px;
205 border: 1px solid rgba(255, 255, 255, 0.3);
206}
207
208.header-subtitle {
209 font-size: 14px;
210 color: rgba(255, 255, 255, 0.9);
211 margin-bottom: 8px;
212 font-weight: 400;
213 line-height: 1.5;
214}
215`;
216
217// ─── Glass note/item cards with color variants ───
218
219export const glassCardStyles = `
220.notes-list {
221 list-style: none;
222 display: flex;
223 flex-direction: column;
224 gap: 16px;
225}
226
227.note-card {
228 --card-rgb: 115, 111, 230;
229 position: relative;
230 background: rgba(var(--card-rgb), var(--glass-alpha));
231 backdrop-filter: blur(22px) saturate(140%);
232 -webkit-backdrop-filter: blur(22px) saturate(140%);
233 border-radius: 16px;
234 padding: 24px;
235 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
236 inset 0 1px 0 rgba(255, 255, 255, 0.25);
237 border: 1px solid rgba(255, 255, 255, 0.28);
238 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
239 color: white;
240 overflow: hidden;
241}
242
243.note-card::before {
244 content: "";
245 position: absolute;
246 inset: -40%;
247 background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
248 opacity: 0;
249 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
250 pointer-events: none;
251}
252
253.note-card:hover {
254 background: rgba(var(--card-rgb), var(--glass-alpha-hover));
255 transform: translateY(-2px);
256 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
257}
258
259.note-card:hover::before {
260 opacity: 1;
261 transform: translateX(30%) translateY(10%);
262}
263
264/* ── Color variants ── */
265.glass-default { --card-rgb: 115, 111, 230; }
266.glass-green { --card-rgb: 72, 187, 120; }
267.glass-red { --card-rgb: 200, 80, 80; }
268.glass-blue { --card-rgb: 80, 130, 220; }
269.glass-cyan { --card-rgb: 56, 189, 210; }
270.glass-gold { --card-rgb: 200, 170, 50; }
271.glass-purple { --card-rgb: 155, 100, 220; }
272.glass-pink { --card-rgb: 210, 100, 160; }
273.glass-orange { --card-rgb: 220, 140, 60; }
274.glass-emerald { --card-rgb: 52, 190, 130; }
275.glass-teal { --card-rgb: 60, 170, 180; }
276.glass-indigo { --card-rgb: 100, 100, 210; }
277
278.note-content {
279 margin-bottom: 12px;
280}
281
282.note-meta {
283 padding-top: 12px;
284 border-top: 1px solid rgba(255, 255, 255, 0.2);
285 font-size: 12px;
286 color: rgba(255, 255, 255, 0.85);
287 line-height: 1.8;
288 display: flex;
289 flex-wrap: wrap;
290 align-items: center;
291 gap: 6px;
292}
293
294.note-meta a {
295 color: white;
296 text-decoration: none;
297 font-weight: 500;
298 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
299 transition: all 0.2s;
300}
301
302.note-meta a:hover {
303 border-bottom-color: white;
304 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
305}
306
307.meta-separator {
308 color: rgba(255, 255, 255, 0.5);
309}
310`;
311
312// ─── Empty state glass panel ───
313
314export const emptyStateStyles = `
315.empty-state {
316 position: relative;
317 overflow: hidden;
318 background: rgba(115, 111, 230, var(--glass-alpha));
319 backdrop-filter: blur(22px) saturate(140%);
320 -webkit-backdrop-filter: blur(22px) saturate(140%);
321 border-radius: 16px;
322 padding: 60px 40px;
323 text-align: center;
324 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
325 inset 0 1px 0 rgba(255, 255, 255, 0.25);
326 border: 1px solid rgba(255, 255, 255, 0.28);
327 color: white;
328 animation: fadeInUp 0.6s ease-out 0.2s both;
329}
330
331.empty-state::before {
332 content: "";
333 position: absolute;
334 inset: -40%;
335 background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
336 opacity: 0;
337 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
338 pointer-events: none;
339}
340
341.empty-state:hover::before {
342 opacity: 1;
343 transform: translateX(30%) translateY(10%);
344}
345
346.empty-state-icon {
347 font-size: 64px;
348 margin-bottom: 16px;
349 filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
350}
351
352.empty-state-text {
353 font-size: 20px;
354 color: white;
355 margin-bottom: 8px;
356 font-weight: 600;
357 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
358}
359
360.empty-state-subtext {
361 font-size: 14px;
362 color: rgba(255, 255, 255, 0.85);
363}
364`;
365
366// ─── Base responsive breakpoints ───
367
368export const responsiveBase = `
369@media (max-width: 640px) {
370 body { padding: 16px; }
371 .header { padding: 24px 20px; }
372 .header h1 { font-size: 24px; }
373 .message-count { display: block; margin-left: 0; margin-top: 8px; width: fit-content; }
374 .note-card { padding: 20px 16px; }
375 .back-nav { flex-direction: column; }
376 .back-link { width: 100%; justify-content: center; }
377 .empty-state { padding: 40px 24px; }
378}
379
380@media (min-width: 641px) and (max-width: 1024px) {
381 .container { max-width: 700px; }
382}
383`;
384
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for chat page */
3/* ─────────────────────────────────────────────── */
4
5import { getLandUrl } from "../../../canopy/identity.js";
6import { baseStyles } from "./baseStyles.js";
7import { escapeHtml } from "./utils.js";
8
9export function renderChat({ username, userId, treesJSON, trees }) {
10 return `<!DOCTYPE html>
11<html lang="en">
12<head>
13 <meta charset="UTF-8" />
14 <title>Chat - TreeOS</title>
15 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
16 <meta name="theme-color" content="#736fe6" />
17 <link rel="icon" href="/tree.png" />
18 <link rel="canonical" href="${getLandUrl()}/chat" />
19 <meta name="robots" content="noindex, nofollow" />
20 <meta name="description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
21 <meta property="og:title" content="Chat - TreeOS" />
22 <meta property="og:description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
23 <meta property="og:url" content="${getLandUrl()}/chat" />
24 <meta property="og:type" content="website" />
25 <meta property="og:site_name" content="TreeOS" />
26 <meta property="og:image" content="${getLandUrl()}/tree.png" />
27 <link rel="preconnect" href="https://fonts.googleapis.com">
28 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
30 <style>
31 ${baseStyles}
32
33 /* ── Chat-specific overrides on base ── */
34 :root {
35 --glass-rgb: 115, 111, 230;
36 --glass-blur: 22px;
37 --glass-border: rgba(255, 255, 255, 0.28);
38 --glass-border-light: rgba(255, 255, 255, 0.15);
39 --glass-highlight: rgba(255, 255, 255, 0.25);
40 --text-primary: #ffffff;
41 --text-secondary: rgba(255, 255, 255, 0.9);
42 --text-muted: rgba(255, 255, 255, 0.6);
43 --accent: #10b981;
44 --accent-glow: rgba(16, 185, 129, 0.6);
45 --error: #ef4444;
46 --header-height: 56px;
47 --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
48 }
49
50 html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); }
51 body { background-attachment: fixed; padding: 0; min-height: auto; }
52
53 /* Hide base orbs for full-screen chat */
54 body::before, body::after { display: none; }
55
56 /* Layout */
57 .container {
58 height: 100%; width: 100%;
59 display: flex; flex-direction: column;
60 max-width: 800px; margin: 0 auto;
61 }
62
63 /* Header */
64 .chat-header {
65 height: var(--header-height); padding: 0 20px;
66 display: flex; align-items: center; justify-content: space-between;
67 border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0;
68 }
69 .chat-title { display: flex; align-items: center; gap: 12px; }
70 .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
71 @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
72 .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
73
74 .header-right { display: flex; align-items: center; gap: 10px; }
75 .back-row {
76 display: none; padding: 8px 20px 0;
77 border-bottom: none; flex-shrink: 0;
78 }
79 .back-row.visible { display: flex; }
80 .back-btn {
81 display: flex; align-items: center; gap: 6px;
82 font-size: 12px; color: var(--text-muted);
83 background: rgba(255,255,255,0.1); border-radius: 8px;
84 padding: 6px 12px; border: 1px solid var(--glass-border-light);
85 cursor: pointer; transition: all var(--transition-fast);
86 font-family: inherit;
87 }
88 .back-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
89 .back-btn svg { width: 12px; height: 12px; }
90
91 .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
92 .status-badge .status-text { display: inline; }
93 .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
94 .status-dot.connected { background: var(--accent); }
95 .status-dot.disconnected { background: var(--error); animation: none; }
96 .status-dot.connecting { background: #f59e0b; }
97 @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
98
99 .advanced-btn {
100 font-size: 12px; color: var(--text-muted);
101 background: rgba(255,255,255,0.1); border-radius: 8px;
102 padding: 6px 14px; border: 1px solid var(--glass-border-light);
103 cursor: pointer; text-decoration: none; transition: all var(--transition-fast);
104 font-family: inherit;
105 }
106 .advanced-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
107
108 /* Root name inline */
109 .root-name-inline {
110 font-size: 13px; font-weight: 400; color: var(--text-muted);
111 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
112 max-width: 200px; opacity: 0; transition: opacity 0.3s ease;
113 }
114 .root-name-inline.visible { opacity: 1; }
115 .root-name-inline::before { content: ' / '; color: var(--glass-border-light); }
116
117 /* Tree picker */
118 .tree-picker {
119 flex: 1; display: flex; flex-direction: column;
120 align-items: center;
121 padding: 32px 20px 40px; gap: 24px;
122 overflow-y: auto; min-height: 0;
123 }
124 .tree-picker-title { font-size: 24px; font-weight: 600; margin-bottom: 4px; flex-shrink: 0; }
125 .tree-picker-sub { color: var(--text-muted); font-size: 15px; text-align: center; flex-shrink: 0; }
126 .tree-list { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 420px; }
127 .tree-item {
128 background: rgba(var(--glass-rgb), var(--glass-alpha));
129 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
130 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
131 border: 1px solid var(--glass-border-light);
132 border-radius: 16px; padding: 18px 22px;
133 cursor: pointer; transition: all var(--transition-fast);
134 display: flex; align-items: center; justify-content: space-between;
135 animation: fadeInUp 0.3s ease-out backwards;
136 }
137 .tree-item:hover { background: rgba(var(--glass-rgb), 0.42); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
138 .tree-item:active { transform: translateY(0) scale(0.98); }
139 .tree-item-left { display: flex; align-items: center; gap: 14px; }
140 .tree-item-icon { font-size: 22px; }
141 .tree-item-name { font-size: 15px; font-weight: 500; }
142 .tree-item-meta { font-size: 12px; color: var(--text-muted); }
143 @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } }
144 ${trees.map((_, i) => `.tree-item:nth-child(${i + 1}) { animation-delay: ${i * 0.06}s; }`).join("\n ")}
145
146 .empty-state {
147 background: rgba(var(--glass-rgb), var(--glass-alpha));
148 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
149 border: 1px solid var(--glass-border-light);
150 border-radius: 20px; padding: 48px 32px;
151 text-align: center; max-width: 400px;
152 }
153 .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; display: block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); }
154 .empty-state h2 { font-size: 20px; margin-bottom: 8px; }
155 .empty-state p { color: var(--text-muted); font-size: 14px; margin-bottom: 20px; line-height: 1.5; }
156 /* Create tree form */
157 .create-tree-form {
158 display: flex; gap: 8px; width: 100%; max-width: 420px; margin-top: 8px;
159 flex-shrink: 0; padding-bottom: 8px;
160 }
161 .create-tree-form input {
162 flex: 1; padding: 14px 18px; font-size: 15px;
163 background: rgba(var(--glass-rgb), 0.25);
164 border: 1px solid var(--glass-border-light);
165 border-radius: 14px; color: var(--text-primary);
166 transition: all 0.2s; outline: none;
167 }
168 .create-tree-form input::placeholder { color: var(--text-muted); }
169 .create-tree-form input:focus {
170 border-color: var(--accent); background: rgba(var(--glass-rgb), 0.35);
171 box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
172 }
173 .create-tree-form button {
174 padding: 14px 18px; font-size: 20px; line-height: 1;
175 background: rgba(var(--glass-rgb), 0.3);
176 border: 1px solid var(--glass-border-light);
177 border-radius: 14px; color: var(--text-primary);
178 cursor: pointer; transition: all 0.2s;
179 }
180 .create-tree-form button:hover {
181 background: var(--accent); border-color: var(--accent);
182 box-shadow: 0 4px 15px var(--accent-glow);
183 }
184 .create-tree-form button:disabled { opacity: 0.4; cursor: not-allowed; }
185
186 /* Chat area */
187 .chat-area { flex: 1; display: none; flex-direction: column; overflow: hidden; }
188 .chat-area.active { display: flex; }
189
190 /* Messages — matches app.js */
191 .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
192 .chat-messages::-webkit-scrollbar { width: 4px; }
193 .chat-messages::-webkit-scrollbar-track { background: transparent; margin: 8px 0; }
194 .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 4px; }
195 .chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
196 .chat-messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.12) transparent; }
197
198 .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
199 @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
200 .message.user { flex-direction: row-reverse; }
201 .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
202 .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
203 .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
204 .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
205 .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
206 .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
207
208 /* Message content formatting — matches app.js */
209 .message-content p { margin: 0 0 10px 0; word-break: break-word; }
210 .message-content p:last-child { margin-bottom: 0; }
211 .message-content h1, .message-content h2, .message-content h3, .message-content h4 { margin: 14px 0 8px 0; font-weight: 600; line-height: 1.3; }
212 .message-content h1:first-child, .message-content h2:first-child, .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
213 .message-content h1 { font-size: 17px; }
214 .message-content h2 { font-size: 16px; }
215 .message-content h3 { font-size: 15px; }
216 .message-content h4 { font-size: 14px; color: var(--text-secondary); }
217 .message-content ul, .message-content ol { margin: 8px 0; padding-left: 0; list-style: none; }
218 .message-content li { margin: 4px 0; padding: 6px 10px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; line-height: 1.4; word-break: break-word; }
219 .message-content li .list-num { color: var(--accent); font-weight: 600; margin-right: 6px; }
220 .message-content strong, .message-content b { font-weight: 600; color: #fff; }
221 .message-content em, .message-content i { font-style: italic; color: var(--text-secondary); }
222 .message-content code { background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }
223 .message-content pre { background: rgba(0, 0, 0, 0.3); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; max-width: 100%; }
224 .message-content pre code { background: none; padding: 0; word-break: normal; white-space: pre-wrap; }
225 .message-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 10px 0; color: var(--text-secondary); font-style: italic; }
226 .message-content hr { border: none; border-top: 1px solid var(--glass-border-light); margin: 14px 0; }
227 .message-content a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
228 .message-content a:hover { text-decoration: none; }
229
230 /* Menu items */
231 .message-content .menu-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin: 6px 0; background: rgba(255, 255, 255, 0.08); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.06); transition: all 0.15s ease; }
232 .message-content .menu-item.clickable { cursor: pointer; user-select: none; }
233 .message-content .menu-item.clickable:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(16, 185, 129, 0.3); transform: translateX(4px); }
234 .message-content .menu-item.clickable:active { transform: translateX(4px) scale(0.98); background: rgba(16, 185, 129, 0.2); }
235 .message-content .menu-item:first-of-type { margin-top: 8px; }
236 .message-content .menu-number { display: flex; align-items: center; justify-content: center; min-width: 26px; max-width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); border-radius: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); transition: all 0.15s ease; }
237 .message-content .menu-item.clickable:hover .menu-number { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5); }
238 .message-content .menu-text { flex: 1; min-width: 0; padding-top: 2px; word-break: break-word; overflow-wrap: break-word; }
239 .message-content .menu-text strong { display: block; margin-bottom: 2px; word-break: break-word; }
240
241 /* Typing indicator — matches app.js */
242 .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
243 .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
244 .typing-dot:nth-child(2) { animation-delay: 0.2s; }
245 .typing-dot:nth-child(3) { animation-delay: 0.4s; }
246 @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
247
248 /* Input — matches app.js */
249 .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); }
250 .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
251 .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
252 .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto; }
253 .chat-input::-webkit-scrollbar { width: 4px; }
254 .chat-input::-webkit-scrollbar-track { background: transparent; }
255 .chat-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
256 .chat-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
257 .chat-area.empty .chat-input { max-height: 40vh; }
258 .chat-input::placeholder { color: var(--text-muted); }
259 .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
260 .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
261 .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
262 .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
263 .send-btn svg { width: 20px; height: 20px; }
264 .send-btn.stop-mode { background: rgba(239, 68, 68, 0.7); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
265 .send-btn.stop-mode:hover:not(:disabled) { background: rgba(239, 68, 68, 0.9); box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); }
266
267 /* Mode toggle */
268 .mode-toggle { display: flex; gap: 4px; padding: 0 2px 10px; }
269 .mode-btn { padding: 4px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text-muted); font-size: 12px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
270 .mode-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-secondary); }
271 .mode-btn.active { background: rgba(255,255,255,0.18); color: var(--text-primary); border-color: rgba(255,255,255,0.25); }
272 .mode-btn.active[data-mode="chat"] { background: var(--accent); border-color: var(--accent); color: #fff; }
273 .mode-btn.active[data-mode="place"] { background: rgba(72,187,120,0.4); border-color: rgba(72,187,120,0.5); color: #fff; }
274 .mode-btn.active[data-mode="query"] { background: rgba(115,111,230,0.4); border-color: rgba(115,111,230,0.5); color: #fff; }
275 .mode-hint { font-size: 11px; color: var(--text-muted); padding: 0 4px 6px; opacity: 0.7; }
276
277 /* Place result message */
278 .place-result { font-size: 13px; color: var(--text-muted); padding: 8px 14px; background: rgba(72,187,120,0.08); border-radius: 12px; border: 1px solid rgba(72,187,120,0.15); margin: 4px 0; }
279
280 /* Empty state — input pinned to vertical center, welcome above it */
281 .chat-area.empty { position: relative; }
282 .chat-area.empty .chat-input-area { position: absolute; top: 40%; left: 50%; transform: translate(-50%, 0); border-top: none; max-width: 600px; width: calc(100% - 40px); }
283 .chat-area.empty .chat-messages { position: absolute; top: 40%; left: 0; right: 0; transform: translateY(-100%); display: flex; flex-direction: column; align-items: center; overflow: visible; flex: none; }
284
285 /* Welcome message */
286 .welcome-message { text-align: center; padding: 40px 20px; }
287 .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
288 @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
289 .chat-area.empty .welcome-message { padding: 8px 20px; }
290 .chat-area.empty .welcome-icon { font-size: 48px; margin-bottom: 12px; }
291 .chat-area.empty .welcome-message h2 { font-size: 18px; margin-bottom: 6px; }
292 .chat-area.empty .welcome-message p { font-size: 13px; }
293 .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
294 .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; }
295 .welcome-message.disconnected { opacity: 0.7; }
296 .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
297
298 /* Notifications panel */
299 .clear-chat-btn {
300 background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
301 border-radius: 8px; padding: 6px 8px; cursor: pointer;
302 color: var(--text-muted); transition: all var(--transition-fast);
303 display: none; align-items: center; justify-content: center;
304 }
305 .clear-chat-btn.visible { display: flex; }
306 .clear-chat-btn:hover { background: rgba(255,255,255,0.2); color: var(--text-primary); }
307 .clear-chat-btn:active { transform: scale(0.93); }
308 .clear-chat-btn svg { width: 14px; height: 14px; }
309 .notif-btn {
310 font-size: 12px; color: var(--text-muted);
311 background: rgba(255,255,255,0.1); border-radius: 8px;
312 padding: 6px 14px; border: 1px solid var(--glass-border-light);
313 cursor: pointer; transition: all var(--transition-fast);
314 font-family: inherit; position: relative;
315 display: flex; align-items: center; gap: 6px;
316 }
317 .notif-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
318 .notif-btn-icon { display: none; font-size: 14px; line-height: 1; }
319 .notif-btn .notif-dot {
320 position: absolute; top: -3px; right: -3px;
321 width: 8px; height: 8px; border-radius: 50%;
322 background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
323 display: none;
324 }
325 .notif-btn .notif-dot.has-notifs { display: block; }
326
327 .notif-overlay {
328 position: fixed; inset: 0; background: rgba(0,0,0,0.4);
329 z-index: 9998; display: none;
330 }
331 .notif-overlay.open { display: block; }
332
333 .notif-panel {
334 position: fixed; top: 0; right: -400px; bottom: 0;
335 width: 380px; max-width: 90vw;
336 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
337 z-index: 9999;
338 display: flex; flex-direction: column;
339 transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
340 box-shadow: -8px 0 32px rgba(0,0,0,0.3);
341 }
342 .notif-panel.open { right: 0; }
343
344 .notif-panel-header {
345 padding: 20px; display: flex; align-items: center;
346 justify-content: space-between; flex-shrink: 0;
347 border-bottom: 1px solid var(--glass-border-light);
348 }
349 .notif-panel-header h2 { font-size: 18px; font-weight: 600; color: white; }
350 .notif-close {
351 width: 32px; height: 32px; border-radius: 8px;
352 background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
353 color: white; cursor: pointer; font-size: 16px; display: flex;
354 align-items: center; justify-content: center; transition: all var(--transition-fast);
355 }
356 .notif-close:hover { background: rgba(255,255,255,0.2); }
357
358 .notif-list {
359 flex: 1; overflow-y: auto; padding: 16px;
360 display: flex; flex-direction: column; gap: 12px;
361 }
362 .notif-list::-webkit-scrollbar { width: 6px; }
363 .notif-list::-webkit-scrollbar-track { background: transparent; }
364 .notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
365
366 .notif-item {
367 background: rgba(var(--glass-rgb), var(--glass-alpha));
368 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
369 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
370 border: 1px solid var(--glass-border-light);
371 border-radius: 14px; padding: 16px;
372 animation: fadeInUp 0.3s ease-out backwards;
373 transition: all var(--transition-fast);
374 }
375 .notif-item:hover {
376 background: rgba(var(--glass-rgb), 0.42);
377 transform: translateY(-1px);
378 }
379 .notif-item.type-thought { border-left: 3px solid #9b64dc; }
380 .notif-item.type-summary { border-left: 3px solid #6464d2; }
381
382 .notif-item-header {
383 display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
384 }
385 .notif-item-icon { font-size: 18px; flex-shrink: 0; }
386 .notif-item-title {
387 font-size: 14px; font-weight: 600; color: white;
388 flex: 1; line-height: 1.3;
389 }
390 .notif-item-badge {
391 font-size: 9px; font-weight: 700; text-transform: uppercase;
392 letter-spacing: 0.5px; padding: 2px 7px; border-radius: 6px;
393 background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
394 border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
395 }
396 .notif-item-content {
397 font-size: 13px; color: rgba(255,255,255,0.85);
398 line-height: 1.55; white-space: pre-wrap; word-break: break-word;
399 }
400 .notif-item-time {
401 font-size: 11px; color: rgba(255,255,255,0.45);
402 margin-top: 8px;
403 }
404 .notif-empty {
405 text-align: center; color: rgba(255,255,255,0.5);
406 font-size: 14px; padding: 40px 20px;
407 }
408 .notif-empty-icon { font-size: 40px; margin-bottom: 12px; display: block; }
409 .notif-loading { text-align: center; color: rgba(255,255,255,0.5); padding: 40px 20px; font-size: 14px; }
410
411 /* Notification tabs */
412 .notif-tabs {
413 display: flex; gap: 0; flex-shrink: 0;
414 border-bottom: 1px solid var(--glass-border-light);
415 }
416 .notif-tab {
417 flex: 1; padding: 10px 0; text-align: center;
418 font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.5);
419 background: none; border: none; border-bottom: 2px solid transparent;
420 cursor: pointer; font-family: inherit; transition: all var(--transition-fast);
421 position: relative;
422 }
423 .notif-tab:hover { color: rgba(255,255,255,0.8); }
424 .notif-tab.active { color: white; border-bottom-color: white; }
425 .notif-tab .tab-dot {
426 display: none; width: 6px; height: 6px; border-radius: 50%;
427 background: var(--accent); position: absolute; top: 8px; right: calc(50% - 30px);
428 }
429 .notif-tab .tab-dot.visible { display: block; }
430
431 /* Invite items */
432 .invite-item {
433 background: rgba(var(--glass-rgb), var(--glass-alpha));
434 backdrop-filter: blur(var(--glass-blur)) saturate(140%);
435 border: 1px solid var(--glass-border-light);
436 border-radius: 14px; padding: 16px;
437 border-left: 3px solid #48bb78;
438 animation: fadeInUp 0.3s ease-out backwards;
439 }
440 .invite-item-text { font-size: 13px; color: rgba(255,255,255,0.9); line-height: 1.5; margin-bottom: 10px; }
441 .invite-item-text strong { color: white; }
442 .invite-item-actions { display: flex; gap: 8px; }
443 .invite-item-actions button {
444 flex: 1; padding: 8px; border-radius: 8px; border: none;
445 font-family: inherit; font-size: 12px; font-weight: 600;
446 cursor: pointer; transition: all var(--transition-fast);
447 }
448 .invite-accept { background: var(--accent); color: white; }
449 .invite-accept:hover { filter: brightness(1.1); }
450 .invite-decline { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid var(--glass-border-light) !important; }
451 .invite-decline:hover { background: rgba(255,255,255,0.18); color: white; }
452
453 /* Members section */
454 .members-section { margin-top: 4px; }
455 .members-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); margin-bottom: 8px; }
456 .member-item {
457 display: flex; align-items: center; justify-content: space-between;
458 padding: 10px 14px; border-radius: 10px;
459 background: rgba(var(--glass-rgb), var(--glass-alpha));
460 border: 1px solid var(--glass-border-light); margin-bottom: 8px;
461 }
462 .member-name { font-size: 13px; color: white; font-weight: 500; }
463 .member-role { font-size: 11px; color: rgba(255,255,255,0.45); margin-left: 6px; }
464 .member-actions { display: flex; gap: 6px; }
465 .member-actions button {
466 padding: 4px 10px; border-radius: 6px; border: 1px solid var(--glass-border-light);
467 background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6);
468 font-family: inherit; font-size: 11px; cursor: pointer;
469 transition: all var(--transition-fast);
470 }
471 .member-actions button:hover { background: rgba(255,255,255,0.15); color: white; }
472 .member-actions .btn-danger:hover { background: rgba(239,68,68,0.3); color: #fca5a5; border-color: rgba(239,68,68,0.4); }
473
474 .invite-form {
475 display: flex; gap: 8px; margin-top: 12px;
476 }
477 .invite-form input {
478 flex: 1; padding: 8px 12px; border-radius: 8px;
479 background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
480 color: white; font-family: inherit; font-size: 13px; outline: none;
481 }
482 .invite-form input::placeholder { color: rgba(255,255,255,0.35); }
483 .invite-form input:focus { border-color: var(--glass-border); }
484 .invite-form button {
485 padding: 8px 16px; border-radius: 8px; border: none;
486 background: var(--accent); color: white; font-family: inherit;
487 font-size: 12px; font-weight: 600; cursor: pointer;
488 transition: all var(--transition-fast);
489 }
490 .invite-form button:hover { filter: brightness(1.1); }
491 .invite-form button:disabled { opacity: 0.5; cursor: not-allowed; }
492
493 .invite-status {
494 font-size: 12px; margin-top: 6px; padding: 6px 10px;
495 border-radius: 6px; display: none;
496 }
497 .invite-status.error { display: block; background: rgba(239,68,68,0.15); color: #fca5a5; }
498 .invite-status.success { display: block; background: rgba(16,185,129,0.15); color: #6ee7b7; }
499
500 /* Dream time config */
501 .dream-config {
502 background: rgba(var(--glass-rgb), var(--glass-alpha));
503 border: 1px solid var(--glass-border-light);
504 border-radius: 14px; padding: 14px 16px; margin-bottom: 12px;
505 }
506 .dream-config-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
507 .dream-config-row { display: flex; align-items: center; gap: 8px; }
508 .dream-config-row input[type="time"] {
509 padding: 6px 10px; border-radius: 8px;
510 background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
511 color: white; font-family: inherit; font-size: 13px; outline: none;
512 color-scheme: dark;
513 }
514 .dream-config-row input[type="time"]:focus { border-color: var(--glass-border); }
515 .dream-config-row button {
516 padding: 6px 12px; border-radius: 8px; border: none;
517 font-family: inherit; font-size: 12px; font-weight: 600;
518 cursor: pointer; transition: all var(--transition-fast);
519 }
520 .dream-config-save { background: var(--accent); color: white; }
521 .dream-config-save:hover { filter: brightness(1.1); }
522 .dream-config-off {
523 background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
524 border: 1px solid var(--glass-border-light) !important;
525 }
526 .dream-config-off:hover { background: rgba(255,255,255,0.18); color: white; }
527 .dream-config-status { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 6px; }
528 .dream-config-hint { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.4; }
529
530 .notif-panel-footer {
531 padding: 16px; border-top: 1px solid var(--glass-border-light); flex-shrink: 0;
532 }
533 .logout-btn {
534 width: 100%; padding: 10px; border-radius: 10px;
535 background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
536 color: #fca5a5; font-family: inherit; font-size: 13px;
537 cursor: pointer; transition: all var(--transition-fast);
538 }
539 .logout-btn:hover { background: rgba(239,68,68,0.3); color: #fecaca; }
540
541 @media (max-width: 600px) {
542 .container { max-width: 100%; }
543 .chat-header { padding: 0 12px; }
544 .header-right { gap: 6px; }
545 .chat-input-area { padding: 12px 16px 16px; }
546
547 /* Collapse status badge to dot only */
548 .status-badge .status-text { display: none; }
549 .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
550
551 /* Collapse notifications button to icon only */
552 .notif-btn-label { display: none; }
553 .notif-btn-icon { display: inline; }
554 .notif-btn { padding: 6px 8px; }
555
556 /* Shrink other buttons */
557 .advanced-btn { padding: 6px 10px; font-size: 11px; }
558 .back-btn { padding: 4px 8px; font-size: 11px; }
559 .back-btn svg { width: 10px; height: 10px; }
560
561 /* Hide title text, keep icon */
562 .chat-title h1 { display: none; }
563
564 .notif-panel { width: 100%; max-width: 100%; right: -100%; }
565 .notif-panel.open { right: 0; }
566
567 /* Empty state — mobile: push to top */
568 .chat-area.empty { overflow: visible; }
569 .chat-area.empty .chat-messages { position: static; flex: 0; padding-top: 0; transform: none; overflow: visible; }
570 .chat-area.empty .chat-input-area { position: static; transform: none; width: 100%; max-width: 100%; margin: 0; }
571 }
572 </style>
573</head>
574<body>
575 <div class="container">
576 <div class="chat-header">
577 <div class="chat-title">
578 <a href="/app" style="text-decoration:none;display:flex;align-items:center;gap:12px;color:inherit;">
579 <span class="tree-icon">🌳</span>
580 <h1>Tree</h1>
581 </a>
582 <span class="root-name-inline" id="rootName"></span>
583 </div>
584 <div class="header-right">
585 <div class="status-badge">
586 <div class="status-dot connecting" id="statusDot"></div>
587 <span class="status-text" id="statusText">Connecting</span>
588 </div>
589 <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
590 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
591 </button>
592 <button class="notif-btn" id="notifBtn" onclick="toggleNotifs()">
593 <span class="notif-dot" id="notifDot"></span>
594 <span class="notif-btn-icon">☰</span>
595 <span class="notif-btn-label">Menu</span>
596 </button>
597 <a href="/dashboard" class="advanced-btn" id="advancedLink">Advanced</a>
598 </div>
599 </div>
600 <div class="back-row" id="backRow">
601 <button class="back-btn" onclick="backToTrees()">
602 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
603 Back
604 </button>
605 </div>
606
607 <!-- Menu panel -->
608 <div class="notif-overlay" id="notifOverlay" onclick="toggleNotifs()"></div>
609 <div class="notif-panel" id="notifPanel">
610 <div class="notif-panel-header">
611 <h2>Menu</h2>
612 <button class="notif-close" onclick="toggleNotifs()">✕</button>
613 </div>
614 <div class="notif-tabs">
615 <button class="notif-tab active" id="tabDreams" onclick="switchTab('dreams')">Dreams<span class="tab-dot" id="dreamsDot"></span></button>
616 <button class="notif-tab" id="tabInvites" onclick="switchTab('invites')">Invites<span class="tab-dot" id="invitesDot"></span></button>
617 </div>
618 <div class="notif-list" id="notifList">
619 <div class="notif-loading">Loading...</div>
620 </div>
621 <div class="notif-list" id="invitesList" style="display:none">
622 <div class="notif-loading">Loading...</div>
623 </div>
624 <div class="notif-panel-footer">
625 <button class="logout-btn" onclick="doLogout()">Log out</button>
626 </div>
627 </div>
628
629 <div class="tree-picker" id="treePicker">
630 ${
631 trees.length === 0
632 ? `<div class="empty-state">
633 <span class="empty-icon">🌱</span>
634 <h2>Plant your first tree</h2>
635 <p>A tree starts with a single root, a broad topic that everything else branches out from. Think big categories like <strong>My Life</strong>, <strong>Career</strong>, or <strong>Health</strong>.</p>
636 <p style="margin-top:8px;">Name it, and you can start chatting with it right away.</p>
637 <form class="create-tree-form" style="margin-top:16px;" onsubmit="createTree(event)">
638 <input type="text" id="newTreeNameEmpty" placeholder="e.g. My Life" autocomplete="off" />
639 <button type="submit" title="Create tree">+</button>
640 </form>
641 </div>`
642 : `<h2 class="tree-picker-title">Your Trees</h2>
643 <p class="tree-picker-sub">Pick a tree to start chatting</p>
644 <div class="tree-list" id="treeList">
645 ${trees
646 .map(
647 (t) => `
648 <div class="tree-item" onclick="selectTree('${t._id}', '${escapeHtml(t.name)}')">
649 <span class="tree-item-icon">🌳</span>
650 <span class="tree-item-name">${escapeHtml(t.name)}</span>
651 </div>`,
652 )
653 .join("")}
654 </div>`
655 }
656 ${trees.length > 0 ? `
657 <form class="create-tree-form" id="createTreeForm" onsubmit="createTree(event)">
658 <input type="text" id="newTreeName" placeholder="New tree name..." autocomplete="off" />
659 <button type="submit" title="Create tree">+</button>
660 </form>` : ""}
661 </div>
662
663 <div class="chat-area empty" id="chatArea">
664 <div class="chat-messages" id="messages">
665 <div class="welcome-message" id="welcomeMsg">
666 <div class="welcome-icon">🌳</div>
667 <h2>Start chatting</h2>
668 <p>Ask anything about your tree or tell it something new.</p>
669 </div>
670 </div>
671 <div class="chat-input-area">
672 <div class="mode-toggle" id="modeToggle">
673 <button class="mode-btn active" data-mode="chat">Chat</button>
674 <button class="mode-btn" data-mode="place">Place</button>
675 <button class="mode-btn" data-mode="query">Query</button>
676 </div>
677 <div class="input-container">
678 <textarea class="chat-input" id="chatInput" placeholder="Say something..." rows="1"></textarea>
679 <button class="send-btn" id="sendBtn" disabled>
680 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
681 </button>
682 </div>
683 </div>
684 </div>
685 </div>
686
687 <script src="/socket.io/socket.io.js"></script>
688 <script>
689 const CONFIG = {
690 username: "${escapeHtml(username)}",
691 userId: "${userId}",
692 trees: ${treesJSON},
693 };
694
695 // State
696 let activeRootId = null;
697 let isConnected = false;
698 let isRegistered = false;
699 let isSending = false;
700 let requestGeneration = 0;
701 let chatMode = "chat";
702
703 // Mode toggle
704 const modeToggle = document.getElementById("modeToggle");
705 const modePlaceholders = { chat: "Full conversation. Places content and responds.", place: "Places content onto your tree but doesn't respond.", query: "Talk to your tree without it making any changes." };
706 modeToggle.addEventListener("click", function(e) {
707 var btn = e.target.closest(".mode-btn");
708 if (!btn || isSending) return;
709 chatMode = btn.dataset.mode;
710 modeToggle.querySelectorAll(".mode-btn").forEach(function(b) { b.classList.remove("active"); });
711 btn.classList.add("active");
712 document.getElementById("chatInput").placeholder = modePlaceholders[chatMode] || "Say something...";
713 });
714
715 // Elements
716 const statusDot = document.getElementById("statusDot");
717 const statusText = document.getElementById("statusText");
718 const treePicker = document.getElementById("treePicker");
719 const chatArea = document.getElementById("chatArea");
720 const chatMessages = document.getElementById("messages");
721 const chatInput = document.getElementById("chatInput");
722 const sendBtn = document.getElementById("sendBtn");
723 const backRow = document.getElementById("backRow");
724 const rootName = document.getElementById("rootName");
725 const advancedLink = document.getElementById("advancedLink");
726
727 function escapeHtml(s) {
728 const d = document.createElement("div");
729 d.textContent = s;
730 return d.innerHTML;
731 }
732
733 // ── Markdown formatting — matches app.js ──────────────────────────
734 function formatMessageContent(text) {
735 if (!text) return '';
736 let html = text;
737
738 html = html.replace(/ /g, ' ');
739 html = html.replace(/&/g, '&');
740 html = html.replace(/</g, '<');
741 html = html.replace(/>/g, '>');
742 html = html.replace(/\\u00A0/g, ' ');
743
744 html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
745
746 // Code blocks
747 html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
748 html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
749
750 // Bold / italic
751 html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
752 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
753 html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
754
755 // Headings
756 html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
757 html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
758 html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
759 html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
760
761 // HR
762 html = html.replace(/^-{3,}$/gm, '<hr>');
763 html = html.replace(/^\\*{3,}$/gm, '<hr>');
764
765 // Blockquote
766 html = html.replace(/^>\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
767
768 // Numbered menu items with bold title
769 html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, function(m, num, title, rest) {
770 return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '"') + '">' +
771 '<span class="menu-number">' + num + '</span>' +
772 '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
773 });
774
775 // Bullet items with bold title
776 html = html.replace(/^[-\\u2013\\u2022]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
777 '<div class="menu-item"><span class="menu-number">\\u2022</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
778
779 // Plain bullet items
780 html = html.replace(/^[-\\u2013\\u2022]\\s+([^<].*)$/gm, '<li>$1</li>');
781
782 // Numbered list items
783 html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
784
785 // Wrap consecutive li in ul
786 let inList = false;
787 const lines = html.split('\\n');
788 const processed = [];
789 for (let i = 0; i < lines.length; i++) {
790 const line = lines[i];
791 const isListItem = line.trim().startsWith('<li>');
792 if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
793 else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
794 processed.push(line);
795 }
796 if (inList) processed.push('</ul>');
797 html = processed.join('\\n');
798
799 // Links
800 html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
801
802 // Paragraphs
803 const blocks = html.split(/\\n\\n+/);
804 html = blocks.map(function(block) {
805 const trimmed = block.trim();
806 if (!trimmed) return '';
807 if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
808 const withBreaks = trimmed.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l; }).join('<br>');
809 return '<p>' + withBreaks + '</p>';
810 }).filter(function(b) { return b; }).join('');
811
812 // Clean up
813 html = html.replace(/<p><\\/p>/g, '');
814 html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote)/g, '$1');
815 html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>)<\\/p>/g, '$1');
816 html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
817 html = html.replace(/(<div[^>]*>)<br>/g, '$1');
818
819 return html;
820 }
821
822 // ── Socket ────────────────────────────────────────────────────────
823 const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
824
825 socket.on("connect", () => {
826 isConnected = true;
827 statusDot.className = "status-dot connecting";
828 statusText.textContent = "Connecting";
829 socket.emit("ready");
830 socket.emit("register", { username: CONFIG.username });
831 });
832
833 socket.on("registered", ({ success }) => {
834 if (success) {
835 isRegistered = true;
836 statusDot.className = "status-dot connected";
837 statusText.textContent = "Connected";
838
839 // Clear disconnected message on reconnect
840 const disc = chatMessages.querySelector(".welcome-message.disconnected");
841 if (disc) {
842 disc.remove();
843 chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
844 chatArea.classList.add("empty");
845 }
846
847 updateSendBtn();
848 if (activeRootId) {
849 socket.emit("setActiveRoot", { rootId: activeRootId });
850 socket.emit("urlChanged", { url: "/api/v1/root/" + activeRootId, rootId: activeRootId });
851 }
852 }
853 });
854
855 socket.on("chatResponse", ({ answer, generation }) => {
856 if (generation !== undefined && generation < requestGeneration) return;
857 removeTyping();
858 addMessage(answer, "assistant");
859 isSending = false;
860 updateSendBtn();
861 });
862
863 socket.on("placeResult", ({ stepSummaries, targetPath, generation }) => {
864 if (generation !== undefined && generation < requestGeneration) return;
865 var el = document.getElementById("placeStatus");
866 var summary = (stepSummaries && stepSummaries.length > 0)
867 ? "Placed on: " + (targetPath || stepSummaries.map(function(s) { return s.summary || s; }).join(", "))
868 : "Nothing to place for that message.";
869 if (el) {
870 el.querySelector(".place-result").textContent = summary;
871 } else {
872 addMessage(summary, "place-status");
873 }
874 isSending = false;
875 updateSendBtn();
876 });
877
878 socket.on("chatError", ({ error, generation }) => {
879 if (generation !== undefined && generation < requestGeneration) return;
880 removeTyping();
881 addMessage("Error: " + error, "error");
882 isSending = false;
883 updateSendBtn();
884 });
885
886 socket.on("chatCancelled", () => {
887 if (isSending) {
888 removeTyping();
889 isSending = false;
890 updateSendBtn();
891 }
892 });
893
894 socket.on("disconnect", () => {
895 isConnected = false;
896 isRegistered = false;
897 isSending = false;
898 statusDot.className = "status-dot disconnected";
899 statusText.textContent = "Disconnected";
900 updateSendBtn();
901
902 chatMessages.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the page to reconnect.</p></div>';
903 });
904
905 // Ignore navigate events — no iframe
906 socket.on("navigate", () => {});
907
908 // ── Create tree ─────────────────────────────────────────────────
909 async function createTree(e) {
910 e.preventDefault();
911 const input = e.target.querySelector("input[type=text]");
912 const name = input.value.trim();
913 if (!name) return;
914
915 const btn = e.target.querySelector("button");
916 btn.disabled = true;
917
918 try {
919 const res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
920 method: "POST",
921 headers: { "Content-Type": "application/json" },
922 credentials: "include",
923 body: JSON.stringify({ name }),
924 });
925 const data = await res.json();
926 if (!data.success) throw new Error(data.error || "Failed");
927
928 // Add to tree list (create it if empty state)
929 let treeList = document.getElementById("treeList");
930 if (!treeList) {
931 // Was empty state — rebuild picker content
932 const emptyState = treePicker.querySelector(".empty-state");
933 if (emptyState) emptyState.remove();
934
935 const title = document.createElement("h2");
936 title.className = "tree-picker-title";
937 title.textContent = "Your Trees";
938
939 const sub = document.createElement("p");
940 sub.className = "tree-picker-sub";
941 sub.textContent = "Pick a tree to start chatting";
942
943 treeList = document.createElement("div");
944 treeList.className = "tree-list";
945 treeList.id = "treeList";
946
947 const form = document.getElementById("createTreeForm");
948 treePicker.insertBefore(treeList, form);
949 treePicker.insertBefore(sub, treeList);
950 treePicker.insertBefore(title, sub);
951 }
952
953 const item = document.createElement("div");
954 item.className = "tree-item";
955 item.onclick = () => selectTree(data.rootId, name);
956 item.innerHTML = \`
957 <span class="tree-item-icon">🌳</span>
958 <span class="tree-item-name">\${escapeHtml(name)}</span>\`;
959 item.style.animation = "fadeInUp 0.3s ease-out";
960 treeList.appendChild(item);
961
962 input.value = "";
963 } catch (err) {
964 console.error("Create tree error:", err);
965 alert("Failed to create tree: " + err.message);
966 } finally {
967 btn.disabled = false;
968 }
969 }
970
971 // ── Tree selection ────────────────────────────────────────────────
972 function selectTree(rootId, name) {
973 activeRootId = rootId;
974 advancedLink.href = "/dashboard?rootId=" + rootId;
975 treePicker.style.display = "none";
976 chatArea.classList.add("active");
977 rootName.textContent = name;
978 rootName.classList.add("visible");
979 backRow.classList.add("visible");
980
981 // Reset chat
982 const welcome = chatMessages.querySelector(".welcome-message");
983 if (welcome) welcome.style.display = "";
984 chatMessages.querySelectorAll(".message, .typing-indicator").forEach(el => el.remove());
985 chatArea.classList.add("empty");
986
987 // Tell server about this root
988 socket.emit("setActiveRoot", { rootId });
989 socket.emit("urlChanged", { url: "/api/v1/root/" + rootId, rootId });
990
991 // Refresh menu panel for this tree
992 dreamsLoaded = false;
993 invitesLoaded = false;
994 if (notifOpen) {
995 if (activeTab === "dreams") fetchDreams();
996 if (activeTab === "invites") fetchInvites();
997 }
998
999 updateSendBtn();
1000 }
1001
1002 function backToTrees() {
1003 // Cancel any in-flight request
1004 if (isSending) {
1005 requestGeneration++;
1006 socket.emit("cancelRequest");
1007 removeTyping();
1008 }
1009
1010 activeRootId = null;
1011 advancedLink.href = "/dashboard";
1012 treePicker.style.display = "";
1013 chatArea.classList.remove("active");
1014 rootName.classList.remove("visible");
1015 backRow.classList.remove("visible");
1016 document.getElementById("clearChatBtn").classList.remove("visible");
1017 isSending = false;
1018 updateSendBtn();
1019
1020 // Tell server we're going home so it properly exits tree mode
1021 socket.emit("urlChanged", { url: "/api/v1/user/" + CONFIG.userId });
1022 socket.emit("clearConversation");
1023 dreamsLoaded = false;
1024 invitesLoaded = false;
1025 if (notifOpen) {
1026 if (activeTab === "dreams") fetchDreams();
1027 if (activeTab === "invites") fetchInvites();
1028 }
1029 }
1030
1031 // ── Messages ──────────────────────────────────────────────────────
1032 function addMessage(content, role) {
1033 const welcome = chatMessages.querySelector(".welcome-message");
1034 if (welcome) {
1035 welcome.remove();
1036 document.getElementById("chatArea").classList.remove("empty");
1037 document.getElementById("clearChatBtn").classList.add("visible");
1038 }
1039
1040 const msg = document.createElement("div");
1041 if (role === "place-status") {
1042 msg.className = "message assistant";
1043 msg.id = "placeStatus";
1044 msg.innerHTML = '<div class="message-avatar">\\ud83c\\udf33</div><div class="message-content"><div class="place-result">' + escapeHtml(content) + '</div></div>';
1045 chatMessages.appendChild(msg);
1046 chatMessages.scrollTop = chatMessages.scrollHeight;
1047 return;
1048 }
1049
1050 msg.className = "message " + role;
1051
1052 const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
1053
1054 msg.innerHTML =
1055 '<div class="message-avatar">' + (role === "user" ? "\\ud83d\\udc64" : "\\ud83c\\udf33") + '</div>' +
1056 '<div class="message-content">' + formattedContent + '</div>';
1057
1058 // Clickable menu items
1059 if (role === "assistant") {
1060 msg.querySelectorAll(".menu-item.clickable").forEach(function(item) {
1061 item.addEventListener("click", function() {
1062 const name = item.dataset.name;
1063 if (name && !isSending) {
1064 chatInput.value = name;
1065 sendMessage();
1066 }
1067 });
1068 });
1069 }
1070
1071 chatMessages.appendChild(msg);
1072 chatMessages.scrollTop = chatMessages.scrollHeight;
1073 }
1074
1075 function addTyping() {
1076 removeTyping();
1077 const msg = document.createElement("div");
1078 msg.className = "message assistant";
1079 msg.id = "typingIndicator";
1080 msg.innerHTML =
1081 '<div class="message-avatar">\\ud83c\\udf33</div>' +
1082 '<div class="message-content typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
1083 chatMessages.appendChild(msg);
1084 chatMessages.scrollTop = chatMessages.scrollHeight;
1085 }
1086
1087 function removeTyping() {
1088 const el = document.getElementById("typingIndicator");
1089 if (el) el.remove();
1090 }
1091
1092 // ── Send ──────────────────────────────────────────────────────────
1093 function sendMessage() {
1094 if (isSending) {
1095 requestGeneration++;
1096 socket.emit("cancelRequest");
1097 removeTyping();
1098 addMessage("Stopped", "error");
1099 isSending = false;
1100 updateSendBtn();
1101 return;
1102 }
1103
1104 const text = chatInput.value.trim();
1105 if (!text || !isRegistered || !activeRootId) return;
1106
1107 chatInput.value = "";
1108 chatInput.style.height = "auto";
1109 addMessage(text, "user");
1110 if (chatMode === "place") {
1111 addMessage("Placing...", "place-status");
1112 } else {
1113 addTyping();
1114 }
1115 isSending = true;
1116 requestGeneration++;
1117 updateSendBtn();
1118 socket.emit("chat", { message: text, username: CONFIG.username, generation: requestGeneration, mode: chatMode });
1119 }
1120
1121 function updateSendBtn() {
1122 const hasText = chatInput.value.trim().length > 0;
1123 if (isSending) {
1124 sendBtn.classList.add("stop-mode");
1125 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
1126 sendBtn.disabled = !(isConnected && isRegistered);
1127 chatInput.disabled = true;
1128 } else {
1129 sendBtn.classList.remove("stop-mode");
1130 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
1131 sendBtn.disabled = !(hasText && isRegistered && activeRootId);
1132 chatInput.disabled = false;
1133 }
1134 }
1135
1136 // ── Input handlers ────────────────────────────────────────────────
1137 chatInput.addEventListener("input", () => {
1138 const maxH = chatArea.classList.contains("empty") ? window.innerHeight * 0.4 : 120;
1139 chatInput.style.height = "auto";
1140 chatInput.style.height = Math.min(chatInput.scrollHeight, maxH) + "px";
1141 updateSendBtn();
1142 });
1143
1144 chatInput.addEventListener("keydown", (e) => {
1145 if (e.key === "Enter" && !e.shiftKey) {
1146 e.preventDefault();
1147 sendMessage();
1148 // On mobile, blur to dismiss keyboard so user can see the response
1149 if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
1150 chatInput.blur();
1151 }
1152 }
1153 });
1154
1155 sendBtn.addEventListener("click", sendMessage);
1156
1157 document.getElementById("clearChatBtn").addEventListener("click", () => {
1158 if (!isRegistered) return;
1159 if (isSending) {
1160 socket.emit("cancelRequest");
1161 removeTyping();
1162 isSending = false;
1163 }
1164 socket.emit("clearConversation");
1165 chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
1166 chatArea.classList.add("empty");
1167 document.getElementById("clearChatBtn").classList.remove("visible");
1168 updateSendBtn();
1169 });
1170
1171 // ── Notifications + Invites ────────────────────────────────────────
1172 const notifPanel = document.getElementById("notifPanel");
1173 const notifOverlay = document.getElementById("notifOverlay");
1174 const notifList = document.getElementById("notifList");
1175 const invitesList = document.getElementById("invitesList");
1176 const notifDot = document.getElementById("notifDot");
1177 let notifOpen = false;
1178 let dreamsLoaded = false;
1179 let invitesLoaded = false;
1180 let activeTab = "dreams";
1181
1182 async function doLogout() {
1183 try {
1184 await fetch("/api/v1/logout", { method: "POST", credentials: "include" });
1185 window.location.href = "/login";
1186 } catch(e) {
1187 alert("Logout failed");
1188 }
1189 }
1190
1191 function toggleNotifs() {
1192 notifOpen = !notifOpen;
1193 notifPanel.classList.toggle("open", notifOpen);
1194 notifOverlay.classList.toggle("open", notifOpen);
1195 if (notifOpen) {
1196 if (activeTab === "dreams" && !dreamsLoaded) fetchDreams();
1197 if (activeTab === "invites" && !invitesLoaded) fetchInvites();
1198 }
1199 }
1200
1201 function switchTab(tab) {
1202 activeTab = tab;
1203 document.getElementById("tabDreams").classList.toggle("active", tab === "dreams");
1204 document.getElementById("tabInvites").classList.toggle("active", tab === "invites");
1205 notifList.style.display = tab === "dreams" ? "" : "none";
1206 invitesList.style.display = tab === "invites" ? "" : "none";
1207 if (tab === "dreams" && !dreamsLoaded) fetchDreams();
1208 if (tab === "invites" && !invitesLoaded) fetchInvites();
1209 }
1210
1211 async function fetchDreams() {
1212 notifList.innerHTML = '<div class="notif-loading">Loading...</div>';
1213 dreamsLoaded = false;
1214 try {
1215 var dreamUrl = "/api/v1/chat/notifications" + (activeRootId ? "?rootId=" + activeRootId : "");
1216 var res = await fetch(dreamUrl, { credentials: "include" });
1217 var data = await res.json();
1218 if (!res.ok) throw new Error(data.error || "Failed");
1219
1220 dreamsLoaded = true;
1221 var notifs = data.notifications || [];
1222 var html = "";
1223
1224 // Dream time config (only when inside a tree and user is owner)
1225 if (activeRootId && data.isOwner) {
1226 if (data.metadata?.dreams?.dreamTime) {
1227 html += '<div class="dream-config">' +
1228 '<div class="dream-config-label">Dream schedule</div>' +
1229 '<div class="dream-config-row">' +
1230 '<input type="time" id="dreamTimeInput" value="' + escapeHtml(data.metadata?.dreams?.dreamTime) + '" />' +
1231 '<button class="dream-config-save" onclick="saveDreamTime()">Save</button>' +
1232 '<button class="dream-config-off" onclick="disableDreamTime()">Turn Off</button>' +
1233 '</div>' +
1234 '<div class="dream-config-status" id="dreamStatus"></div>' +
1235 '</div>';
1236 } else {
1237 html += '<div class="dream-config">' +
1238 '<div class="dream-config-hint">Dreams are off for this tree. Set a time to enable nightly dreams. Your tree will reflect, reorganize, and share thoughts with you.</div>' +
1239 '<div class="dream-config-row" style="margin-top:8px">' +
1240 '<input type="time" id="dreamTimeInput" value="" />' +
1241 '<button class="dream-config-save" onclick="saveDreamTime()">Enable</button>' +
1242 '</div>' +
1243 '<div class="dream-config-status" id="dreamStatus"></div>' +
1244 '</div>';
1245 }
1246 }
1247
1248 if (notifs.length === 0) {
1249 html += '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udd14</span>' +
1250 (activeRootId ? 'No dreams from this tree yet' : 'No dream notifications from the last 7 days') +
1251 '</div>';
1252 notifList.innerHTML = html;
1253 return;
1254 }
1255
1256 document.getElementById("dreamsDot").classList.add("visible");
1257 notifDot.classList.add("has-notifs");
1258 html += notifs.map(function(n, i) {
1259 var isThought = n.type === "dream-thought";
1260 var icon = isThought ? "\\ud83d\\udcad" : "\\ud83d\\udccb";
1261 var badge = isThought ? "Thought" : "Summary";
1262 var cls = isThought ? "type-thought" : "type-summary";
1263 var date = new Date(n.createdAt).toLocaleDateString(undefined, {
1264 month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
1265 });
1266 return '<div class="notif-item ' + cls + '" style="animation-delay:' + (i * 0.04) + 's">' +
1267 '<div class="notif-item-header">' +
1268 '<span class="notif-item-icon">' + icon + '</span>' +
1269 '<span class="notif-item-title">' + escapeHtml(n.title) + '</span>' +
1270 '<span class="notif-item-badge">' + badge + '</span>' +
1271 '</div>' +
1272 '<div class="notif-item-content">' + escapeHtml(n.content) + '</div>' +
1273 '<div class="notif-item-time">' + date + '</div>' +
1274 '</div>';
1275 }).join("");
1276 notifList.innerHTML = html;
1277 } catch (err) {
1278 console.error("Dreams error:", err);
1279 notifList.innerHTML = '<div class="notif-empty">Failed to load notifications</div>';
1280 }
1281 }
1282
1283 async function saveDreamTime() {
1284 var input = document.getElementById("dreamTimeInput");
1285 var status = document.getElementById("dreamStatus");
1286 if (!input.value) { status.textContent = "Pick a time first"; return; }
1287 try {
1288 var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1289 method: "POST",
1290 headers: { "Content-Type": "application/json" },
1291 credentials: "include",
1292 body: JSON.stringify({ dreamTime: input.value }),
1293 });
1294 var data = await res.json();
1295 if (!res.ok) throw new Error(data.error || "Failed");
1296 status.textContent = "Dreams set for " + input.value;
1297 dreamsLoaded = false;
1298 fetchDreams();
1299 } catch (err) {
1300 status.textContent = err.message;
1301 }
1302 }
1303
1304 async function disableDreamTime() {
1305 var status = document.getElementById("dreamStatus");
1306 try {
1307 var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1308 method: "POST",
1309 headers: { "Content-Type": "application/json" },
1310 credentials: "include",
1311 body: JSON.stringify({ dreamTime: null }),
1312 });
1313 var data = await res.json();
1314 if (!res.ok) throw new Error(data.error || "Failed");
1315 status.textContent = "Dreams disabled";
1316 dreamsLoaded = false;
1317 fetchDreams();
1318 } catch (err) {
1319 status.textContent = err.message;
1320 }
1321 }
1322
1323 async function fetchInvites() {
1324 invitesList.innerHTML = '<div class="notif-loading">Loading...</div>';
1325 invitesLoaded = false;
1326 try {
1327 var invUrl = "/api/v1/chat/invites" + (activeRootId ? "?rootId=" + activeRootId : "");
1328 var res = await fetch(invUrl, { credentials: "include" });
1329 var data = await res.json();
1330 if (!res.ok) throw new Error(data.error || "Failed");
1331
1332 invitesLoaded = true;
1333 var html = "";
1334
1335 // Pending invites section
1336 var invites = data.invites || [];
1337 if (invites.length > 0) {
1338 document.getElementById("invitesDot").classList.add("visible");
1339 notifDot.classList.add("has-notifs");
1340 html += invites.map(function(inv, i) {
1341 return '<div class="invite-item" style="animation-delay:' + (i * 0.04) + 's">' +
1342 '<div class="invite-item-text"><strong>' + escapeHtml(inv.from) + '</strong> invited you to <strong>' + escapeHtml(inv.treeName) + '</strong></div>' +
1343 '<div class="invite-item-actions">' +
1344 '<button class="invite-accept" onclick="respondInvite(\\'' + inv.id + '\\', true, this)">Accept</button>' +
1345 '<button class="invite-decline" onclick="respondInvite(\\'' + inv.id + '\\', false, this)">Decline</button>' +
1346 '</div>' +
1347 '</div>';
1348 }).join("");
1349 }
1350
1351 // Members section (only when inside a tree)
1352 if (activeRootId && data.members) {
1353 var members = data.members;
1354 html += '<div class="members-section">';
1355 html += '<div class="members-section-title">Members</div>';
1356
1357 // Owner
1358 if (members.owner) {
1359 html += '<div class="member-item">' +
1360 '<div><span class="member-name">' + escapeHtml(members.owner.username) + '</span><span class="member-role">Owner</span></div>' +
1361 '</div>';
1362 }
1363
1364 // Contributors
1365 (members.contributors || []).forEach(function(c) {
1366 var isSelf = c._id === CONFIG.userId;
1367 var isOwner = members.isOwner;
1368 var actions = '';
1369 if (isOwner || isSelf) {
1370 var label = isSelf ? "Leave" : "Remove";
1371 var cls = isSelf ? "btn-danger" : "btn-danger";
1372 actions = '<div class="member-actions">';
1373 if (isOwner && !isSelf) {
1374 actions += '<button onclick="transferOwner(\\'' + c._id + '\\', this)">Transfer</button>';
1375 }
1376 actions += '<button class="' + cls + '" onclick="removeMember(\\'' + c._id + '\\', \\'' + label + '\\', this)">' + label + '</button>';
1377 actions += '</div>';
1378 }
1379 html += '<div class="member-item">' +
1380 '<div><span class="member-name">' + escapeHtml(c.username) + '</span></div>' +
1381 actions +
1382 '</div>';
1383 });
1384
1385 // Invite form (owner only)
1386 if (members.isOwner) {
1387 html += '<form class="invite-form" onsubmit="sendInvite(event)">' +
1388 '<input type="text" id="inviteUsername" placeholder="Username to invite..." />' +
1389 '<button type="submit">Invite</button>' +
1390 '</form>' +
1391 '<div class="invite-status" id="inviteStatus"></div>';
1392 }
1393 html += '</div>';
1394 }
1395
1396 if (!html) {
1397 html = '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udcec</span>No pending invites</div>';
1398 }
1399
1400 invitesList.innerHTML = html;
1401 } catch (err) {
1402 console.error("Invites error:", err);
1403 invitesList.innerHTML = '<div class="notif-empty">Failed to load invites</div>';
1404 }
1405 }
1406
1407 async function respondInvite(inviteId, accept, btn) {
1408 var item = btn.closest(".invite-item");
1409 item.style.opacity = "0.5";
1410 item.style.pointerEvents = "none";
1411 try {
1412 var res = await fetch("/api/v1/chat/invites/" + inviteId, {
1413 method: "POST",
1414 headers: { "Content-Type": "application/json" },
1415 credentials: "include",
1416 body: JSON.stringify({ accept: accept }),
1417 });
1418 var data = await res.json();
1419 if (!res.ok) throw new Error(data.error || "Failed");
1420 item.remove();
1421 // Refresh tree list if accepted
1422 if (accept) {
1423 location.reload();
1424 }
1425 } catch (err) {
1426 item.style.opacity = "1";
1427 item.style.pointerEvents = "";
1428 alert(err.message);
1429 }
1430 }
1431
1432 async function sendInvite(e) {
1433 e.preventDefault();
1434 var input = document.getElementById("inviteUsername");
1435 var status = document.getElementById("inviteStatus");
1436 var username = input.value.trim();
1437 if (!username) return;
1438
1439 status.className = "invite-status";
1440 status.textContent = "";
1441
1442 try {
1443 var res = await fetch("/api/v1/root/" + activeRootId + "/invite", {
1444 method: "POST",
1445 headers: { "Content-Type": "application/json" },
1446 credentials: "include",
1447 body: JSON.stringify({ userReceiving: username }),
1448 });
1449 var data = await res.json();
1450 if (!res.ok) throw new Error(data.error || "Failed");
1451
1452 status.textContent = "Invite sent!";
1453 status.className = "invite-status success";
1454 input.value = "";
1455 } catch (err) {
1456 status.textContent = err.message;
1457 status.className = "invite-status error";
1458 }
1459 }
1460
1461 async function removeMember(userId, label, btn) {
1462 if (!confirm("Are you sure you want to " + label.toLowerCase() + "?")) return;
1463 btn.disabled = true;
1464 try {
1465 var res = await fetch("/api/v1/root/" + activeRootId + "/remove-user", {
1466 method: "POST",
1467 headers: { "Content-Type": "application/json" },
1468 credentials: "include",
1469 body: JSON.stringify({ userReceiving: userId }),
1470 });
1471 var data = await res.json();
1472 if (!res.ok) throw new Error(data.error || "Failed");
1473 if (userId === CONFIG.userId) {
1474 location.reload();
1475 } else {
1476 invitesLoaded = false;
1477 fetchInvites();
1478 }
1479 } catch (err) {
1480 btn.disabled = false;
1481 alert(err.message);
1482 }
1483 }
1484
1485 async function transferOwner(userId, btn) {
1486 if (!confirm("Transfer ownership? This cannot be undone.")) return;
1487 btn.disabled = true;
1488 try {
1489 var res = await fetch("/api/v1/root/" + activeRootId + "/transfer-owner", {
1490 method: "POST",
1491 headers: { "Content-Type": "application/json" },
1492 credentials: "include",
1493 body: JSON.stringify({ userReceiving: userId }),
1494 });
1495 var data = await res.json();
1496 if (!res.ok) throw new Error(data.error || "Failed");
1497 invitesLoaded = false;
1498 fetchInvites();
1499 } catch (err) {
1500 btn.disabled = false;
1501 alert(err.message);
1502 }
1503 }
1504
1505 // Check for notifications + invites on load
1506 fetch("/api/v1/chat/notifications", { credentials: "include" })
1507 .then(function(r) { return r.json(); })
1508 .then(function(d) {
1509 if (d.notifications && d.notifications.length > 0) {
1510 notifDot.classList.add("has-notifs");
1511 document.getElementById("dreamsDot").classList.add("visible");
1512 }
1513 })
1514 .catch(function() {});
1515
1516 fetch("/api/v1/chat/invites", { credentials: "include" })
1517 .then(function(r) { return r.json(); })
1518 .then(function(d) {
1519 if (d.invites && d.invites.length > 0) {
1520 notifDot.classList.add("has-notifs");
1521 document.getElementById("invitesDot").classList.add("visible");
1522 }
1523 })
1524 .catch(function() {});
1525 </script>
1526</body>
1527</html>
1528`;
1529}
1530
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for contributions page */
3/* ─────────────────────────────────────────────── */
4
5import {
6 baseStyles,
7 backNavStyles,
8 glassHeaderStyles,
9 glassCardStyles,
10 emptyStateStyles,
11 responsiveBase,
12} from "./baseStyles.js";
13import { esc, actionColorClass } from "./utils.js";
14
15const link = (id, queryString) =>
16 id
17 ? `<a href="/api/v1/node/${id}${queryString}"><code>${esc(id)}</code></a>`
18 : `<code>unknown</code>`;
19
20const userTag = (u, queryString) => {
21 if (!u) return `<code>unknown user</code>`;
22 if (typeof u === "object" && u.username)
23 return `<a href="/api/v1/user/${u._id}${queryString}"><code>${esc(u.username)}</code></a>`;
24 if (typeof u === "string")
25 return `<code>${esc(u)}</code>`;
26 return `<code>unknown user</code>`;
27};
28
29const kvMap = (data) => {
30 if (!data) return "";
31 const entries =
32 data instanceof Map
33 ? [...data.entries()]
34 : typeof data === "object"
35 ? Object.entries(data)
36 : [];
37 if (entries.length === 0) return "";
38 return entries
39 .map(
40 ([k, v]) =>
41 `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
42 )
43 .join(" ");
44};
45
46function renderAction(rawC, { nodeId, parsedVersion, nextVersion, queryString }) {
47 // Merge extensionData so action renderers can access extension fields directly
48 const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
49 switch (c.action) {
50 case "create":
51 return `Created node`;
52
53 case "editStatus":
54 return `Marked as <code>${esc(c.statusEdited)}</code>`;
55
56 case "editValue":
57 return `Adjusted values ${kvMap(c.valueEdited)}`;
58
59 case "prestige":
60 return `Prestiged to <a href="/api/v1/node/${nodeId}/${nextVersion}${queryString}"><code>Version ${nextVersion}</code></a>`;
61
62 case "trade":
63 return `Traded on node`;
64
65 case "delete":
66 return `Deleted node`;
67
68 case "invite": {
69 const ia = c.inviteAction || {};
70 const target = userTag(ia.receivingId, queryString);
71 const labels = {
72 invite: `Invited ${target} to collaborate`,
73 acceptInvite: `Accepted an invitation`,
74 denyInvite: `Declined an invitation`,
75 removeContributor: `Removed ${target}`,
76 switchOwner: `Transferred ownership to ${target}`,
77 };
78 return labels[ia.action] || "Updated collaboration";
79 }
80
81 case "editSchedule": {
82 const s = c.scheduleEdited || {};
83 const parts = [];
84 if (s.date)
85 parts.push(
86 `date to <code>${new Date(s.date).toLocaleString()}</code>`,
87 );
88 if (s.reeffectTime != null)
89 parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
90 return parts.length
91 ? `Set ${parts.join(" and ")}`
92 : `Updated the schedule`;
93 }
94
95 case "editGoal":
96 return `Set new goals ${kvMap(c.goalEdited)}`;
97
98 case "transaction": {
99 const tm = c.transactionMeta;
100 if (!tm) return `Recorded a transaction`;
101 const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
102 const counterparty = tm.counterpartyNodeId
103 ? ` with ${link(tm.counterpartyNodeId, queryString)}`
104 : "";
105 const sent = kvMap(tm.valuesSent);
106 const recv = kvMap(tm.valuesReceived);
107 let flow = "";
108 if (sent) flow += ` — sent ${sent}`;
109 if (recv) flow += `${sent ? "," : " —"} received ${recv}`;
110 return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)})${counterparty}${flow}`;
111 }
112
113 case "note": {
114 const na = c.noteAction || {};
115
116 let verb;
117 switch (na.action) {
118 case "add":
119 verb = "Added a note";
120 break;
121 case "edit":
122 verb = "Edited a note";
123 break;
124 case "remove":
125 verb = "Removed a note";
126 break;
127 default:
128 verb = "Updated a note";
129 }
130
131 const noteRef = na.noteId
132 ? ` <a href="/api/v1/node/${nodeId}/${parsedVersion}/notes/${na.noteId}${queryString}"><code>${esc(na.noteId)}</code></a>`
133 : "";
134
135 return `${verb}${noteRef}`;
136 }
137
138 case "updateParent": {
139 const up = c.updateParent || {};
140 const from = up.oldParentId
141 ? link(up.oldParentId, queryString)
142 : `<code>none</code>`;
143 const to = up.newParentId
144 ? link(up.newParentId, queryString)
145 : `<code>none</code>`;
146 return `Moved from ${from} to ${to}`;
147 }
148
149 case "editScript": {
150 const es = c.editScript || {};
151 return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code>`;
152 }
153
154 case "executeScript": {
155 const xs = c.executeScript || {};
156 const icon = xs.success ? "✅" : "❌";
157 let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code>`;
158 if (xs.error) text += ` — <code>${esc(xs.error)}</code>`;
159 return text;
160 }
161
162 case "updateChildNode": {
163 const uc = c.updateChildNode || {};
164 return uc.action === "added"
165 ? `Added child ${link(uc.childId, queryString)}`
166 : `Removed child ${link(uc.childId, queryString)}`;
167 }
168
169 case "editNameNode": {
170 const en = c.editNameNode || {};
171 return `Renamed from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
172 }
173
174 case "rawIdea": {
175 const ri = c.rawIdeaAction || {};
176 const uId = c.userId?._id || c.userId;
177 const ideaRef = ri.rawIdeaId && uId
178 ? `<a href="/api/v1/user/${uId}/raw-ideas/${ri.rawIdeaId}${queryString}"><code>${esc(ri.rawIdeaId)}</code></a>`
179 : ri.rawIdeaId
180 ? `<code>${esc(ri.rawIdeaId)}</code>`
181 : `<code>unknown</code>`;
182 if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
183 if (ri.action === "delete") return `Discarded raw idea ${ideaRef}`;
184 if (ri.action === "placed") {
185 const target = ri.targetNodeId ? link(ri.targetNodeId, queryString) : "node";
186 return `Placed raw idea ${ideaRef} into ${target}`;
187 }
188 if (ri.action === "aiStarted")
189 return `AI began processing raw idea ${ideaRef}`;
190 if (ri.action === "aiFailed")
191 return `AI failed to place raw idea ${ideaRef}`;
192 return `Updated raw idea ${ideaRef}`;
193 }
194
195 case "branchLifecycle": {
196 const bl = c.branchLifecycle || {};
197 if (bl.action === "retired") {
198 let text = `Retired branch`;
199 if (bl.fromParentId) text += ` from ${link(bl.fromParentId, queryString)}`;
200 return text;
201 }
202 if (bl.action === "revived") {
203 let text = `Revived branch`;
204 if (bl.toParentId) text += ` under ${link(bl.toParentId, queryString)}`;
205 return text;
206 }
207 return `Revived as a new root`;
208 }
209
210 case "purchase": {
211 const pm = c.purchaseMeta || {};
212 const parts = [];
213 if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
214 if (pm.energyAmount)
215 parts.push(`<code>${pm.energyAmount}</code> energy`);
216 const price = pm.totalCents
217 ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
218 : "";
219 return parts.length
220 ? `Purchased ${parts.join(" and ")}${price}`
221 : `Made a purchase${price}`;
222 }
223
224 case "understanding": {
225 const um = c.understandingMeta || {};
226 const rootNode = um.rootNodeId || nodeId;
227 const runId = um.understandingRunId;
228
229 if (um.stage === "createRun") {
230 const runLink =
231 runId && rootNode
232 ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${queryString}"><code>${esc(runId)}</code></a>`
233 : `<code>unknown run</code>`;
234 let text = `Started understanding run ${runLink}`;
235 if (um.nodeCount != null)
236 text += ` spanning <code>${um.nodeCount}</code> nodes`;
237 if (um.perspective) text += ` — "${esc(um.perspective)}"`;
238 return text;
239 }
240
241 if (um.stage === "processStep") {
242 const uNodeId = um.understandingNodeId;
243 const uNodeLink =
244 uNodeId && runId && rootNode
245 ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${queryString}"><code>${esc(uNodeId)}</code></a>`
246 : uNodeId
247 ? `<code>${esc(uNodeId)}</code>`
248 : `<code>unknown</code>`;
249 let text = `Understanding encoded ${uNodeLink}`;
250 if (um.mode)
251 text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
252 if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
253 return text;
254 }
255
256 return `Understanding activity`;
257 }
258
259 default:
260 return `<code>${esc(c.action)}</code>`;
261 }
262}
263
264export function renderContributions({ nodeId, version, nodeName, contributions, queryString }) {
265 const parsedVersion = Number(version);
266 const nextVersion = parsedVersion + 1;
267
268 const items = contributions.map((c) => {
269 const time = new Date(c.date).toLocaleString();
270 const actionHtml = renderAction(c, { nodeId, parsedVersion, nextVersion, queryString });
271 const colorClass = actionColorClass(c.action);
272
273 const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
274 const energyBadge =
275 c.energyUsed != null && c.energyUsed > 0
276 ? `<span class="badge badge-energy">⚡ ${c.energyUsed}</span>`
277 : "";
278
279 const user = userTag(c.userId, queryString);
280
281 return `
282 <li class="note-card ${colorClass}">
283 <div class="note-content">
284 <div class="contribution-action">${actionHtml}</div>
285 </div>
286 <div class="note-meta">
287 ${user}
288 <span class="meta-separator">·</span>
289 ${time}
290 ${aiBadge}${energyBadge}
291 <span class="meta-separator">·</span>
292 <code class="contribution-id">${esc(c._id)}</code>
293 </div>
294 </li>`;
295 });
296
297 const qs = queryString || "";
298 const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
299 const backUrl = `/api/v1/node/${nodeId}/${version}${qs}`;
300
301 return `
302<!DOCTYPE html>
303<html lang="en">
304<head>
305 <meta charset="UTF-8">
306 <meta name="viewport" content="width=device-width, initial-scale=1.0">
307 <meta name="theme-color" content="#667eea">
308 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
309 <title>${esc(nodeName || nodeId)} — Contributions</title>
310 <style>
311${baseStyles}
312${backNavStyles}
313${glassHeaderStyles}
314${glassCardStyles}
315${emptyStateStyles}
316${responsiveBase}
317
318/* ── Page-specific: contributions ── */
319
320.version-badge {
321 display: inline-block;
322 padding: 6px 14px;
323 background: rgba(255,255,255,0.25);
324 color: white; border-radius: 980px;
325 font-size: 13px; font-weight: 600;
326 border: 1px solid rgba(255,255,255,0.3);
327 margin-right: 8px;
328}
329
330.header-subtitle { margin-bottom: 16px; }
331
332.notes-list { animation: fadeInUp 0.6s ease-out 0.2s both; }
333
334.note-card:nth-child(1) { animation-delay: 0.25s; }
335.note-card:nth-child(2) { animation-delay: 0.3s; }
336.note-card:nth-child(3) { animation-delay: 0.35s; }
337.note-card:nth-child(4) { animation-delay: 0.4s; }
338.note-card:nth-child(5) { animation-delay: 0.45s; }
339.note-card:nth-child(n+6) { animation-delay: 0.5s; }
340
341.contribution-action {
342 font-size: 15px; line-height: 1.6;
343 color: white; font-weight: 400;
344 word-wrap: break-word;
345}
346
347.contribution-action a {
348 color: white; text-decoration: none;
349 border-bottom: 1px solid rgba(255,255,255,0.3);
350 transition: all 0.2s;
351}
352
353.contribution-action a:hover {
354 border-bottom-color: white;
355 text-shadow: 0 0 12px rgba(255,255,255,0.8);
356}
357
358.contribution-action code {
359 background: rgba(255,255,255,0.18);
360 padding: 2px 7px; border-radius: 5px;
361 font-size: 13px;
362 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
363 border: 1px solid rgba(255,255,255,0.15);
364}
365
366.contribution-id {
367 background: rgba(255,255,255,0.12);
368 padding: 2px 6px; border-radius: 4px;
369 font-size: 11px;
370 font-family: 'SF Mono', 'Fira Code', monospace;
371 color: rgba(255,255,255,0.6);
372 border: 1px solid rgba(255,255,255,0.1);
373}
374
375.badge {
376 display: inline-flex; align-items: center;
377 padding: 3px 10px; border-radius: 980px;
378 font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
379 border: 1px solid rgba(255,255,255,0.2);
380}
381
382.badge-ai {
383 background: rgba(255,200,50,0.35);
384 color: #fff;
385 text-shadow: 0 1px 2px rgba(0,0,0,0.2);
386}
387
388.badge-energy {
389 background: rgba(100,220,255,0.3);
390 color: #fff;
391 text-shadow: 0 1px 2px rgba(0,0,0,0.2);
392}
393
394.kv-chip {
395 display: inline-block;
396 padding: 2px 8px;
397 background: rgba(255,255,255,0.15);
398 border-radius: 6px; font-size: 12px;
399 margin: 2px 2px;
400 border: 1px solid rgba(255,255,255,0.15);
401}
402
403.kv-chip code {
404 background: none !important;
405 border: none !important;
406 padding: 0 !important;
407 font-weight: 600;
408}
409 </style>
410</head>
411<body>
412 <div class="container">
413 <div class="back-nav">
414 <a href="${backTreeUrl}" class="back-link">← Back to Tree</a>
415 <a href="${backUrl}" class="back-link">Back to Version</a>
416 </div>
417
418 <div class="header">
419 <h1>
420 Contributions on
421 <a href="${backUrl}">${esc(nodeName || nodeId)}</a>
422 ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
423 </h1>
424 <div class="header-subtitle">
425 <span class="version-badge">Version ${parsedVersion}</span>
426 Activity & change history
427 </div>
428 </div>
429
430 ${
431 items.length
432 ? `<ul class="notes-list">${items.join("")}</ul>`
433 : `
434 <div class="empty-state">
435 <div class="empty-state-icon">📊</div>
436 <div class="empty-state-text">No contributions yet</div>
437 <div class="empty-state-subtext">Contributions and activity will appear here</div>
438 </div>`
439 }
440 </div>
441
442</body>
443</html>
444`;
445}
446
1import { getLandUrl } from "../../../canopy/identity.js";
2import { baseStyles } from "./baseStyles.js";
3
4export function renderLoginPage(req, res) {
5 const redirect = req.query.redirect || "";
6
7 res.setHeader("Content-Type", "text/html");
8 res.send(`<!DOCTYPE html>
9<html lang="en">
10<head>
11 <meta charset="UTF-8" />
12 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
13 <meta name="theme-color" content="#736fe6">
14 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15 <title>TreeOS - Login</title>
16
17 <style>
18 ${baseStyles}
19
20 body {
21 display: flex;
22 flex-direction: column;
23 align-items: center;
24 justify-content: center;
25 overflow-y: auto;
26 }
27
28 @keyframes fadeInDown {
29 from {
30 opacity: 0;
31 transform: translateY(-30px);
32 }
33 to {
34 opacity: 1;
35 transform: translateY(0);
36 }
37 }
38
39 @keyframes slideUp {
40 from {
41 opacity: 0;
42 transform: translateY(30px);
43 }
44 to {
45 opacity: 1;
46 transform: translateY(0);
47 }
48 }
49
50 /* Brand Header */
51 .brand-header {
52 position: relative;
53 z-index: 1;
54 margin-bottom: 32px;
55 text-align: center;
56 animation: fadeInDown 0.8s ease-out;
57 }
58
59 .brand-logo {
60 font-size: 80px;
61 margin-bottom: 16px;
62 display: inline-block;
63 filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
64 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
65 }
66
67 @keyframes grow {
68 0%, 100% {
69 transform: scale(1);
70 }
71 50% {
72 transform: scale(1.06);
73 }
74 }
75
76 .brand-title {
77 font-size: 56px;
78 font-weight: 600;
79 color: white;
80 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
81 letter-spacing: -1.5px;
82 margin-bottom: 8px;
83 }
84
85 .brand-subtitle {
86 font-size: 18px;
87 color: rgba(255, 255, 255, 0.85);
88 font-weight: 400;
89 letter-spacing: 0.2px;
90 }
91
92 /* Login Container - Glass */
93 .login-container {
94 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
95 backdrop-filter: blur(22px) saturate(140%);
96 -webkit-backdrop-filter: blur(22px) saturate(140%);
97 padding: 48px;
98 border-radius: 16px;
99 width: 100%;
100 max-width: 460px;
101 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
102 inset 0 1px 0 rgba(255, 255, 255, 0.25);
103 border: 1px solid rgba(255, 255, 255, 0.28);
104 text-align: center;
105 position: relative;
106 z-index: 1;
107 animation: slideUp 0.6s ease-out 0.2s both;
108 }
109
110 h2 {
111 font-size: 32px;
112 font-weight: 600;
113 color: white;
114 margin-bottom: 12px;
115 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
116 letter-spacing: -0.5px;
117 }
118
119 /* Form */
120 form {
121 margin-bottom: 16px;
122 }
123
124 .input-group {
125 margin-bottom: 16px;
126 text-align: left;
127 }
128
129 label {
130 display: block;
131 font-size: 14px;
132 font-weight: 600;
133 color: white;
134 margin-bottom: 8px;
135 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
136 letter-spacing: -0.2px;
137 }
138
139 input {
140 width: 100%;
141 padding: 14px 18px;
142 border-radius: 12px;
143 border: 2px solid rgba(255, 255, 255, 0.3);
144 font-size: 16px;
145 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
146 background: rgba(255, 255, 255, 0.15);
147 backdrop-filter: blur(20px) saturate(150%);
148 -webkit-backdrop-filter: blur(20px) saturate(150%);
149 font-family: inherit;
150 color: white;
151 font-weight: 500;
152 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
153 inset 0 1px 0 rgba(255, 255, 255, 0.25);
154 touch-action: manipulation;
155 }
156
157 input:focus {
158 outline: none;
159 border-color: rgba(255, 255, 255, 0.6);
160 background: rgba(255, 255, 255, 0.25);
161 backdrop-filter: blur(25px) saturate(160%);
162 -webkit-backdrop-filter: blur(25px) saturate(160%);
163 box-shadow:
164 0 0 0 4px rgba(255, 255, 255, 0.15),
165 0 8px 30px rgba(0, 0, 0, 0.15),
166 inset 0 1px 0 rgba(255, 255, 255, 0.4);
167 transform: translateY(-2px);
168 }
169
170 input::placeholder {
171 color: rgba(255, 255, 255, 0.5);
172 font-weight: 400;
173 }
174
175 /* Glass Button */
176 button {
177 width: 100%;
178 padding: 16px;
179 margin-top: 8px;
180 border-radius: 980px;
181 border: 1px solid rgba(255, 255, 255, 0.3);
182 background: rgba(255, 255, 255, 0.25);
183 backdrop-filter: blur(10px);
184 color: white;
185 font-size: 16px;
186 font-weight: 600;
187 cursor: pointer;
188 transition: all 0.3s;
189 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
190 font-family: inherit;
191 letter-spacing: -0.2px;
192 position: relative;
193 overflow: hidden;
194 touch-action: manipulation;
195 }
196
197 button::before {
198 content: "";
199 position: absolute;
200 inset: -40%;
201 background: radial-gradient(
202 120% 60% at 0% 0%,
203 rgba(255, 255, 255, 0.35),
204 transparent 60%
205 );
206 opacity: 0;
207 transform: translateX(-30%) translateY(-10%);
208 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
209 pointer-events: none;
210 }
211
212 button:hover::before {
213 opacity: 1;
214 transform: translateX(30%) translateY(10%);
215 }
216
217 button:hover {
218 background: rgba(255, 255, 255, 0.35);
219 transform: translateY(-2px);
220 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
221 }
222
223 button:active {
224 transform: translateY(0);
225 }
226
227 .error-message {
228 color: white;
229 margin-top: 16px;
230 margin-bottom: 16px;
231 padding: 12px 16px;
232 background: rgba(239, 68, 68, 0.3);
233 backdrop-filter: blur(10px);
234 border-radius: 10px;
235 font-size: 14px;
236 font-weight: 600;
237 border: 1px solid rgba(239, 68, 68, 0.4);
238 text-align: left;
239 }
240
241 .error-message:empty {
242 display: none;
243 }
244
245 /* Secondary Actions */
246 .secondary-actions {
247 display: flex;
248 flex-direction: column;
249 gap: 8px;
250 margin-top: 24px;
251 padding-top: 24px;
252 }
253
254 .back-btn,
255 .secondary-btn {
256 width: 100%;
257 background: rgba(255, 255, 255, 0.15);
258 color: white;
259 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
260 font-weight: 600;
261 padding: 12px;
262 font-size: 15px;
263 }
264
265 .back-btn:hover,
266 .secondary-btn:hover {
267 background: rgba(255, 255, 255, 0.25);
268 }
269
270 .secondary-btn {
271 margin-top: 0;
272 }
273
274 /* Divider */
275 .divider {
276 display: flex;
277 align-items: center;
278 text-align: center;
279 margin: 20px 0;
280 }
281
282 .divider::before,
283 .divider::after {
284 content: '';
285 flex: 1;
286 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
287 }
288
289 .divider span {
290 padding: 0 16px;
291 color: rgba(255, 255, 255, 0.8);
292 font-size: 13px;
293 font-weight: 600;
294 text-transform: uppercase;
295 letter-spacing: 0.5px;
296 }
297
298 /* Loading State */
299 button.loading {
300 position: relative;
301 color: transparent;
302 pointer-events: none;
303 }
304
305 button.loading::after {
306 content: '';
307 position: absolute;
308 width: 20px;
309 height: 20px;
310 top: 50%;
311 left: 50%;
312 margin-left: -10px;
313 margin-top: -10px;
314 border: 3px solid rgba(255, 255, 255, 0.3);
315 border-radius: 50%;
316 border-top-color: white;
317 animation: spin 0.8s linear infinite;
318 }
319
320 @keyframes spin {
321 to {
322 transform: rotate(360deg);
323 }
324 }
325
326 /* Responsive */
327 @media (max-width: 640px) {
328 body {
329 padding: 20px 16px;
330 justify-content: center;
331 }
332
333 .brand-header {
334 margin-bottom: 24px;
335 }
336
337 .brand-logo {
338 font-size: 64px;
339 }
340
341 .brand-title {
342 font-size: 42px;
343 letter-spacing: -1px;
344 }
345
346 .brand-subtitle {
347 font-size: 16px;
348 }
349
350 .login-container {
351 padding: 32px 24px;
352 }
353
354 h2 {
355 font-size: 28px;
356 }
357
358 input {
359 font-size: 16px;
360 }
361 }
362
363 @media (min-width: 641px) and (max-width: 1024px) {
364 .login-container {
365 max-width: 420px;
366 }
367 }
368 </style>
369</head>
370
371<body>
372 <!-- Brand Header -->
373 <div class="brand-header">
374 <a href="/" style="text-decoration: none;">
375 <div class="brand-logo">🌳</div>
376 <h1 class="brand-title">TreeOS</h1></a>
377 <div class="brand-subtitle">Organize your life, efficiently</div>
378 </div>
379
380 <!-- Login Container -->
381 <div class="login-container">
382 <h2>Welcome Back</h2>
383
384 <form id="loginForm">
385 <div class="input-group">
386 <label for="username">Username</label>
387 <input
388 type="text"
389 id="username"
390 placeholder="Enter your username"
391 required
392 autocomplete="username"
393 autocapitalize="off"
394 autocorrect="off"
395 />
396 </div>
397
398 <div class="input-group">
399 <label for="password">Password</label>
400 <input
401 type="password"
402 id="password"
403 placeholder="Enter your password"
404 required
405 autocomplete="current-password"
406 />
407 </div>
408
409 <button type="submit" id="loginBtn">Login</button>
410 </form>
411
412 <p id="errorMessage" class="error-message"></p>
413
414 <div class="divider">
415 <span>Need Help?</span>
416 </div>
417
418 <div class="secondary-actions">
419 <button type="button" id="registerBtn" class="secondary-btn">
420 Create an account
421 </button>
422 <button type="button" id="forgotPasswordBtn" class="secondary-btn">
423 Forgot your password?
424 </button>
425
426
427
428 <button class="back-btn" onclick="goBack()">← Back to Home</button>
429 </div>
430 </div>
431
432 <script>
433 const apiUrl = "${getLandUrl()}";
434 const redirectAfterLogin = "${redirect}" || null;
435
436 // Secondary button handlers
437 document.getElementById("registerBtn").addEventListener("click", () => {
438 window.location.href = "/register";
439 });
440
441 document.getElementById("forgotPasswordBtn").addEventListener("click", () => {
442 window.location.href = "/forgot-password";
443 });
444
445 // Login form submission
446 document.getElementById("loginForm").addEventListener("submit", async (e) => {
447 e.preventDefault();
448
449 const username = document.getElementById("username").value.trim();
450 const password = document.getElementById("password").value;
451 const errorEl = document.getElementById("errorMessage");
452 const loginBtn = document.getElementById("loginBtn");
453
454 errorEl.textContent = "";
455 loginBtn.classList.add("loading");
456 loginBtn.disabled = true;
457
458 try {
459 const res = await fetch(\`\${apiUrl}/login\`, {
460 method: "POST",
461 headers: { "Content-Type": "application/json" },
462 credentials: "include",
463 body: JSON.stringify({ username, password })
464 });
465
466 const data = await res.json();
467
468 if (!res.ok) {
469 errorEl.textContent = data.message || "Login failed. Please check your credentials.";
470 loginBtn.classList.remove("loading");
471 loginBtn.disabled = false;
472 return;
473 }
474
475 window.location.href = redirectAfterLogin || "/chat";
476
477 } catch (err) {
478 console.error(err);
479 errorEl.textContent = "An error occurred. Please try again.";
480 loginBtn.classList.remove("loading");
481 loginBtn.disabled = false;
482 }
483 });
484
485 function goBack() {
486 window.location.href = "/";
487 }
488 </script>
489</body>
490</html>`);
491}
492export function renderRegisterPage(req, res) {
493 res.setHeader("Content-Type", "text/html");
494 res.send(`<!DOCTYPE html>
495<html lang="en">
496<head>
497 <meta charset="UTF-8" />
498 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
499 <meta name="theme-color" content="#736fe6">
500 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
501 <title>TreeOS - Register</title>
502
503 <style>
504 ${baseStyles}
505
506 body {
507 display: flex;
508 flex-direction: column;
509 align-items: center;
510 justify-content: center;
511 overflow-y: auto;
512 }
513
514 @keyframes fadeInDown {
515 from { opacity: 0; transform: translateY(-30px); }
516 to { opacity: 1; transform: translateY(0); }
517 }
518
519 @keyframes slideUp {
520 from { opacity: 0; transform: translateY(30px); }
521 to { opacity: 1; transform: translateY(0); }
522 }
523
524 .brand-header {
525 position: relative;
526 z-index: 1;
527 margin-bottom: 32px;
528 text-align: center;
529 animation: fadeInDown 0.8s ease-out;
530 }
531
532 .brand-logo {
533 font-size: 80px;
534 margin-bottom: 16px;
535 display: inline-block;
536 filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
537 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
538 }
539
540 @keyframes grow {
541 0%, 100% { transform: scale(1); }
542 50% { transform: scale(1.06); }
543 }
544
545 .brand-title {
546 font-size: 56px;
547 font-weight: 600;
548 color: white;
549 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
550 letter-spacing: -1.5px;
551 margin-bottom: 8px;
552 }
553
554 .brand-subtitle {
555 font-size: 18px;
556 color: rgba(255, 255, 255, 0.85);
557 font-weight: 400;
558 letter-spacing: 0.2px;
559 }
560
561 .register-container {
562 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
563 backdrop-filter: blur(22px) saturate(140%);
564 -webkit-backdrop-filter: blur(22px) saturate(140%);
565 padding: 48px;
566 border-radius: 16px;
567 width: 100%;
568 max-width: 460px;
569 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
570 inset 0 1px 0 rgba(255, 255, 255, 0.25);
571 border: 1px solid rgba(255, 255, 255, 0.28);
572 text-align: center;
573 position: relative;
574 z-index: 1;
575 animation: slideUp 0.6s ease-out 0.2s both;
576 }
577
578 h2 {
579 font-size: 32px;
580 font-weight: 600;
581 color: white;
582 margin-bottom: 8px;
583 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
584 letter-spacing: -0.5px;
585 }
586
587 .subtitle {
588 font-size: 15px;
589 color: rgba(255, 255, 255, 0.85);
590 margin-bottom: 32px;
591 line-height: 1.5;
592 font-weight: 400;
593 }
594
595 form { margin-bottom: 16px; }
596
597 .input-group {
598 margin-bottom: 16px;
599 text-align: left;
600 }
601
602 label {
603 display: block;
604 font-size: 14px;
605 font-weight: 600;
606 color: white;
607 margin-bottom: 8px;
608 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
609 letter-spacing: -0.2px;
610 }
611
612 input {
613 width: 100%;
614 padding: 14px 18px;
615 border-radius: 12px;
616 border: 2px solid rgba(255, 255, 255, 0.3);
617 font-size: 16px;
618 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
619 background: rgba(255, 255, 255, 0.15);
620 backdrop-filter: blur(20px) saturate(150%);
621 -webkit-backdrop-filter: blur(20px) saturate(150%);
622 font-family: inherit;
623 color: white;
624 font-weight: 500;
625 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
626 inset 0 1px 0 rgba(255, 255, 255, 0.25);
627 touch-action: manipulation;
628 }
629
630 input:focus {
631 outline: none;
632 border-color: rgba(255, 255, 255, 0.6);
633 background: rgba(255, 255, 255, 0.25);
634 backdrop-filter: blur(25px) saturate(160%);
635 -webkit-backdrop-filter: blur(25px) saturate(160%);
636 box-shadow:
637 0 0 0 4px rgba(255, 255, 255, 0.15),
638 0 8px 30px rgba(0, 0, 0, 0.15),
639 inset 0 1px 0 rgba(255, 255, 255, 0.4);
640 transform: translateY(-2px);
641 }
642
643 input::placeholder {
644 color: rgba(255, 255, 255, 0.5);
645 font-weight: 400;
646 }
647
648 input.error {
649 border-color: rgba(239, 68, 68, 0.6);
650 background: rgba(239, 68, 68, 0.1);
651 }
652
653 input.error:focus {
654 box-shadow:
655 0 0 0 4px rgba(239, 68, 68, 0.2),
656 0 8px 30px rgba(239, 68, 68, 0.2);
657 }
658
659 .password-hint {
660 font-size: 12px;
661 color: rgba(255, 255, 255, 0.7);
662 margin-top: 6px;
663 text-align: left;
664 font-weight: 400;
665 }
666
667 button {
668 width: 100%;
669 padding: 16px;
670 margin-top: 8px;
671 border-radius: 980px;
672 border: 1px solid rgba(255, 255, 255, 0.3);
673 background: rgba(255, 255, 255, 0.25);
674 backdrop-filter: blur(10px);
675 color: white;
676 font-size: 16px;
677 font-weight: 600;
678 cursor: pointer;
679 transition: all 0.3s;
680 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
681 font-family: inherit;
682 letter-spacing: -0.2px;
683 position: relative;
684 overflow: hidden;
685 touch-action: manipulation;
686 }
687
688 button::before {
689 content: "";
690 position: absolute;
691 inset: -40%;
692 background: radial-gradient(
693 120% 60% at 0% 0%,
694 rgba(255, 255, 255, 0.35),
695 transparent 60%
696 );
697 opacity: 0;
698 transform: translateX(-30%) translateY(-10%);
699 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
700 pointer-events: none;
701 }
702
703 button:hover::before {
704 opacity: 1;
705 transform: translateX(30%) translateY(10%);
706 }
707
708 button:hover {
709 background: rgba(255, 255, 255, 0.35);
710 transform: translateY(-2px);
711 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
712 }
713
714 button:active { transform: translateY(0); }
715
716 .message {
717 margin-top: 16px;
718 margin-bottom: 16px;
719 padding: 12px 16px;
720 border-radius: 10px;
721 font-size: 14px;
722 font-weight: 600;
723 text-align: left;
724 display: none;
725 }
726
727 .error-message {
728 color: white;
729 background: rgba(239, 68, 68, 0.3);
730 backdrop-filter: blur(10px);
731 border: 1px solid rgba(239, 68, 68, 0.4);
732 }
733
734 .success-message {
735 color: white;
736 background: rgba(16, 185, 129, 0.3);
737 backdrop-filter: blur(10px);
738 border: 1px solid rgba(16, 185, 129, 0.4);
739 }
740
741 .message.show { display: block; }
742
743 .secondary-actions {
744 display: flex;
745 flex-direction: column;
746 gap: 8px;
747 margin-top: 24px;
748 padding-top: 24px;
749 }
750
751 .back-btn {
752 width: 100%;
753 background: rgba(255, 255, 255, 0.15);
754 color: white;
755 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
756 font-weight: 600;
757 padding: 12px;
758 font-size: 15px;
759 margin-top: 0;
760 }
761
762 .back-btn:hover { background: rgba(255, 255, 255, 0.25); }
763
764 button.loading {
765 position: relative;
766 color: transparent;
767 pointer-events: none;
768 }
769
770 button.loading::after {
771 content: '';
772 position: absolute;
773 width: 20px;
774 height: 20px;
775 top: 50%;
776 left: 50%;
777 margin-left: -10px;
778 margin-top: -10px;
779 border: 3px solid rgba(255, 255, 255, 0.3);
780 border-radius: 50%;
781 border-top-color: white;
782 animation: spin 0.8s linear infinite;
783 }
784
785 @keyframes spin { to { transform: rotate(360deg); } }
786
787 /* Agreement text */
788 .agreement-text {
789 font-size: 13px;
790 color: rgba(255, 255, 255, 0.65);
791 line-height: 1.5;
792 margin-top: 20px;
793 margin-bottom: 4px;
794 text-align: center;
795 }
796
797 .agreement-link {
798 color: rgba(255, 255, 255, 0.9);
799 text-decoration: underline;
800 text-underline-offset: 2px;
801 cursor: pointer;
802 font-weight: 500;
803 transition: color 0.2s;
804 }
805
806 .agreement-link:hover {
807 color: white;
808 }
809
810 /* Modal overlay */
811 .modal-overlay {
812 display: none;
813 position: fixed;
814 inset: 0;
815 z-index: 1000;
816 background: rgba(0, 0, 0, 0.6);
817 backdrop-filter: blur(8px);
818 -webkit-backdrop-filter: blur(8px);
819 align-items: center;
820 justify-content: center;
821 padding: 20px;
822 animation: modalFadeIn 0.25s ease-out;
823 }
824
825 .modal-overlay.show {
826 display: flex;
827 }
828
829 @keyframes modalFadeIn {
830 from { opacity: 0; }
831 to { opacity: 1; }
832 }
833
834 .modal-container {
835 width: 100%;
836 max-width: 720px;
837 height: 85vh;
838 height: 85dvh;
839 background: rgba(var(--glass-water-rgb), 0.35);
840 backdrop-filter: blur(22px) saturate(140%);
841 -webkit-backdrop-filter: blur(22px) saturate(140%);
842 border-radius: 20px;
843 border: 1px solid rgba(255, 255, 255, 0.28);
844 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
845 display: flex;
846 flex-direction: column;
847 overflow: hidden;
848 animation: modalSlideUp 0.3s ease-out;
849 }
850
851 @keyframes modalSlideUp {
852 from { opacity: 0; transform: translateY(40px) scale(0.97); }
853 to { opacity: 1; transform: translateY(0) scale(1); }
854 }
855
856 .modal-header {
857 display: flex;
858 align-items: center;
859 justify-content: space-between;
860 padding: 16px 20px;
861 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
862 flex-shrink: 0;
863 }
864
865 .modal-title {
866 font-size: 16px;
867 font-weight: 600;
868 color: white;
869 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
870 }
871
872 .modal-close {
873 width: 32px;
874 height: 32px;
875 min-width: 32px;
876 border-radius: 50%;
877 border: 1px solid rgba(255, 255, 255, 0.25);
878 background: rgba(255, 255, 255, 0.15);
879 color: white;
880 font-size: 18px;
881 cursor: pointer;
882 display: flex;
883 align-items: center;
884 justify-content: center;
885 padding: 0;
886 margin: 0;
887 transition: background 0.2s;
888 box-shadow: none;
889 backdrop-filter: none;
890 }
891
892 .modal-close:hover {
893 background: rgba(255, 255, 255, 0.3);
894 transform: none;
895 box-shadow: none;
896 }
897
898 .modal-close::before { display: none; }
899
900 .modal-body {
901 flex: 1;
902 overflow: hidden;
903 }
904
905 .modal-body iframe {
906 width: 100%;
907 height: 100%;
908 border: none;
909 background: transparent;
910 }
911
912 @media (max-width: 640px) {
913 body {
914 padding: 20px 16px;
915 justify-content: center;
916 }
917
918 .brand-header { margin-bottom: 24px; }
919 .brand-logo { font-size: 64px; }
920
921 .brand-title {
922 font-size: 42px;
923 letter-spacing: -1px;
924 }
925
926 .brand-subtitle { font-size: 16px; }
927 .register-container { padding: 32px 24px; }
928 h2 { font-size: 28px; }
929 input { font-size: 16px; }
930
931 .modal-container {
932 height: 90vh;
933 height: 90dvh;
934 border-radius: 16px;
935 }
936
937 .modal-overlay {
938 padding: 10px;
939 }
940 }
941
942 @media (min-width: 641px) and (max-width: 1024px) {
943 .register-container { max-width: 420px; }
944 }
945 </style>
946</head>
947
948<body>
949 <div class="brand-header">
950 <a href="/" style="text-decoration: none;">
951
952 <div class="brand-logo">🌳</div>
953 <h1 class="brand-title">TreeOS</h1></a>
954 <div class="brand-subtitle">Organize your life, efficiently</div>
955 </div>
956
957 <div class="register-container">
958 <h2>Create Account</h2>
959 <p class="subtitle">Sign up to get started with TreeOS</p>
960
961 <form id="registerForm">
962 <div class="input-group">
963 <label for="username">Username</label>
964 <input
965 type="text"
966 id="username"
967 placeholder="Choose a username"
968 required
969 autocomplete="username"
970 autocapitalize="off"
971 autocorrect="off"
972 />
973 </div>
974
975 <div class="input-group">
976 <label for="email">Email</label>
977 <input
978 type="email"
979 id="email"
980 placeholder="Enter your email"
981 required
982 autocomplete="email"
983 autocapitalize="off"
984 />
985 </div>
986
987 <div class="input-group">
988 <label for="password">Password</label>
989 <input
990 type="password"
991 id="password"
992 placeholder="Create a password"
993 required
994 autocomplete="new-password"
995 />
996 <div class="password-hint">Must be at least 8 characters</div>
997 </div>
998
999 <div class="input-group">
1000 <label for="confirmPassword">Confirm Password</label>
1001 <input
1002 type="password"
1003 id="confirmPassword"
1004 placeholder="Confirm your password"
1005 required
1006 autocomplete="new-password"
1007 />
1008 </div>
1009 <button type="submit" id="registerBtn">Create Account</button>
1010
1011 <div class="agreement-text">
1012 By creating an account, you agree to our
1013 <span class="agreement-link" onclick="openModal('terms')">Terms of Service</span>
1014 and
1015 <span class="agreement-link" onclick="openModal('privacy')">Privacy Policy</span>.
1016 </div>
1017
1018 </form>
1019
1020 <div id="errorMessage" class="message error-message"></div>
1021 <div id="successMessage" class="message success-message">
1022 ✓ Registration successful! Check your email to complete registration.
1023 </div>
1024
1025 <div class="secondary-actions">
1026 <button class="back-btn" onclick="window.location.href='/login'">
1027 ← Back to Login
1028 </button>
1029 </div>
1030 </div>
1031
1032 <!-- Terms Modal -->
1033 <div class="modal-overlay" id="termsModal">
1034 <div class="modal-container">
1035 <div class="modal-header">
1036 <span class="modal-title">Terms of Service</span>
1037 <button class="modal-close" onclick="closeModal('terms')">✕</button>
1038 </div>
1039 <div class="modal-body">
1040 <iframe src="/terms" title="Terms of Service"></iframe>
1041 </div>
1042 </div>
1043 </div>
1044
1045 <!-- Privacy Modal -->
1046 <div class="modal-overlay" id="privacyModal">
1047 <div class="modal-container">
1048 <div class="modal-header">
1049 <span class="modal-title">Privacy Policy</span>
1050 <button class="modal-close" onclick="closeModal('privacy')">✕</button>
1051 </div>
1052 <div class="modal-body">
1053 <iframe src="/privacy" title="Privacy Policy"></iframe>
1054 </div>
1055 </div>
1056 </div>
1057
1058 <script>
1059 const apiUrl = "${getLandUrl()}";
1060
1061 function openModal(type) {
1062 const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1063 document.getElementById(id).classList.add('show');
1064 document.body.style.overflow = 'hidden';
1065 }
1066
1067 function closeModal(type) {
1068 const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1069 document.getElementById(id).classList.remove('show');
1070 document.body.style.overflow = '';
1071 }
1072
1073 // Close modal on overlay click
1074 document.querySelectorAll('.modal-overlay').forEach(overlay => {
1075 overlay.addEventListener('click', (e) => {
1076 if (e.target === overlay) {
1077 overlay.classList.remove('show');
1078 document.body.style.overflow = '';
1079 }
1080 });
1081 });
1082
1083 // Close modal on Escape key
1084 document.addEventListener('keydown', (e) => {
1085 if (e.key === 'Escape') {
1086 document.querySelectorAll('.modal-overlay.show').forEach(m => {
1087 m.classList.remove('show');
1088 });
1089 document.body.style.overflow = '';
1090 }
1091 });
1092
1093 document.getElementById("registerForm").addEventListener("submit", async (e) => {
1094 e.preventDefault();
1095
1096 const username = document.getElementById("username").value.trim();
1097 const email = document.getElementById("email").value.trim();
1098 const password = document.getElementById("password").value;
1099 const confirmPassword = document.getElementById("confirmPassword").value;
1100
1101 const errorEl = document.getElementById("errorMessage");
1102 const successEl = document.getElementById("successMessage");
1103 const btn = document.getElementById("registerBtn");
1104 const passwordInput = document.getElementById("password");
1105 const confirmPasswordInput = document.getElementById("confirmPassword");
1106
1107 errorEl.classList.remove("show");
1108 successEl.classList.remove("show");
1109 passwordInput.classList.remove("error");
1110 confirmPasswordInput.classList.remove("error");
1111
1112 if (password.length < 8) {
1113 errorEl.textContent = "Password must be at least 8 characters long.";
1114 errorEl.classList.add("show");
1115 passwordInput.classList.add("error");
1116 passwordInput.focus();
1117 return;
1118 }
1119
1120 if (password !== confirmPassword) {
1121 errorEl.textContent = "Passwords do not match.";
1122 errorEl.classList.add("show");
1123 confirmPasswordInput.classList.add("error");
1124 confirmPasswordInput.focus();
1125 return;
1126 }
1127
1128 btn.classList.add("loading");
1129 btn.disabled = true;
1130
1131 try {
1132 const res = await fetch(\`\${apiUrl}/register\`, {
1133 method: "POST",
1134 headers: { "Content-Type": "application/json" },
1135 body: JSON.stringify({ username, email, password })
1136 });
1137
1138 const data = await res.json();
1139
1140 if (!res.ok) {
1141 errorEl.textContent = data.message || "Registration failed. Please try again.";
1142 errorEl.classList.add("show");
1143 btn.classList.remove("loading");
1144 btn.disabled = false;
1145 return;
1146 }
1147
1148 document.getElementById("registerForm").reset();
1149 successEl.classList.add("show");
1150 btn.classList.remove("loading");
1151 btn.disabled = false;
1152
1153 setTimeout(() => {
1154 window.location.href = "/login";
1155 }, 7000);
1156
1157 } catch (err) {
1158 console.error(err);
1159 errorEl.textContent = "An error occurred. Please try again.";
1160 errorEl.classList.add("show");
1161 btn.classList.remove("loading");
1162 btn.disabled = false;
1163 }
1164 });
1165
1166 document.getElementById("confirmPassword").addEventListener("input", (e) => {
1167 const password = document.getElementById("password").value;
1168 const confirmPassword = e.target.value;
1169 if (confirmPassword && password !== confirmPassword) {
1170 e.target.classList.add("error");
1171 } else {
1172 e.target.classList.remove("error");
1173 }
1174 });
1175 </script>
1176</body>
1177</html>`);
1178}
1179
1180export function renderForgotPasswordPage(req, res) {
1181 res.setHeader("Content-Type", "text/html");
1182 res.send(`<!DOCTYPE html>
1183<html lang="en">
1184<head>
1185 <meta charset="UTF-8" />
1186 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
1187 <meta name="theme-color" content="#736fe6">
1188 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1189 <title>TreeOS - Reset Password</title>
1190
1191 <style>
1192 ${baseStyles}
1193
1194 body {
1195 display: flex;
1196 flex-direction: column;
1197 align-items: center;
1198 justify-content: center;
1199 overflow-y: auto;
1200 }
1201
1202 @keyframes fadeInDown {
1203 from { opacity: 0; transform: translateY(-30px); }
1204 to { opacity: 1; transform: translateY(0); }
1205 }
1206
1207 @keyframes slideUp {
1208 from { opacity: 0; transform: translateY(30px); }
1209 to { opacity: 1; transform: translateY(0); }
1210 }
1211
1212 @keyframes grow {
1213 0%, 100% { transform: scale(1); }
1214 50% { transform: scale(1.06); }
1215 }
1216
1217 @keyframes spin {
1218 to { transform: rotate(360deg); }
1219 }
1220
1221 .brand-header {
1222 position: relative;
1223 z-index: 1;
1224 margin-bottom: 32px;
1225 text-align: center;
1226 animation: fadeInDown 0.8s ease-out;
1227 }
1228
1229 .brand-logo {
1230 font-size: 80px;
1231 margin-bottom: 16px;
1232 display: inline-block;
1233 filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
1234 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
1235 }
1236
1237 .brand-title {
1238 font-size: 56px;
1239 font-weight: 600;
1240 color: white;
1241 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1242 letter-spacing: -1.5px;
1243 margin-bottom: 8px;
1244 }
1245
1246 .brand-subtitle {
1247 font-size: 18px;
1248 color: rgba(255, 255, 255, 0.85);
1249 font-weight: 400;
1250 letter-spacing: 0.2px;
1251 }
1252
1253 .forgot-container {
1254 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1255 backdrop-filter: blur(22px) saturate(140%);
1256 -webkit-backdrop-filter: blur(22px) saturate(140%);
1257 padding: 48px;
1258 border-radius: 16px;
1259 width: 100%;
1260 max-width: 460px;
1261 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1262 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1263 border: 1px solid rgba(255, 255, 255, 0.28);
1264 text-align: center;
1265 position: relative;
1266 z-index: 1;
1267 animation: slideUp 0.6s ease-out 0.2s both;
1268 }
1269
1270 h2 {
1271 font-size: 32px;
1272 font-weight: 600;
1273 color: white;
1274 margin-bottom: 8px;
1275 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1276 letter-spacing: -0.5px;
1277 }
1278
1279 .subtitle {
1280 font-size: 15px;
1281 color: rgba(255, 255, 255, 0.85);
1282 margin-bottom: 32px;
1283 line-height: 1.5;
1284 font-weight: 400;
1285 }
1286
1287 form {
1288 margin-bottom: 16px;
1289 }
1290
1291 .input-group {
1292 margin-bottom: 16px;
1293 text-align: left;
1294 }
1295
1296 label {
1297 display: block;
1298 font-size: 14px;
1299 font-weight: 600;
1300 color: white;
1301 margin-bottom: 8px;
1302 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1303 letter-spacing: -0.2px;
1304 }
1305
1306 input {
1307 width: 100%;
1308 padding: 14px 18px;
1309 border-radius: 12px;
1310 border: 2px solid rgba(255, 255, 255, 0.3);
1311 font-size: 16px;
1312 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1313 background: rgba(255, 255, 255, 0.15);
1314 backdrop-filter: blur(20px) saturate(150%);
1315 -webkit-backdrop-filter: blur(20px) saturate(150%);
1316 font-family: inherit;
1317 color: white;
1318 font-weight: 500;
1319 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
1320 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1321 touch-action: manipulation;
1322 }
1323
1324 input:focus {
1325 outline: none;
1326 border-color: rgba(255, 255, 255, 0.6);
1327 background: rgba(255, 255, 255, 0.25);
1328 backdrop-filter: blur(25px) saturate(160%);
1329 -webkit-backdrop-filter: blur(25px) saturate(160%);
1330 box-shadow:
1331 0 0 0 4px rgba(255, 255, 255, 0.15),
1332 0 8px 30px rgba(0, 0, 0, 0.15),
1333 inset 0 1px 0 rgba(255, 255, 255, 0.4);
1334 transform: translateY(-2px);
1335 }
1336
1337 input::placeholder {
1338 color: rgba(255, 255, 255, 0.5);
1339 font-weight: 400;
1340 }
1341
1342 input.error {
1343 border-color: rgba(239, 68, 68, 0.6);
1344 background: rgba(239, 68, 68, 0.1);
1345 }
1346
1347 input.error:focus {
1348 box-shadow:
1349 0 0 0 4px rgba(239, 68, 68, 0.2),
1350 0 8px 30px rgba(239, 68, 68, 0.2);
1351 }
1352
1353 button {
1354 width: 100%;
1355 padding: 16px;
1356 margin-top: 8px;
1357 border-radius: 980px;
1358 border: 1px solid rgba(255, 255, 255, 0.3);
1359 background: rgba(255, 255, 255, 0.25);
1360 backdrop-filter: blur(10px);
1361 color: white;
1362 font-size: 16px;
1363 font-weight: 600;
1364 cursor: pointer;
1365 transition: all 0.3s;
1366 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1367 font-family: inherit;
1368 letter-spacing: -0.2px;
1369 position: relative;
1370 overflow: hidden;
1371 touch-action: manipulation;
1372 }
1373
1374 button::before {
1375 content: "";
1376 position: absolute;
1377 inset: -40%;
1378 background: radial-gradient(
1379 120% 60% at 0% 0%,
1380 rgba(255, 255, 255, 0.35),
1381 transparent 60%
1382 );
1383 opacity: 0;
1384 transform: translateX(-30%) translateY(-10%);
1385 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1386 pointer-events: none;
1387 }
1388
1389 button:hover::before {
1390 opacity: 1;
1391 transform: translateX(30%) translateY(10%);
1392 }
1393
1394 button:hover {
1395 background: rgba(255, 255, 255, 0.35);
1396 transform: translateY(-2px);
1397 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1398 }
1399
1400 button:active { transform: translateY(0); }
1401
1402 button.loading {
1403 position: relative;
1404 color: transparent;
1405 pointer-events: none;
1406 }
1407
1408 button.loading::after {
1409 content: '';
1410 position: absolute;
1411 width: 20px;
1412 height: 20px;
1413 top: 50%;
1414 left: 50%;
1415 margin-left: -10px;
1416 margin-top: -10px;
1417 border: 3px solid rgba(255, 255, 255, 0.3);
1418 border-radius: 50%;
1419 border-top-color: white;
1420 animation: spin 0.8s linear infinite;
1421 }
1422
1423 .message {
1424 margin-top: 16px;
1425 margin-bottom: 16px;
1426 padding: 12px 16px;
1427 border-radius: 10px;
1428 font-size: 14px;
1429 font-weight: 600;
1430 text-align: left;
1431 display: none;
1432 }
1433
1434 .error-message {
1435 color: white;
1436 background: rgba(239, 68, 68, 0.3);
1437 backdrop-filter: blur(10px);
1438 border: 1px solid rgba(239, 68, 68, 0.4);
1439 }
1440
1441 .success-message {
1442 color: white;
1443 background: rgba(16, 185, 129, 0.3);
1444 backdrop-filter: blur(10px);
1445 border: 1px solid rgba(16, 185, 129, 0.4);
1446 }
1447
1448 .message.show { display: block; }
1449
1450 .secondary-actions {
1451 display: flex;
1452 flex-direction: column;
1453 gap: 8px;
1454 margin-top: 24px;
1455 padding-top: 24px;
1456 }
1457
1458 .back-btn {
1459 width: 100%;
1460 background: rgba(255, 255, 255, 0.15);
1461 color: white;
1462 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
1463 font-weight: 600;
1464 padding: 12px;
1465 font-size: 15px;
1466 margin-top: 0;
1467 }
1468
1469 .back-btn:hover {
1470 background: rgba(255, 255, 255, 0.25);
1471 }
1472
1473 @media (max-width: 640px) {
1474 body {
1475 padding: 20px 16px;
1476 justify-content: center;
1477 }
1478
1479 .brand-header { margin-bottom: 24px; }
1480 .brand-logo { font-size: 64px; }
1481
1482 .brand-title {
1483 font-size: 42px;
1484 letter-spacing: -1px;
1485 }
1486
1487 .brand-subtitle { font-size: 16px; }
1488 .forgot-container { padding: 32px 24px; }
1489 h2 { font-size: 28px; }
1490 input { font-size: 16px; }
1491 }
1492
1493 @media (min-width: 641px) and (max-width: 1024px) {
1494 .forgot-container { max-width: 420px; }
1495 }
1496 </style>
1497</head>
1498
1499<body>
1500 <div class="brand-header">
1501 <a href="/" style="text-decoration: none;">
1502 <div class="brand-logo">🌳</div>
1503 <h1 class="brand-title">TreeOS</h1>
1504 </a>
1505 <div class="brand-subtitle">Organize your life, efficiently</div>
1506 </div>
1507
1508 <div class="forgot-container">
1509 <h2>Reset Password</h2>
1510 <p class="subtitle">Enter your email address and we'll send you a link to reset your password.</p>
1511
1512 <form id="forgotForm">
1513 <div class="input-group">
1514 <label for="email">Email Address</label>
1515 <input
1516 type="email"
1517 id="email"
1518 placeholder="Enter your email"
1519 required
1520 autocomplete="email"
1521 autocapitalize="off"
1522 />
1523 </div>
1524
1525 <button type="submit" id="submitBtn">Send Reset Link</button>
1526 </form>
1527
1528 <div id="errorMessage" class="message error-message"></div>
1529 <div id="successMessage" class="message success-message">
1530 ✓ If an account exists for that email, a password reset link has been sent. Check your inbox.
1531 </div>
1532
1533 <div class="secondary-actions">
1534 <button class="back-btn" onclick="window.location.href='/login'">
1535 ← Back to Login
1536 </button>
1537 </div>
1538 </div>
1539
1540 <script>
1541 const apiUrl = "${getLandUrl()}";
1542 const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
1543
1544 document.getElementById("forgotForm").addEventListener("submit", async (e) => {
1545 e.preventDefault();
1546
1547 const email = document.getElementById("email").value.trim();
1548 const emailInput = document.getElementById("email");
1549 const btn = document.getElementById("submitBtn");
1550 const errorEl = document.getElementById("errorMessage");
1551 const successEl = document.getElementById("successMessage");
1552
1553 errorEl.classList.remove("show");
1554 successEl.classList.remove("show");
1555 emailInput.classList.remove("error");
1556
1557 if (!email) {
1558 errorEl.textContent = "Please enter your email address.";
1559 errorEl.classList.add("show");
1560 emailInput.classList.add("error");
1561 emailInput.focus();
1562 return;
1563 }
1564
1565 if (!EMAIL_REGEX.test(email)) {
1566 errorEl.textContent = "Please enter a valid email address.";
1567 errorEl.classList.add("show");
1568 emailInput.classList.add("error");
1569 emailInput.focus();
1570 return;
1571 }
1572
1573 if (email.length > 320) {
1574 errorEl.textContent = "Email address is too long.";
1575 errorEl.classList.add("show");
1576 emailInput.classList.add("error");
1577 emailInput.focus();
1578 return;
1579 }
1580
1581 btn.classList.add("loading");
1582 btn.disabled = true;
1583
1584 try {
1585 await fetch(\`\${apiUrl}/forgot-password\`, {
1586 method: "POST",
1587 headers: { "Content-Type": "application/json" },
1588 body: JSON.stringify({ email })
1589 });
1590
1591 document.getElementById("forgotForm").reset();
1592 successEl.classList.add("show");
1593 btn.classList.remove("loading");
1594 btn.disabled = false;
1595
1596 } catch (err) {
1597 console.error(err);
1598 document.getElementById("forgotForm").reset();
1599 successEl.classList.add("show");
1600 btn.classList.remove("loading");
1601 btn.disabled = false;
1602 }
1603 });
1604 </script>
1605</body>
1606</html>`);
1607}
1608
1609
1/* ------------------------------------------------------------------ */
2/* HTML renderers for node routes */
3/* ------------------------------------------------------------------ */
4
5import { baseStyles, backNavStyles, glassHeaderStyles, emptyStateStyles, responsiveBase } from "./baseStyles.js";
6import {
7 esc,
8 truncate,
9 formatTime,
10 formatDuration,
11 modeLabel,
12 sourceLabel,
13 actionLabel,
14 actionColorHex,
15 groupIntoChains,
16} from "./utils.js";
17
18const linkifyNodeIds = (html, token) =>
19 html.replace(
20 /Placed on node ([0-9a-f-]{36})/g,
21 (_, id) =>
22 `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${token}&html` : "?html"}">${id}</a>`,
23 );
24
25const formatContent = (str) => {
26 if (!str) return "";
27 const s = String(str).trim();
28 if (
29 (s.startsWith("{") && s.endsWith("}")) ||
30 (s.startsWith("[") && s.endsWith("]"))
31 ) {
32 try {
33 const parsed = JSON.parse(s);
34 const pretty = JSON.stringify(parsed, null, 2);
35 return `<span class="chain-json">${esc(pretty)}</span>`;
36 } catch (_) {}
37 }
38 return esc(s);
39};
40
41const renderTreeContext = (tc, tokenQS) => {
42 if (!tc) return "";
43 const parts = [];
44 const tcNodeId = tc.targetNodeId?._id || tc.targetNodeId;
45 const tcNodeName = tc.targetNodeId?.name || tc.targetNodeName;
46 if (tcNodeId && tcNodeName && typeof tcNodeId === "string") {
47 parts.push(
48 `<a href="/api/v1/node/${tcNodeId}${tokenQS}" class="tree-target-link">${esc(tcNodeName)}</a>`,
49 );
50 } else if (tcNodeName) {
51 parts.push(`<span class="tree-target-name">${esc(tcNodeName)}</span>`);
52 } else if (tc.targetPath) {
53 const pathParts = tc.targetPath.split(" / ");
54 const last = pathParts[pathParts.length - 1];
55 parts.push(`<span class="tree-target-name">${esc(last)}</span>`);
56 }
57 if (tc.planStepIndex != null && tc.planTotalSteps != null) {
58 parts.push(
59 `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
60 );
61 }
62 if (tc.stepResult) {
63 const resultClasses = {
64 success: "badge-done",
65 failed: "badge-stopped",
66 skipped: "badge-skipped",
67 pending: "badge-pending",
68 };
69 const resultIcons = {
70 success: "done",
71 failed: "failed",
72 skipped: "skip",
73 pending: "...",
74 };
75 parts.push(
76 `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
77 );
78 }
79 if (parts.length === 0) return "";
80 return `<div class="tree-context-bar">${parts.join("")}</div>`;
81};
82
83const renderDirective = (tc) => {
84 if (!tc?.directive) return "";
85 return `<div class="tree-directive">${esc(tc.directive)}</div>`;
86};
87
88const getTargetName = (tc) => {
89 if (!tc) return null;
90 return tc.targetNodeId?.name || tc.targetNodeName || null;
91};
92
93const renderModelBadge = (chat) => {
94 const connName = chat.llmProvider?.connectionId?.name;
95 const model = connName || chat.llmProvider?.model;
96 if (!model) return "";
97 return `<span class="chain-model">${esc(model)}</span>`;
98};
99
100const groupStepsIntoPhases = (steps) => {
101 const phases = [];
102 let currentPlan = null;
103 for (const step of steps) {
104 const mode = step.aiContext?.path || "";
105 if (mode === "translator") {
106 currentPlan = null;
107 phases.push({ type: "translate", step });
108 } else if (mode.startsWith("tree:orchestrator:plan:")) {
109 currentPlan = { type: "plan", marker: step, substeps: [] };
110 phases.push(currentPlan);
111 } else if (mode === "tree:respond") {
112 currentPlan = null;
113 phases.push({ type: "respond", step });
114 } else if (currentPlan) {
115 currentPlan.substeps.push(step);
116 } else {
117 phases.push({ type: "step", step });
118 }
119 }
120 return phases;
121};
122
123const renderSubstep = (chat, tokenQS) => {
124 const duration = formatDuration(
125 chat.startMessage?.time,
126 chat.endMessage?.time,
127 );
128 const stopped = chat.endMessage?.stopped;
129 const tc = chat.treeContext;
130 const dotClass = stopped
131 ? "chain-dot-stopped"
132 : tc?.stepResult === "failed"
133 ? "chain-dot-stopped"
134 : tc?.stepResult === "skipped"
135 ? "chain-dot-skipped"
136 : chat.endMessage?.time
137 ? "chain-dot-done"
138 : "chain-dot-pending";
139 const targetName = getTargetName(tc);
140 const inputFull = formatContent(chat.startMessage?.content);
141 const outputFull = formatContent(chat.endMessage?.content);
142
143 return `
144 <details class="chain-substep">
145 <summary class="chain-substep-summary">
146 <span class="chain-dot ${dotClass}"></span>
147 <span class="chain-step-mode">${modeLabel(chat.aiContext?.path)}</span>
148 ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
149 ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
150 ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
151 ${renderModelBadge(chat)}
152 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
153 </summary>
154 <div class="chain-step-body">
155 ${renderTreeContext(tc, tokenQS)}
156 ${renderDirective(tc)}
157 <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
158 ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
159 </div>
160 </details>`;
161};
162
163const renderPhases = (steps, tokenQS) => {
164 const phases = groupStepsIntoPhases(steps);
165 if (phases.length === 0) return "";
166
167 const phaseHtml = phases
168 .map((phase) => {
169 if (phase.type === "translate") {
170 const s = phase.step;
171 const tc = s.treeContext;
172 const duration = formatDuration(
173 s.startMessage?.time,
174 s.endMessage?.time,
175 );
176 const outputFull = formatContent(s.endMessage?.content);
177 return `
178 <details class="chain-phase chain-phase-translate">
179 <summary class="chain-phase-summary">
180 <span class="chain-phase-icon">T</span>
181 <span class="chain-phase-label">Translator</span>
182 ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
183 ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
184 ${renderModelBadge(s)}
185 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
186 </summary>
187 ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
188 </details>`;
189 }
190
191 if (phase.type === "plan") {
192 const m = phase.marker;
193 const tc = m.treeContext;
194 const targetName = getTargetName(tc);
195 const hasSubsteps = phase.substeps.length > 0;
196 const counts = { success: 0, failed: 0, skipped: 0 };
197 for (const sub of phase.substeps) {
198 const r = sub.treeContext?.stepResult;
199 if (r && counts[r] !== undefined) counts[r]++;
200 }
201 const countBadges = [
202 counts.success > 0
203 ? `<span class="badge badge-done">${counts.success} done</span>`
204 : "",
205 counts.failed > 0
206 ? `<span class="badge badge-stopped">${counts.failed} failed</span>`
207 : "",
208 counts.skipped > 0
209 ? `<span class="badge badge-skipped">${counts.skipped} skipped</span>`
210 : "",
211 ]
212 .filter(Boolean)
213 .join("");
214
215 const directiveText = tc?.directive || "";
216 const inputFull = directiveText
217 ? esc(directiveText)
218 : formatContent(m.startMessage?.content);
219
220 return `
221 <div class="chain-phase chain-phase-plan">
222 <div class="chain-phase-header">
223 <span class="chain-phase-icon">P</span>
224 <span class="chain-phase-label">${modeLabel(m.aiContext?.path)}</span>
225 ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
226 ${tc?.planStepIndex != null && tc?.planTotalSteps != null ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>` : ""}
227 ${countBadges}
228 ${renderModelBadge(m)}
229 </div>
230 <div class="chain-plan-directive">${inputFull}</div>
231 ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map((s) => renderSubstep(s, tokenQS)).join("")}</div>` : ""}
232 </div>`;
233 }
234
235 if (phase.type === "respond") {
236 const s = phase.step;
237 const tc = s.treeContext;
238 const duration = formatDuration(
239 s.startMessage?.time,
240 s.endMessage?.time,
241 );
242 const inputFull = formatContent(s.startMessage?.content);
243 const outputFull = formatContent(s.endMessage?.content);
244 return `
245 <details class="chain-phase chain-phase-respond">
246 <summary class="chain-phase-summary">
247 <span class="chain-phase-icon">R</span>
248 <span class="chain-phase-label">${modeLabel(s.aiContext?.path)}</span>
249 ${renderModelBadge(s)}
250 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
251 </summary>
252 <div class="chain-step-body">
253 ${renderTreeContext(tc, tokenQS)}
254 ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
255 ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
256 </div>
257 </details>`;
258 }
259
260 return renderSubstep(phase.step, tokenQS);
261 })
262 .join("");
263
264 const summaryParts = phases
265 .map((p) => {
266 if (p.type === "translate") {
267 const tc = p.step.treeContext;
268 return tc?.planTotalSteps ? `T ${tc.planTotalSteps}-step` : "T";
269 }
270 if (p.type === "plan") {
271 const tc = p.marker.treeContext;
272 const targetName = getTargetName(tc);
273 const sub = p.substeps
274 .map((s) => {
275 const stc = s.treeContext;
276 const icon =
277 stc?.stepResult === "failed"
278 ? "X "
279 : stc?.stepResult === "skipped"
280 ? "- "
281 : stc?.stepResult === "success"
282 ? "v "
283 : "";
284 return `${icon}${modeLabel(s.aiContext?.path)}`;
285 })
286 .join(" > ");
287 const label = targetName ? `P ${esc(targetName)}` : "P";
288 return sub ? `${label}: ${sub}` : label;
289 }
290 if (p.type === "respond") return "R";
291 return modeLabel(p.step?.aiContext?.path);
292 })
293 .join(" ");
294
295 return `
296 <details class="chain-dropdown">
297 <summary class="chain-summary">
298 ${phases.length} phase${phases.length !== 1 ? "s" : ""}
299 <span class="chain-modes">${summaryParts}</span>
300 </summary>
301 <div class="chain-phases">${phaseHtml}</div>
302 </details>`;
303};
304
305const renderChain = (chain, tokenQS, token) => {
306 const chat = chain.root;
307 const steps = chain.steps;
308 const duration = formatDuration(
309 chat.startMessage?.time,
310 chat.endMessage?.time,
311 );
312 const stopped = chat.endMessage?.stopped;
313 const contribs = chat.contributions || [];
314 const hasContribs = contribs.length > 0;
315 const hasSteps = steps.length > 0;
316 const modelName =
317 chat.llmProvider?.connectionId?.name ||
318 chat.llmProvider?.model ||
319 "unknown";
320
321 const tc = chat.treeContext;
322 const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
323 const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
324 const treeLink =
325 treeNodeId && treeNodeName
326 ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">${esc(treeNodeName)}</a>`
327 : treeNodeName
328 ? `<span class="tree-target-name">${esc(treeNodeName)}</span>`
329 : "";
330
331 const statusBadge = stopped
332 ? `<span class="badge badge-stopped">Stopped</span>`
333 : chat.endMessage?.time
334 ? `<span class="badge badge-done">Done</span>`
335 : `<span class="badge badge-pending">Pending</span>`;
336
337 const contribRows = contribs
338 .map((c) => {
339 const nId = c.nodeId?._id || c.nodeId;
340 const nName = c.nodeId?.name || nId || "--";
341 const nodeRef = nId
342 ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
343 : `<span style="opacity:0.5">--</span>`;
344 const aiBadge = c.wasAi
345 ? `<span class="mini-badge mini-ai">AI</span>`
346 : "";
347 const cEnergyBadge =
348 c.energyUsed > 0
349 ? `<span class="mini-badge mini-energy">E${c.energyUsed}</span>`
350 : "";
351 const understandingLink =
352 c.action === "understanding" &&
353 c.understandingMeta?.understandingRunId &&
354 c.understandingMeta?.rootNodeId
355 ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">View run</a>`
356 : "";
357 const color = actionColorHex(c.action);
358 return `
359 <tr class="contrib-row">
360 <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
361 <td>${nodeRef}</td>
362 <td>${aiBadge}${cEnergyBadge}</td>
363 <td class="contrib-time">${formatTime(c.date)}</td>
364 </tr>`;
365 })
366 .join("");
367
368 const stepsHtml = hasSteps ? renderPhases(steps, tokenQS) : "";
369
370 return `
371 <li class="note-card">
372 <div class="chat-header">
373 <div class="chat-header-left">
374 <span class="chat-mode">${modeLabel(chat.aiContext?.path)}</span>
375 ${treeLink}
376 <span class="chat-model">${esc(modelName)}</span>
377 </div>
378 <div class="chat-badges">
379 ${statusBadge}
380 ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
381 <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
382 </div>
383 </div>
384
385 <div class="note-content">
386 <div class="chat-message chat-user">
387 <span class="msg-label">${chat.userId?._id ? `<a href="/api/v1/user/${chat.userId._id}${tokenQS}" class="msg-user-link">${esc(chat.userId.username || "User")}</a>` : esc("User")}</span>
388 <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
389 ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
390 </div>
391 ${
392 chat.endMessage?.content
393 ? `
394 <div class="chat-message chat-ai">
395 <span class="msg-label">AI</span>
396 <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content), token)}</div>
397 ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
398 </div>`
399 : ""
400 }
401 </div>
402
403 ${stepsHtml}
404
405 ${
406 hasContribs
407 ? `
408 <details class="contrib-dropdown">
409 <summary class="contrib-summary">
410 ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
411 </summary>
412 <div class="contrib-table-wrap">
413 <table class="contrib-table">
414 <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
415 <tbody>${contribRows}</tbody>
416 </table>
417 </div>
418 </details>`
419 : ""
420 }
421
422 <div class="note-meta">
423 ${formatTime(chat.startMessage?.time)}
424 <span class="meta-separator">|</span>
425 <code class="contribution-id">${esc(chat._id)}</code>
426 </div>
427 </li>`;
428};
429
430/* ================================================================== */
431/* 1. renderNodeChats */
432/* ================================================================== */
433
434export function renderNodeChats({
435 nodeId,
436 nodeName,
437 nodePath,
438 sessions,
439 allChats,
440 token,
441 tokenQS,
442}) {
443 const sessionGroups = sessions;
444
445 const renderedSections = sessionGroups
446 .map((group) => {
447 const chatCount = group.chatCount;
448 const sessionTime = formatTime(group.startTime);
449 const shortId = group.sessionId.slice(0, 8);
450 const chains = groupIntoChains(group.chats);
451 const chatCards = chains.map((c) => renderChain(c, tokenQS, token)).join("");
452
453 return `
454 <div class="session-group">
455 <div class="session-pane">
456 <div class="session-pane-header">
457 <div class="session-header-left">
458 <span class="session-id">${esc(shortId)}</span>
459 <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
460 </div>
461 <span class="session-time">${sessionTime}</span>
462 </div>
463 <ul class="notes-list">${chatCards}</ul>
464 </div>
465 </div>`;
466 })
467 .join("");
468
469 return `
470<!DOCTYPE html>
471<html lang="en">
472<head>
473 <meta charset="UTF-8">
474 <meta name="viewport" content="width=device-width, initial-scale=1.0">
475 <meta name="theme-color" content="#667eea">
476 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
477 <title>${esc(nodeName)} -- AI Chats</title>
478 <style>
479${baseStyles}
480${backNavStyles}
481${glassHeaderStyles}
482.header-path { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; font-family: 'SF Mono', 'Fira Code', monospace; }
483
484.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
485.session-pane {
486 background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
487 border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
488}
489.session-pane-header {
490 display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
491 background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
492}
493.session-header-left { display: flex; align-items: center; gap: 10px; }
494.session-id {
495 font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
496 color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
497 border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
498}
499.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
500.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
501
502.notes-list { list-style: none; display: flex; flex-direction: column; gap: 16px; padding: 16px; }
503.note-card {
504 --card-rgb: 115, 111, 230; position: relative;
505 background: rgba(var(--card-rgb), var(--glass-alpha)); backdrop-filter: blur(22px) saturate(140%);
506 -webkit-backdrop-filter: blur(22px) saturate(140%); border-radius: 16px; padding: 24px;
507 box-shadow: 0 8px 24px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
508 border: 1px solid rgba(255,255,255,0.28); transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
509 color: white; overflow: hidden; opacity: 0; transform: translateY(30px);
510}
511.note-card.visible { animation: fadeInUp 0.6s cubic-bezier(0.4,0,0.2,1) forwards; }
512.note-card::before {
513 content: ""; position: absolute; inset: -40%;
514 background: radial-gradient(120% 60% at 0% 0%, rgba(255,255,255,0.35), transparent 60%);
515 opacity: 0; transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22,1,0.36,1); pointer-events: none;
516}
517.note-card:hover { background: rgba(var(--card-rgb), var(--glass-alpha-hover)); transform: translateY(-2px); box-shadow: 0 12px 32px rgba(0,0,0,0.18); }
518.note-card:hover::before { opacity: 1; transform: translateX(30%) translateY(10%); }
519
520.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
521.chat-header-left { display: flex; align-items: center; gap: 8px; }
522.chat-mode {
523 font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
524 padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
525}
526.chat-model {
527 font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
528 font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
529 text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
530}
531.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
532
533.note-content { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
534.chat-message { display: flex; gap: 10px; align-items: flex-start; }
535.msg-label {
536 flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
537 letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
538}
539.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
540.chat-ai .msg-label { background: rgba(100,220,255,0.25); color: white; }
541.msg-user-link { color: inherit; text-decoration: none; }
542.msg-user-link:hover { text-decoration: underline; }
543.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
544.msg-clamp {
545 display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
546 overflow: hidden; max-height: calc(1.65em * 4); transition: max-height 0.3s ease;
547}
548.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
549.expand-btn {
550 background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
551 font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px; transition: color 0.2s;
552}
553.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
554.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
555.node-link:hover { background: rgba(50,220,120,0.3); }
556.understanding-link {
557 color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
558 margin-left: 4px; transition: color 0.2s;
559}
560.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
561.chat-user .msg-text { font-weight: 500; }
562
563.chain-dropdown { margin-bottom: 12px; }
564.chain-summary {
565 cursor: pointer; font-size: 13px; font-weight: 600;
566 color: rgba(255,255,255,0.85); padding: 8px 14px;
567 background: rgba(255,255,255,0.1); border-radius: 10px;
568 border: 1px solid rgba(255,255,255,0.15);
569 transition: all 0.2s; list-style: none;
570 display: flex; align-items: center; gap: 8px;
571}
572.chain-summary::-webkit-details-marker { display: none; }
573.chain-summary::before { content: ">"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
574details[open] > .chain-summary::before { transform: rotate(90deg); }
575.chain-summary:hover { background: rgba(255,255,255,0.18); }
576.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
577.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
578
579.chain-phase { border-radius: 10px; overflow: hidden; }
580.chain-phase-header {
581 display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
582}
583.chain-phase-icon { font-size: 14px; }
584.chain-phase-label { color: rgba(255,255,255,0.85); }
585.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
586.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
587.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
588.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
589
590.chain-phase-summary, .chain-substep-summary {
591 cursor: pointer; list-style: none;
592 display: flex; align-items: center; gap: 8px;
593 padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
594}
595.chain-phase-summary::-webkit-details-marker,
596.chain-substep-summary::-webkit-details-marker { display: none; }
597.chain-phase-summary::before,
598.chain-substep-summary::before {
599 content: ">"; font-size: 8px; color: rgba(255,255,255,0.35);
600 transition: transform 0.15s; display: inline-block;
601}
602details[open] > .chain-phase-summary::before,
603details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
604.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
605
606.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
607.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
608.chain-substep:hover { background: rgba(255,255,255,0.07); }
609
610.chain-dot {
611 display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
612 border: 2px solid rgba(255,255,255,0.3);
613}
614.chain-dot-done { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
615.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
616.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
617.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
618
619.chain-step-mode {
620 font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
621 background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
622}
623.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
624.chain-model {
625 font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
626 color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
627 overflow: hidden; text-overflow: ellipsis; max-width: 150px;
628}
629
630.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
631.chain-io-label {
632 display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
633 padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
634}
635.chain-io-in { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
636.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
637
638.chain-step-input {
639 font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
640 word-break: break-word; white-space: pre-wrap;
641 font-family: 'SF Mono', 'Fira Code', monospace;
642}
643.chain-step-output {
644 font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
645 margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
646 word-break: break-word; white-space: pre-wrap;
647 font-family: 'SF Mono', 'Fira Code', monospace;
648}
649.chain-json { color: rgba(255,255,255,0.8); }
650
651.tree-context-bar {
652 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
653 padding: 6px 12px; margin-bottom: 6px;
654 background: rgba(255,255,255,0.06); border-radius: 6px; font-size: 12px;
655}
656.tree-target-link {
657 color: rgba(100,220,255,0.95); text-decoration: none;
658 border-bottom: 1px solid rgba(100,220,255,0.3);
659 font-weight: 600; font-size: 12px; transition: all 0.2s;
660}
661.tree-target-link:hover {
662 border-bottom-color: rgba(100,220,255,0.8);
663 text-shadow: 0 0 8px rgba(100,220,255,0.5);
664}
665.tree-target-name { color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px; }
666.tree-directive {
667 padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
668 line-height: 1.5; font-style: italic;
669 border-left: 2px solid rgba(255,255,255,0.15); margin: 0 12px 8px;
670}
671.chain-step-counter {
672 font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
673 background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
674}
675.chain-step-target {
676 font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
677 max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
678}
679.chain-step-failed {
680 font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
681 background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px; letter-spacing: 0.5px;
682}
683.chain-step-fail-reason {
684 font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
685 font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
686}
687.badge-step {
688 background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
689 font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
690}
691.badge-skipped { background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7); }
692.chain-plan-summary-text {
693 font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
694 font-style: italic; overflow: hidden; text-overflow: ellipsis;
695 white-space: nowrap; max-width: 300px;
696}
697
698.contrib-dropdown { margin-bottom: 12px; }
699.contrib-summary {
700 cursor: pointer; font-size: 13px; font-weight: 600;
701 color: rgba(255,255,255,0.85); padding: 8px 14px;
702 background: rgba(255,255,255,0.1); border-radius: 10px;
703 border: 1px solid rgba(255,255,255,0.15);
704 transition: all 0.2s; list-style: none;
705 display: flex; align-items: center; gap: 6px;
706}
707.contrib-summary::-webkit-details-marker { display: none; }
708.contrib-summary::before { content: ">"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
709details[open] .contrib-summary::before { transform: rotate(90deg); }
710.contrib-summary:hover { background: rgba(255,255,255,0.18); }
711.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
712.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
713.contrib-table thead th {
714 text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
715 letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
716 border-bottom: 1px solid rgba(255,255,255,0.15);
717}
718.contrib-row td {
719 padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
720 color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
721}
722.contrib-row:last-child td { border-bottom: none; }
723.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
724.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
725.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
726.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
727
728.mini-badge {
729 display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
730 font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
731}
732.mini-ai { background: rgba(255,200,50,0.35); color: #fff; }
733.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
734
735.badge {
736 display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
737 font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
738}
739.badge-done { background: rgba(72,187,120,0.35); color: #fff; }
740.badge-stopped { background: rgba(200,80,80,0.35); color: #fff; }
741.badge-pending { background: rgba(255,200,50,0.3); color: #fff; }
742.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
743.badge-source { background: rgba(100,100,210,0.3); color: #fff; }
744
745.note-meta {
746 padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.2);
747 font-size: 12px; color: rgba(255,255,255,0.85); line-height: 1.8;
748 display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
749}
750.meta-separator { color: rgba(255,255,255,0.5); }
751.contribution-id {
752 background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
753 font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
754 color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
755}
756
757${emptyStateStyles}
758
759${responsiveBase}
760@media (max-width: 640px) {
761 .chat-header { flex-direction: column; align-items: flex-start; }
762 .contrib-row td { font-size: 12px; padding: 5px 6px; }
763 .session-pane-header { flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px 16px; }
764 .notes-list { padding: 12px; gap: 12px; }
765 .chat-model { max-width: 140px; }
766 .msg-text { font-size: 14px; }
767 .chain-plan-directive { font-size: 11px; }
768 .chain-step-target { max-width: 100px; }
769 .chain-plan-summary-text { max-width: 160px; }
770 .chain-step-fail-reason { max-width: 120px; }
771}
772 </style>
773</head>
774<body>
775 <div class="container">
776 <div class="back-nav">
777 <a href="/api/v1/node/${nodeId}${tokenQS}" class="back-link"><- Back to Node</a>
778 </div>
779
780 <div class="header">
781 <h1>
782 AI Chats for
783 <a href="/api/v1/node/${nodeId}${tokenQS}">${esc(nodeName)}</a>
784 ${allChats.length > 0 ? `<span class="message-count">${allChats.length}</span>` : ""}
785 </h1>
786 <div class="header-subtitle">
787 AI sessions that targeted or modified this node.
788 </div>
789 <div class="header-path">${esc(nodePath)}</div>
790 </div>
791
792 ${
793 sessionGroups.length
794 ? renderedSections
795 : `
796 <div class="empty-state">
797 <div class="empty-state-icon">AI</div>
798 <div class="empty-state-text">No AI chats yet</div>
799 <div class="empty-state-subtext">AI conversations involving this node will appear here</div>
800 </div>`
801 }
802 </div>
803
804 <script>
805 var observer = new IntersectionObserver(function(entries) {
806 entries.forEach(function(entry, index) {
807 if (entry.isIntersecting) {
808 setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
809 observer.unobserve(entry.target);
810 }
811 });
812 }, { root: null, rootMargin: '50px', threshold: 0.1 });
813 document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
814
815 function toggleExpand(btn) {
816 var text = btn.previousElementSibling;
817 if (!text) return;
818 var expanded = text.classList.toggle('expanded');
819 btn.textContent = expanded ? 'Show less' : 'Show more';
820 }
821 </script>
822</body>
823</html>
824`;
825}
826
827/* ================================================================== */
828/* 2. renderNodeDetail */
829/* ================================================================== */
830
831export function renderNodeDetail({ node, nodeId, qs, parentName, rootUrl, isPublicAccess }) {
832 const _nodeScripts = (node.metadata instanceof Map ? node.metadata?.get("scripts") : node.metadata?.scripts)?.list || [];
833 return `
834<!DOCTYPE html>
835<html lang="en">
836<head>
837 <meta charset="UTF-8">
838 <meta name="viewport" content="width=device-width, initial-scale=1.0">
839 <meta name="theme-color" content="#667eea">
840 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
841 <title>${node.name} — Node</title>
842 <style>
843${baseStyles}
844
845/* =========================================================
846 UNIFIED GLASS BUTTON SYSTEM
847 ========================================================= */
848
849.glass-btn,
850button,
851.action-button,
852.back-link,
853.versions-list a,
854.children-list a,
855button[type="submit"],
856.primary-button,
857.warning-button,
858.danger-button {
859 position: relative;
860 overflow: hidden;
861
862 padding: 10px 20px;
863 border-radius: 980px;
864
865 display: inline-flex;
866 align-items: center;
867 justify-content: center;
868 white-space: nowrap;
869
870 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
871 backdrop-filter: blur(22px) saturate(140%);
872 -webkit-backdrop-filter: blur(22px) saturate(140%);
873
874 color: white;
875 text-decoration: none;
876 font-family: inherit;
877
878 font-size: 15px;
879 font-weight: 600;
880 letter-spacing: -0.2px;
881
882 border: 1px solid rgba(255, 255, 255, 0.28);
883
884 box-shadow:
885 0 8px 24px rgba(0, 0, 0, 0.12),
886 inset 0 1px 0 rgba(255, 255, 255, 0.25);
887
888 cursor: pointer;
889
890 transition:
891 background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
892 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
893 box-shadow 0.3s ease;
894}
895
896/* Liquid light layer */
897.glass-btn::before,
898button::before,
899.action-button::before,
900.back-link::before,
901.versions-list a::before,
902.children-list a::before,
903button[type="submit"]::before,
904.primary-button::before,
905.warning-button::before,
906.danger-button::before {
907 content: "";
908 position: absolute;
909 inset: -40%;
910
911 background:
912 radial-gradient(
913 120% 60% at 0% 0%,
914 rgba(255, 255, 255, 0.35),
915 transparent 60%
916 ),
917 linear-gradient(
918 120deg,
919 transparent 30%,
920 rgba(255, 255, 255, 0.25),
921 transparent 70%
922 );
923
924 opacity: 0;
925 transform: translateX(-30%) translateY(-10%);
926 transition:
927 opacity 0.35s ease,
928 transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
929
930 pointer-events: none;
931}
932
933/* Hover motion */
934.glass-btn:hover,
935button:hover,
936.action-button:hover,
937.back-link:hover,
938.versions-list a:hover,
939.children-list a:hover,
940button[type="submit"]:hover,
941.primary-button:hover,
942.warning-button:hover,
943.danger-button:hover {
944 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
945 transform: translateY(-2px);
946}
947
948.glass-btn:hover::before,
949button:hover::before,
950.action-button:hover::before,
951.back-link:hover::before,
952.versions-list a:hover::before,
953.children-list a:hover::before,
954button[type="submit"]:hover::before,
955.primary-button:hover::before,
956.warning-button:hover::before,
957.danger-button:hover::before {
958 opacity: 1;
959 transform: translateX(30%) translateY(10%);
960}
961
962/* Active press */
963.glass-btn:active,
964button:active,
965.primary-button:active,
966.warning-button:active,
967.danger-button:active {
968 background: rgba(var(--glass-water-rgb), 0.45);
969 transform: translateY(0);
970}
971
972/* Emphasis variants */
973.primary-button {
974 --glass-water-rgb: 72, 187, 178;
975 --glass-alpha: 0.34;
976 --glass-alpha-hover: 0.46;
977 font-weight: 600;
978}
979
980.warning-button {
981 --glass-water-rgb: 100, 116, 139;
982 font-weight: 600;
983}
984
985.danger-button {
986 --glass-water-rgb: 198, 40, 40;
987 font-weight: 600;
988}
989
990/* =========================================================
991 CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
992 ========================================================= */
993
994.header,
995.hierarchy-section,
996.versions-section,
997.scripts-section,
998.actions-section {
999 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1000 backdrop-filter: blur(22px) saturate(140%);
1001 -webkit-backdrop-filter: blur(22px) saturate(140%);
1002 border-radius: 16px;
1003 padding: 28px;
1004 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1005 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1006 border: 1px solid rgba(255, 255, 255, 0.28);
1007 margin-bottom: 24px;
1008 animation: fadeInUp 0.6s ease-out;
1009 animation-fill-mode: both;
1010 position: relative;
1011 overflow: hidden;
1012}
1013
1014.header {
1015 animation-delay: 0.1s;
1016}
1017
1018.versions-section {
1019 animation-delay: 0.15s;
1020}
1021
1022.hierarchy-section {
1023 animation-delay: 0.2s;
1024}
1025
1026.scripts-section {
1027 animation-delay: 0.25s;
1028}
1029
1030.actions-section {
1031 animation-delay: 0.3s;
1032}
1033
1034.header::before,
1035.hierarchy-section::before,
1036.versions-section::before,
1037.scripts-section::before,
1038.actions-section::before {
1039 content: "";
1040 position: absolute;
1041 inset: 0;
1042 border-radius: inherit;
1043 background: linear-gradient(
1044 180deg,
1045 rgba(255, 255, 255, 0.18),
1046 rgba(255, 255, 255, 0.05)
1047 );
1048 pointer-events: none;
1049}
1050
1051.meta-card {
1052 background: rgba(255, 255, 255, 0.15);
1053 backdrop-filter: blur(22px) saturate(140%);
1054 -webkit-backdrop-filter: blur(22px) saturate(140%);
1055 border-radius: 12px;
1056 padding: 16px 20px;
1057 border: 1px solid rgba(255, 255, 255, 0.28);
1058 color: white;
1059}
1060
1061.header h1 {
1062 font-size: 28px;
1063 font-weight: 600;
1064 letter-spacing: -0.5px;
1065 line-height: 1.3;
1066 margin-bottom: 8px;
1067 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1068 color: white;
1069}
1070
1071.hierarchy-section h2,
1072.versions-section h2,
1073.scripts-section h2,
1074.actions-section h3 {
1075 font-size: 18px;
1076 font-weight: 600;
1077 color: white;
1078 margin-bottom: 16px;
1079 letter-spacing: -0.3px;
1080 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1081}
1082
1083.hierarchy-section h3 {
1084 font-size: 16px;
1085 font-weight: 600;
1086 color: rgba(255, 255, 255, 0.9);
1087 margin: 24px 0 12px 0;
1088}
1089
1090/* =========================================================
1091 NAV + META
1092 ========================================================= */
1093
1094.back-nav {
1095 display: flex;
1096 gap: 12px;
1097 margin-bottom: 20px;
1098 flex-wrap: wrap;
1099 animation: fadeInUp 0.5s ease-out;
1100}
1101
1102.node-id-container {
1103 display: flex;
1104 align-items: center;
1105 gap: 8px;
1106 margin-top: 12px;
1107 flex-wrap: wrap;
1108 padding: 10px 14px;
1109 background: rgba(255, 255, 255, 0.15);
1110 border-radius: 8px;
1111 border: 1px solid rgba(255, 255, 255, 0.2);
1112}
1113
1114code {
1115 background: transparent;
1116 padding: 0;
1117 border-radius: 0;
1118 font-size: 13px;
1119 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1120 color: white;
1121 word-break: break-all;
1122 flex: 1;
1123}
1124
1125#copyNodeIdBtn {
1126 background: rgba(255, 255, 255, 0.2);
1127 border: 1px solid rgba(255, 255, 255, 0.3);
1128 cursor: pointer;
1129 padding: 6px 10px;
1130 border-radius: 6px;
1131 opacity: 1;
1132 font-size: 16px;
1133 transition: all 0.2s;
1134 flex-shrink: 0;
1135}
1136
1137#copyNodeIdBtn:hover {
1138 background: rgba(255, 255, 255, 0.3);
1139 transform: scale(1.1);
1140}
1141
1142#copyNodeIdBtn::before {
1143 display: none;
1144}
1145
1146.meta-row {
1147 display: flex;
1148 gap: 24px;
1149 flex-wrap: wrap;
1150 padding-top: 12px;
1151 border-top: 1px solid rgba(255, 255, 255, 0.2);
1152}
1153
1154.meta-item {
1155 display: flex;
1156 flex-direction: column;
1157 gap: 4px;
1158}
1159
1160.meta-label {
1161 font-size: 12px;
1162 font-weight: 600;
1163 text-transform: uppercase;
1164 letter-spacing: 0.5px;
1165 color: rgba(255, 255, 255, 0.7);
1166}
1167
1168.meta-value {
1169 font-size: 16px;
1170 font-weight: 600;
1171 color: white;
1172}
1173
1174/* =========================================================
1175 LISTS
1176 ========================================================= */
1177
1178.versions-list {
1179 list-style: none;
1180 display: grid;
1181 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1182 gap: 10px;
1183}
1184
1185.versions-list li {
1186 margin: 0;
1187}
1188
1189.versions-list a {
1190 display: block;
1191 padding: 14px 18px;
1192 text-align: center;
1193}
1194
1195.children-list {
1196 list-style: none;
1197 margin-bottom: 20px;
1198}
1199
1200.children-list li {
1201 margin: 0 0 8px 0;
1202}
1203
1204.children-list a {
1205 display: block;
1206 padding: 12px 16px;
1207}
1208
1209.hierarchy-section a {
1210 color: white;
1211 text-decoration: none;
1212 font-weight: 600;
1213 transition: opacity 0.2s;
1214}
1215
1216.hierarchy-section a:hover {
1217 opacity: 0.8;
1218}
1219
1220.hierarchy-section em {
1221 color: rgba(255, 255, 255, 0.7);
1222 font-style: normal;
1223}
1224
1225.hierarchy-section > p {
1226 margin-bottom: 16px;
1227}
1228
1229/* =========================================================
1230 SCRIPTS
1231 ========================================================= */
1232
1233.scripts-list {
1234 list-style: none;
1235}
1236
1237.scripts-list li {
1238 margin-bottom: 16px;
1239 padding: 16px;
1240 background: rgba(255, 255, 255, 0.1);
1241 border-radius: 10px;
1242 border: 1px solid rgba(255, 255, 255, 0.2);
1243}
1244
1245.scripts-list li:last-child {
1246 margin-bottom: 0;
1247}
1248
1249.scripts-list a {
1250 color: white;
1251 text-decoration: none;
1252 display: block;
1253}
1254
1255.scripts-list a:hover {
1256 opacity: 0.9;
1257}
1258
1259.scripts-list strong {
1260 display: block;
1261 margin-bottom: 8px;
1262 color: white;
1263 font-size: 15px;
1264}
1265
1266.scripts-list pre {
1267 background: rgba(0, 0, 0, 0.3);
1268 color: #e0e0e0;
1269 padding: 14px;
1270 border-radius: 8px;
1271 overflow-x: auto;
1272 font-size: 13px;
1273 line-height: 1.5;
1274 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1275 border: 1px solid rgba(255, 255, 255, 0.1);
1276}
1277
1278.scripts-list em {
1279 color: rgba(255, 255, 255, 0.6);
1280 font-style: normal;
1281}
1282
1283.scripts-section h2 a {
1284 color: white;
1285 text-decoration: none;
1286}
1287
1288.scripts-section h2 a:hover {
1289 opacity: 0.8;
1290}
1291
1292/* =========================================================
1293 FORMS
1294 ========================================================= */
1295
1296.action-form {
1297 display: flex;
1298 gap: 10px;
1299 align-items: stretch;
1300 margin-top: 12px;
1301 flex-wrap: wrap;
1302}
1303
1304.action-form input[type="text"] {
1305 flex: 1;
1306 min-width: 200px;
1307 padding: 12px 14px;
1308 font-size: 15px;
1309 border-radius: 10px;
1310 border: 2px solid rgba(255, 255, 255, 0.3);
1311 background: rgba(255, 255, 255, 0.15);
1312 color: white;
1313 font-family: inherit;
1314 font-weight: 500;
1315 transition: all 0.2s;
1316}
1317
1318.action-form input[type="text"]::placeholder {
1319 color: rgba(255, 255, 255, 0.5);
1320}
1321
1322.action-form input[type="text"]:focus {
1323 outline: none;
1324 border-color: rgba(255, 255, 255, 0.6);
1325 background: rgba(255, 255, 255, 0.25);
1326 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1327 transform: translateY(-2px);
1328}
1329
1330/* =========================================================
1331 RESPONSIVE
1332 ========================================================= */
1333
1334${responsiveBase}
1335
1336@media (max-width: 640px) {
1337 .container {
1338 max-width: 100%;
1339 }
1340
1341 .header,
1342 .hierarchy-section,
1343 .versions-section,
1344 .scripts-section,
1345 .actions-section {
1346 padding: 20px;
1347 }
1348
1349 .meta-row {
1350 flex-direction: column;
1351 gap: 12px;
1352 }
1353
1354 .versions-list {
1355 grid-template-columns: 1fr;
1356 }
1357
1358 .action-form {
1359 flex-direction: column;
1360 }
1361
1362 .action-form input[type="text"] {
1363 width: 100%;
1364 min-width: 0;
1365 }
1366
1367 .action-form button {
1368 width: 100%;
1369 }
1370
1371 code {
1372 font-size: 11px;
1373 max-width: 180px;
1374 overflow: hidden;
1375 text-overflow: ellipsis;
1376 }
1377}
1378
1379@media (min-width: 641px) and (max-width: 1024px) {
1380 .versions-list {
1381 grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
1382 }
1383}
1384 </style>
1385</head>
1386<body>
1387 <div class="container">
1388 <!-- Back Navigation -->
1389 <div class="back-nav">
1390 <a href="${rootUrl}" class="back-link">
1391 ← Back to Tree
1392 </a>
1393 <a href="/api/v1/node/${nodeId}/chats${qs}" class="back-link">
1394 AI Chats
1395 </a>
1396 </div>
1397
1398 <!-- Header -->
1399 <div class="header">
1400 <h1
1401 id="nodeNameDisplay"
1402 ${!isPublicAccess ? `style="cursor:pointer;" title="Click to rename" onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"` : ""}
1403 >${node.name}</h1>
1404 ${!isPublicAccess ? `<form
1405 id="renameForm"
1406 method="POST"
1407 action="/api/v1/node/${nodeId}/${0}/editName${qs}"
1408 style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
1409 >
1410 <input
1411 type="text"
1412 name="name"
1413 value="${node.name.replace(/"/g, '"')}"
1414 required
1415 style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
1416 />
1417 <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
1418 <button
1419 type="button"
1420 class="warning-button"
1421 style="padding:8px 16px;"
1422 onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
1423 >Cancel</button>
1424 </form>` : ""}
1425
1426 <div class="node-id-container">
1427 <code id="nodeIdCode">${node._id}</code>
1428 <button id="copyNodeIdBtn" title="Copy ID">📋</button>
1429 </div>
1430
1431 <div class="meta-row">
1432 <div class="meta-item">
1433 <div class="meta-label">Type</div>
1434 <div class="meta-value">${node.type ?? "None"}</div>
1435 </div>
1436 <div class="meta-item">
1437 <div class="meta-label">Status</div>
1438 <div class="meta-value">${node.status || "active"}</div>
1439 </div>
1440 </div>
1441 </div>
1442
1443 ${!isPublicAccess ? `<!-- Edit Type -->
1444 <div class="hierarchy-section">
1445 <h2>Node Type</h2>
1446 <form
1447 method="POST"
1448 action="/api/v1/node/${nodeId}/editType${qs}"
1449 class="action-form"
1450 >
1451 <select name="type" style="flex:1;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;font-size:14px;">
1452 <option value="" ${!node.type ? "selected" : ""}>None</option>
1453 <option value="goal" ${node.type === "goal" ? "selected" : ""}>goal</option>
1454 <option value="plan" ${node.type === "plan" ? "selected" : ""}>plan</option>
1455 <option value="task" ${node.type === "task" ? "selected" : ""}>task</option>
1456 <option value="knowledge" ${node.type === "knowledge" ? "selected" : ""}>knowledge</option>
1457 <option value="resource" ${node.type === "resource" ? "selected" : ""}>resource</option>
1458 <option value="identity" ${node.type === "identity" ? "selected" : ""}>identity</option>
1459 </select>
1460 <input
1461 type="text"
1462 name="customType"
1463 placeholder="or custom type..."
1464 style="flex:1;"
1465 />
1466 <button type="submit" class="primary-button">Set Type</button>
1467 </form>
1468 </div>` : ""}
1469
1470 <!-- AI Tools Config -->
1471 ${!isPublicAccess ? (() => {
1472 const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
1473 const tools = meta.tools || {};
1474 const allowed = (tools.allowed || []).join(", ");
1475 const blocked = (tools.blocked || []).join(", ");
1476 return `<div class="hierarchy-section">
1477 <h2>AI Tools</h2>
1478 <p style="color:rgba(255,255,255,0.5);font-size:0.85rem;margin-bottom:12px;">
1479 Control what the AI can do at this node. Inherits up the tree.
1480 <a href="/api/v1/node/${nodeId}/tools" target="_blank" style="color:rgba(255,255,255,0.6);text-decoration:underline;margin-left:8px;">View resolved list</a>
1481 </p>
1482 ${allowed ? `<div style="margin-bottom:8px;"><span style="color:rgba(16,185,129,0.9);font-size:0.85rem;">Added: ${allowed}</span></div>` : ""}
1483 ${blocked ? `<div style="margin-bottom:8px;"><span style="color:rgba(239,68,68,0.9);font-size:0.85rem;">Blocked: ${blocked}</span></div>` : ""}
1484 <form method="POST" action="/api/v1/node/${nodeId}/tools${qs}">
1485 <div style="margin-bottom:8px;">
1486 <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Allow tools (comma-separated)</label>
1487 <input type="text" name="allowedRaw" value="${allowed}" placeholder="execute-shell, web-search" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
1488 </div>
1489 <div style="margin-bottom:8px;">
1490 <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Block tools (comma-separated)</label>
1491 <input type="text" name="blockedRaw" value="${blocked}" placeholder="delete-node-branch" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
1492 </div>
1493 <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
1494 </form>
1495 </div>`;
1496 })() : ""}
1497
1498 <!-- Versions Section (prestige extension) -->
1499 ${(() => {
1500 const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
1501 const prestige = meta.prestige || { current: 0, history: [] };
1502 const history = prestige.history || [];
1503 return `<div class="versions-section">
1504 <h2>Versions</h2>
1505 <ul class="versions-list">
1506 ${[...Array(prestige.current + 1)].map((_, i) =>
1507 `<li><a href="/api/v1/node/${nodeId}/${i}${qs}">Version ${i}${i === prestige.current ? " (current)" : ""}</a></li>`
1508 ).reverse().join("")}
1509 </ul>
1510 ${!isPublicAccess ? `<form
1511 method="POST"
1512 action="/api/v1/node/${nodeId}/prestige${qs}"
1513 onsubmit="return confirm('This will complete the current version and create a new prestige level. Continue?')"
1514 style="margin-top: 16px;">
1515 <button type="submit" class="primary-button">Add New Version</button>
1516 </form>` : ""}
1517 </div>`;
1518 })()}
1519
1520 <!-- Parent Section -->
1521 <div class="hierarchy-section">
1522 <h2>Parent</h2>
1523 ${
1524 node.parent
1525 ? `<a href="/api/v1/node/${node.parent}${qs}" style="display:block;padding:12px 16px;margin-bottom:16px;">${parentName}</a>`
1526 : `<p style="margin-bottom:16px;"><em>None (This is a root node)</em></p>`
1527 }
1528
1529 ${!isPublicAccess ? `<h3>Change Parent</h3>
1530 <form
1531 method="POST"
1532 action="/api/v1/node/${nodeId}/updateParent${qs}"
1533 class="action-form"
1534 >
1535 <input
1536 type="text"
1537 name="newParentId"
1538 placeholder="New parent node ID"
1539 required
1540 />
1541 <button type="submit" class="warning-button">
1542 Move Node
1543 </button>
1544 </form>` : ""}
1545 </div>
1546
1547 <!-- Children Section -->
1548 <div class="hierarchy-section">
1549 <h2>Children</h2>
1550 <ul class="children-list">
1551 ${
1552 node.children && node.children.length
1553 ? node.children
1554 .map(
1555 (c) =>
1556 `<li><a href="/api/v1/node/${c._id}${qs}">${c.name}</a></li>`,
1557 )
1558 .join("")
1559 : `<li><em>No children yet</em></li>`
1560 }
1561 </ul>
1562
1563 <h3>Add Child</h3>
1564 <form
1565 method="POST"
1566 action="/api/v1/node/${nodeId}/createChild${qs}"
1567 class="action-form"
1568 >
1569 <input
1570 type="text"
1571 name="name"
1572 placeholder="Child name"
1573 required
1574 />
1575 <button type="submit" class="primary-button">
1576 Create Child
1577 </button>
1578 </form>
1579 </div>
1580
1581 <!-- Scripts Section -->
1582 <div class="scripts-section">
1583 <h2><a href="/api/v1/node/${node._id}/scripts/help${qs}">Scripts</a></h2>
1584 <form
1585 method="POST"
1586 action="/api/v1/node/${nodeId}/script/create${qs}"
1587 style="display:flex;gap:8px;align-items:center;margin-bottom:16px;"
1588 >
1589 <input
1590 type="text"
1591 name="name"
1592 placeholder="New script name"
1593 required
1594 style="
1595 padding:12px 16px;
1596 border-radius:10px;
1597 border:1px solid rgba(255,255,255,0.3);
1598 background:rgba(255,255,255,0.2);
1599 color:white;
1600 font-size:15px;
1601 min-width:200px;
1602 flex:1;
1603 "
1604 />
1605 <button
1606 type="submit"
1607 class="primary-button"
1608 title="Create script"
1609 style="padding:10px 18px;font-size:16px;"
1610 >
1611 ➕
1612 </button>
1613 </form>
1614 <ul class="scripts-list">
1615 ${
1616 _nodeScripts.length
1617 ? _nodeScripts
1618 .map(
1619 (s) => `
1620 <a href="/api/v1/node/${node._id}/script/${s._id}${qs}">
1621 <li>
1622 <strong>${s.name}</strong>
1623 <pre>${s.script}</pre>
1624 </li>
1625 </a>`,
1626 )
1627 .join("")
1628 : `<li><em>No scripts defined</em></li>`
1629 }
1630 </ul>
1631 </div>
1632
1633 ${!isPublicAccess ? `<!-- Delete Section -->
1634 <div class="actions-section">
1635 <h3>Delete</h3>
1636 <form
1637 method="POST"
1638 action="/api/v1/node/${nodeId}/delete${qs}"
1639 onsubmit="return confirm('Delete this node and its branch? This can be revived later.')"
1640 >
1641 <button type="submit" class="danger-button">
1642 Delete Node
1643 </button>
1644 </form>
1645 </div>` : ""}
1646 </div>
1647
1648 <script>
1649 // Copy ID functionality
1650 const btn = document.getElementById("copyNodeIdBtn");
1651 const code = document.getElementById("nodeIdCode");
1652
1653 btn.addEventListener("click", () => {
1654 navigator.clipboard.writeText(code.textContent).then(() => {
1655 btn.textContent = "✔️";
1656 setTimeout(() => (btn.textContent = "📋"), 900);
1657 });
1658 });
1659 </script>
1660</body>
1661</html>
1662`;
1663}
1664
1665/* ================================================================== */
1666/* 3. renderVersionDetail */
1667/* ================================================================== */
1668
1669export function renderVersionDetail({
1670 node,
1671 nodeId,
1672 version,
1673 data,
1674 qs,
1675 backUrl,
1676 backTreeUrl,
1677 createdDate,
1678 scheduleHtml,
1679 reeffectTime,
1680 showPrestige,
1681 ALL_STATUSES,
1682 STATUS_LABELS,
1683}) {
1684 return `
1685<!DOCTYPE html>
1686<html lang="en">
1687<head>
1688 <meta charset="UTF-8">
1689 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1690 <meta name="theme-color" content="#667eea">
1691 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1692 <title>${node.name} v${version}</title>
1693 <style>
1694${baseStyles}
1695
1696/* =========================================================
1697 UNIFIED GLASS BUTTON SYSTEM
1698 ========================================================= */
1699
1700.glass-btn,
1701button,
1702.action-button,
1703.back-link,
1704.nav-links a,
1705.meta-value button,
1706.contributors-list button,
1707button[type="submit"],
1708.status-button,
1709.primary-button {
1710 position: relative;
1711 overflow: hidden;
1712
1713 padding: 10px 20px;
1714 border-radius: 980px;
1715
1716 display: inline-flex;
1717 align-items: center;
1718 justify-content: center;
1719 white-space: nowrap;
1720
1721 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1722 backdrop-filter: blur(22px) saturate(140%);
1723 -webkit-backdrop-filter: blur(22px) saturate(140%);
1724
1725 color: white;
1726 text-decoration: none;
1727 font-family: inherit;
1728
1729 font-size: 15px;
1730 font-weight: 600;
1731 letter-spacing: -0.2px;
1732
1733 border: 1px solid rgba(255, 255, 255, 0.28);
1734
1735 box-shadow:
1736 0 8px 24px rgba(0, 0, 0, 0.12),
1737 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1738
1739 cursor: pointer;
1740
1741 transition:
1742 background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1743 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1744 box-shadow 0.3s ease;
1745}
1746
1747/* Liquid light layer */
1748.glass-btn::before,
1749button::before,
1750.action-button::before,
1751.back-link::before,
1752.nav-links a::before,
1753.meta-value button::before,
1754.contributors-list button::before,
1755button[type="submit"]::before,
1756.status-button::before,
1757.primary-button::before {
1758 content: "";
1759 position: absolute;
1760 inset: -40%;
1761
1762 background:
1763 radial-gradient(
1764 120% 60% at 0% 0%,
1765 rgba(255, 255, 255, 0.35),
1766 transparent 60%
1767 ),
1768 linear-gradient(
1769 120deg,
1770 transparent 30%,
1771 rgba(255, 255, 255, 0.25),
1772 transparent 70%
1773 );
1774
1775 opacity: 0;
1776 transform: translateX(-30%) translateY(-10%);
1777 transition:
1778 opacity 0.35s ease,
1779 transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1780
1781 pointer-events: none;
1782}
1783
1784/* Hover motion */
1785.glass-btn:hover,
1786button:hover,
1787.action-button:hover,
1788.back-link:hover,
1789.nav-links a:hover,
1790.meta-value button:hover,
1791.contributors-list button:hover,
1792button[type="submit"]:hover,
1793.status-button:hover,
1794.primary-button:hover {
1795 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
1796 transform: translateY(-2px);
1797}
1798
1799.glass-btn:hover::before,
1800button:hover::before,
1801.action-button:hover::before,
1802.back-link:hover::before,
1803.nav-links a:hover::before,
1804.meta-value button:hover::before,
1805.contributors-list button:hover::before,
1806button[type="submit"]:hover::before,
1807.status-button:hover::before,
1808.primary-button:hover::before {
1809 opacity: 1;
1810 transform: translateX(30%) translateY(10%);
1811}
1812
1813/* Active press */
1814.glass-btn:active,
1815button:active,
1816.status-button:active,
1817.primary-button:active {
1818 background: rgba(var(--glass-water-rgb), 0.45);
1819 transform: translateY(0);
1820}
1821
1822/* Emphasis variants */
1823.primary-button {
1824 --glass-water-rgb: 72, 187, 178;
1825 --glass-alpha: 0.34;
1826 --glass-alpha-hover: 0.46;
1827 font-weight: 600;
1828}
1829
1830.legacy-btn {
1831 opacity: 0.85;
1832}
1833.legacy-btn:hover {
1834 opacity: 1;
1835}
1836
1837/* =========================================================
1838 CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
1839 ========================================================= */
1840
1841.header,
1842.nav-section,
1843.actions-section {
1844 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1845 backdrop-filter: blur(22px) saturate(140%);
1846 -webkit-backdrop-filter: blur(22px) saturate(140%);
1847 border-radius: 16px;
1848 padding: 28px;
1849 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1850 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1851 border: 1px solid rgba(255, 255, 255, 0.28);
1852 margin-bottom: 24px;
1853 animation: fadeInUp 0.6s ease-out;
1854 animation-fill-mode: both;
1855 position: relative;
1856 overflow: hidden;
1857}
1858
1859.header {
1860 animation-delay: 0.1s;
1861}
1862
1863.nav-section {
1864 animation-delay: 0.15s;
1865}
1866
1867.actions-section {
1868 animation-delay: 0.2s;
1869}
1870
1871.header::before,
1872.nav-section::before,
1873.actions-section::before {
1874 content: "";
1875 position: absolute;
1876 inset: 0;
1877 border-radius: inherit;
1878 background: linear-gradient(
1879 180deg,
1880 rgba(255, 255, 255, 0.18),
1881 rgba(255, 255, 255, 0.05)
1882 );
1883 pointer-events: none;
1884}
1885
1886.meta-card {
1887 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1888 backdrop-filter: blur(22px) saturate(140%);
1889 -webkit-backdrop-filter: blur(22px) saturate(140%);
1890 border-radius: 12px;
1891 padding: 16px 20px;
1892 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1893 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1894 border: 1px solid rgba(255, 255, 255, 0.28);
1895 color: white;
1896 animation: fadeInUp 0.6s ease-out;
1897 animation-fill-mode: both;
1898 position: relative;
1899 overflow: hidden;
1900}
1901
1902.meta-card::before {
1903 content: "";
1904 position: absolute;
1905 inset: 0;
1906 border-radius: inherit;
1907 background: linear-gradient(
1908 180deg,
1909 rgba(255, 255, 255, 0.18),
1910 rgba(255, 255, 255, 0.05)
1911 );
1912 pointer-events: none;
1913}
1914
1915/* Stagger meta-card animations */
1916.meta-card:nth-child(1) { animation-delay: 0.2s; }
1917.meta-card:nth-child(2) { animation-delay: 0.25s; }
1918
1919.header h1 {
1920 font-size: 28px;
1921 font-weight: 600;
1922 letter-spacing: -0.5px;
1923 line-height: 1.3;
1924 margin-bottom: 8px;
1925 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1926}
1927
1928.header h1 a {
1929 color: white;
1930 text-decoration: none;
1931 transition: opacity 0.2s;
1932}
1933
1934.header h1 a:hover {
1935 opacity: 0.8;
1936}
1937
1938.nav-section h2,
1939.actions-section h3 {
1940 font-size: 18px;
1941 font-weight: 600;
1942 color: white;
1943 margin-bottom: 16px;
1944 letter-spacing: -0.3px;
1945 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1946}
1947
1948/* =========================================================
1949 NAV + META
1950 ========================================================= */
1951
1952.back-nav {
1953 display: flex;
1954 gap: 12px;
1955 margin-bottom: 20px;
1956 flex-wrap: wrap;
1957 animation: fadeInUp 0.5s ease-out;
1958}
1959
1960.version-badge {
1961 display: inline-block;
1962 padding: 6px 14px;
1963 background: rgba(16, 185, 129, 0.25);
1964 backdrop-filter: blur(10px);
1965 color: white;
1966 border-radius: 20px;
1967 font-size: 14px;
1968 font-weight: 600;
1969 margin-top: 8px;
1970 border: 1px solid rgba(16, 185, 129, 0.4);
1971 position: relative;
1972 overflow: hidden;
1973 box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
1974 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1975}
1976
1977/* Version badge colors matching status */
1978.version-badge.version-status-active {
1979 background: rgba(16, 185, 129, 0.25);
1980 border: 1px solid rgba(16, 185, 129, 0.4);
1981 box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
1982 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1983}
1984
1985.version-badge.version-status-completed {
1986 background: rgba(139, 92, 246, 0.25);
1987 border: 1px solid rgba(139, 92, 246, 0.4);
1988 box-shadow: 0 4px 12px rgba(139, 92, 246, 0.15),
1989 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1990}
1991
1992.version-badge.version-status-trimmed {
1993 background: rgba(220, 38, 38, 0.25);
1994 border: 1px solid rgba(220, 38, 38, 0.4);
1995 box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15),
1996 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1997}
1998
1999.version-badge::after {
2000 content: "";
2001 position: absolute;
2002 inset: 0;
2003
2004 background: linear-gradient(
2005 100deg,
2006 transparent 40%,
2007 rgba(255, 255, 255, 0.5),
2008 transparent 60%
2009 );
2010
2011 opacity: 0;
2012 transform: translateX(-100%);
2013 transition: transform 0.8s ease, opacity 0.3s ease;
2014
2015 animation: openAppHoverShimmerClone 1.6s ease forwards;
2016 animation-delay: 0.5s;
2017
2018 pointer-events: none;
2019}
2020
2021@keyframes openAppHoverShimmerClone {
2022 0% {
2023 opacity: 0;
2024 transform: translateX(-100%);
2025 }
2026
2027 100% {
2028 opacity: 1;
2029 transform: translateX(100%);
2030 }
2031}
2032
2033.created-date {
2034 font-size: 13px;
2035 color: rgba(255, 255, 255, 0.7);
2036 margin-top: 10px;
2037 font-weight: 500;
2038}
2039
2040.node-id-container {
2041 display: flex;
2042 align-items: center;
2043 gap: 8px;
2044 margin-top: 12px;
2045 flex-wrap: wrap;
2046 padding: 10px 14px;
2047 background: rgba(255, 255, 255, 0.15);
2048 border-radius: 8px;
2049 border: 1px solid rgba(255, 255, 255, 0.2);
2050 width: 100%;
2051}
2052
2053code {
2054 background: transparent;
2055 padding: 0;
2056 border-radius: 0;
2057 font-size: 13px;
2058 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
2059 color: white;
2060 word-break: break-all;
2061 flex: 1;
2062 min-width: 0;
2063 overflow-wrap: break-word;
2064}
2065
2066#copyNodeIdBtn {
2067 background: rgba(255, 255, 255, 0.2);
2068 border: 1px solid rgba(255, 255, 255, 0.3);
2069 cursor: pointer;
2070 padding: 6px 10px;
2071 border-radius: 6px;
2072 opacity: 1;
2073 font-size: 16px;
2074 transition: all 0.2s;
2075 flex-shrink: 0;
2076}
2077
2078#copyNodeIdBtn:hover {
2079 background: rgba(255, 255, 255, 0.3);
2080 transform: scale(1.1);
2081}
2082
2083#copyNodeIdBtn::before {
2084 display: none;
2085}
2086
2087.meta-grid {
2088 display: grid;
2089 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
2090 gap: 16px;
2091 margin-bottom: 24px;
2092}
2093
2094.meta-label {
2095 font-size: 12px;
2096 font-weight: 600;
2097 text-transform: uppercase;
2098 letter-spacing: 0.5px;
2099 color: rgba(255, 255, 255, 0.7);
2100 margin-bottom: 6px;
2101}
2102
2103.meta-value {
2104 font-size: 15px;
2105 font-weight: 600;
2106 color: white;
2107 word-break: break-word;
2108 overflow-wrap: break-word;
2109}
2110
2111.status-badge {
2112 display: inline-block;
2113 padding: 6px 12px;
2114 border-radius: 6px;
2115 font-size: 13px;
2116 font-weight: 600;
2117 text-transform: capitalize;
2118 background: rgba(255, 255, 255, 0.25);
2119 color: white;
2120 border: 1px solid rgba(255, 255, 255, 0.3);
2121}
2122
2123/* Official status colors with glass effect - UPDATED COLORS */
2124.status-badge.status-active {
2125 background: rgba(16, 185, 129, 0.35);
2126 border: 1px solid rgba(16, 185, 129, 0.5);
2127 backdrop-filter: blur(10px);
2128 box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
2129}
2130
2131.status-badge.status-completed {
2132 background: rgba(139, 92, 246, 0.35);
2133 border: 1px solid rgba(139, 92, 246, 0.5);
2134 backdrop-filter: blur(10px);
2135 box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
2136}
2137
2138.status-badge.status-trimmed {
2139 background: rgba(220, 38, 38, 0.35);
2140 border: 1px solid rgba(220, 38, 38, 0.5);
2141 backdrop-filter: blur(10px);
2142 box-shadow: 0 0 12px rgba(220, 38, 38, 0.2);
2143}
2144
2145.nav-links {
2146 display: grid;
2147 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2148 gap: 12px;
2149}
2150
2151.nav-links a {
2152 padding: 14px 18px;
2153 font-size: 15px;
2154 text-align: center;
2155}
2156
2157/* =========================================================
2158 STATUS CARD WITH BUTTONS - UPDATED COLORS
2159 ========================================================= */
2160
2161.status-controls {
2162 display: flex;
2163 align-items: center;
2164 gap: 12px;
2165 flex-wrap: wrap;
2166 margin-top: 12px;
2167}
2168
2169.status-controls button {
2170 padding: 8px 16px;
2171 font-size: 13px;
2172 position: relative;
2173}
2174
2175/* Faint glass colors for status buttons - UPDATED */
2176.status-controls button[value="active"] {
2177 --glass-water-rgb: 16, 185, 129; /* green */
2178 --glass-alpha: 0.15;
2179 --glass-alpha-hover: 0.25;
2180}
2181
2182.status-controls button[value="completed"] {
2183 --glass-water-rgb: 139, 92, 246; /* purple */
2184 --glass-alpha: 0.15;
2185 --glass-alpha-hover: 0.25;
2186}
2187
2188.status-controls button[value="trimmed"] {
2189 --glass-water-rgb: 220, 38, 38; /* red */
2190 --glass-alpha: 0.15;
2191 --glass-alpha-hover: 0.25;
2192}
2193
2194/* =========================================================
2195 SCHEDULE CARD
2196 ========================================================= */
2197
2198.schedule-info {
2199 display: flex;
2200 flex-direction: column;
2201 gap: 8px;
2202 width: 100%;
2203}
2204
2205.schedule-row {
2206 display: flex;
2207 align-items: flex-start;
2208 gap: 12px;
2209 width: 100%;
2210}
2211
2212.schedule-text {
2213 flex: 1;
2214 min-width: 0;
2215 overflow: hidden;
2216}
2217
2218.schedule-text .meta-value {
2219 word-break: break-word;
2220 overflow-wrap: break-word;
2221}
2222
2223.repeat-text {
2224 font-size: 13px;
2225 color: rgba(255, 255, 255, 0.8);
2226 margin-top: 6px;
2227}
2228
2229#editScheduleBtn {
2230 flex-shrink: 0;
2231}
2232
2233/* =========================================================
2234 ACTIONS & FORMS
2235 ========================================================= */
2236
2237.action-form {
2238 margin-bottom: 24px;
2239}
2240
2241.action-form:last-child {
2242 margin-bottom: 0;
2243}
2244
2245.button-group {
2246 display: flex;
2247 gap: 12px;
2248 flex-wrap: wrap;
2249}
2250
2251button[type="submit"],
2252.status-button {
2253 padding: 12px 20px;
2254 font-size: 14px;
2255}
2256
2257/* =========================================================
2258 MODAL
2259 ========================================================= */
2260
2261#scheduleModal {
2262 display: none;
2263 position: fixed;
2264 inset: 0;
2265 background: rgba(0, 0, 0, 0.5);
2266 backdrop-filter: blur(8px);
2267 align-items: center;
2268 justify-content: center;
2269 z-index: 1000;
2270}
2271
2272#scheduleModal > div {
2273 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2274 backdrop-filter: blur(22px) saturate(140%);
2275 -webkit-backdrop-filter: blur(22px) saturate(140%);
2276 padding: 28px;
2277 border-radius: 16px;
2278 width: 320px;
2279 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
2280 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2281 border: 1px solid rgba(255, 255, 255, 0.28);
2282 position: relative;
2283 overflow: hidden;
2284}
2285
2286#scheduleModal > div::before {
2287 content: "";
2288 position: absolute;
2289 inset: 0;
2290 border-radius: inherit;
2291 background: linear-gradient(
2292 180deg,
2293 rgba(255, 255, 255, 0.18),
2294 rgba(255, 255, 255, 0.05)
2295 );
2296 pointer-events: none;
2297}
2298
2299#scheduleModal label {
2300 display: block;
2301 margin-bottom: 12px;
2302 color: white;
2303 font-weight: 600;
2304 font-size: 14px;
2305 letter-spacing: -0.2px;
2306 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
2307 position: relative;
2308}
2309
2310#scheduleModal input {
2311 width: 100%;
2312 margin-top: 6px;
2313 padding: 12px 14px;
2314 border-radius: 10px;
2315 border: 2px solid rgba(255, 255, 255, 0.3);
2316 background: rgba(255, 255, 255, 0.15);
2317 font-size: 15px;
2318 font-family: inherit;
2319 font-weight: 500;
2320 transition: all 0.2s;
2321 color: white;
2322 position: relative;
2323}
2324
2325#scheduleModal input::placeholder {
2326 color: rgba(255, 255, 255, 0.5);
2327}
2328
2329#scheduleModal input:focus {
2330 outline: none;
2331 border-color: rgba(255, 255, 255, 0.6);
2332 background: rgba(255, 255, 255, 0.25);
2333 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
2334 transform: translateY(-2px);
2335}
2336
2337#scheduleModal button {
2338 padding: 10px 18px;
2339 border-radius: 980px;
2340 font-weight: 600;
2341 font-size: 14px;
2342 cursor: pointer;
2343 font-family: inherit;
2344 transition: all 0.2s;
2345 border: 1px solid rgba(255, 255, 255, 0.28);
2346 position: relative;
2347}
2348
2349#scheduleModal button[type="button"] {
2350 background: rgba(255, 255, 255, 0.15);
2351 color: white;
2352 border: 1px solid rgba(255, 255, 255, 0.28) !important;
2353 box-shadow: none !important;
2354}
2355
2356#scheduleModal button[type="button"]:hover {
2357 background: rgba(255, 255, 255, 0.25);
2358}
2359
2360#scheduleModal button[type="button"]::before {
2361 display: none;
2362}
2363
2364#scheduleModal > div > form > div {
2365 display: flex;
2366 gap: 10px;
2367 justify-content: flex-end;
2368 margin-top: 16px;
2369}
2370
2371/* =========================================================
2372 RESPONSIVE
2373 ========================================================= */
2374
2375${responsiveBase}
2376
2377@media (max-width: 640px) {
2378 .container {
2379 max-width: 100%;
2380 }
2381
2382 .header,
2383 .nav-section,
2384 .actions-section {
2385 padding: 20px;
2386 }
2387
2388 .meta-grid {
2389 grid-template-columns: 1fr;
2390 gap: 12px;
2391 }
2392
2393 .meta-card {
2394 padding: 14px 16px;
2395 }
2396
2397 .nav-links {
2398 grid-template-columns: 1fr;
2399 }
2400
2401 .button-group {
2402 flex-direction: column;
2403 }
2404
2405 button,
2406 .status-button,
2407 .primary-button {
2408 width: 100%;
2409 }
2410
2411 .status-controls {
2412 flex-direction: column;
2413 align-items: stretch;
2414 }
2415
2416 .status-controls button {
2417 width: 100%;
2418 }
2419
2420 code {
2421 font-size: 12px;
2422 word-break: break-all;
2423 }
2424
2425 .schedule-row {
2426 flex-direction: column;
2427 align-items: stretch;
2428 gap: 8px;
2429 }
2430
2431 #editScheduleBtn {
2432 width: 100%;
2433 justify-content: center;
2434 }
2435
2436 #scheduleModal > div {
2437 width: calc(100% - 40px);
2438 max-width: 320px;
2439 }
2440}
2441
2442@media (min-width: 641px) and (max-width: 1024px) {
2443 .meta-grid {
2444 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
2445 }
2446}
2447 </style>
2448</head>
2449<body>
2450 <div class="container">
2451 <!-- Back Navigation -->
2452 <div class="back-nav">
2453 <a href="${backTreeUrl}" class="back-link">
2454 ← Back to Tree
2455 </a>
2456 <a href="${backUrl}" class="back-link">
2457 View All Versions
2458 </a>
2459 </div>
2460
2461 <!-- Header -->
2462 <div class="header">
2463 <h1
2464 id="nodeNameDisplay"
2465 style="cursor:pointer;"
2466 title="Click to rename"
2467 onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"
2468 >${node.name}</h1>
2469 <form
2470 id="renameForm"
2471 method="POST"
2472 action="/api/v1/node/${nodeId}/${version}/editName${qs}"
2473 style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
2474 >
2475 <input
2476 type="text"
2477 name="name"
2478 value="${node.name.replace(/"/g, '"')}"
2479 required
2480 style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
2481 />
2482 <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
2483 <button
2484 type="button"
2485 class="warning-button"
2486 style="padding:8px 16px;"
2487 onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
2488 >Cancel</button>
2489 </form>
2490
2491 <div class="meta-row" style="margin-top:4px;">
2492 <div class="meta-item">
2493 <div class="meta-label">Type</div>
2494 <div class="meta-value">${node.type ?? "None"}</div>
2495 </div>
2496 </div>
2497
2498 <span class="version-badge version-status-${data.status}">Version ${version}</span>
2499
2500 <div class="created-date">Created: ${createdDate}</div>
2501
2502 <div class="node-id-container">
2503 <code id="nodeIdCode">${node._id}</code>
2504 <button id="copyNodeIdBtn" title="Copy ID">📋</button>
2505 </div>
2506 </div>
2507
2508 <!-- Navigation Links -->
2509 <div class="nav-section">
2510 <h2>Quick Access</h2>
2511 <div class="nav-links">
2512 <a href="/api/v1/node/${nodeId}/${version}/notes${qs}">Notes</a>
2513 <a href="/api/v1/node/${nodeId}/${version}/values${qs}">Values / Goals</a>
2514 <a href="/api/v1/node/${nodeId}/${version}/contributions${qs}">Contributions</a>
2515 <a href="/api/v1/node/${nodeId}/${version}/transactions${qs}">Transactions</a>
2516 <a href="/api/v1/node/${nodeId}/chats${qs}">AI Chats</a>
2517 </div>
2518 </div>
2519
2520 <!-- Metadata Grid -->
2521 <div class="meta-grid">
2522 <!-- Status Card with Controls -->
2523 <div class="meta-card">
2524 <div class="meta-label">Status</div>
2525 <div class="meta-value">
2526 <span class="status-badge status-${data.status}">${data.status}</span>
2527 </div>
2528 <form
2529 method="POST"
2530 action="/api/v1/node/${nodeId}/${version}/editStatus${qs}"
2531 onsubmit="return confirm('This will apply to all children. Is that ok?')"
2532 class="status-controls"
2533 >
2534 <input type="hidden" name="isInherited" value="true" />
2535 ${ALL_STATUSES.filter((s) => s !== data.status)
2536 .map(
2537 (s) => `
2538 <button type="submit" name="status" value="${s}" class="status-button">
2539 ${STATUS_LABELS[s]}
2540 </button>
2541 `,
2542 )
2543 .join("")}
2544 </form>
2545 </div>
2546
2547 <!-- Schedule + Repeat Hours Card -->
2548 <div class="meta-card">
2549 <div class="meta-label">Schedule</div>
2550 <div class="schedule-info">
2551 <div class="schedule-row">
2552 <div class="schedule-text">
2553 <div class="meta-value">${scheduleHtml}</div>
2554 <div class="repeat-text">Repeat: ${reeffectTime} hours</div>
2555 </div>
2556 <button id="editScheduleBtn" style="padding:8px 12px;">✏️</button>
2557 </div>
2558 </div>
2559 </div>
2560 </div>
2561
2562 ${
2563 showPrestige
2564 ? `
2565 <!-- Version Control Section -->
2566 <div class="actions-section">
2567 <h3>Version Control</h3>
2568 <form
2569 method="POST"
2570 action="/api/v1/node/${nodeId}/${version}/prestige${qs}"
2571 onsubmit="return confirm('This will complete the current version and create a new prestige level. Continue?')"
2572 class="action-form"
2573 >
2574 <button type="submit" class="primary-button">
2575 Add New Version
2576 </button>
2577 </form>
2578 </div>
2579 `
2580 : ""
2581 }
2582 </div>
2583
2584 <!-- Schedule Modal -->
2585 <div id="scheduleModal">
2586 <div>
2587 <form
2588 method="POST"
2589 action="/api/v1/node/${nodeId}/${version}/editSchedule${qs}"
2590 >
2591 <label>
2592 TIME
2593 <input
2594 type="datetime-local"
2595 name="newSchedule"
2596 value="${
2597 data.schedule
2598 ? new Date(data.schedule).toISOString().slice(0, 16)
2599 : ""
2600 }"
2601 />
2602 </label>
2603
2604 <label>
2605 REPEAT HOURS
2606 <input
2607 type="number"
2608 name="reeffectTime"
2609 min="0"
2610 value="${data.reeffectTime ?? 0}"
2611 />
2612 </label>
2613
2614 <div style="display:flex;gap:10px;justify-content:flex-end;">
2615 <button type="button" id="cancelSchedule">Cancel</button>
2616 <button type="submit" class="primary-button">Save</button>
2617 </div>
2618 </form>
2619 </div>
2620 </div>
2621
2622 <script>
2623 // Copy ID functionality
2624 const btn = document.getElementById("copyNodeIdBtn");
2625 const code = document.getElementById("nodeIdCode");
2626
2627 btn.addEventListener("click", () => {
2628 navigator.clipboard.writeText(code.textContent).then(() => {
2629 btn.textContent = "✔️";
2630 setTimeout(() => (btn.textContent = "📋"), 900);
2631 });
2632 });
2633
2634 // Schedule modal
2635 const editBtn = document.getElementById("editScheduleBtn");
2636 const modal = document.getElementById("scheduleModal");
2637 const cancelBtn = document.getElementById("cancelSchedule");
2638
2639 if (editBtn) {
2640 editBtn.onclick = () => {
2641 modal.style.display = "flex";
2642 };
2643 }
2644
2645 if (cancelBtn) {
2646 cancelBtn.onclick = () => {
2647 modal.style.display = "none";
2648 };
2649 }
2650 </script>
2651</body>
2652</html>
2653`;
2654}
2655
2656/* ================================================================== */
2657/* 4. renderScriptDetail */
2658/* ================================================================== */
2659
2660export function renderScriptDetail({
2661 nodeId,
2662 script,
2663 contributions,
2664 qsWithQ,
2665}) {
2666 const editHistory = contributions.filter((c) => c.type === "edit");
2667 const executionHistory = contributions.filter((c) => c.type === "execute");
2668
2669 const editHistoryHtml = editHistory.length
2670 ? editHistory
2671 .map(
2672 (c, i) => `
2673<li class="history-item">
2674 <div class="history-header">
2675 <div class="history-title">
2676 <span class="edit-number">Edit ${editHistory.length - i}</span>
2677 ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
2678 ${i === 0 ? `<span class="current-badge">Current</span>` : ""}
2679 </div>
2680 <div class="history-meta">
2681 <span class="version-badge">v${c.nodeVersion}</span>
2682 <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
2683 </div>
2684 </div>
2685
2686 ${
2687 c.contents
2688 ? `
2689 <details>
2690 <summary>
2691 <span class="summary-icon">▶</span>
2692 View code
2693 </summary>
2694 <pre class="history-code">${c.contents}</pre>
2695 </details>`
2696 : `<div class="empty-history-item">Empty script</div>`
2697 }
2698</li>
2699`,
2700 )
2701 .join("")
2702 : `<li class="empty-history">No edit history yet</li>`;
2703
2704 const executionHistoryHtml = executionHistory.length
2705 ? executionHistory
2706 .map(
2707 (c, i) => `
2708<li class="history-item ${c.success ? "success" : "failure"}">
2709 <div class="history-header">
2710 <div class="history-title">
2711 <span class="edit-number">Run ${executionHistory.length - i}</span>
2712 ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
2713 ${
2714 c.success
2715 ? `<span class="current-badge success-badge">Success</span>`
2716 : `<span class="current-badge failure-badge">Failed</span>`
2717 }
2718 </div>
2719 <div class="history-meta">
2720 <span class="version-badge">v${c.nodeVersion}</span>
2721 <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
2722 </div>
2723 </div>
2724
2725 ${
2726 c.logs && c.logs.length
2727 ? `
2728 <details>
2729 <summary>
2730 <span class="summary-icon">▶</span>
2731 View logs (${c.logs.length} ${c.logs.length === 1 ? "entry" : "entries"})
2732 </summary>
2733 <pre class="history-code">${c.logs.join("\n")}</pre>
2734 </details>`
2735 : ""
2736 }
2737
2738 ${
2739 c.error
2740 ? `<div class="error-message">
2741 <div class="error-label">Error:</div>
2742 <pre class="error-code">${c.error}</pre>
2743 </div>`
2744 : ""
2745 }
2746
2747 ${
2748 !c.logs?.length && !c.error
2749 ? `<div class="empty-history-item">No logs or output</div>`
2750 : ""
2751 }
2752</li>
2753`,
2754 )
2755 .join("")
2756 : `<li class="empty-history">No executions yet</li>`;
2757
2758 return `
2759<!DOCTYPE html>
2760<html lang="en">
2761<head>
2762 <meta charset="UTF-8">
2763 <meta name="viewport" content="width=device-width, initial-scale=1.0">
2764 <meta name="theme-color" content="#667eea">
2765 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2766 <title>${script.name} — Script</title>
2767 <style>
2768${baseStyles}
2769.container { max-width: 1000px; }
2770
2771/* =========================================================
2772 UNIFIED GLASS BUTTON SYSTEM
2773 ========================================================= */
2774
2775.glass-btn,
2776button,
2777.back-link,
2778.btn-copy,
2779.btn-execute,
2780.btn-save {
2781 position: relative;
2782 overflow: hidden;
2783
2784 padding: 10px 20px;
2785 border-radius: 980px;
2786
2787 display: inline-flex;
2788 align-items: center;
2789 justify-content: center;
2790 white-space: nowrap;
2791
2792 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2793 backdrop-filter: blur(22px) saturate(140%);
2794 -webkit-backdrop-filter: blur(22px) saturate(140%);
2795
2796 color: white;
2797 text-decoration: none;
2798 font-family: inherit;
2799
2800 font-size: 15px;
2801 font-weight: 500;
2802 letter-spacing: -0.2px;
2803
2804 border: 1px solid rgba(255, 255, 255, 0.28);
2805
2806 box-shadow:
2807 0 8px 24px rgba(0, 0, 0, 0.12),
2808 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2809
2810 cursor: pointer;
2811
2812 transition:
2813 background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
2814 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
2815 box-shadow 0.3s ease;
2816}
2817
2818/* Liquid light layer */
2819.glass-btn::before,
2820button::before,
2821.back-link::before,
2822.btn-copy::before,
2823.btn-execute::before,
2824.btn-save::before {
2825 content: "";
2826 position: absolute;
2827 inset: -40%;
2828
2829 background:
2830 radial-gradient(
2831 120% 60% at 0% 0%,
2832 rgba(255, 255, 255, 0.35),
2833 transparent 60%
2834 ),
2835 linear-gradient(
2836 120deg,
2837 transparent 30%,
2838 rgba(255, 255, 255, 0.25),
2839 transparent 70%
2840 );
2841
2842 opacity: 0;
2843 transform: translateX(-30%) translateY(-10%);
2844 transition:
2845 opacity 0.35s ease,
2846 transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2847
2848 pointer-events: none;
2849}
2850
2851/* Hover motion */
2852.glass-btn:hover,
2853button:hover,
2854.back-link:hover,
2855.btn-copy:hover,
2856.btn-execute:hover,
2857.btn-save:hover {
2858 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
2859 transform: translateY(-1px);
2860 animation: waterDrift 2.2s ease-in-out infinite alternate;
2861}
2862
2863.glass-btn:hover::before,
2864button:hover::before,
2865.back-link:hover::before,
2866.btn-copy:hover::before,
2867.btn-execute:hover::before,
2868.btn-save:hover::before {
2869 opacity: 1;
2870 transform: translateX(30%) translateY(10%);
2871}
2872
2873/* Active press */
2874.glass-btn:active,
2875button:active,
2876.btn-copy:active,
2877.btn-execute:active,
2878.btn-save:active {
2879 background: rgba(var(--glass-water-rgb), 0.45);
2880 transform: translateY(0);
2881 animation: none;
2882}
2883
2884@keyframes waterDrift {
2885 0% { transform: translateY(-1px); }
2886 100% { transform: translateY(1px); }
2887}
2888
2889/* Button variants */
2890.btn-execute {
2891 --glass-water-rgb: 16, 185, 129;
2892 font-weight: 600;
2893}
2894
2895.btn-save {
2896 --glass-alpha: 0.34;
2897 --glass-alpha-hover: 0.46;
2898 font-weight: 600;
2899}
2900
2901.btn-copy {
2902 padding: 6px 12px;
2903 font-size: 13px;
2904}
2905
2906/* =========================================================
2907 CONTENT CARDS
2908 ========================================================= */
2909
2910.header,
2911.section {
2912 background: rgba(255, 255, 255, 0.15);
2913 backdrop-filter: blur(22px) saturate(140%);
2914 -webkit-backdrop-filter: blur(22px) saturate(140%);
2915 border-radius: 14px;
2916 padding: 28px;
2917 border: 1px solid rgba(255, 255, 255, 0.28);
2918 color: white;
2919 margin-bottom: 24px;
2920}
2921
2922.header h1 {
2923 font-size: 28px;
2924 font-weight: 600;
2925 letter-spacing: -0.5px;
2926 line-height: 1.3;
2927 margin-bottom: 12px;
2928 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2929 color: white;
2930 word-break: break-word;
2931}
2932
2933.header h1::before {
2934 content: '⚡ ';
2935 font-size: 26px;
2936}
2937
2938.script-id {
2939 font-size: 13px;
2940 color: rgba(255, 255, 255, 0.8);
2941 font-family: 'SF Mono', Monaco, monospace;
2942 background: rgba(255, 255, 255, 0.1);
2943 padding: 6px 12px;
2944 border-radius: 6px;
2945 display: inline-block;
2946 margin-top: 8px;
2947 border: 1px solid rgba(255, 255, 255, 0.2);
2948}
2949
2950.section-title {
2951 font-size: 18px;
2952 font-weight: 600;
2953 color: white;
2954 margin-bottom: 20px;
2955 letter-spacing: -0.3px;
2956 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2957}
2958
2959/* =========================================================
2960 NAV
2961 ========================================================= */
2962
2963.back-nav {
2964 display: flex;
2965 gap: 12px;
2966 margin-bottom: 20px;
2967 flex-wrap: wrap;
2968}
2969
2970/* =========================================================
2971 CODE DISPLAY
2972 ========================================================= */
2973
2974.code-container {
2975 position: relative;
2976}
2977
2978.code-header {
2979 display: flex;
2980 justify-content: space-between;
2981 align-items: center;
2982 margin-bottom: 12px;
2983}
2984
2985.code-label {
2986 font-size: 14px;
2987 font-weight: 600;
2988 color: rgba(255, 255, 255, 0.7);
2989 text-transform: uppercase;
2990 letter-spacing: 0.5px;
2991}
2992
2993pre {
2994 background: rgba(0, 0, 0, 0.3);
2995 color: #e0e0e0;
2996 padding: 20px;
2997 border-radius: 12px;
2998 overflow-x: auto;
2999 font-size: 14px;
3000 line-height: 1.6;
3001 font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3002 border: 1px solid rgba(255, 255, 255, 0.1);
3003}
3004
3005/* =========================================================
3006 ACTION BUTTONS
3007 ========================================================= */
3008
3009.action-bar {
3010 display: flex;
3011 gap: 12px;
3012 margin-top: 20px;
3013 flex-wrap: wrap;
3014}
3015
3016.btn-execute::before {
3017 content: '▶ ';
3018 font-size: 14px;
3019}
3020
3021/* =========================================================
3022 FORMS
3023 ========================================================= */
3024
3025.edit-form {
3026 display: flex;
3027 flex-direction: column;
3028 gap: 16px;
3029}
3030
3031.form-group {
3032 display: flex;
3033 flex-direction: column;
3034 gap: 8px;
3035}
3036
3037.form-label {
3038 font-size: 14px;
3039 font-weight: 600;
3040 color: white;
3041}
3042
3043input[type="text"],
3044textarea {
3045 width: 100%;
3046 padding: 12px 16px;
3047 border: 1px solid rgba(255, 255, 255, 0.3);
3048 border-radius: 10px;
3049 font-size: 15px;
3050 font-family: inherit;
3051 transition: all 0.2s;
3052 background: rgba(255, 255, 255, 0.2);
3053 color: white;
3054}
3055
3056input[type="text"]::placeholder,
3057textarea::placeholder {
3058 color: rgba(255, 255, 255, 0.6);
3059}
3060
3061textarea {
3062 font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3063 resize: vertical;
3064 min-height: 300px;
3065}
3066
3067input[type="text"]:focus,
3068textarea:focus {
3069 outline: none;
3070 border-color: rgba(255, 255, 255, 0.5);
3071 background: rgba(255, 255, 255, 0.25);
3072 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
3073}
3074
3075/* =========================================================
3076 HISTORY
3077 ========================================================= */
3078
3079.history-list {
3080 list-style: none;
3081 display: flex;
3082 flex-direction: column;
3083 gap: 12px;
3084}
3085
3086.history-item {
3087 background: rgba(255, 255, 255, 0.1);
3088 border-radius: 12px;
3089 padding: 16px;
3090 border: 1px solid rgba(255, 255, 255, 0.2);
3091 transition: all 0.2s;
3092}
3093
3094.history-item:hover {
3095 background: rgba(255, 255, 255, 0.15);
3096 transform: translateX(4px);
3097}
3098
3099.history-item.success {
3100 border-left: 4px solid #10b981;
3101}
3102
3103.history-item.failure {
3104 border-left: 4px solid #ef4444;
3105}
3106
3107.history-header {
3108 display: flex;
3109 justify-content: space-between;
3110 align-items: center;
3111 margin-bottom: 12px;
3112 flex-wrap: wrap;
3113 gap: 12px;
3114}
3115
3116.history-title {
3117 display: flex;
3118 align-items: center;
3119 gap: 12px;
3120 flex-wrap: wrap;
3121}
3122
3123.edit-number {
3124 font-weight: 600;
3125 color: white;
3126 font-size: 15px;
3127}
3128
3129.script-name {
3130 font-size: 13px;
3131 color: white;
3132 background: rgba(255, 255, 255, 0.2);
3133 padding: 4px 10px;
3134 border-radius: 8px;
3135 font-weight: 600;
3136 border: 1px solid rgba(255, 255, 255, 0.3);
3137}
3138
3139.current-badge {
3140 padding: 4px 10px;
3141 background: rgba(16, 185, 129, 0.9);
3142 color: white;
3143 border-radius: 12px;
3144 font-size: 12px;
3145 font-weight: 600;
3146}
3147
3148.success-badge {
3149 background: rgba(16, 185, 129, 0.9);
3150}
3151
3152.failure-badge {
3153 background: rgba(239, 68, 68, 0.9);
3154}
3155
3156.history-meta {
3157 display: flex;
3158 align-items: center;
3159 gap: 12px;
3160 flex-wrap: wrap;
3161}
3162
3163.version-badge {
3164 padding: 4px 10px;
3165 background: rgba(255, 255, 255, 0.2);
3166 color: white;
3167 border-radius: 8px;
3168 font-size: 12px;
3169 font-weight: 600;
3170 border: 1px solid rgba(255, 255, 255, 0.3);
3171}
3172
3173.timestamp {
3174 font-size: 13px;
3175 color: rgba(255, 255, 255, 0.8);
3176}
3177
3178details {
3179 margin-top: 8px;
3180}
3181
3182details summary {
3183 cursor: pointer;
3184 font-weight: 600;
3185 color: white;
3186 font-size: 14px;
3187 display: flex;
3188 align-items: center;
3189 gap: 8px;
3190 padding: 8px 0;
3191 user-select: none;
3192 transition: opacity 0.2s;
3193}
3194
3195details summary:hover {
3196 opacity: 0.8;
3197}
3198
3199.summary-icon {
3200 font-size: 10px;
3201 transition: transform 0.2s;
3202}
3203
3204details[open] .summary-icon {
3205 transform: rotate(90deg);
3206}
3207
3208details summary::-webkit-details-marker {
3209 display: none;
3210}
3211
3212.history-code {
3213 margin-top: 12px;
3214 font-size: 13px;
3215}
3216
3217.empty-history {
3218 text-align: center;
3219 padding: 40px;
3220 color: rgba(255, 255, 255, 0.7);
3221 font-style: italic;
3222 background: rgba(255, 255, 255, 0.1);
3223 border-radius: 12px;
3224 border: 1px solid rgba(255, 255, 255, 0.2);
3225}
3226
3227.empty-history-item {
3228 text-align: center;
3229 padding: 20px;
3230 color: rgba(255, 255, 255, 0.7);
3231 font-style: italic;
3232 font-size: 14px;
3233}
3234
3235/* =========================================================
3236 ERROR MESSAGES
3237 ========================================================= */
3238
3239.error-message {
3240 margin-top: 12px;
3241 padding: 12px;
3242 background: rgba(239, 68, 68, 0.2);
3243 border-left: 3px solid #ef4444;
3244 border-radius: 8px;
3245}
3246
3247.error-label {
3248 font-size: 12px;
3249 font-weight: 600;
3250 color: #ff6b6b;
3251 text-transform: uppercase;
3252 letter-spacing: 0.5px;
3253 margin-bottom: 8px;
3254}
3255
3256.error-code {
3257 color: #ffcccb;
3258 background: rgba(0, 0, 0, 0.2);
3259 padding: 12px;
3260 border-radius: 6px;
3261 font-size: 13px;
3262 margin: 0;
3263}
3264
3265/* =========================================================
3266 RESPONSIVE
3267 ========================================================= */
3268
3269${responsiveBase}
3270
3271@media (max-width: 640px) {
3272 .container {
3273 max-width: 100%;
3274 }
3275
3276 .header,
3277 .section {
3278 padding: 20px;
3279 }
3280
3281 .action-bar {
3282 flex-direction: column;
3283 }
3284
3285 .btn-execute,
3286 .btn-save {
3287 width: 100%;
3288 justify-content: center;
3289 }
3290
3291 .history-header {
3292 flex-direction: column;
3293 align-items: flex-start;
3294 }
3295
3296 .history-title {
3297 width: 100%;
3298 }
3299
3300 pre {
3301 font-size: 12px;
3302 padding: 16px;
3303 }
3304
3305 textarea {
3306 min-height: 200px;
3307 }
3308}
3309
3310@media (min-width: 641px) and (max-width: 1024px) {
3311 .container {
3312 max-width: 800px;
3313 }
3314}
3315 </style>
3316</head>
3317<body>
3318 <div class="container">
3319 <!-- Back Navigation -->
3320 <div class="back-nav">
3321 <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
3322 ← Back to Node
3323 </a>
3324 <a href="/api/v1/node/${nodeId}/scripts/help${qsWithQ}" class="back-link">
3325 📚 Help
3326 </a>
3327 </div>
3328
3329 <!-- Header -->
3330 <div class="header">
3331 <h1>${script.name}</h1>
3332 <div class="script-id">ID: ${script.id}</div>
3333 </div>
3334
3335 <!-- Current Script -->
3336 <div class="section">
3337 <div class="code-container">
3338 <div class="code-header">
3339 <div class="code-label">Current Script</div>
3340 <button class="btn-copy" onclick="copyCode()">📋 Copy</button>
3341 </div>
3342 <pre id="scriptCode">${script.script}</pre>
3343 </div>
3344
3345 <!-- Execute Button -->
3346 <div class="action-bar">
3347 <form
3348 method="POST"
3349 action="/api/v1/node/${nodeId}/script/${script.id}/execute${qsWithQ}"
3350 onsubmit="return confirm('Execute this script now?')"
3351 style="margin: 0;"
3352 >
3353 <button type="submit" class="btn-execute">Run Script</button>
3354 </form>
3355 </div>
3356 </div>
3357
3358 <!-- Edit Script -->
3359 <div class="section">
3360 <div class="section-title">Edit Script</div>
3361 <form
3362 method="POST"
3363 action="/api/v1/node/${nodeId}/script/${script.id}/edit${qsWithQ}"
3364 class="edit-form"
3365 >
3366 <div class="form-group">
3367 <label class="form-label">Script Name</label>
3368 <input
3369 type="text"
3370 name="name"
3371 value="${script.name}"
3372 placeholder="Enter script name"
3373 required
3374 />
3375 </div>
3376
3377 <div class="form-group">
3378 <label class="form-label">Script Code</label>
3379 <textarea
3380 name="script"
3381 rows="14"
3382 placeholder="// Enter your script code here"
3383 required
3384 >${script.script}</textarea>
3385 </div>
3386
3387 <button type="submit" class="btn-save">💾 Save Changes</button>
3388 </form>
3389 </div>
3390
3391 <!-- Execution History -->
3392 <div class="section">
3393 <div class="section-title">Execution History</div>
3394 <ul class="history-list">
3395 ${executionHistoryHtml}
3396 </ul>
3397 </div>
3398
3399 <!-- Edit History -->
3400 <div class="section">
3401 <div class="section-title">Edit History</div>
3402 <ul class="history-list">
3403 ${editHistoryHtml}
3404 </ul>
3405 </div>
3406 </div>
3407
3408 <script>
3409 function copyCode() {
3410 const code = document.getElementById('scriptCode').textContent;
3411 navigator.clipboard.writeText(code).then(() => {
3412 const btn = document.querySelector('.btn-copy');
3413 const originalText = btn.textContent;
3414 btn.textContent = '✓ Copied!';
3415 setTimeout(() => {
3416 btn.textContent = originalText;
3417 }, 2000);
3418 });
3419 }
3420 </script>
3421</body>
3422</html>
3423 `;
3424}
3425
3426/* ================================================================== */
3427/* 5. renderScriptHelp */
3428/* ================================================================== */
3429
3430export function renderScriptHelp({ nodeId, nodeName, data, qsWithQ }) {
3431 return `
3432<!DOCTYPE html>
3433<html lang="en">
3434<head>
3435 <meta charset="UTF-8">
3436 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3437 <meta name="theme-color" content="#667eea">
3438 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3439 <title>Script Help — ${nodeName}</title>
3440 <style>
3441${baseStyles}
3442.container { max-width: 1100px; }
3443
3444/* =========================================================
3445 UNIFIED GLASS BUTTON SYSTEM
3446 ========================================================= */
3447
3448.glass-btn,
3449button,
3450.back-link,
3451.quick-nav-item,
3452.btn-copy {
3453 position: relative;
3454 overflow: hidden;
3455
3456 padding: 10px 20px;
3457 border-radius: 980px;
3458
3459 display: inline-flex;
3460 align-items: center;
3461 justify-content: center;
3462 white-space: nowrap;
3463
3464 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3465 backdrop-filter: blur(22px) saturate(140%);
3466 -webkit-backdrop-filter: blur(22px) saturate(140%);
3467
3468 color: white;
3469 text-decoration: none;
3470 font-family: inherit;
3471
3472 font-size: 15px;
3473 font-weight: 500;
3474 letter-spacing: -0.2px;
3475
3476 border: 1px solid rgba(255, 255, 255, 0.28);
3477
3478 box-shadow:
3479 0 8px 24px rgba(0, 0, 0, 0.12),
3480 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3481
3482 cursor: pointer;
3483
3484 transition:
3485 background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3486 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3487 box-shadow 0.3s ease;
3488}
3489
3490/* Liquid light layer */
3491.glass-btn::before,
3492button::before,
3493.back-link::before,
3494.quick-nav-item::before,
3495.btn-copy::before {
3496 content: "";
3497 position: absolute;
3498 inset: -40%;
3499
3500 background:
3501 radial-gradient(
3502 120% 60% at 0% 0%,
3503 rgba(255, 255, 255, 0.35),
3504 transparent 60%
3505 ),
3506 linear-gradient(
3507 120deg,
3508 transparent 30%,
3509 rgba(255, 255, 255, 0.25),
3510 transparent 70%
3511 );
3512
3513 opacity: 0;
3514 transform: translateX(-30%) translateY(-10%);
3515 transition:
3516 opacity 0.35s ease,
3517 transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3518
3519 pointer-events: none;
3520}
3521
3522/* Hover motion */
3523.glass-btn:hover,
3524button:hover,
3525.back-link:hover,
3526.quick-nav-item:hover,
3527.btn-copy:hover {
3528 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
3529 transform: translateY(-1px);
3530 animation: waterDrift 2.2s ease-in-out infinite alternate;
3531}
3532
3533.glass-btn:hover::before,
3534button:hover::before,
3535.back-link:hover::before,
3536.quick-nav-item:hover::before,
3537.btn-copy:hover::before {
3538 opacity: 1;
3539 transform: translateX(30%) translateY(10%);
3540}
3541
3542/* Active press */
3543.glass-btn:active,
3544button:active,
3545.btn-copy:active,
3546.quick-nav-item:active {
3547 background: rgba(var(--glass-water-rgb), 0.45);
3548 transform: translateY(0);
3549 animation: none;
3550}
3551
3552@keyframes waterDrift {
3553 0% { transform: translateY(-1px); }
3554 100% { transform: translateY(1px); }
3555}
3556
3557/* Button variants */
3558.btn-copy {
3559 padding: 6px 12px;
3560 font-size: 13px;
3561}
3562
3563/* =========================================================
3564 CONTENT CARDS
3565 ========================================================= */
3566
3567.header,
3568.section {
3569 background: rgba(255, 255, 255, 0.15);
3570 backdrop-filter: blur(22px) saturate(140%);
3571 -webkit-backdrop-filter: blur(22px) saturate(140%);
3572 border-radius: 14px;
3573 padding: 28px;
3574 border: 1px solid rgba(255, 255, 255, 0.28);
3575 color: white;
3576 margin-bottom: 24px;
3577}
3578
3579.header h1 {
3580 font-size: 28px;
3581 font-weight: 600;
3582 letter-spacing: -0.5px;
3583 line-height: 1.3;
3584 margin-bottom: 8px;
3585 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3586 color: white;
3587}
3588
3589.header h1::before {
3590 content: '📚 ';
3591 font-size: 26px;
3592}
3593
3594.header-subtitle {
3595 font-size: 14px;
3596 color: rgba(255, 255, 255, 0.8);
3597}
3598
3599.section-title {
3600 font-size: 18px;
3601 font-weight: 600;
3602 color: white;
3603 margin-bottom: 16px;
3604 letter-spacing: -0.3px;
3605 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3606}
3607
3608.section-description {
3609 font-size: 14px;
3610 color: rgba(255, 255, 255, 0.9);
3611 line-height: 1.6;
3612 margin-bottom: 16px;
3613 padding: 12px;
3614 background: rgba(255, 255, 255, 0.1);
3615 border-radius: 8px;
3616 border-left: 3px solid rgba(255, 255, 255, 0.5);
3617}
3618
3619/* =========================================================
3620 NAV
3621 ========================================================= */
3622
3623.back-nav {
3624 display: flex;
3625 gap: 12px;
3626 margin-bottom: 20px;
3627 flex-wrap: wrap;
3628}
3629
3630/* =========================================================
3631 QUICK NAV
3632 ========================================================= */
3633
3634.quick-nav {
3635 display: grid;
3636 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
3637 gap: 12px;
3638}
3639
3640.quick-nav-item {
3641 padding: 12px 16px;
3642 text-align: center;
3643}
3644
3645/* =========================================================
3646 TABLES
3647 ========================================================= */
3648
3649table {
3650 width: 100%;
3651 border-collapse: collapse;
3652 background: rgba(255, 255, 255, 0.1);
3653 border-radius: 8px;
3654 overflow: hidden;
3655 border: 1px solid rgba(255, 255, 255, 0.2);
3656}
3657
3658thead {
3659 background: rgba(255, 255, 255, 0.15);
3660}
3661
3662th {
3663 padding: 14px 16px;
3664 text-align: left;
3665 font-weight: 600;
3666 color: white;
3667 font-size: 14px;
3668 text-transform: uppercase;
3669 letter-spacing: 0.5px;
3670 border-bottom: 2px solid rgba(255, 255, 255, 0.2);
3671}
3672
3673td {
3674 padding: 14px 16px;
3675 border-bottom: 1px solid rgba(255, 255, 255, 0.1);
3676 font-size: 14px;
3677 line-height: 1.6;
3678 vertical-align: top;
3679 color: rgba(255, 255, 255, 0.95);
3680}
3681
3682tbody tr:last-child td {
3683 border-bottom: none;
3684}
3685
3686tbody tr {
3687 transition: background 0.2s;
3688}
3689
3690tbody tr:hover {
3691 background: rgba(255, 255, 255, 0.05);
3692}
3693
3694code {
3695 background: rgba(255, 255, 255, 0.2);
3696 padding: 3px 8px;
3697 border-radius: 4px;
3698 font-size: 13px;
3699 font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3700 color: white;
3701 font-weight: 600;
3702 border: 1px solid rgba(255, 255, 255, 0.3);
3703}
3704
3705pre {
3706 background: rgba(0, 0, 0, 0.3);
3707 color: #e0e0e0;
3708 padding: 20px;
3709 border-radius: 12px;
3710 overflow-x: auto;
3711 font-size: 14px;
3712 line-height: 1.6;
3713 font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3714 border: 1px solid rgba(255, 255, 255, 0.1);
3715 margin-top: 12px;
3716}
3717
3718pre code {
3719 background: none;
3720 padding: 0;
3721 color: inherit;
3722 font-weight: normal;
3723 border: none;
3724}
3725
3726/* =========================================================
3727 INFO BOX
3728 ========================================================= */
3729
3730.info-box {
3731 background: rgba(255, 193, 7, 0.2);
3732 padding: 16px;
3733 border-radius: 10px;
3734 border-left: 4px solid #ffa500;
3735 margin-bottom: 16px;
3736}
3737
3738.info-box-title {
3739 font-weight: 600;
3740 color: #ffd700;
3741 margin-bottom: 8px;
3742 display: flex;
3743 align-items: center;
3744 gap: 8px;
3745}
3746
3747.info-box-title::before {
3748 content: '⚠️';
3749 font-size: 16px;
3750}
3751
3752.info-box-content {
3753 font-size: 14px;
3754 color: rgba(255, 255, 255, 0.9);
3755 line-height: 1.6;
3756}
3757
3758/* =========================================================
3759 EXAMPLE BOX
3760 ========================================================= */
3761
3762.example-box {
3763 margin-top: 12px;
3764}
3765
3766.example-header {
3767 display: flex;
3768 justify-content: space-between;
3769 align-items: center;
3770 margin-bottom: 12px;
3771}
3772
3773.example-label {
3774 font-size: 14px;
3775 font-weight: 600;
3776 color: rgba(255, 255, 255, 0.7);
3777 text-transform: uppercase;
3778 letter-spacing: 0.5px;
3779}
3780
3781/* =========================================================
3782 RESPONSIVE
3783 ========================================================= */
3784
3785${responsiveBase}
3786
3787@media (max-width: 640px) {
3788 .container {
3789 max-width: 100%;
3790 }
3791
3792 .header,
3793 .section {
3794 padding: 20px;
3795 }
3796
3797 table {
3798 font-size: 13px;
3799 }
3800
3801 th, td {
3802 padding: 10px;
3803 }
3804
3805 pre {
3806 font-size: 12px;
3807 padding: 16px;
3808 }
3809
3810 .quick-nav {
3811 grid-template-columns: 1fr;
3812 }
3813}
3814
3815@media (min-width: 641px) and (max-width: 1024px) {
3816 .container {
3817 max-width: 900px;
3818 }
3819}
3820 </style>
3821</head>
3822<body>
3823 <div class="container">
3824 <!-- Back Navigation -->
3825 <div class="back-nav">
3826 <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
3827 ← Back to Node
3828 </a>
3829 </div>
3830
3831 <!-- Header -->
3832 <div class="header">
3833 <h1>Script Help</h1>
3834 <div class="header-subtitle">Learn how to write scripts for your nodes</div>
3835 </div>
3836
3837 <!-- Quick Navigation -->
3838 <div class="section">
3839 <div class="section-title">Quick Jump</div>
3840 <div class="quick-nav">
3841 <a href="#node-data" class="quick-nav-item">Node Data</a>
3842 <a href="#version-properties" class="quick-nav-item">Version Properties</a>
3843 <a href="#other-properties" class="quick-nav-item">Other Properties</a>
3844 <a href="#functions" class="quick-nav-item">Built-in Functions</a>
3845 <a href="#example" class="quick-nav-item">Example Script</a>
3846 </div>
3847 </div>
3848
3849 <!-- Node Data -->
3850 <div class="section" id="node-data">
3851 <div class="section-title">Accessing Node Data</div>
3852
3853 <div class="info-box">
3854 <div class="info-box-title">Important</div>
3855 <div class="info-box-content">
3856 ${data.importantNote}
3857 </div>
3858 </div>
3859
3860 <table>
3861 <thead>
3862 <tr>
3863 <th>Property</th>
3864 <th>Description</th>
3865 </tr>
3866 </thead>
3867 <tbody>
3868 ${data.nodeProperties.basic
3869 .map(
3870 (item) => `
3871 <tr>
3872 <td><code>${item.property}</code></td>
3873 <td>${item.description}</td>
3874 </tr>
3875 `,
3876 )
3877 .join("")}
3878 </tbody>
3879 </table>
3880 </div>
3881
3882 <!-- Version Properties -->
3883 <div class="section" id="version-properties">
3884 <div class="section-title">Version Properties</div>
3885
3886 <div class="section-description">
3887 Access version data using index <code>i</code>. Use <code>0</code> for the first version,
3888 or <code>0</code> for the latest version.
3889 </div>
3890
3891 <table>
3892 <thead>
3893 <tr>
3894 <th>Property</th>
3895 <th>Description</th>
3896 </tr>
3897 </thead>
3898 <tbody>
3899 ${data.nodeProperties.version
3900 .map(
3901 (item) => `
3902 <tr>
3903 <td><code>${item.property}</code></td>
3904 <td>${item.description}${
3905 item.example ? `: <code>${item.example}</code>` : ""
3906 }</td>
3907 </tr>
3908 `,
3909 )
3910 .join("")}
3911 </tbody>
3912 </table>
3913 </div>
3914
3915 <!-- Other Properties -->
3916 <div class="section" id="other-properties">
3917 <div class="section-title">Other Node Properties</div>
3918
3919 <table>
3920 <thead>
3921 <tr>
3922 <th>Property</th>
3923 <th>Description</th>
3924 </tr>
3925 </thead>
3926 <tbody>
3927 ${data.nodeProperties.other
3928 .map(
3929 (item) => `
3930 <tr>
3931 <td><code>${item.property}</code></td>
3932 <td>${item.description}${
3933 item.example ? `: <code>${item.example}</code>` : ""
3934 }</td>
3935 </tr>
3936 `,
3937 )
3938 .join("")}
3939 </tbody>
3940 </table>
3941 </div>
3942
3943 <!-- Built-in Functions -->
3944 <div class="section" id="functions">
3945 <div class="section-title">Built-in Functions</div>
3946
3947 <div class="section-description">
3948 These functions are available globally in all scripts and provide access to node operations.
3949 </div>
3950
3951 <table>
3952 <thead>
3953 <tr>
3954 <th style="width: 40%;">Function</th>
3955 <th>Description</th>
3956 </tr>
3957 </thead>
3958 <tbody>
3959 ${data.builtInFunctions
3960 .map(
3961 (fn) => `
3962 <tr>
3963 <td><code>${fn.name}</code></td>
3964 <td>${fn.description}</td>
3965 </tr>
3966 `,
3967 )
3968 .join("")}
3969 </tbody>
3970 </table>
3971 </div>
3972
3973 <!-- Example Script -->
3974 <div class="section" id="example">
3975 <div class="section-title">Example Script</div>
3976
3977 <div class="section-description">
3978 This example demonstrates a script that tapers a value over time by increasing it by 5%
3979 each time it runs, then schedules itself to run again.
3980 </div>
3981
3982 <div class="example-box">
3983 <div class="example-header">
3984 <div class="example-label">Tapering Script</div>
3985 <button class="btn-copy" onclick="copyExample()">📋 Copy</button>
3986 </div>
3987 <pre id="exampleCode">${data.exampleScript}</pre>
3988 </div>
3989 </div>
3990 </div>
3991
3992 <script>
3993 function copyExample() {
3994 const code = document.getElementById('exampleCode').textContent;
3995 navigator.clipboard.writeText(code).then(() => {
3996 const btn = document.querySelector('.btn-copy');
3997 const originalText = btn.textContent;
3998 btn.textContent = '✓ Copied!';
3999 setTimeout(() => {
4000 btn.textContent = originalText;
4001 }, 2000);
4002 });
4003 }
4004
4005 // Smooth scroll for quick nav
4006 document.querySelectorAll('.quick-nav-item').forEach(link => {
4007 link.addEventListener('click', (e) => {
4008 e.preventDefault();
4009 const target = document.querySelector(link.getAttribute('href'));
4010 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4011 });
4012 });
4013 </script>
4014</body>
4015</html>
4016 `;
4017}
4018
1import { baseStyles } from "./baseStyles.js";
2
3export function errorHtml(status, title, message) {
4 return `<!DOCTYPE html>
5<html lang="en">
6<head>
7<meta charset="UTF-8">
8<meta name="viewport" content="width=device-width, initial-scale=1.0">
9<meta name="theme-color" content="#736fe6">
10<meta name="apple-mobile-web-app-capable" content="yes">
11<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12<title>${title} - TreeOS</title>
13<style>
14${baseStyles}
15
16/* ── 404 page overrides on base ── */
17html, body { height: 100%; }
18body {
19 color: white;
20 display: flex;
21 align-items: center;
22 justify-content: center;
23}
24
25/* Hide base orbs for centered layout */
26body::before, body::after { display: none; }
27.card {
28 background: rgba(255,255,255,0.12);
29 backdrop-filter: blur(20px);
30 -webkit-backdrop-filter: blur(20px);
31 border: 1px solid rgba(255,255,255,0.2);
32 border-radius: 20px;
33 padding: 48px 40px;
34 max-width: 480px;
35 width: 100%;
36 text-align: center;
37 box-shadow: 0 20px 60px rgba(0,0,0,0.2);
38}
39.code {
40 display: inline-block;
41 margin-bottom: 12px;
42 font-size: 13px;
43 font-weight: 700;
44 color: #dc2626;
45 letter-spacing: 1px;
46 background: rgba(255,255,255,0.18);
47 border-radius: 10px;
48 padding: 6px 16px;
49}
50.icon { font-size: 48px; margin-bottom: 8px; }
51.brand {
52 font-size: 28px;
53 font-weight: 700;
54 color: white;
55 margin-bottom: 20px;
56 text-decoration: none;
57 display: block;
58}
59.brand:hover { opacity: 0.9; }
60h1 {
61 font-size: 22px;
62 font-weight: 700;
63 margin-bottom: 12px;
64 color: white;
65}
66p {
67 font-size: 15px;
68 line-height: 1.6;
69 color: rgba(255,255,255,0.75);
70 margin-bottom: 28px;
71}
72.btn {
73 display: inline-block;
74 padding: 12px 32px;
75 border-radius: 980px;
76 background: rgba(255,255,255,0.18);
77 border: 1px solid rgba(255,255,255,0.25);
78 color: white;
79 font-size: 14px;
80 font-weight: 600;
81 text-decoration: none;
82 transition: all 0.2s;
83}
84.btn:hover {
85 background: rgba(255,255,255,0.28);
86 transform: translateY(-1px);
87}
88.ai-note {
89 margin-top: 20px;
90 padding: 12px 16px;
91 background: rgba(239,68,68,0.2);
92 border: 1px solid rgba(239,68,68,0.35);
93 border-radius: 12px;
94 font-size: 13px;
95 line-height: 1.5;
96 color: rgba(255,255,255,0.85);
97}
98@keyframes heroGrow {
99 0%, 100% { transform: scale(1); }
100 50% { transform: scale(1.06); }
101}
102.icon { animation: heroGrow 4.5s ease-in-out infinite; }
103</style>
104</head>
105<body>
106<div class="card">
107 <div class="code">${status}</div>
108 <a href="/" class="brand" onclick="event.preventDefault(); window.top.location.href='/';">
109 <div class="icon">\u{1F333}</div>
110 Tree
111 </a>
112 <h1>${title}</h1>
113 <p>${message}</p>
114 <a href="/" class="btn" onclick="event.preventDefault(); window.top.location.href='/';">Back to Home</a>
115 <div class="ai-note">If this was triggered by an AI automated process, wait a moment. You may be redirected shortly.</div>
116</div>
117</body>
118</html>`;
119}
120
1/* --------------------------------------------------------- */
2/* HTML renderers for notes pages */
3/* --------------------------------------------------------- */
4
5import mime from "mime-types";
6import { getLandUrl } from "../../../canopy/identity.js";
7import { baseStyles, backNavStyles } from "./baseStyles.js";
8import { escapeHtml, renderMedia } from "./utils.js";
9
10function renderBookNode(node, depth, token, version) {
11 const level = Math.min(depth, 5);
12 const H = `h${level}`;
13 const qs = token ? `?token=${token}&html` : `?html`;
14
15 let html = `
16 <section class="book-section depth-${depth}" id="toc-${node.nodeId}">
17 <${H}>${escapeHtml(node.nodeName ?? node.nodeId)}</${H}>
18 `;
19
20 for (const note of node.notes) {
21 const noteUrl = `/api/v1/node/${node.nodeId}/${note.version}/notes/${note.noteId}${qs}`;
22
23 if (note.type === "text") {
24 html += `
25 <div class="note-content">
26 <a href="${noteUrl}" class="note-link">${escapeHtml(note.content)}</a>
27 </div>
28 `;
29 }
30
31 if (note.type === "file") {
32 const fileUrl = `/api/v1/uploads/${note.content}${
33 token ? `?token=${token}` : ""
34 }`;
35 const mimeType = mime.lookup(note.content) || "";
36
37 html += `
38 <div class="file-container">
39 <a href="${noteUrl}" class="note-link file-link">${escapeHtml(note.content)}</a>
40 ${renderMedia(fileUrl, mimeType)}
41 </div>
42 `;
43 }
44 }
45
46 for (const child of node.children) {
47 html += renderBookNode(child, depth + 1, token, version);
48 }
49
50 html += `</section>`;
51 return html;
52}
53
54function renderToc(node, maxDepth, depth = 1, isRoot = false) {
55 const children = node.children || [];
56 const hasChildren = children.length > 0 && (maxDepth === 0 || isRoot || depth < maxDepth);
57
58 const childList = hasChildren
59 ? `<ul class="toc-list">${children.map((c) => renderToc(c, maxDepth, isRoot ? 1 : depth + 1, false)).join("")}</ul>`
60 : "";
61
62 if (isRoot) return childList;
63
64 const name = escapeHtml(node.nodeName ?? node.nodeId);
65 const link = `<a href="javascript:void(0)" onclick="tocScroll('toc-${node.nodeId}')" class="toc-link">${name}</a>`;
66
67 return `<li>${link}${childList}</li>`;
68}
69
70function renderTocBlock(book, maxDepth) {
71 const inner = renderToc(book, maxDepth, 1, true);
72 return `<nav class="book-toc"><div class="toc-title">Table of Contents</div>${inner}</nav>`;
73}
74
75function getBookDepth(node, depth = 0) {
76 const children = node.children || [];
77 if (children.length === 0) return depth;
78 return Math.max(...children.map((c) => getBookDepth(c, depth + 1)));
79}
80
81const parseBool = (v) => v === "true";
82
83function normalizeStatusFilters(query) {
84 const parse = (v) =>
85 v === "true" ? true : v === "false" ? false : undefined;
86
87 const filters = {
88 active: parse(query.active),
89 trimmed: parse(query.trimmed),
90 completed: parse(query.completed),
91 };
92
93 const hasAny = Object.values(filters).some((v) => v !== undefined);
94 return hasAny ? filters : null;
95}
96
97/* --------------------------------------------------------- */
98/* Exported render functions */
99/* --------------------------------------------------------- */
100
101export function renderEditorPage({
102 nodeId,
103 version,
104 noteId,
105 noteContent,
106 qs,
107 tokenQS,
108 originalLength,
109}) {
110 const isNew = !noteId;
111 const safeContent = (noteContent || "")
112 .replace(/&/g, "&")
113 .replace(/</g, "<")
114 .replace(/>/g, ">")
115 .replace(/"/g, """);
116
117 return `<!DOCTYPE html>
118<html lang="en">
119<head>
120<meta charset="UTF-8">
121<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
122<meta name="theme-color" content="#667eea">
123<title>${isNew ? "New Note" : "Edit Note"} · Editor</title>
124<style>
125${baseStyles}
126
127/* ── Editor-specific overrides on base ── */
128:root {
129 --glass-rgb: 115, 111, 230;
130 --sidebar-w: 280px;
131 --toolbar-h: 52px;
132 --bottombar-h: 44px;
133 --editor-font-size: 13px;
134 --editor-line-height: 2.1;
135 --editor-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
136}
137
138html, body { height: 100%; overflow: hidden; }
139body {
140 font-family: var(--editor-font);
141 color: white; display: flex; flex-direction: column;
142 height: 100vh; height: 100dvh;
143 padding: 0; min-height: auto;
144}
145
146/* Override base orbs: editor uses a single subtler orb */
147body::before {
148 opacity: 0.05;
149 animation-duration: 25s;
150}
151body::before { transform: none; }
152body::after { display: none; }
153
154/* ── TOOLBAR ─────────────────── */
155.toolbar {
156 height: var(--toolbar-h); display: flex; align-items: center; gap: 6px;
157 padding: 0 12px;
158 background: rgba(var(--glass-rgb), 0.35);
159 backdrop-filter: blur(22px) saturate(140%);
160 -webkit-backdrop-filter: blur(22px) saturate(140%);
161 border-bottom: 1px solid rgba(255,255,255,0.15);
162 flex-shrink: 0; z-index: 20;
163 overflow-x: auto; overflow-y: hidden;
164 -webkit-overflow-scrolling: touch;
165}
166.toolbar::-webkit-scrollbar { display: none; }
167
168.tb-btn {
169 padding: 6px 12px; border-radius: 8px;
170 border: 1px solid rgba(255,255,255,0.15);
171 background: rgba(255,255,255,0.08);
172 color: rgba(255,255,255,0.8);
173 font-size: 13px; font-weight: 600; font-family: inherit;
174 cursor: pointer; transition: all 0.2s;
175 white-space: nowrap; flex-shrink: 0;
176 display: inline-flex; align-items: center; gap: 4px;
177}
178.tb-btn:hover { background: rgba(255,255,255,0.18); color: white; }
179.tb-btn.active { background: rgba(72,187,178,0.35); border-color: rgba(72,187,178,0.5); color: white; }
180
181.tb-sep { width: 1px; height: 24px; background: rgba(255,255,255,0.12); flex-shrink: 0; margin: 0 4px; }
182.tb-range-wrap { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
183.tb-range-label { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
184
185.tb-range {
186 -webkit-appearance: none; appearance: none;
187 width: 80px; height: 4px;
188 background: rgba(255,255,255,0.2);
189 border-radius: 4px; outline: none; cursor: pointer;
190}
191.tb-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); cursor: pointer; }
192.tb-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); border: none; cursor: pointer; }
193
194.tb-spacer { flex: 1; min-width: 8px; }
195
196.tb-copy {
197 padding: 6px 12px; border-radius: 8px;
198 border: 1px solid rgba(255,255,255,0.15);
199 background: rgba(255,255,255,0.08);
200 color: rgba(255,255,255,0.8);
201 font-size: 13px; font-weight: 600; font-family: inherit;
202 cursor: pointer; transition: all 0.2s;
203 white-space: nowrap; flex-shrink: 0;
204 display: inline-flex; align-items: center; gap: 4px;
205 min-width: 36px; justify-content: center;
206}
207.tb-copy:hover { background: rgba(255,255,255,0.18); color: white; }
208.tb-copy.copied { background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.5); color: white; }
209
210@media (max-width: 768px) {
211 .tb-copy { padding: 6px 10px; }
212}
213
214.tb-back {
215 padding: 6px 14px; border-radius: 8px;
216 border: 1px solid rgba(255,255,255,0.15);
217 background: rgba(255,255,255,0.08);
218 color: rgba(255,255,255,0.8);
219 font-size: 13px; font-weight: 600; font-family: inherit;
220 cursor: pointer; text-decoration: none; transition: all 0.2s;
221 flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
222}
223.tb-back:hover { background: rgba(255,255,255,0.18); color: white; }
224
225/* ── MAIN ────────────────────── */
226.main { flex: 1; display: flex; overflow: hidden; position: relative; }
227
228/* ── SIDEBAR ─────────────────── */
229.sidebar {
230 width: var(--sidebar-w); flex-shrink: 0;
231 display: flex; flex-direction: column;
232 background: rgba(var(--glass-rgb), 0.22);
233 backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
234 border-right: 1px solid rgba(255,255,255,0.12);
235 overflow: hidden;
236 z-index: 15;
237}
238.sidebar.hidden { display: none; }
239
240.sidebar-header {
241 padding: 16px; border-bottom: 1px solid rgba(255,255,255,0.1);
242 display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
243}
244.sidebar-title { font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.9); }
245
246.sidebar-close {
247 width: 28px; height: 28px; border-radius: 8px;
248 border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
249 color: rgba(255,255,255,0.6); font-size: 14px;
250 cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s;
251}
252.sidebar-close:hover { background: rgba(255,255,255,0.2); color: white; }
253
254.sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
255.sidebar-list::-webkit-scrollbar { width: 4px; }
256.sidebar-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
257
258.note-item {
259 display: flex; align-items: center; gap: 10px;
260 padding: 10px 12px; border-radius: 10px;
261 cursor: pointer; transition: all 0.2s;
262 border: 1px solid transparent; margin-bottom: 2px;
263}
264.note-item:hover { background: rgba(255,255,255,0.1); }
265.note-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
266
267.note-item-icon {
268 width: 32px; height: 32px; border-radius: 8px;
269 background: rgba(255,255,255,0.1);
270 display: flex; align-items: center; justify-content: center;
271 font-size: 14px; flex-shrink: 0;
272}
273.note-item-info { min-width: 0; flex: 1; }
274.note-item-username { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
275.note-item-preview { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
276.note-item-meta { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; }
277
278.sidebar-new {
279 margin: 8px; padding: 10px; border-radius: 10px;
280 border: 2px dashed rgba(255,255,255,0.15); background: transparent;
281 color: rgba(255,255,255,0.5); font-size: 13px; font-weight: 600;
282 font-family: inherit; cursor: pointer; transition: all 0.2s;
283 text-align: center; flex-shrink: 0;
284}
285.sidebar-new:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.8); }
286
287/* ── EDITOR ──────────────────── */
288.editor-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
289
290.editor-scroll {
291 flex: 1; overflow-y: auto; overflow-x: hidden;
292 padding: 16px 16px; display: flex; justify-content: center;
293 -webkit-overflow-scrolling: touch;
294}
295.editor-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
296.editor-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
297.editor-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
298.editor-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
299
300/* Code mode: enable horizontal scroll on outer container */
301.editor-scroll.code-scroll-enabled {
302 overflow-x: auto;
303 justify-content: flex-start;
304}
305
306.editor-container { width: 100%; max-width: 100%; }
307
308/* Code mode: container expands to fit content */
309.editor-container.code-mode-active {
310 width: max-content;
311 min-width: 100%;
312 max-width: none;
313}
314
315/* ── LINE NUMBERS + EDITOR LAYOUT ── */
316.editor-with-lines {
317 display: flex;
318 width: 100%;
319}
320
321.editor-container.code-mode-active .editor-with-lines {
322 width: max-content;
323 min-width: 100%;
324}
325
326.line-numbers {
327 display: none;
328 flex-shrink: 0;
329 padding-right: 12px;
330 margin-right: 12px;
331 border-right: 1px solid rgba(255,255,255,0.03);
332 text-align: right;
333 user-select: none;
334 pointer-events: none;
335 color: rgba(255,255,255,0.3);
336 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
337 font-size: var(--editor-font-size);
338 line-height: var(--editor-line-height);
339}
340
341.line-numbers.show {
342 display: block;
343}
344
345.line-numbers span {
346 display: block;
347}
348
349.editor-code-scroll {
350 flex: 1;
351 min-width: 0;
352 overflow-x: visible;
353 overflow-y: visible;
354}
355
356.editor-code-scroll::-webkit-scrollbar { height: 8px; }
357.editor-code-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
358.editor-code-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
359.editor-code-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
360
361#editor {
362 width: 100%;
363 min-height: calc(100vh - var(--toolbar-h) - var(--bottombar-h) - 32px);
364 background: transparent; border: none; outline: none; resize: none;
365 color: rgba(255,255,255,0.95);
366 font-family: var(--editor-font);
367 font-size: var(--editor-font-size);
368 line-height: var(--editor-line-height);
369 caret-color: rgba(72,187,178,0.9);
370 padding: 0; -webkit-font-smoothing: antialiased;
371 overflow: hidden;
372}
373#editor::placeholder { color: rgba(255,255,255,0.25); font-style: italic; }
374#editor.mono {
375 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
376 white-space: pre;
377 word-wrap: normal;
378 overflow-wrap: normal;
379}
380
381/* ── BOTTOM BAR ──────────────── */
382.bottombar {
383 height: var(--bottombar-h);
384 display: flex; align-items: center; justify-content: space-between;
385 padding: 0 16px;
386 background: rgba(var(--glass-rgb), 0.3);
387 backdrop-filter: blur(22px) saturate(140%);
388 -webkit-backdrop-filter: blur(22px) saturate(140%);
389 border-top: 1px solid rgba(255,255,255,0.12);
390 flex-shrink: 0; z-index: 20; gap: 12px;
391}
392.bb-left, .bb-right { display: flex; align-items: center; gap: 12px; }
393.bb-stat { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; white-space: nowrap; }
394
395.bb-energy {
396 color: rgba(100,220,255,0.7); font-weight: 600;
397 padding: 2px 8px; background: rgba(100,220,255,0.1);
398 border-radius: 980px; border: 1px solid rgba(100,220,255,0.15);
399}
400
401.bb-status { font-size: 12px; font-weight: 600; white-space: nowrap; transition: color 0.3s; }
402.bb-status.saved { color: rgba(72,187,120,0.8); }
403.bb-status.unsaved { color: rgba(250,204,21,0.8); }
404.bb-status.saving { color: rgba(100,220,255,0.8); }
405.bb-status.error { color: rgba(239,68,68,0.8); }
406
407.save-btn {
408 padding: 6px 20px; border-radius: 980px;
409 border: 1px solid rgba(72,187,178,0.45); background: rgba(72,187,178,0.3);
410 color: white; font-size: 13px; font-weight: 700;
411 font-family: inherit; cursor: pointer; transition: all 0.2s; white-space: nowrap;
412}
413.save-btn:hover { background: rgba(72,187,178,0.45); transform: translateY(-1px); }
414.save-btn:active { transform: translateY(0); }
415.save-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
416
417.delete-btn {
418 padding: 6px 16px; border-radius: 980px;
419 border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.2);
420 color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 600;
421 font-family: inherit; cursor: pointer; transition: all 0.2s;
422 white-space: nowrap; display: none;
423}
424.delete-btn:hover { background: rgba(239,68,68,0.35); color: white; }
425.delete-btn.show { display: inline-flex; }
426
427/* ── DELETE MODAL ────────────── */
428.modal-overlay {
429 display: none; position: fixed; inset: 0; z-index: 100;
430 background: rgba(0,0,0,0.6);
431 backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
432 align-items: center; justify-content: center; padding: 20px;
433}
434.modal-overlay.show { display: flex; }
435
436.modal-box {
437 background: rgba(var(--glass-rgb), 0.5);
438 backdrop-filter: blur(22px) saturate(140%);
439 -webkit-backdrop-filter: blur(22px) saturate(140%);
440 border-radius: 20px; padding: 32px;
441 border: 1px solid rgba(255,255,255,0.28);
442 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
443 max-width: 420px; width: 100%; text-align: center;
444}
445.modal-icon { font-size: 48px; margin-bottom: 16px; }
446.modal-title { font-size: 20px; font-weight: 700; color: white; margin-bottom: 8px; }
447.modal-text { font-size: 14px; color: rgba(255,255,255,0.7); line-height: 1.6; margin-bottom: 24px; }
448.modal-actions { display: flex; gap: 12px; justify-content: center; }
449
450.modal-btn {
451 padding: 10px 24px; border-radius: 980px;
452 font-size: 14px; font-weight: 600; font-family: inherit;
453 cursor: pointer; transition: all 0.2s; border: 1px solid;
454}
455.modal-btn-cancel { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); color: rgba(255,255,255,0.8); }
456.modal-btn-cancel:hover { background: rgba(255,255,255,0.22); color: white; }
457.modal-btn-delete { background: rgba(239,68,68,0.3); border-color: rgba(239,68,68,0.5); color: white; }
458.modal-btn-delete:hover { background: rgba(239,68,68,0.5); }
459
460/* ── ZEN ─────────────────────── */
461body.zen .toolbar { display: none; }
462body.zen .sidebar { display: none; }
463body.zen .bottombar { opacity: 0; transition: opacity 0.3s; }
464body.zen:hover .bottombar { opacity: 1; }
465body.zen .editor-scroll { padding: 24px; }
466
467/* ── ZEN EXIT BUTTON (mobile only) ── */
468.zen-exit-btn {
469 display: none;
470 position: fixed;
471 top: 16px;
472 right: 16px;
473 width: 44px;
474 height: 44px;
475 border-radius: 50%;
476 border: 1px solid rgba(255,255,255,0.2);
477 background: rgba(var(--glass-rgb), 0.5);
478 backdrop-filter: blur(16px);
479 -webkit-backdrop-filter: blur(16px);
480 color: rgba(255,255,255,0.8);
481 font-size: 18px;
482 cursor: pointer;
483 z-index: 30;
484 align-items: center;
485 justify-content: center;
486 transition: all 0.2s;
487 box-shadow: 0 4px 20px rgba(0,0,0,0.2);
488}
489.zen-exit-btn:hover {
490 background: rgba(var(--glass-rgb), 0.7);
491 color: white;
492 transform: scale(1.05);
493}
494
495@media (max-width: 768px) {
496 body.zen .zen-exit-btn {
497 display: flex;
498 }
499}
500
501/* ── MOBILE ──────────────────── */
502@media (max-width: 768px) {
503 :root { --sidebar-w: 280px; }
504
505 .sidebar {
506 position: fixed; top: 0; left: 0; bottom: 0;
507 width: var(--sidebar-w);
508 background: rgba(var(--glass-rgb), 0.95);
509 backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
510 z-index: 50;
511 display: flex;
512 }
513
514 .sidebar.hidden { display: none; }
515 .toolbar { gap: 4px; padding: 0 8px; }
516 .tb-range-wrap { display: flex; }
517 .tb-range-label { display: none; }
518 .tb-range { width: 80px; }
519 .tb-sep { display: none; }
520 .editor-scroll { padding: 16px; }
521 body.zen .bottombar { opacity: 1; }
522 .tb-back span { display: none; }
523 .tb-copy span { display: none; }
524}
525
526@media (max-width: 480px) {
527 .bb-stat:not(.bb-energy) { display: none; }
528 .save-btn { padding: 6px 16px; }
529 .tb-range { width: 60px; }
530}
531
532/* ── Hidden text measurer ── */
533#textMeasurer {
534 position: absolute;
535 visibility: hidden;
536 height: auto;
537 width: auto;
538 white-space: pre;
539 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
540 font-size: var(--editor-font-size);
541 line-height: var(--editor-line-height);
542 pointer-events: none;
543}
544
545/* ── HISTORY PANEL ──────────── */
546.history-overlay {
547 position: fixed; inset: 0; z-index: 90;
548 background: rgba(0,0,0,0.5);
549 backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
550 display: flex; justify-content: center; align-items: center;
551 padding: 20px;
552}
553.history-overlay.hidden { display: none; }
554
555.history-panel {
556 background: rgba(var(--glass-rgb), 0.65);
557 backdrop-filter: blur(22px) saturate(140%);
558 -webkit-backdrop-filter: blur(22px) saturate(140%);
559 border-radius: 16px;
560 border: 1px solid rgba(255,255,255,0.2);
561 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
562 width: 100%; max-width: 700px;
563 max-height: 80vh;
564 display: flex; flex-direction: column;
565 overflow: hidden;
566}
567
568.history-header {
569 padding: 16px 20px;
570 border-bottom: 1px solid rgba(255,255,255,0.1);
571 display: flex; align-items: center; justify-content: space-between;
572 flex-shrink: 0;
573}
574.history-title { font-size: 16px; font-weight: 700; color: rgba(255,255,255,0.9); }
575
576.history-list {
577 max-height: 200px; overflow-y: auto; padding: 8px 12px;
578 flex-shrink: 0;
579 border-bottom: 1px solid rgba(255,255,255,0.08);
580}
581.history-list::-webkit-scrollbar { width: 4px; }
582.history-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
583
584.history-item {
585 display: flex; align-items: center; gap: 10px;
586 padding: 8px 12px; border-radius: 8px;
587 cursor: pointer; transition: all 0.2s;
588 border: 1px solid transparent; margin-bottom: 2px;
589}
590.history-item:hover { background: rgba(255,255,255,0.1); }
591.history-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
592
593.history-item-badge {
594 padding: 2px 8px; border-radius: 980px;
595 font-size: 10px; font-weight: 700; text-transform: uppercase;
596 letter-spacing: 0.5px; flex-shrink: 0;
597}
598.history-item-badge.add { background: rgba(72,187,120,0.25); color: rgba(72,187,120,0.9); }
599.history-item-badge.edit { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
600
601.history-item-info { flex: 1; min-width: 0; }
602.history-item-user { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.85); }
603.history-item-date { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 1px; }
604
605.history-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
606.history-view.hidden { display: none; }
607
608.history-view-header {
609 padding: 10px 16px;
610 display: flex; align-items: center; justify-content: space-between; gap: 8px;
611 border-bottom: 1px solid rgba(255,255,255,0.08);
612 flex-shrink: 0;
613}
614.history-view-modes { display: flex; gap: 4px; }
615
616.history-view-content {
617 flex: 1; overflow-y: auto; padding: 12px 16px;
618 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
619 font-size: 12px; line-height: 1.6;
620 white-space: pre-wrap; word-break: break-word;
621}
622.history-view-content::-webkit-scrollbar { width: 4px; }
623.history-view-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
624
625.diff-line { padding: 1px 8px; border-radius: 3px; margin: 0; }
626.diff-same { color: rgba(255,255,255,0.7); }
627.diff-add { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.95); }
628.diff-del { background: rgba(239,68,68,0.2); color: rgba(239,68,68,0.95); }
629.history-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,0.3); font-size: 13px; }
630
631@media (max-width: 768px) {
632 .history-panel { max-width: 100%; max-height: 90vh; border-radius: 12px; }
633 .history-list { max-height: 150px; }
634}
635</style>
636</head>
637<body>
638
639<!-- Hidden element for measuring text width -->
640<div id="textMeasurer"></div>
641
642<!-- ── ZEN EXIT BUTTON (mobile) ─────────────── -->
643<button class="zen-exit-btn" id="zenExitBtn" title="Exit Zen Mode">✕</button>
644
645<!-- ── TOOLBAR ──────────────────────────────── -->
646<div class="toolbar">
647 <a href="/api/v1/node/${nodeId}/${version}/notes${qs}" class="tb-back" id="backBtn">← <span>Notes</span></a>
648 <div class="tb-sep"></div>
649 <button class="tb-btn" id="sidebarToggle" title="Toggle sidebar">☰</button>
650 <button class="tb-btn" id="zenToggle" title="Zen mode">🧘</button>
651 <button class="tb-btn" id="monoToggle" title="Code mode (monospace)">{ }</button>
652 <div class="tb-sep"></div>
653 <div class="tb-range-wrap tb-fontsize">
654 <span class="tb-range-label">Font</span>
655 <input type="range" class="tb-range" id="fontSizeRange" min="13" max="28" value="20" title="Font Size">
656 </div>
657 <div class="tb-range-wrap tb-lineheight">
658 <span class="tb-range-label">Spacing</span>
659 <input type="range" class="tb-range" id="lineHeightRange" min="12" max="30" value="16" step="1" title="Line Spacing">
660 </div>
661
662 <div class="tb-spacer"></div>
663 ${isNew ? "" : '<button class="tb-btn" id="historyToggle" title="Edit history">🕒</button>'}
664 <button class="tb-copy" id="copyBtn" title="Copy all text">📋 <span>Copy</span></button>
665</div>
666
667<!-- ── MAIN ─────────────────────────────────── -->
668<div class="main">
669
670 <!-- SIDEBAR -->
671 <div class="sidebar hidden" id="sidebar">
672 <div class="sidebar-header">
673 <span class="sidebar-title">Notes</span>
674 <button class="sidebar-close" id="sidebarCloseBtn">✕</button>
675 </div>
676 <div class="sidebar-list" id="notesList">
677 <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading…</div>
678 </div>
679 <button class="sidebar-new" id="newNoteBtn">+ New Note</button>
680 </div>
681
682 <!-- EDITOR -->
683 <div class="editor-wrap">
684 <div class="editor-scroll" id="editorScroll">
685 <div class="editor-container" id="editorContainer">
686 <div class="editor-with-lines" id="editorWithLines">
687 <div class="line-numbers" id="lineNumbers"></div>
688 <div class="editor-code-scroll" id="editorCodeScroll">
689 <textarea id="editor" placeholder="Start writing…">${safeContent}</textarea>
690 </div>
691 </div>
692 </div>
693 </div>
694 </div>
695</div>
696
697<!-- ── BOTTOM BAR ──────────────────────────── -->
698<div class="bottombar">
699 <div class="bb-left">
700 <span class="bb-stat" id="charCount">0 chars</span>
701 <span class="bb-stat" id="wordCount">0 words</span>
702 <span class="bb-stat" id="lineCount">0 lines</span>
703 <span class="bb-stat bb-energy" id="energyCost">⚡0</span>
704 </div>
705 <div class="bb-right">
706 <span class="bb-status" id="saveStatus">${isNew ? "New note" : "Loaded"}</span>
707 <button class="delete-btn" id="deleteBtn">Delete</button>
708 <button class="save-btn" id="saveBtn">Save</button>
709 </div>
710</div>
711
712<!-- ── DELETE MODAL ─────────────────────────── -->
713<div class="modal-overlay" id="deleteModal">
714 <div class="modal-box">
715 <div class="modal-icon">🗑️</div>
716 <div class="modal-title">Delete this note?</div>
717 <div class="modal-text">
718 It looks like you cleared everything out.<br>
719 Would you like to delete this note entirely?<br>
720 This cannot be undone.
721 </div>
722 <div class="modal-actions">
723 <button class="modal-btn modal-btn-cancel" id="deleteCancelBtn">Cancel</button>
724 <button class="modal-btn modal-btn-delete" id="deleteConfirmBtn">Delete</button>
725 </div>
726 </div>
727</div>
728
729<!-- ── HISTORY PANEL ───────────────────────────── -->
730<div class="history-overlay hidden" id="historyOverlay">
731 <div class="history-panel">
732 <div class="history-header">
733 <span class="history-title">Edit History</span>
734 <button class="sidebar-close" id="historyCloseBtn">✕</button>
735 </div>
736 <div class="history-list" id="historyList">
737 <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>
738 </div>
739 <div class="history-view hidden" id="historyView">
740 <div class="history-view-header">
741 <div class="history-view-modes">
742 <button class="tb-btn active" id="historyFullBtn">Full Content</button>
743 <button class="tb-btn" id="historyDiffBtn">Show Changes</button>
744 </div>
745 <button class="save-btn" id="historyRestoreBtn">Restore</button>
746 </div>
747 <div class="history-view-content" id="historyViewContent"></div>
748 </div>
749 </div>
750</div>
751
752<script>
753/* ═══════════════════════════════════════════════════
754 STATE
755 ═══════════════════════════════════════════════════ */
756var nodeId = "${nodeId}";
757var version = "${version}";
758var currentNoteId = ${noteId ? '"' + noteId + '"' : "null"};
759var qs = "${qs}";
760var tokenQS = "${tokenQS}";
761var isNew = ${isNew};
762var originalLen = ${originalLength || 0};
763var lastSaved = ${isNew ? '""' : 'document.getElementById("editor").value'};
764var saving = false;
765var navigatingAway = false;
766
767/* ═══════════════════════════════════════════════════
768 DOM REFS
769 ═══════════════════════════════════════════════════ */
770var editor = document.getElementById("editor");
771var saveBtn = document.getElementById("saveBtn");
772var deleteBtn = document.getElementById("deleteBtn");
773var saveStatus = document.getElementById("saveStatus");
774var charCountEl = document.getElementById("charCount");
775var wordCountEl = document.getElementById("wordCount");
776var lineCountEl = document.getElementById("lineCount");
777var energyCostEl = document.getElementById("energyCost");
778var sidebar = document.getElementById("sidebar");
779var notesList = document.getElementById("notesList");
780var lineNumbersEl = document.getElementById("lineNumbers");
781var editorScroll = document.getElementById("editorScroll");
782var editorWithLines = document.getElementById("editorWithLines");
783var editorCodeScroll = document.getElementById("editorCodeScroll");
784var editorContainer = document.getElementById("editorContainer");
785var textMeasurer = document.getElementById("textMeasurer");
786
787/* ═══════════════════════════════════════════════════
788 SETTINGS (persisted in localStorage)
789 ═══════════════════════════════════════════════════ */
790function loadSettings() {
791 try {
792 var s = JSON.parse(localStorage.getItem("tree-editor-settings") || "{}");
793 if (s.fontSize) document.getElementById("fontSizeRange").value = s.fontSize;
794 if (s.lineHeight) document.getElementById("lineHeightRange").value = s.lineHeight;
795 if (s.mono) {
796 editor.classList.add("mono");
797 lineNumbersEl.classList.add("show");
798 editorScroll.classList.add("code-scroll-enabled");
799 editorContainer.classList.add("code-mode-active");
800 document.getElementById("monoToggle").classList.add("active");
801 }
802 applySettings();
803 } catch (e) {}
804}
805
806function persistSettings() {
807 try {
808 localStorage.setItem("tree-editor-settings", JSON.stringify({
809 fontSize: document.getElementById("fontSizeRange").value,
810 lineHeight: document.getElementById("lineHeightRange").value,
811 mono: editor.classList.contains("mono")
812 }));
813 } catch (e) {}
814}
815
816function applySettings() {
817 var fontSize = document.getElementById("fontSizeRange").value;
818 var lineHeight = document.getElementById("lineHeightRange").value;
819
820 document.documentElement.style.setProperty("--editor-font-size", fontSize + "px");
821 document.documentElement.style.setProperty("--editor-line-height", lineHeight / 10);
822
823 autoGrowEditor();
824}
825
826document.getElementById("fontSizeRange").oninput = function() { applySettings(); persistSettings(); };
827document.getElementById("lineHeightRange").oninput = function() { applySettings(); persistSettings(); };
828
829document.getElementById("monoToggle").onclick = function() {
830 editor.classList.toggle("mono");
831 lineNumbersEl.classList.toggle("show");
832 editorScroll.classList.toggle("code-scroll-enabled");
833 editorContainer.classList.toggle("code-mode-active");
834 this.classList.toggle("active");
835 autoGrowEditor();
836 persistSettings();
837};
838
839/* ═══════════════════════════════════════════════════
840 MEASURE TEXT WIDTH FOR CODE MODE
841 ═══════════════════════════════════════════════════ */
842function measureTextWidth(text) {
843 // Update measurer styles to match editor
844 var computedStyle = getComputedStyle(editor);
845 textMeasurer.style.fontFamily = computedStyle.fontFamily;
846 textMeasurer.style.fontSize = computedStyle.fontSize;
847 textMeasurer.style.lineHeight = computedStyle.lineHeight;
848 textMeasurer.style.letterSpacing = computedStyle.letterSpacing;
849
850 // Find the longest line
851 var lines = text.split("\\n");
852 var maxWidth = 0;
853
854 for (var i = 0; i < lines.length; i++) {
855 textMeasurer.textContent = lines[i] || " ";
856 var width = textMeasurer.offsetWidth;
857 if (width > maxWidth) maxWidth = width;
858 }
859
860 return maxWidth;
861}
862
863function updateEditorWidth() {
864 if (!editor.classList.contains("mono")) {
865 // Normal mode: reset width
866 editor.style.width = "100%";
867 return;
868 }
869
870 // Code mode: measure and set width to fit longest line
871 var contentWidth = measureTextWidth(editor.value);
872 var minWidth = editorScroll.clientWidth - 80; // Account for padding and line numbers
873 var newWidth = Math.max(contentWidth + 20, minWidth); // Add some padding
874
875 editor.style.width = newWidth + "px";
876}
877
878/* ═══════════════════════════════════════════════════
879 AUTO-GROW EDITOR (like VS Code / Word)
880 ═══════════════════════════════════════════════════ */
881function autoGrowEditor() {
882 var minH = window.innerHeight - 52 - 44 - 32;
883
884 editor.style.height = 'auto';
885 var newHeight = Math.max(editor.scrollHeight, minH);
886 editor.style.height = newHeight + 'px';
887
888 updateLineNumbers();
889 updateEditorWidth();
890}
891
892/* ═══════════════════════════════════════════════════
893 LINE NUMBERS
894 ═══════════════════════════════════════════════════ */
895function updateLineNumbers() {
896 if (!editor.classList.contains("mono")) return;
897
898 var lines = editor.value.split("\\n");
899 var count = lines.length;
900 var html = "";
901 for (var i = 1; i <= count; i++) {
902 html += "<span>" + i + "</span>";
903 }
904 lineNumbersEl.innerHTML = html;
905}
906
907/* ═══════════════════════════════════════════════════
908 ZEN MODE
909 ═══════════════════════════════════════════════════ */
910function exitZenMode() {
911 document.body.classList.remove("zen");
912 document.getElementById("zenToggle").classList.remove("active");
913 autoGrowEditor();
914}
915
916document.getElementById("zenToggle").onclick = function() {
917 document.body.classList.toggle("zen");
918 this.classList.toggle("active");
919 autoGrowEditor();
920};
921
922document.getElementById("zenExitBtn").onclick = exitZenMode;
923
924/* ═══════════════════════════════════════════════════
925 COPY ALL TEXT
926 ═══════════════════════════════════════════════════ */
927document.getElementById("copyBtn").onclick = function() {
928 var btn = this;
929 var btnSpan = btn.querySelector("span");
930
931 // Select all text
932 editor.select();
933 editor.setSelectionRange(0, editor.value.length);
934
935 // Copy to clipboard
936 navigator.clipboard.writeText(editor.value).then(function() {
937 btn.firstChild.textContent = "✓ ";
938 if (btnSpan) btnSpan.textContent = "Copied";
939 btn.classList.add("copied");
940
941 setTimeout(function() {
942 btn.firstChild.textContent = "📋 ";
943 if (btnSpan) btnSpan.textContent = "Copy";
944 btn.classList.remove("copied");
945 }, 1500);
946 }).catch(function() {
947 // Fallback for older browsers
948 try {
949 document.execCommand("copy");
950 btn.firstChild.textContent = "✓ ";
951 if (btnSpan) btnSpan.textContent = "Copied";
952 btn.classList.add("copied");
953
954 setTimeout(function() {
955 btn.firstChild.textContent = "📋 ";
956 if (btnSpan) btnSpan.textContent = "Copy";
957 btn.classList.remove("copied");
958 }, 1500);
959 } catch (e) {
960 if (btnSpan) btnSpan.textContent = "Failed";
961 setTimeout(function() {
962 btn.firstChild.textContent = "📋 ";
963 if (btnSpan) btnSpan.textContent = "Copy";
964 }, 1500);
965 }
966 });
967};
968
969/* ═══════════════════════════════════════════════════
970 SIDEBAR TOGGLE
971 ═══════════════════════════════════════════════════ */
972function toggleSidebar() {
973 sidebar.classList.toggle("hidden");
974 document.getElementById("sidebarToggle").classList.toggle("active");
975}
976
977document.getElementById("sidebarToggle").onclick = toggleSidebar;
978
979document.getElementById("sidebarCloseBtn").onclick = function() {
980 sidebar.classList.add("hidden");
981 document.getElementById("sidebarToggle").classList.remove("active");
982};
983
984/* ═══════════════════════════════════════════════════
985 ENERGY ESTIMATE (mirrors server: min 1, max 5)
986 ═══════════════════════════════════════════════════ */
987function estimateEnergy(chars) {
988 return Math.min(5, Math.max(1, 1 + Math.floor(chars / 1000)));
989}
990
991/* ═══════════════════════════════════════════════════
992 STATS + ENERGY + EMPTY DETECTION
993 ═══════════════════════════════════════════════════ */
994function updateStats() {
995 var text = editor.value;
996 var len = text.length;
997 var trimmed = text.trim().length;
998
999 charCountEl.textContent = len + " chars";
1000 wordCountEl.textContent = (text.trim() ? text.trim().split(/\\s+/).length : 0) + " words";
1001 lineCountEl.textContent = text.split("\\n").length + " lines";
1002
1003 var cost;
1004 if (isNew && !currentNoteId) {
1005 cost = len > 0 ? estimateEnergy(len) : 0;
1006 } else {
1007 var delta = Math.max(0, len - originalLen);
1008 cost = delta > 0 ? estimateEnergy(delta) : 1;
1009 }
1010 energyCostEl.textContent = "\\u26A1" + cost;
1011
1012 if (!isNew && currentNoteId) {
1013 if (trimmed === 0) {
1014 deleteBtn.classList.add("show");
1015 saveBtn.disabled = true;
1016 } else {
1017 deleteBtn.classList.remove("show");
1018 saveBtn.disabled = false;
1019 }
1020 } else {
1021 saveBtn.disabled = trimmed === 0;
1022 }
1023}
1024
1025/* ═══════════════════════════════════════════════════
1026 DIRTY TRACKING
1027 ═══════════════════════════════════════════════════ */
1028function isDirty() {
1029 return editor.value !== lastSaved;
1030}
1031
1032function markDirty() {
1033 if (isDirty()) {
1034 saveStatus.textContent = "Unsaved changes";
1035 saveStatus.className = "bb-status unsaved";
1036 }
1037}
1038
1039editor.addEventListener("input", function() {
1040 updateStats();
1041 markDirty();
1042 autoGrowEditor();
1043});
1044
1045editor.addEventListener("paste", function() {
1046 setTimeout(autoGrowEditor, 0);
1047});
1048
1049/* ═══════════════════════════════════════════════════
1050 NAVIGATION WITH UNSAVED CHECK
1051 ═══════════════════════════════════════════════════ */
1052function navigateWithCheck(url) {
1053 if (isDirty()) {
1054 if (!confirm("Unsaved changes. Discard?")) {
1055 return false;
1056 }
1057 }
1058 navigatingAway = true;
1059 window.location.href = url;
1060 return true;
1061}
1062
1063document.getElementById("backBtn").onclick = function(e) {
1064 e.preventDefault();
1065 navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes" + qs);
1066};
1067
1068/* ═══════════════════════════════════════════════════
1069 SAVE → POST (new) or PUT (existing)
1070 ═══════════════════════════════════════════════════ */
1071async function doSave() {
1072 if (saving) return;
1073 var content = editor.value;
1074
1075 if (!isNew && currentNoteId && !content.trim()) {
1076 openDeleteModal();
1077 return;
1078 }
1079
1080 if (!content.trim()) {
1081 saveStatus.textContent = "Cannot save empty note";
1082 saveStatus.className = "bb-status error";
1083 return;
1084 }
1085
1086 saving = true;
1087 saveBtn.disabled = true;
1088 saveStatus.textContent = "Saving\\u2026";
1089 saveStatus.className = "bb-status saving";
1090
1091 try {
1092 var url, method;
1093
1094 if (currentNoteId) {
1095 url = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId;
1096 method = "PUT";
1097 } else {
1098 url = "/api/v1/node/" + nodeId + "/" + version + "/notes";
1099 method = "POST";
1100 }
1101
1102 var res = await fetch(url, {
1103 method: method,
1104 headers: { "Content-Type": "application/json" },
1105 body: JSON.stringify({ content: content, contentType: "text" }),
1106 credentials: "include"
1107 });
1108
1109 if (!res.ok) {
1110 var errData = await res.json().catch(function() { return {}; });
1111 throw new Error(errData.error || "Save failed (" + res.status + ")");
1112 }
1113
1114 var data = await res.json();
1115
1116 if (!currentNoteId) {
1117 var newId = data._id || (data.note && data.note._id);
1118 if (newId) {
1119 currentNoteId = newId;
1120 isNew = false;
1121 originalLen = content.length;
1122 history.replaceState(null, "",
1123 "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/editor" + qs
1124 );
1125 }
1126 } else {
1127 originalLen = content.length;
1128 }
1129
1130 lastSaved = content;
1131
1132 var msg = "Saved";
1133 var eu = data.energyUsed || 0;
1134 if (eu > 0) msg += " \\u00b7 \\u26A1" + eu;
1135 saveStatus.textContent = msg;
1136 saveStatus.className = "bb-status saved";
1137
1138 navigatingAway = true;
1139 if (currentNoteId) {
1140 window.location.href =
1141 "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + qs;
1142 } else {
1143 window.location.href =
1144 "/api/v1/node/" + nodeId + "/" + version + "/notes" + qs;
1145 }
1146
1147 loadNotes();
1148
1149 } catch (err) {
1150 saveStatus.textContent = err.message;
1151 saveStatus.className = "bb-status error";
1152 } finally {
1153 saving = false;
1154 saveBtn.disabled = false;
1155 updateStats();
1156 }
1157}
1158
1159saveBtn.onclick = doSave;
1160
1161/* ═══════════════════════════════════════════════════
1162 DELETE → DELETE route
1163 ═══════════════════════════════════════════════════ */
1164function openDeleteModal() { document.getElementById("deleteModal").classList.add("show"); }
1165function closeDeleteModal() { document.getElementById("deleteModal").classList.remove("show"); }
1166
1167document.getElementById("deleteCancelBtn").onclick = closeDeleteModal;
1168document.getElementById("deleteModal").onclick = function(e) { if (e.target === this) closeDeleteModal(); };
1169
1170document.getElementById("deleteConfirmBtn").onclick = async function() {
1171 if (!currentNoteId) return;
1172 this.disabled = true;
1173 this.textContent = "Deleting\\u2026";
1174
1175 try {
1176 var res = await fetch(
1177 "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId,
1178 { method: "DELETE", credentials: "include" }
1179 );
1180
1181 if (!res.ok) {
1182 var errData = await res.json().catch(function() { return {}; });
1183 throw new Error(errData.error || "Delete failed");
1184 }
1185
1186 navigatingAway = true;
1187 window.location.href = "/api/v1/" + nodeId + "/" + version + "/notes" + qs;
1188
1189 } catch (err) {
1190 closeDeleteModal();
1191 saveStatus.textContent = err.message;
1192 saveStatus.className = "bb-status error";
1193 this.disabled = false;
1194 this.textContent = "Delete";
1195 }
1196};
1197
1198deleteBtn.onclick = openDeleteModal;
1199
1200/* ═══════════════════════════════════════════════════
1201 KEYBOARD SHORTCUTS
1202 ═══════════════════════════════════════════════════ */
1203document.addEventListener("keydown", function(e) {
1204 if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); doSave(); }
1205 if (e.key === "Escape") {
1206 if (document.getElementById("deleteModal").classList.contains("show")) closeDeleteModal();
1207 else if (document.body.classList.contains("zen")) {
1208 exitZenMode();
1209 }
1210 }
1211});
1212
1213editor.addEventListener("keydown", function(e) {
1214 // Enter key in code mode: scroll to left to see line numbers
1215 if (e.key === "Enter" && editor.classList.contains("mono")) {
1216 setTimeout(function() {
1217 editorScroll.scrollLeft = 0;
1218 }, 0);
1219 }
1220
1221 if (e.key !== "Tab") return;
1222 e.preventDefault();
1223 var s = this.selectionStart, end = this.selectionEnd, v = this.value;
1224
1225 if (e.shiftKey) {
1226 var ls = v.lastIndexOf("\\n", s - 1) + 1;
1227 if (v.substring(ls, ls + 2) === " ") {
1228 this.value = v.substring(0, ls) + v.substring(ls + 2);
1229 this.selectionStart = Math.max(s - 2, ls);
1230 this.selectionEnd = Math.max(end - 2, ls);
1231 }
1232 } else {
1233 this.value = v.substring(0, s) + " " + v.substring(end);
1234 this.selectionStart = this.selectionEnd = s + 2;
1235 }
1236 updateStats(); markDirty(); autoGrowEditor();
1237});
1238
1239/* ═══════════════════════════════════════════════════
1240 SIDEBAR: LOAD NOTES LIST
1241 ═══════════════════════════════════════════════════ */
1242async function loadNotes() {
1243 try {
1244 var token = new URLSearchParams(qs.replace("?","")).get("token");
1245 var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes";
1246 if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1247 var res = await fetch(fetchUrl, { credentials: "include" });
1248 var data = await res.json();
1249 var notes = data.notes || data || [];
1250 if (!Array.isArray(notes)) notes = [];
1251 if (!notes.length) { notesList.innerHTML = emptyMsg("No notes yet"); return; }
1252
1253 var html = "";
1254 for (var i = 0; i < notes.length; i++) {
1255 var n = notes[i];
1256 var nId = n._id || n.id;
1257 var isFile = n.contentType === "file";
1258 var icon = isFile ? "\\ud83d\\udcce" : "\\ud83d\\udcdd";
1259 var preview;
1260 var username = n.username || n.user || n.author || "Unknown";
1261
1262 if (isFile) preview = n.content ? n.content.split("/").pop() : "File";
1263 else preview = (n.content || "").slice(0, 60) || "Empty note";
1264
1265 var active = nId === currentNoteId;
1266 var date = n.createdAt ? new Date(n.createdAt).toLocaleDateString() : "";
1267
1268 html +=
1269 '<div class="note-item' + (active ? " active" : "") +
1270 '" data-id="' + nId + '" data-type="' + (n.contentType || "text") + '">' +
1271 '<div class="note-item-icon">' + icon + '</div>' +
1272 '<div class="note-item-info">' +
1273 '<div class="note-item-username">' + esc(username) + '</div>' +
1274 '<div class="note-item-preview">' + esc(preview) + '</div>' +
1275 '<div class="note-item-meta">' + date + '</div>' +
1276 '</div>' +
1277 '</div>';
1278 }
1279 notesList.innerHTML = html;
1280
1281 notesList.querySelectorAll(".note-item").forEach(function(item) {
1282 item.onclick = function() {
1283 var nId = item.dataset.id;
1284 var nType = item.dataset.type;
1285 if (nId === currentNoteId) return;
1286
1287 var targetUrl;
1288 if (nType === "file")
1289 targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + tokenQS;
1290 else
1291 targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + "/editor" + qs;
1292
1293 navigateWithCheck(targetUrl);
1294 };
1295 });
1296
1297 } catch (err) {
1298 notesList.innerHTML = emptyMsg("Error loading notes");
1299 }
1300}
1301
1302function emptyMsg(t) {
1303 return '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">' + t + '</div>';
1304}
1305
1306function esc(s) { return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
1307
1308/* ═══════════════════════════════════════════════════
1309 NEW NOTE BUTTON
1310 ═══════════════════════════════════════════════════ */
1311document.getElementById("newNoteBtn").onclick = function() {
1312 navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes/editor" + qs);
1313};
1314
1315/* ═══════════════════════════════════════════════════
1316 WARN ON LEAVE
1317 ═══════════════════════════════════════════════════ */
1318window.addEventListener("beforeunload", function(e) {
1319 if (!navigatingAway && isDirty()) {
1320 e.preventDefault();
1321 e.returnValue = "";
1322 }
1323});
1324
1325/* ═══════════════════════════════════════════════════
1326 WINDOW RESIZE
1327 ═══════════════════════════════════════════════════ */
1328window.addEventListener("resize", function() {
1329 autoGrowEditor();
1330});
1331
1332/* ═══════════════════════════════════════════════════
1333 EDIT HISTORY
1334 ═══════════════════════════════════════════════════ */
1335var historyData = [];
1336var selectedHistoryIdx = -1;
1337var historyMode = "full"; // "full" or "diff"
1338
1339var historyToggleBtn = document.getElementById("historyToggle");
1340var historyOverlay = document.getElementById("historyOverlay");
1341var historyCloseBtn = document.getElementById("historyCloseBtn");
1342var historyListEl = document.getElementById("historyList");
1343var historyView = document.getElementById("historyView");
1344var historyViewContent = document.getElementById("historyViewContent");
1345var historyFullBtn = document.getElementById("historyFullBtn");
1346var historyDiffBtn = document.getElementById("historyDiffBtn");
1347var historyRestoreBtn = document.getElementById("historyRestoreBtn");
1348
1349if (historyToggleBtn) {
1350 historyToggleBtn.onclick = function() {
1351 historyOverlay.classList.remove("hidden");
1352 loadHistory();
1353 };
1354}
1355
1356if (historyCloseBtn) {
1357 historyCloseBtn.onclick = function() {
1358 historyOverlay.classList.add("hidden");
1359 };
1360}
1361
1362if (historyOverlay) {
1363 historyOverlay.onclick = function(e) {
1364 if (e.target === historyOverlay) historyOverlay.classList.add("hidden");
1365 };
1366}
1367
1368async function loadHistory() {
1369 historyListEl.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>';
1370 historyView.classList.add("hidden");
1371 selectedHistoryIdx = -1;
1372
1373 try {
1374 var token = new URLSearchParams(qs.replace("?","")).get("token");
1375 var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/history";
1376 if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1377 var res = await fetch(fetchUrl, { credentials: "include" });
1378 var data = await res.json();
1379 historyData = data.history || [];
1380
1381 if (!historyData.length) {
1382 historyListEl.innerHTML = '<div class="history-empty">No edit history available yet.<br>History is recorded on future saves.</div>';
1383 return;
1384 }
1385
1386 var html = "";
1387 for (var i = historyData.length - 1; i >= 0; i--) {
1388 var h = historyData[i];
1389 var d = new Date(h.date);
1390 var dateStr = d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1391 var badgeClass = h.action === "add" ? "add" : "edit";
1392 var badgeLabel = h.action === "add" ? "Created" : "Edit";
1393
1394 html +=
1395 '<div class="history-item" data-idx="' + i + '">' +
1396 '<span class="history-item-badge ' + badgeClass + '">' + badgeLabel + '</span>' +
1397 '<div class="history-item-info">' +
1398 '<div class="history-item-user">' + esc(h.username) + '</div>' +
1399 '<div class="history-item-date">' + esc(dateStr) + '</div>' +
1400 '</div>' +
1401 '</div>';
1402 }
1403 historyListEl.innerHTML = html;
1404
1405 var items = historyListEl.querySelectorAll(".history-item");
1406 for (var j = 0; j < items.length; j++) {
1407 items[j].onclick = function() {
1408 var idx = parseInt(this.getAttribute("data-idx"));
1409 selectHistoryEntry(idx);
1410 var all = historyListEl.querySelectorAll(".history-item");
1411 for (var k = 0; k < all.length; k++) all[k].classList.remove("active");
1412 this.classList.add("active");
1413 };
1414 }
1415 } catch (e) {
1416 historyListEl.innerHTML = '<div class="history-empty">Failed to load history.</div>';
1417 }
1418}
1419
1420function selectHistoryEntry(idx) {
1421 selectedHistoryIdx = idx;
1422 historyView.classList.remove("hidden");
1423 renderHistoryView();
1424}
1425
1426if (historyFullBtn) {
1427 historyFullBtn.onclick = function() {
1428 historyMode = "full";
1429 historyFullBtn.classList.add("active");
1430 historyDiffBtn.classList.remove("active");
1431 renderHistoryView();
1432 };
1433}
1434
1435if (historyDiffBtn) {
1436 historyDiffBtn.onclick = function() {
1437 historyMode = "diff";
1438 historyDiffBtn.classList.add("active");
1439 historyFullBtn.classList.remove("active");
1440 renderHistoryView();
1441 };
1442}
1443
1444if (historyRestoreBtn) {
1445 historyRestoreBtn.onclick = function() {
1446 if (selectedHistoryIdx < 0 || !historyData[selectedHistoryIdx]) return;
1447 editor.value = historyData[selectedHistoryIdx].content;
1448 historyOverlay.classList.add("hidden");
1449 updateStats();
1450 markDirty();
1451 autoGrowEditor();
1452 };
1453}
1454
1455function renderHistoryView() {
1456 if (selectedHistoryIdx < 0) return;
1457 var entry = historyData[selectedHistoryIdx];
1458
1459 if (entry.content === null || entry.content === undefined) {
1460 historyViewContent.innerHTML = '<div class="history-empty">Content was not recorded for this entry.</div>';
1461 historyRestoreBtn.style.display = "none";
1462 return;
1463 }
1464 historyRestoreBtn.style.display = "";
1465
1466 if (historyMode === "full") {
1467 historyViewContent.innerHTML = '<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,0.85);">' + esc(entry.content) + '</pre>';
1468 } else {
1469 var prevContent = "";
1470 for (var p = selectedHistoryIdx - 1; p >= 0; p--) {
1471 if (historyData[p].content !== null && historyData[p].content !== undefined) {
1472 prevContent = historyData[p].content;
1473 break;
1474 }
1475 }
1476 var diffHtml = computeDiff(prevContent, entry.content);
1477 historyViewContent.innerHTML = diffHtml;
1478 }
1479}
1480
1481// ── LCS-based line diff ──
1482function computeDiff(oldText, newText) {
1483 var oldLines = oldText.split("\\n");
1484 var newLines = newText.split("\\n");
1485 var m = oldLines.length;
1486 var n = newLines.length;
1487
1488 // Build LCS table
1489 var dp = [];
1490 for (var i = 0; i <= m; i++) {
1491 dp[i] = [];
1492 for (var j = 0; j <= n; j++) {
1493 if (i === 0 || j === 0) dp[i][j] = 0;
1494 else if (oldLines[i-1] === newLines[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
1495 else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
1496 }
1497 }
1498
1499 // Backtrack to get diff ops
1500 var ops = [];
1501 var ci = m, cj = n;
1502 while (ci > 0 || cj > 0) {
1503 if (ci > 0 && cj > 0 && oldLines[ci-1] === newLines[cj-1]) {
1504 ops.push({ type: "same", text: oldLines[ci-1] });
1505 ci--; cj--;
1506 } else if (cj > 0 && (ci === 0 || dp[ci][cj-1] >= dp[ci-1][cj])) {
1507 ops.push({ type: "add", text: newLines[cj-1] });
1508 cj--;
1509 } else {
1510 ops.push({ type: "del", text: oldLines[ci-1] });
1511 ci--;
1512 }
1513 }
1514 ops.reverse();
1515
1516 var html = '<div>';
1517 for (var k = 0; k < ops.length; k++) {
1518 var op = ops[k];
1519 var cls = op.type === "same" ? "diff-same" : (op.type === "add" ? "diff-add" : "diff-del");
1520 var prefix = op.type === "same" ? " " : (op.type === "add" ? "+ " : "- ");
1521 html += '<div class="diff-line ' + cls + '">' + esc(prefix + op.text) + '</div>';
1522 }
1523 html += '</div>';
1524 return html;
1525}
1526
1527/* ═══════════════════════════════════════════════════
1528 INIT
1529 ═══════════════════════════════════════════════════ */
1530loadSettings();
1531updateStats();
1532loadNotes();
1533if (!isNew) lastSaved = editor.value;
1534autoGrowEditor();
1535setTimeout(function() { editor.focus(); }, 100);
1536
1537try {
1538 var draft = sessionStorage.getItem("tree-editor-draft");
1539 if (draft && isNew && !editor.value) {
1540 editor.value = draft;
1541 sessionStorage.removeItem("tree-editor-draft");
1542 updateStats();
1543 markDirty();
1544 autoGrowEditor();
1545 }
1546} catch (e) {}
1547</script>
1548</body>
1549</html>`;
1550}
1551
1552export function renderBookPage({
1553 nodeId,
1554 token,
1555 title,
1556 content,
1557 options,
1558 tocEnabled,
1559 tocDepth,
1560 isStatusActive,
1561 isStatusCompleted,
1562 isStatusTrimmed,
1563 book,
1564 hasContent,
1565}) {
1566 const treeDepth = hasContent ? Math.min(getBookDepth(book), 5) : 0;
1567
1568 let tocDepthSelect = "";
1569 if (tocEnabled && hasContent && treeDepth > 1) {
1570 let opts = `<option value="0" ${tocDepth === 0 ? "selected" : ""}>All Depths</option>`;
1571 for (let i = 1; i <= treeDepth; i++) {
1572 opts += `<option value="${i}" ${tocDepth === i ? "selected" : ""}>Depth ${i}${i === 5 ? " (max)" : ""}</option>`;
1573 }
1574 tocDepthSelect = `<select class="toc-select" onchange="setTocDepth(this.value)">${opts}</select>`;
1575 }
1576
1577 const bookContent = hasContent
1578 ? renderBookNode(book, 1, token)
1579 : `
1580 <div class="empty-state">
1581 <div class="empty-state-icon">📖</div>
1582 <div class="empty-state-text">No content</div>
1583 <div class="empty-state-subtext">
1584 This node has no notes or child notes under the current filters.
1585 </div>
1586 </div>
1587 `;
1588
1589 return `
1590<!DOCTYPE html>
1591<html lang="en">
1592<head>
1593 <meta charset="UTF-8">
1594 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
1595 <meta name="theme-color" content="#667eea">
1596 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1597<title>Book: ${escapeHtml(title)}</title>
1598 <style>
1599 ${baseStyles}
1600
1601 /* ── Book page overrides on base ── */
1602 body { padding: 0; }
1603
1604 /* Top Navigation Bar - Glass */
1605 .top-nav {
1606 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1607 backdrop-filter: blur(22px) saturate(140%);
1608 -webkit-backdrop-filter: blur(22px) saturate(140%);
1609 padding: 10px 20px;
1610 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1611 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1612 border-bottom: 1px solid rgba(255, 255, 255, 0.28);
1613 position: sticky;
1614 top: 0;
1615 z-index: 100;
1616 animation: fadeInUp 0.5s ease-out;
1617 }
1618
1619 .top-nav-content {
1620 max-width: 900px;
1621 margin: 0 auto;
1622 }
1623
1624 .nav-buttons {
1625 display: flex;
1626 justify-content: space-between;
1627 align-items: center;
1628 gap: 8px;
1629 flex-wrap: wrap;
1630 margin-bottom: 4px;
1631 }
1632
1633 .nav-left {
1634 display: flex;
1635 gap: 8px;
1636 flex-wrap: wrap;
1637 }
1638
1639 /* Glass Navigation Buttons */
1640 .nav-button {
1641 display: inline-flex;
1642 align-items: center;
1643 gap: 6px;
1644 padding: 8px 14px;
1645 background: rgba(255, 255, 255, 0.2);
1646 backdrop-filter: blur(10px);
1647 color: white;
1648 text-decoration: none;
1649 border-radius: 980px;
1650 font-weight: 600;
1651 font-size: 14px;
1652 transition: all 0.3s;
1653 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1654 border: 1px solid rgba(255, 255, 255, 0.3);
1655 position: relative;
1656 overflow: hidden;
1657 cursor: pointer;
1658 touch-action: manipulation;
1659 }
1660
1661 .nav-button::before {
1662 content: "";
1663 position: absolute;
1664 inset: -40%;
1665 background: radial-gradient(
1666 120% 60% at 0% 0%,
1667 rgba(255, 255, 255, 0.35),
1668 transparent 60%
1669 );
1670 opacity: 0;
1671 transform: translateX(-30%) translateY(-10%);
1672 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1673 pointer-events: none;
1674 }
1675
1676 .nav-button:hover {
1677 background: rgba(255, 255, 255, 0.3);
1678 transform: translateY(-2px);
1679 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
1680 }
1681
1682 .nav-button:hover::before {
1683 opacity: 1;
1684 transform: translateX(30%) translateY(10%);
1685 }
1686
1687 .page-title {
1688 font-size: 20px;
1689 font-weight: 600;
1690 color: white;
1691 margin-bottom: 12px;
1692 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1693 letter-spacing: -0.3px;
1694 }
1695
1696 /* Glass Filter Buttons */
1697 .filters {
1698 display: flex;
1699 gap: 8px;
1700 flex-wrap: wrap;
1701 }
1702
1703 .filter-button {
1704 padding: 8px 14px;
1705 font-size: 13px;
1706 font-weight: 600;
1707 border-radius: 980px;
1708 border: 1px solid rgba(255, 255, 255, 0.25);
1709 background: rgba(255, 255, 255, 0.15);
1710 backdrop-filter: blur(10px);
1711 color: white;
1712 cursor: pointer;
1713 transition: all 0.3s;
1714 font-family: inherit;
1715 white-space: nowrap;
1716 position: relative;
1717 overflow: hidden;
1718 }
1719
1720 .filter-button::before {
1721 content: "";
1722 position: absolute;
1723 inset: -40%;
1724 background: radial-gradient(
1725 120% 60% at 0% 0%,
1726 rgba(255, 255, 255, 0.35),
1727 transparent 60%
1728 );
1729 opacity: 0;
1730 transform: translateX(-30%) translateY(-10%);
1731 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1732 pointer-events: none;
1733 }
1734
1735 .filter-button:hover {
1736 background: rgba(255, 255, 255, 0.25);
1737 transform: translateY(-1px);
1738 }
1739
1740 .filter-button:hover::before {
1741 opacity: 1;
1742 transform: translateX(30%) translateY(10%);
1743 }
1744
1745 .filter-button.active {
1746 background: rgba(255, 255, 255, 0.35);
1747 border-color: rgba(255, 255, 255, 0.5);
1748 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15),
1749 inset 0 1px 0 rgba(255, 255, 255, 0.4);
1750 }
1751
1752 .filter-button.active:hover {
1753 background: rgba(255, 255, 255, 0.45);
1754 transform: translateY(-2px);
1755 box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
1756 }
1757
1758 .toc-select {
1759 padding: 8px 14px;
1760 font-size: 13px;
1761 font-weight: 600;
1762 border-radius: 980px;
1763 border: 1px solid rgba(255, 255, 255, 0.25);
1764 background: rgba(255, 255, 255, 0.15);
1765 backdrop-filter: blur(10px);
1766 color: white;
1767 cursor: pointer;
1768 font-family: inherit;
1769 appearance: none;
1770 -webkit-appearance: none;
1771 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
1772 background-repeat: no-repeat;
1773 background-position: right 12px center;
1774 padding-right: 30px;
1775 }
1776
1777 .toc-select option {
1778 background: #5a56c4;
1779 color: white;
1780 }
1781
1782 /* Content Container */
1783 .content-wrapper {
1784 padding: 24px 20px;
1785 }
1786
1787 .content {
1788 max-width: 900px;
1789 margin: 0 auto;
1790 font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
1791 line-height: 1.7;
1792 word-wrap: break-word;
1793 overflow-wrap: break-word;
1794 animation: fadeInUp 0.6s ease-out 0.1s both;
1795 }
1796
1797 /* Layered Glass Sections - Each depth gets more opaque glass */
1798 .book-section {
1799 margin-bottom: 40px;
1800 position: relative;
1801 }
1802
1803 .book-section.depth-1 {
1804 margin-bottom: 48px;
1805 padding: 24px;
1806 background: rgba(255, 255, 255, 0.08);
1807 border-radius: 12px;
1808 border: 1px solid rgba(255, 255, 255, 0.15);
1809 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
1810 }
1811
1812 .book-section.depth-2 {
1813 margin-bottom: 32px;
1814 margin-left: 8px;
1815 padding: 20px;
1816 background: rgba(255, 255, 255, 0.06);
1817 border-radius: 10px;
1818 border: 1px solid rgba(255, 255, 255, 0.12);
1819 }
1820
1821 .book-section.depth-3 {
1822 margin-bottom: 24px;
1823 margin-left: 8px;
1824 padding: 16px;
1825 background: rgba(255, 255, 255, 0.04);
1826 border-radius: 8px;
1827 border: 1px solid rgba(255, 255, 255, 0.1);
1828 }
1829
1830 .book-section.depth-4 {
1831 margin-bottom: 20px;
1832 margin-left: 8px;
1833 padding: 12px;
1834 background: rgba(255, 255, 255, 0.03);
1835 border-radius: 6px;
1836 border: 1px solid rgba(255, 255, 255, 0.08);
1837 }
1838
1839 .book-section.depth-5 {
1840 margin-bottom: 16px;
1841 margin-left: 8px;
1842 padding: 10px;
1843 background: rgba(255, 255, 255, 0.02);
1844 border-radius: 6px;
1845 border: 1px solid rgba(255, 255, 255, 0.06);
1846 }
1847
1848 /* Heading Hierarchy */
1849 h1, h2, h3, h4, h5 {
1850 font-weight: 600;
1851 line-height: 1.3;
1852 margin: 0 0 16px 0;
1853 color: white;
1854 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1855 letter-spacing: -0.5px;
1856 }
1857
1858 h1 {
1859 font-size: 36px;
1860 margin-top: 48px;
1861 margin-bottom: 24px;
1862 padding-bottom: 16px;
1863 border-bottom: 2px solid rgba(255, 255, 255, 0.3);
1864 }
1865
1866 .book-section.depth-1:first-child h1 {
1867 margin-top: 0;
1868 }
1869
1870 h2 {
1871 font-size: 30px;
1872 margin-top: 40px;
1873 margin-bottom: 20px;
1874 padding-bottom: 12px;
1875 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
1876 }
1877
1878 h3 {
1879 font-size: 24px;
1880 margin-top: 32px;
1881 margin-bottom: 16px;
1882 }
1883
1884 h4 {
1885 font-size: 20px;
1886 margin-top: 24px;
1887 margin-bottom: 12px;
1888 }
1889
1890 h5 {
1891 font-size: 18px;
1892 margin-top: 20px;
1893 margin-bottom: 10px;
1894 }
1895
1896 /* Note Content - Glowing Text */
1897 .note-content {
1898 margin: 16px 0 28px 0;
1899 padding: 0;
1900 font-size: 18px;
1901 line-height: 1.8;
1902 color: white;
1903 word-wrap: break-word;
1904 overflow-wrap: break-word;
1905 font-weight: 400;
1906 }
1907
1908 .note-link {
1909 color: inherit;
1910 text-decoration: none;
1911 white-space: pre-wrap;
1912 word-wrap: break-word;
1913 overflow-wrap: break-word;
1914 display: block;
1915 padding: 12px 16px;
1916 margin: -12px -16px;
1917 border-radius: 8px;
1918 transition: all 0.3s;
1919 position: relative;
1920 overflow: hidden;
1921 }
1922
1923 .note-link::before {
1924 content: "";
1925 position: absolute;
1926 inset: 0;
1927 background: linear-gradient(
1928 110deg,
1929 transparent 40%,
1930 rgba(255, 255, 255, 0.2),
1931 transparent 60%
1932 );
1933 opacity: 0;
1934 transform: translateX(-100%);
1935 pointer-events: none;
1936 }
1937
1938 .note-link:hover {
1939 background-color: rgba(255, 255, 255, 0.1);
1940 transform: translateX(4px);
1941 }
1942
1943 .note-link:hover::before {
1944 opacity: 1;
1945 animation: glassShimmer 1s ease forwards;
1946 }
1947
1948 @keyframes glassShimmer {
1949 0% {
1950 opacity: 0;
1951 transform: translateX(-120%) skewX(-15deg);
1952 }
1953 50% {
1954 opacity: 1;
1955 }
1956 100% {
1957 opacity: 0;
1958 transform: translateX(120%) skewX(-15deg);
1959 }
1960 }
1961
1962 .note-link:active {
1963 background-color: rgba(255, 255, 255, 0.15);
1964 }
1965
1966 /* File Containers - Deeper Glass */
1967 .file-container {
1968 margin: 24px 0;
1969 padding: 20px;
1970 background: rgba(255, 255, 255, 0.15);
1971 backdrop-filter: blur(18px);
1972 border: 1px solid rgba(255, 255, 255, 0.3);
1973 border-radius: 12px;
1974 transition: all 0.3s;
1975 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
1976 }
1977
1978 .file-container:hover {
1979 border-color: rgba(255, 255, 255, 0.5);
1980 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
1981 background: rgba(255, 255, 255, 0.2);
1982 }
1983
1984 .file-container .note-link {
1985 display: inline-block;
1986 margin-bottom: 12px;
1987 color: white;
1988 font-size: 16px;
1989 font-weight: 600;
1990 padding: 4px 8px;
1991 margin: -4px -8px 8px;
1992 }
1993
1994 .file-container .note-link:hover {
1995 background-color: rgba(255, 255, 255, 0.15);
1996 text-decoration: underline;
1997 }
1998
1999 /* Media Elements */
2000 img {
2001 max-width: 100%;
2002 height: auto;
2003 border-radius: 8px;
2004 margin-top: 12px;
2005 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2006 border: 1px solid rgba(255, 255, 255, 0.2);
2007 }
2008
2009 video, audio {
2010 max-width: 100%;
2011 margin-top: 12px;
2012 border-radius: 8px;
2013 border: 1px solid rgba(255, 255, 255, 0.2);
2014 }
2015
2016 iframe {
2017 width: 100%;
2018 height: 600px;
2019 border: none;
2020 border-radius: 8px;
2021 margin-top: 12px;
2022 border: 1px solid rgba(255, 255, 255, 0.2);
2023 }
2024
2025 /* Empty State */
2026 .empty-state {
2027 text-align: center;
2028 padding: 80px 40px;
2029 }
2030
2031 .empty-state-icon {
2032 font-size: 64px;
2033 margin-bottom: 16px;
2034 filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
2035 }
2036
2037 .empty-state-text {
2038 font-size: 24px;
2039 color: white;
2040 margin-bottom: 8px;
2041 font-weight: 600;
2042 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2043 }
2044
2045 .empty-state-subtext {
2046 font-size: 16px;
2047 color: rgba(255, 255, 255, 0.8);
2048 }
2049
2050 /* Responsive Design */
2051 @media (max-width: 1024px) {
2052 }
2053
2054 @media (max-width: 768px) {
2055 .top-nav {
2056 padding: 12px 16px;
2057 }
2058
2059 .nav-button {
2060 padding: 8px 12px;
2061 font-size: 13px;
2062 }
2063
2064 .page-title {
2065 font-size: 18px;
2066 }
2067
2068 .filter-button {
2069 padding: 6px 12px;
2070 font-size: 12px;
2071 }
2072
2073 .content-wrapper {
2074 padding: 24px 16px;
2075 }
2076
2077 h1 {
2078 font-size: 30px;
2079 }
2080
2081 h2 {
2082 font-size: 26px;
2083 }
2084
2085 h3 {
2086 font-size: 22px;
2087 }
2088
2089 h4 {
2090 font-size: 19px;
2091 }
2092
2093 h5 {
2094 font-size: 17px;
2095 }
2096
2097 .note-content {
2098 font-size: 17px;
2099 }
2100
2101 .book-section.depth-2,
2102 .book-section.depth-3,
2103 .book-section.depth-4,
2104 .book-section.depth-5 {
2105 margin-left: 4px;
2106 }
2107 }
2108
2109 @media (max-width: 480px) {
2110 .nav-buttons {
2111 flex-direction: column;
2112 align-items: stretch;
2113 }
2114
2115 .nav-left {
2116 width: 100%;
2117 flex-direction: column;
2118 }
2119
2120 .nav-button {
2121 justify-content: center;
2122 width: 100%;
2123 }
2124
2125 .book-section.depth-1,
2126 .book-section.depth-2,
2127 .book-section.depth-3,
2128 .book-section.depth-4,
2129 .book-section.depth-5 {
2130 margin-left: 0;
2131 padding: 12px;
2132 }
2133 }
2134
2135 html { scroll-behavior: smooth; }
2136
2137 .book-toc {
2138 max-width: 900px;
2139 margin: 20px auto 24px;
2140 padding: 20px 28px;
2141 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2142 backdrop-filter: blur(22px) saturate(140%);
2143 -webkit-backdrop-filter: blur(22px) saturate(140%);
2144 border: 1px solid rgba(255, 255, 255, 0.28);
2145 border-radius: 16px;
2146 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2147 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2148 }
2149
2150 .toc-title {
2151 font-size: 18px;
2152 font-weight: 700;
2153 color: white;
2154 margin-bottom: 10px;
2155 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2156 }
2157
2158 .toc-list {
2159 list-style: none;
2160 padding-left: 18px;
2161 margin: 0;
2162 }
2163
2164 .book-toc > .toc-list {
2165 padding-left: 0;
2166 }
2167
2168 .book-toc li {
2169 margin: 2px 0;
2170 }
2171
2172 .toc-link {
2173 display: inline-block;
2174 color: white;
2175 text-decoration: none;
2176 padding: 3px 0;
2177 font-size: 15px;
2178 font-weight: 500;
2179 transition: opacity 0.2s;
2180 }
2181
2182 .toc-link:hover {
2183 opacity: 0.7;
2184 text-decoration: underline;
2185 }
2186
2187 .book-toc > .toc-list > li > .toc-link {
2188 font-weight: 700;
2189 font-size: 16px;
2190 }
2191 </style>
2192</head>
2193<body>
2194 <!-- Top Navigation -->
2195 <div class="top-nav">
2196 <div class="top-nav-content">
2197 <div class="nav-buttons">
2198 <div class="nav-left">
2199 <a href="/api/v1/root/${nodeId}?token=${token ?? ""}&html" class="nav-button">
2200 ← Back to Tree
2201 </a>
2202
2203 </div>
2204 <button class="nav-button" onclick="generateShare()">
2205 🔗 Generate Share Link
2206 </button>
2207 </div>
2208
2209<div class="page-title">Book: ${escapeHtml(title)}</div>
2210
2211 <!-- Filters -->
2212 <div class="filters">
2213 <button onclick="toggleFlag('latestVersionOnly')" class="filter-button ${
2214 options.latestVersionOnly ? "active" : ""
2215 }">
2216 Latest Versions Only
2217 </button>
2218 <button onclick="toggleFlag('lastNoteOnly')" class="filter-button ${
2219 options.lastNoteOnly ? "active" : ""
2220 }">
2221 Most Recent Note
2222 </button>
2223 <button onclick="toggleFlag('leafNotesOnly')" class="filter-button ${
2224 options.leafNotesOnly ? "active" : ""
2225 }">
2226 Leaf Details Only
2227 </button>
2228 <button onclick="toggleFlag('filesOnly')" class="filter-button ${
2229 options.filesOnly ? "active" : ""
2230 }">
2231 Files Only
2232 </button>
2233 <button onclick="toggleFlag('textOnly')" class="filter-button ${
2234 options.textOnly ? "active" : ""
2235 }">
2236 Text Only
2237 </button>
2238 <button onclick="toggleStatus('active')" class="filter-button ${
2239 isStatusActive ? "active" : ""
2240 }">
2241 Active
2242 </button>
2243 <button onclick="toggleStatus('completed')" class="filter-button ${
2244 isStatusCompleted ? "active" : ""
2245 }">
2246 Completed
2247 </button>
2248 <button onclick="toggleStatus('trimmed')" class="filter-button ${
2249 isStatusTrimmed ? "active" : ""
2250 }">
2251 Trimmed
2252 </button>
2253 <button onclick="toggleFlag('toc')" class="filter-button ${
2254 tocEnabled ? "active" : ""
2255 }">
2256 Table of Contents
2257 </button>
2258 ${tocDepthSelect}
2259 </div>
2260 </div>
2261 </div>
2262
2263 <!-- Content -->
2264 <div class="content-wrapper">
2265 ${tocEnabled && hasContent ? renderTocBlock(book, tocDepth) : ""}
2266 <div class="content">
2267 ${bookContent}
2268 </div>
2269 </div>
2270
2271 <script>
2272 function tocScroll(id) {
2273 var el = document.getElementById(id);
2274 if (!el) return;
2275 var nav = document.querySelector('.top-nav');
2276 var offset = nav ? nav.offsetHeight + 12 : 12;
2277 var top = el.getBoundingClientRect().top + window.scrollY - offset;
2278 window.scrollTo({ top: top, behavior: 'smooth' });
2279 }
2280 </script>
2281
2282 <!-- Lazy Media Loader -->
2283 <script>
2284 const lazyObserver = new IntersectionObserver(
2285 (entries, observer) => {
2286 entries.forEach(entry => {
2287 if (!entry.isIntersecting) return;
2288
2289 const el = entry.target;
2290 const src = el.dataset.src;
2291
2292 if (src) {
2293 el.src = src;
2294 el.removeAttribute("data-src");
2295 }
2296
2297 observer.unobserve(el);
2298 });
2299 },
2300 { rootMargin: "200px" }
2301 );
2302
2303 document
2304 .querySelectorAll(".lazy-media[data-src]")
2305 .forEach(el => lazyObserver.observe(el));
2306 </script>
2307
2308 <script>
2309 function toggleFlag(flag) {
2310 const url = new URL(window.location.href);
2311
2312 if (url.searchParams.has(flag)) {
2313 url.searchParams.delete(flag);
2314 } else {
2315 url.searchParams.set(flag, "true");
2316 }
2317
2318 url.searchParams.set("html", "true");
2319 window.location.href = url.toString();
2320 }
2321
2322 function toggleStatus(flag) {
2323 const url = new URL(window.location.href);
2324 const params = url.searchParams;
2325
2326 const defaults = {
2327 active: true,
2328 completed: true,
2329 trimmed: false,
2330 };
2331
2332 const current = params.has(flag)
2333 ? params.get(flag) === "true"
2334 : defaults[flag];
2335
2336 const next = !current;
2337
2338 if (next === defaults[flag]) {
2339 params.delete(flag);
2340 } else {
2341 params.set(flag, String(next));
2342 }
2343
2344 params.set("html", "true");
2345 window.location.href = url.toString();
2346 }
2347
2348 async function generateShare() {
2349 const params = Object.fromEntries(new URLSearchParams(window.location.search));
2350 const res = await fetch(window.location.pathname + "/generate", {
2351 method: "POST",
2352 headers: { "Content-Type": "application/json" },
2353 body: JSON.stringify(params),
2354 });
2355
2356 const data = await res.json();
2357 if (data.redirect) {
2358 window.location.href = data.redirect;
2359 }
2360 }
2361
2362 function setTocDepth(val) {
2363 const url = new URL(window.location.href);
2364 if (val === "0") {
2365 url.searchParams.delete("tocDepth");
2366 } else {
2367 url.searchParams.set("tocDepth", val);
2368 }
2369 url.searchParams.set("html", "true");
2370 window.location.href = url.toString();
2371 }
2372 </script>
2373
2374</body>
2375</html>
2376 `;
2377}
2378
2379export function renderSharedBookPage({
2380 nodeId,
2381 title,
2382 content,
2383 shareTocEnabled,
2384 shareTocDepth,
2385 book,
2386 hasContent,
2387}) {
2388 return `
2389<!DOCTYPE html>
2390<html lang="en">
2391<head>
2392 <meta charset="UTF-8">
2393 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
2394 <meta name="theme-color" content="#667eea">
2395 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2396 <title>Book: ${escapeHtml(title)} - TreeOS</title>
2397 <meta name="description" content="Book view of ${escapeHtml(title)} on TreeOS." />
2398 <meta property="og:title" content="Book: ${escapeHtml(title)} - TreeOS" />
2399 <meta property="og:description" content="Book view of ${escapeHtml(title)} on TreeOS." />
2400 <meta property="og:type" content="article" />
2401 <meta property="og:site_name" content="TreeOS" />
2402 <meta property="og:image" content="${getLandUrl()}/tree.png" />
2403 <style>
2404 ${baseStyles}
2405
2406 /* ── Shared book page overrides on base ── */
2407 body { padding: 0; }
2408
2409 /* Top Navigation Bar - Glass */
2410 .top-nav {
2411 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2412 backdrop-filter: blur(22px) saturate(140%);
2413 -webkit-backdrop-filter: blur(22px) saturate(140%);
2414 padding: 10px 20px;
2415 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2416 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2417 border-bottom: 1px solid rgba(255, 255, 255, 0.28);
2418 position: sticky;
2419 top: 0;
2420 z-index: 100;
2421 animation: fadeInUp 0.5s ease-out;
2422 }
2423
2424 .top-nav-content {
2425 max-width: 900px;
2426 margin: 0 auto;
2427 }
2428
2429 .nav-buttons {
2430 display: flex;
2431 align-items: center;
2432 gap: 8px;
2433 flex-wrap: nowrap;
2434 }
2435
2436 /* Glass Navigation Buttons */
2437 .nav-button {
2438 display: inline-flex;
2439 align-items: center;
2440 justify-content: center;
2441 gap: 4px;
2442 padding: 8px 10px;
2443 flex: 1;
2444 background: rgba(255, 255, 255, 0.2);
2445 backdrop-filter: blur(10px);
2446 color: white;
2447 text-decoration: none;
2448 border-radius: 980px;
2449 font-weight: 600;
2450 font-size: 13px;
2451 white-space: nowrap;
2452 transition: all 0.3s;
2453 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
2454 border: 1px solid rgba(255, 255, 255, 0.3);
2455 position: relative;
2456 overflow: hidden;
2457 cursor: pointer;
2458 touch-action: manipulation;
2459 }
2460
2461 .nav-button::before {
2462 content: "";
2463 position: absolute;
2464 inset: -40%;
2465 background: radial-gradient(
2466 120% 60% at 0% 0%,
2467 rgba(255, 255, 255, 0.35),
2468 transparent 60%
2469 );
2470 opacity: 0;
2471 transform: translateX(-30%) translateY(-10%);
2472 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2473 pointer-events: none;
2474 }
2475
2476 .nav-button:hover {
2477 background: rgba(255, 255, 255, 0.3);
2478 transform: translateY(-2px);
2479 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
2480 }
2481
2482 .nav-button:hover::before {
2483 opacity: 1;
2484 transform: translateX(30%) translateY(10%);
2485 }
2486
2487 .page-title {
2488 font-size: 20px;
2489 font-weight: 600;
2490 color: white;
2491 margin-bottom: 12px;
2492 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2493 letter-spacing: -0.3px;
2494 }
2495
2496 /* Glass Filter Buttons */
2497 .filters {
2498 display: flex;
2499 gap: 8px;
2500 flex-wrap: wrap;
2501 }
2502
2503 .filter-button {
2504 padding: 8px 14px;
2505 font-size: 13px;
2506 font-weight: 600;
2507 border-radius: 980px;
2508 border: 1px solid rgba(255, 255, 255, 0.25);
2509 background: rgba(255, 255, 255, 0.15);
2510 backdrop-filter: blur(10px);
2511 color: white;
2512 cursor: pointer;
2513 transition: all 0.3s;
2514 font-family: inherit;
2515 white-space: nowrap;
2516 position: relative;
2517 overflow: hidden;
2518 }
2519
2520 .filter-button::before {
2521 content: "";
2522 position: absolute;
2523 inset: -40%;
2524 background: radial-gradient(
2525 120% 60% at 0% 0%,
2526 rgba(255, 255, 255, 0.35),
2527 transparent 60%
2528 );
2529 opacity: 0;
2530 transform: translateX(-30%) translateY(-10%);
2531 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2532 pointer-events: none;
2533 }
2534
2535 .filter-button:hover {
2536 background: rgba(255, 255, 255, 0.25);
2537 transform: translateY(-1px);
2538 }
2539
2540 .filter-button:hover::before {
2541 opacity: 1;
2542 transform: translateX(30%) translateY(10%);
2543 }
2544
2545 .filter-button.active {
2546 background: rgba(255, 255, 255, 0.35);
2547 border-color: rgba(255, 255, 255, 0.5);
2548 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15),
2549 inset 0 1px 0 rgba(255, 255, 255, 0.4);
2550 }
2551
2552 .filter-button.active:hover {
2553 background: rgba(255, 255, 255, 0.45);
2554 transform: translateY(-2px);
2555 box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
2556 }
2557
2558 .toc-select {
2559 padding: 8px 14px;
2560 font-size: 13px;
2561 font-weight: 600;
2562 border-radius: 980px;
2563 border: 1px solid rgba(255, 255, 255, 0.25);
2564 background: rgba(255, 255, 255, 0.15);
2565 backdrop-filter: blur(10px);
2566 color: white;
2567 cursor: pointer;
2568 font-family: inherit;
2569 appearance: none;
2570 -webkit-appearance: none;
2571 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
2572 background-repeat: no-repeat;
2573 background-position: right 12px center;
2574 padding-right: 30px;
2575 }
2576
2577 .toc-select option {
2578 background: #5a56c4;
2579 color: white;
2580 }
2581
2582 /* Content Container */
2583 .content-wrapper {
2584 padding: 24px 20px;
2585 }
2586
2587 .content {
2588 max-width: 900px;
2589 margin: 0 auto;
2590 font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
2591 line-height: 1.7;
2592 word-wrap: break-word;
2593 overflow-wrap: break-word;
2594 animation: fadeInUp 0.6s ease-out 0.1s both;
2595 }
2596
2597 /* Layered Glass Sections - Each depth gets more opaque glass */
2598 .book-section {
2599 margin-bottom: 40px;
2600 position: relative;
2601 }
2602
2603 .book-section.depth-1 {
2604 margin-bottom: 48px;
2605 padding: 24px;
2606 background: rgba(255, 255, 255, 0.08);
2607 border-radius: 12px;
2608 border: 1px solid rgba(255, 255, 255, 0.15);
2609 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
2610 }
2611
2612 .book-section.depth-2 {
2613 margin-bottom: 32px;
2614 margin-left: 8px;
2615 padding: 20px;
2616 background: rgba(255, 255, 255, 0.06);
2617 border-radius: 10px;
2618 border: 1px solid rgba(255, 255, 255, 0.12);
2619 }
2620
2621 .book-section.depth-3 {
2622 margin-bottom: 24px;
2623 margin-left: 8px;
2624 padding: 16px;
2625 background: rgba(255, 255, 255, 0.04);
2626 border-radius: 8px;
2627 border: 1px solid rgba(255, 255, 255, 0.1);
2628 }
2629
2630 .book-section.depth-4 {
2631 margin-bottom: 20px;
2632 margin-left: 8px;
2633 padding: 12px;
2634 background: rgba(255, 255, 255, 0.03);
2635 border-radius: 6px;
2636 border: 1px solid rgba(255, 255, 255, 0.08);
2637 }
2638
2639 .book-section.depth-5 {
2640 margin-bottom: 16px;
2641 margin-left: 8px;
2642 padding: 10px;
2643 background: rgba(255, 255, 255, 0.02);
2644 border-radius: 6px;
2645 border: 1px solid rgba(255, 255, 255, 0.06);
2646 }
2647
2648 /* Heading Hierarchy */
2649 h1, h2, h3, h4, h5 {
2650 font-weight: 600;
2651 line-height: 1.3;
2652 margin: 0 0 16px 0;
2653 color: white;
2654 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2655 letter-spacing: -0.5px;
2656 }
2657
2658 h1 {
2659 font-size: 36px;
2660 margin-top: 48px;
2661 margin-bottom: 24px;
2662 padding-bottom: 16px;
2663 border-bottom: 2px solid rgba(255, 255, 255, 0.3);
2664 }
2665
2666 .book-section.depth-1:first-child h1 {
2667 margin-top: 0;
2668 }
2669
2670 h2 {
2671 font-size: 30px;
2672 margin-top: 40px;
2673 margin-bottom: 20px;
2674 padding-bottom: 12px;
2675 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
2676 }
2677
2678 h3 {
2679 font-size: 24px;
2680 margin-top: 32px;
2681 margin-bottom: 16px;
2682 }
2683
2684 h4 {
2685 font-size: 20px;
2686 margin-top: 24px;
2687 margin-bottom: 12px;
2688 }
2689
2690 h5 {
2691 font-size: 18px;
2692 margin-top: 20px;
2693 margin-bottom: 10px;
2694 }
2695
2696 /* Note Content - Glowing Text */
2697 .note-content {
2698 margin: 16px 0 28px 0;
2699 padding: 0;
2700 font-size: 18px;
2701 line-height: 1.8;
2702 color: #F5F5DC;
2703 word-wrap: break-word;
2704 overflow-wrap: break-word;
2705 font-weight: 400;
2706 }
2707
2708 .note-link {
2709 color: inherit;
2710 text-decoration: none;
2711 white-space: pre-wrap;
2712 word-wrap: break-word;
2713 overflow-wrap: break-word;
2714 display: block;
2715 padding: 12px 16px;
2716 margin: -12px -16px;
2717 border-radius: 8px;
2718 transition: all 0.3s;
2719 position: relative;
2720 overflow: hidden;
2721 }
2722
2723 .note-link::before {
2724 content: "";
2725 position: absolute;
2726 inset: 0;
2727 background: linear-gradient(
2728 110deg,
2729 transparent 40%,
2730 rgba(255, 255, 255, 0.2),
2731 transparent 60%
2732 );
2733 opacity: 0;
2734 transform: translateX(-100%);
2735 pointer-events: none;
2736 }
2737
2738 .note-link:hover {
2739 background-color: rgba(255, 255, 255, 0.1);
2740 transform: translateX(4px);
2741 }
2742
2743 .note-link:hover::before {
2744 opacity: 1;
2745 animation: glassShimmer 1s ease forwards;
2746 }
2747
2748 @keyframes glassShimmer {
2749 0% {
2750 opacity: 0;
2751 transform: translateX(-120%) skewX(-15deg);
2752 }
2753 50% {
2754 opacity: 1;
2755 }
2756 100% {
2757 opacity: 0;
2758 transform: translateX(120%) skewX(-15deg);
2759 }
2760 }
2761
2762 .note-link:active {
2763 background-color: rgba(255, 255, 255, 0.15);
2764 }
2765
2766 /* File Containers - Deeper Glass */
2767 .file-container {
2768 margin: 24px 0;
2769 padding: 20px;
2770 background: rgba(255, 255, 255, 0.15);
2771 backdrop-filter: blur(18px);
2772 border: 1px solid rgba(255, 255, 255, 0.3);
2773 border-radius: 12px;
2774 transition: all 0.3s;
2775 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
2776 }
2777
2778 .file-container:hover {
2779 border-color: rgba(255, 255, 255, 0.5);
2780 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
2781 background: rgba(255, 255, 255, 0.2);
2782 }
2783
2784 .file-container .note-link {
2785 display: inline-block;
2786 margin-bottom: 12px;
2787 color: white;
2788 font-size: 16px;
2789 font-weight: 600;
2790 padding: 4px 8px;
2791 margin: -4px -8px 8px;
2792 }
2793
2794 .file-container .note-link:hover {
2795 background-color: rgba(255, 255, 255, 0.15);
2796 text-decoration: underline;
2797 }
2798
2799 /* Media Elements */
2800 img {
2801 max-width: 100%;
2802 height: auto;
2803 border-radius: 8px;
2804 margin-top: 12px;
2805 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2806 border: 1px solid rgba(255, 255, 255, 0.2);
2807 }
2808
2809 video, audio {
2810 max-width: 100%;
2811 margin-top: 12px;
2812 border-radius: 8px;
2813 border: 1px solid rgba(255, 255, 255, 0.2);
2814 }
2815
2816 iframe {
2817 width: 100%;
2818 height: 600px;
2819 border: none;
2820 border-radius: 8px;
2821 margin-top: 12px;
2822 border: 1px solid rgba(255, 255, 255, 0.2);
2823 }
2824
2825 /* Empty State */
2826 .empty-state {
2827 text-align: center;
2828 padding: 80px 40px;
2829 }
2830
2831 .empty-state-icon {
2832 font-size: 64px;
2833 margin-bottom: 16px;
2834 filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
2835 }
2836
2837 .empty-state-text {
2838 font-size: 24px;
2839 color: white;
2840 margin-bottom: 8px;
2841 font-weight: 600;
2842 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2843 }
2844
2845 .empty-state-subtext {
2846 font-size: 16px;
2847 color: rgba(255, 255, 255, 0.8);
2848 }
2849
2850 /* Responsive Design */
2851 @media (max-width: 1024px) {
2852 }
2853
2854 @media (max-width: 768px) {
2855 .top-nav {
2856 padding: 12px 16px;
2857 }
2858
2859 .nav-button {
2860 padding: 8px 12px;
2861 font-size: 13px;
2862 }
2863
2864 .page-title {
2865 font-size: 18px;
2866 }
2867
2868 .filter-button {
2869 padding: 6px 12px;
2870 font-size: 12px;
2871 }
2872
2873 .content-wrapper {
2874 padding: 24px 16px;
2875 }
2876
2877 h1 {
2878 font-size: 30px;
2879 }
2880
2881 h2 {
2882 font-size: 26px;
2883 }
2884
2885 h3 {
2886 font-size: 22px;
2887 }
2888
2889 h4 {
2890 font-size: 19px;
2891 }
2892
2893 h5 {
2894 font-size: 17px;
2895 }
2896
2897 .note-content {
2898 font-size: 17px;
2899 }
2900
2901 .book-section.depth-2,
2902 .book-section.depth-3,
2903 .book-section.depth-4,
2904 .book-section.depth-5 {
2905 margin-left: 4px;
2906 }
2907 }
2908
2909 @media (max-width: 480px) {
2910 .nav-button {
2911 padding: 8px 6px;
2912 font-size: 11px;
2913 gap: 2px;
2914 }
2915
2916 .book-section.depth-1,
2917 .book-section.depth-2,
2918 .book-section.depth-3,
2919 .book-section.depth-4,
2920 .book-section.depth-5 {
2921 margin-left: 0;
2922 padding: 12px;
2923 }
2924 }
2925
2926 html { scroll-behavior: smooth; }
2927
2928 .book-toc {
2929 max-width: 900px;
2930 margin: 20px auto 24px;
2931 padding: 20px 28px;
2932 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2933 backdrop-filter: blur(22px) saturate(140%);
2934 -webkit-backdrop-filter: blur(22px) saturate(140%);
2935 border: 1px solid rgba(255, 255, 255, 0.28);
2936 border-radius: 16px;
2937 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2938 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2939 }
2940
2941 .toc-title {
2942 font-size: 18px;
2943 font-weight: 700;
2944 color: white;
2945 margin-bottom: 10px;
2946 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2947 }
2948
2949 .toc-list {
2950 list-style: none;
2951 padding-left: 18px;
2952 margin: 0;
2953 }
2954
2955 .book-toc > .toc-list {
2956 padding-left: 0;
2957 }
2958
2959 .book-toc li {
2960 margin: 2px 0;
2961 }
2962
2963 .toc-link {
2964 display: inline-block;
2965 color: white;
2966 text-decoration: none;
2967 padding: 3px 0;
2968 font-size: 15px;
2969 font-weight: 500;
2970 transition: opacity 0.2s;
2971 }
2972
2973 .toc-link:hover {
2974 opacity: 0.7;
2975 text-decoration: underline;
2976 }
2977
2978 .book-toc > .toc-list > li > .toc-link {
2979 font-weight: 700;
2980 font-size: 16px;
2981 }
2982
2983 .share-book-title {
2984 max-width: 900px;
2985 margin: 24px auto 0;
2986 font-size: 28px;
2987 font-weight: 700;
2988 color: white;
2989 text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
2990 text-align: center;
2991 }
2992
2993 /* Title toggle active state */
2994 .nav-button.active {
2995 background: rgba(255, 255, 255, 0.4);
2996 border-color: rgba(255, 255, 255, 0.5);
2997 }
2998
2999 /* Hide titles mode */
3000 #bookContent.hide-titles h1,
3001 #bookContent.hide-titles h2,
3002 #bookContent.hide-titles h3,
3003 #bookContent.hide-titles h4,
3004 #bookContent.hide-titles h5 {
3005 display: none;
3006 }
3007
3008 /* TOC scroll-to-top circle */
3009 .toc-top-btn {
3010 position: fixed;
3011 top: 60px;
3012 right: 16px;
3013 z-index: 200;
3014 width: 42px;
3015 height: 42px;
3016 border-radius: 50%;
3017 border: 1px solid rgba(255, 255, 255, 0.3);
3018 background: rgba(var(--glass-water-rgb), 0.5);
3019 backdrop-filter: blur(16px);
3020 -webkit-backdrop-filter: blur(16px);
3021 color: white;
3022 font-size: 18px;
3023 cursor: pointer;
3024 display: flex;
3025 align-items: center;
3026 justify-content: center;
3027 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
3028 opacity: 0;
3029 pointer-events: none;
3030 transition: opacity 0.3s, transform 0.3s;
3031 touch-action: manipulation;
3032 }
3033
3034 .toc-top-btn.visible {
3035 opacity: 1;
3036 pointer-events: auto;
3037 }
3038
3039 .toc-top-btn:hover {
3040 background: rgba(var(--glass-water-rgb), 0.7);
3041 transform: scale(1.1);
3042 }
3043 </style>
3044</head>
3045<body>
3046 <!-- Share Nav -->
3047 <div class="top-nav">
3048 <div class="top-nav-content">
3049 <div class="nav-buttons">
3050 <a href="/" class="nav-button" onclick="event.preventDefault();window.top.location.href='/';">Home</a>
3051 <button class="nav-button" id="copyUrlBtn">Copy URL</button>
3052 <button class="nav-button" id="copyTextBtn">Copy Text</button>
3053 <button class="nav-button" id="toggleTitlesBtn" onclick="toggleTitles()" title="Toggle Titles">Aa</button>
3054 </div>
3055 </div>
3056 </div>
3057
3058 ${shareTocEnabled && hasContent ? `<button class="toc-top-btn" id="tocTopBtn" onclick="window.scrollTo({top:0,behavior:'smooth'})">▲</button>` : ""}
3059
3060 <!-- Content -->
3061 <div class="content-wrapper">
3062 ${shareTocEnabled && hasContent ? `<div class="share-book-title">${escapeHtml(title)}</div>${renderTocBlock(book, shareTocDepth)}` : ""}
3063 <div class="content" id="bookContent">
3064 ${content}
3065 </div>
3066 </div>
3067
3068 <script>
3069 function tocScroll(id) {
3070 var el = document.getElementById(id);
3071 if (!el) return;
3072 var nav = document.querySelector('.top-nav');
3073 var offset = nav ? nav.offsetHeight + 12 : 12;
3074 var top = el.getBoundingClientRect().top + window.scrollY - offset;
3075 window.scrollTo({ top: top, behavior: 'smooth' });
3076 }
3077
3078 function toggleTitles() {
3079 var bc = document.getElementById('bookContent');
3080 var btn = document.getElementById('toggleTitlesBtn');
3081 bc.classList.toggle('hide-titles');
3082 if (bc.classList.contains('hide-titles')) {
3083 btn.classList.add('active');
3084 } else {
3085 btn.classList.remove('active');
3086 }
3087 }
3088
3089 ${shareTocEnabled && hasContent ? `
3090 (function() {
3091 var tocBtn = document.getElementById('tocTopBtn');
3092 if (!tocBtn) return;
3093 window.addEventListener('scroll', function() {
3094 if (window.scrollY > 200) {
3095 tocBtn.classList.add('visible');
3096 } else {
3097 tocBtn.classList.remove('visible');
3098 }
3099 }, { passive: true });
3100 })();
3101 ` : ""}
3102 </script>
3103
3104 <!-- Lazy Media Loader -->
3105 <script>
3106 document.getElementById("copyUrlBtn").addEventListener("click", function() {
3107 var url = new URL(window.location.href);
3108 url.searchParams.delete("token");
3109 if (!url.searchParams.has("html")) url.searchParams.set("html", "");
3110 navigator.clipboard.writeText(url.toString()).then(function() {
3111 this.textContent = "Copied";
3112 setTimeout(function() { document.getElementById("copyUrlBtn").textContent = "Copy URL"; }, 900);
3113 }.bind(this));
3114 });
3115
3116 document.getElementById("copyTextBtn").addEventListener("click", function() {
3117 var text = document.getElementById("bookContent").innerText;
3118 navigator.clipboard.writeText(text).then(function() {
3119 document.getElementById("copyTextBtn").textContent = "Copied";
3120 setTimeout(function() { document.getElementById("copyTextBtn").textContent = "Copy Text"; }, 900);
3121 });
3122 });
3123
3124 const lazyObserver = new IntersectionObserver(
3125 (entries, observer) => {
3126 entries.forEach(entry => {
3127 if (!entry.isIntersecting) return;
3128
3129 const el = entry.target;
3130 const src = el.dataset.src;
3131
3132 if (src) {
3133 el.src = src;
3134 el.removeAttribute("data-src");
3135 }
3136
3137 observer.unobserve(el);
3138 });
3139 },
3140 { rootMargin: "200px" }
3141 );
3142
3143 document
3144 .querySelectorAll(".lazy-media[data-src]")
3145 .forEach(el => lazyObserver.observe(el));
3146 </script>
3147
3148 <script>
3149 function toggleFlag(flag) {
3150 const url = new URL(window.location.href);
3151
3152 if (url.searchParams.has(flag)) {
3153 url.searchParams.delete(flag);
3154 } else {
3155 url.searchParams.set(flag, "true");
3156 }
3157
3158 url.searchParams.set("html", "true");
3159 window.location.href = url.toString();
3160 }
3161
3162 function toggleStatus(flag) {
3163 const url = new URL(window.location.href);
3164 const params = url.searchParams;
3165
3166 const defaults = {
3167 active: true,
3168 completed: true,
3169 trimmed: false,
3170 };
3171
3172 const current = params.has(flag)
3173 ? params.get(flag) === "true"
3174 : defaults[flag];
3175
3176 const next = !current;
3177
3178 if (next === defaults[flag]) {
3179 params.delete(flag);
3180 } else {
3181 params.set(flag, String(next));
3182 }
3183
3184 params.set("html", "true");
3185 window.location.href = url.toString();
3186 }
3187
3188 async function generateShare() {
3189 const params = Object.fromEntries(new URLSearchParams(window.location.search));
3190 const res = await fetch(window.location.pathname + "/generate", {
3191 method: "POST",
3192 headers: { "Content-Type": "application/json" },
3193 body: JSON.stringify(params),
3194 });
3195
3196 const data = await res.json();
3197 if (data.redirect) {
3198 window.location.href = data.redirect;
3199 }
3200 }
3201 </script>
3202
3203</body>
3204</html>
3205 `;
3206}
3207
3208export function renderNotesList({
3209 nodeId,
3210 version,
3211 token,
3212 nodeName,
3213 notes,
3214 currentUserId,
3215}) {
3216 const base = `/api/v1/node/${nodeId}/${version}`;
3217
3218 return `
3219<!DOCTYPE html>
3220<html lang="en">
3221<head>
3222 <meta charset="UTF-8">
3223 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3224 <meta name="theme-color" content="#667eea">
3225 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3226<title>${escapeHtml(nodeName)} — Notes</title>
3227 <style>
3228${baseStyles}
3229
3230/* ── Notes list overrides on base ── */
3231body {
3232 height: 100vh;
3233 height: 100dvh;
3234 display: flex;
3235 flex-direction: column;
3236 overflow: hidden;
3237 padding: 0;
3238 min-height: auto;
3239}
3240
3241/* Glass Top Navigation */
3242.top-nav {
3243 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3244 backdrop-filter: blur(22px) saturate(140%);
3245 -webkit-backdrop-filter: blur(22px) saturate(140%);
3246 padding: 16px 20px;
3247 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
3248 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3249 border-bottom: 1px solid rgba(255, 255, 255, 0.28);
3250 flex-shrink: 0;
3251}
3252
3253.top-nav-content {
3254 max-width: 900px;
3255 margin: 0 auto;
3256 display: flex;
3257 justify-content: space-between;
3258 align-items: center;
3259 gap: 12px;
3260 flex-wrap: wrap;
3261}
3262
3263.nav-left {
3264 display: flex;
3265 gap: 10px;
3266 flex-wrap: wrap;
3267}
3268
3269/* Glass Navigation Buttons */
3270.nav-button,
3271.book-button {
3272 position: relative;
3273 overflow: hidden;
3274 display: inline-flex;
3275 align-items: center;
3276 gap: 6px;
3277 padding: 10px 20px;
3278 border-radius: 980px;
3279 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3280 backdrop-filter: blur(22px) saturate(140%);
3281 -webkit-backdrop-filter: blur(22px) saturate(140%);
3282 color: white;
3283 text-decoration: none;
3284 font-size: 15px;
3285 font-weight: 500;
3286 letter-spacing: -0.2px;
3287 border: 1px solid rgba(255, 255, 255, 0.28);
3288 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
3289 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3290 cursor: pointer;
3291 transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3292 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3293 box-shadow 0.3s ease;
3294 white-space: nowrap;
3295}
3296
3297.nav-button::before,
3298.book-button::before {
3299 content: "";
3300 position: absolute;
3301 inset: -40%;
3302 background: radial-gradient(
3303 120% 60% at 0% 0%,
3304 rgba(255, 255, 255, 0.35),
3305 transparent 60%
3306 ),
3307 linear-gradient(
3308 120deg,
3309 transparent 30%,
3310 rgba(255, 255, 255, 0.25),
3311 transparent 70%
3312 );
3313 opacity: 0;
3314 transform: translateX(-30%) translateY(-10%);
3315 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3316 pointer-events: none;
3317}
3318
3319.nav-button:hover,
3320.book-button:hover {
3321 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
3322 transform: translateY(-1px);
3323 animation: waterDrift 2.2s ease-in-out infinite alternate;
3324}
3325
3326.nav-button:hover::before,
3327.book-button:hover::before {
3328 opacity: 1;
3329 transform: translateX(30%) translateY(10%);
3330}
3331
3332@keyframes waterDrift {
3333 0% { transform: translateY(-1px); }
3334 100% { transform: translateY(1px); }
3335}
3336
3337.book-button {
3338 --glass-alpha: 0.34;
3339 --glass-alpha-hover: 0.46;
3340 font-weight: 600;
3341}
3342
3343.page-title {
3344 width: 100%;
3345 margin-top: 12px;
3346 font-size: 18px;
3347 font-weight: 600;
3348 color: white;
3349 letter-spacing: -0.3px;
3350 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3351}
3352
3353.page-title a {
3354 color: white;
3355 text-decoration: none;
3356 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
3357 transition: all 0.2s;
3358}
3359
3360.page-title a:hover {
3361 border-bottom-color: white;
3362 text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
3363}
3364
3365/* Notes Container */
3366.notes-container {
3367 flex: 1;
3368 overflow-y: auto;
3369 padding: 20px;
3370 position: relative;
3371 z-index: 1;
3372}
3373
3374.notes-wrapper {
3375 max-width: 900px;
3376 margin: 0 auto;
3377 width: 100%;
3378}
3379
3380.notes-list {
3381 list-style: none;
3382 padding: 0;
3383 display: flex;
3384 flex-direction: column;
3385 gap: 12px;
3386}
3387
3388/* Glass Note Messages */
3389.note-item {
3390 display: flex;
3391 animation: slideIn 0.3s ease-out;
3392}
3393
3394@keyframes slideIn {
3395 from {
3396 opacity: 0;
3397 transform: translateY(10px);
3398 }
3399 to {
3400 opacity: 1;
3401 transform: translateY(0);
3402 }
3403}
3404
3405.note-item.self {
3406 flex-direction: row-reverse;
3407}
3408
3409.note-bubble {
3410 position: relative;
3411 max-width: 70%;
3412 padding: 14px 18px;
3413 border-radius: 12px;
3414 background: rgba(255, 255, 255, 0.15);
3415 backdrop-filter: blur(22px) saturate(140%);
3416 -webkit-backdrop-filter: blur(22px) saturate(140%);
3417 border: 1px solid rgba(255, 255, 255, 0.28);
3418 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
3419 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3420 color: white;
3421 word-wrap: break-word;
3422 overflow-wrap: break-word;
3423}
3424
3425/* Self messages - slightly more opaque */
3426.note-item.self .note-bubble {
3427 background: rgba(255, 255, 255, 0.2);
3428}
3429
3430/* Reflection messages - golden tint */
3431.note-item.reflection .note-bubble {
3432 background: rgba(255, 215, 79, 0.25);
3433 border-color: rgba(255, 215, 79, 0.4);
3434 box-shadow: 0 4px 16px rgba(255, 193, 7, 0.2),
3435 inset 0 1px 0 rgba(255, 255, 255, 0.3);
3436}
3437
3438.file-badge {
3439 display: inline-block;
3440 padding: 4px 10px;
3441 background: rgba(255, 255, 255, 0.2);
3442 border-radius: 12px;
3443 font-size: 11px;
3444 font-weight: 600;
3445 margin-bottom: 8px;
3446 text-transform: uppercase;
3447 letter-spacing: 0.5px;
3448 border: 1px solid rgba(255, 255, 255, 0.3);
3449}
3450
3451.note-author {
3452 font-weight: 600;
3453 margin-bottom: 6px;
3454 font-size: 13px;
3455 opacity: 0.85;
3456 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
3457 letter-spacing: -0.2px;
3458}
3459
3460.note-author a {
3461 color: inherit;
3462 text-decoration: none;
3463 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
3464}
3465
3466.note-author a:hover {
3467 border-bottom-color: white;
3468}
3469
3470.note-item.self .note-author {
3471 display: none;
3472}
3473
3474.note-content {
3475 font-size: 15px;
3476 line-height: 1.5;
3477 margin-bottom: 6px;
3478 font-weight: 400;
3479}
3480
3481.note-content a {
3482 color: inherit;
3483 text-decoration: none;
3484}
3485
3486.note-content a:hover {
3487 text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
3488}
3489
3490.note-meta {
3491 font-size: 11px;
3492 opacity: 0.7;
3493 margin-top: 6px;
3494 display: flex;
3495 justify-content: space-between;
3496 align-items: center;
3497 gap: 8px;
3498}
3499
3500.delete-button {
3501 background: rgba(255, 255, 255, 0.15);
3502 border: 1px solid rgba(255, 255, 255, 0.25);
3503 border-radius: 50%;
3504 width: 24px;
3505 height: 24px;
3506 display: flex;
3507 align-items: center;
3508 justify-content: center;
3509 cursor: pointer;
3510 padding: 0;
3511 opacity: 0.7;
3512 transition: all 0.2s;
3513 font-size: 12px;
3514 color: white;
3515}
3516
3517/* Character counter */
3518.char-counter {
3519 display: flex;
3520 justify-content: flex-end;
3521 align-items: center;
3522 margin-top: 6px;
3523 font-size: 12px;
3524 color: rgba(255, 255, 255, 0.6);
3525 font-weight: 500;
3526 transition: color 0.2s;
3527}
3528
3529.char-counter.warning {
3530 color: rgba(255, 193, 7, 0.9);
3531}
3532
3533.char-counter.danger {
3534 color: rgba(239, 68, 68, 0.9);
3535 font-weight: 600;
3536}
3537
3538.char-counter.disabled {
3539 opacity: 0.4;
3540}
3541
3542/* Energy display (shared between text and file) */
3543.energy-display {
3544 display: inline-flex;
3545 align-items: center;
3546 gap: 4px;
3547 margin-left: 10px;
3548 padding: 2px 8px;
3549 background: rgba(255, 215, 79, 0.2);
3550 border: 1px solid rgba(255, 215, 79, 0.3);
3551 border-radius: 10px;
3552 font-size: 11px;
3553 font-weight: 600;
3554 color: rgba(255, 215, 79, 1);
3555 transition: all 0.2s;
3556}
3557
3558.energy-display:empty {
3559 display: none;
3560}
3561
3562.energy-display.file-energy {
3563 background: rgba(255, 220, 100, 0.9);
3564 border-color: rgba(255, 200, 50, 1);
3565 color: #1a1a1a;
3566 font-size: 13px;
3567 font-weight: 700;
3568 padding: 4px 12px;
3569 box-shadow: 0 2px 8px rgba(255, 200, 50, 0.4);
3570}
3571
3572/* File selected indicator */
3573.file-selected-badge {
3574 display: none;
3575 align-items: center;
3576 gap: 6px;
3577 padding: 6px 12px;
3578 background: rgba(255, 255, 255, 0.15);
3579 border: 1px solid rgba(255, 255, 255, 0.25);
3580 border-radius: 20px;
3581 font-size: 12px;
3582 font-weight: 500;
3583 color: white;
3584}
3585
3586.file-selected-badge.visible {
3587 display: inline-flex;
3588}
3589
3590.file-selected-badge .file-name {
3591 max-width: 120px;
3592 overflow: hidden;
3593 text-overflow: ellipsis;
3594 white-space: nowrap;
3595}
3596
3597.file-selected-badge .clear-file {
3598 background: rgba(255, 255, 255, 0.2);
3599 border: none;
3600 border-radius: 50%;
3601 width: 18px;
3602 height: 18px;
3603 display: flex;
3604 align-items: center;
3605 justify-content: center;
3606 cursor: pointer;
3607 font-size: 10px;
3608 color: white;
3609 transition: all 0.2s;
3610}
3611
3612.file-selected-badge .clear-file:hover {
3613 background: rgba(239, 68, 68, 0.4);
3614}
3615
3616@keyframes fadeIn {
3617 from { opacity: 0; transform: scale(0.95); }
3618 to { opacity: 1; transform: scale(1); }
3619}
3620
3621.delete-button:hover {
3622 opacity: 1;
3623 background: rgba(239, 68, 68, 0.3);
3624 border-color: rgba(239, 68, 68, 0.5);
3625 transform: scale(1.1);
3626}
3627
3628/* Glass Input Bar */
3629.input-bar {
3630 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3631 backdrop-filter: blur(22px) saturate(140%);
3632 -webkit-backdrop-filter: blur(22px) saturate(140%);
3633 padding: 20px;
3634 border-top: 1px solid rgba(255, 255, 255, 0.28);
3635 box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12),
3636 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3637 flex-shrink: 0;
3638}
3639
3640.input-form {
3641 max-width: 900px;
3642 margin: 0 auto;
3643}
3644
3645textarea {
3646 width: 100%;
3647 padding: 14px 16px;
3648 border: 2px solid rgba(255, 255, 255, 0.3);
3649 border-radius: 12px;
3650 font-family: inherit;
3651 font-size: 16px;
3652 line-height: 1.5;
3653 resize: none;
3654 transition: all 0.3s;
3655 background: rgba(255, 255, 255, 0.15);
3656 backdrop-filter: blur(20px) saturate(150%);
3657 -webkit-backdrop-filter: blur(20px) saturate(150%);
3658 color: white;
3659 font-weight: 500;
3660 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
3661 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3662 height: 56px;
3663 max-height: 120px;
3664 overflow-y: hidden;
3665}
3666
3667textarea::placeholder {
3668 color: rgba(255, 255, 255, 0.5);
3669}
3670
3671textarea:focus {
3672 outline: none;
3673 border-color: rgba(255, 255, 255, 0.6);
3674 background: rgba(255, 255, 255, 0.25);
3675 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
3676 0 8px 30px rgba(0, 0, 0, 0.15),
3677 inset 0 1px 0 rgba(255, 255, 255, 0.4);
3678 transform: translateY(-2px);
3679}
3680
3681textarea:disabled {
3682 opacity: 0.4;
3683 cursor: not-allowed;
3684 background: rgba(255, 255, 255, 0.08);
3685 transform: none;
3686}
3687
3688textarea:disabled::placeholder {
3689 color: rgba(255, 255, 255, 0.3);
3690}
3691
3692.input-controls {
3693 display: flex;
3694 justify-content: space-between;
3695 align-items: center;
3696 gap: 12px;
3697 margin-top: 12px;
3698 flex-wrap: wrap;
3699}
3700
3701.input-options {
3702 display: flex;
3703 align-items: center;
3704 gap: 12px;
3705 flex-wrap: wrap;
3706}
3707
3708input[type="file"] {
3709 font-size: 13px;
3710 color: rgba(255, 255, 255, 0.9);
3711 cursor: pointer;
3712}
3713
3714input[type="file"]::file-selector-button {
3715 padding: 8px 16px;
3716 border-radius: 980px;
3717 border: 1px solid rgba(255, 255, 255, 0.3);
3718 background: rgba(255, 255, 255, 0.2);
3719 backdrop-filter: blur(10px);
3720 color: white;
3721 cursor: pointer;
3722 font-size: 13px;
3723 font-weight: 600;
3724 transition: all 0.2s;
3725 margin-right: 10px;
3726}
3727
3728input[type="file"]::file-selector-button:hover {
3729 background: rgba(255, 255, 255, 0.3);
3730 transform: translateY(-1px);
3731}
3732
3733/* Hide file input when file is selected, show badge instead */
3734input[type="file"].hidden-input {
3735 display: none;
3736}
3737
3738/* Glass Send Button */
3739.send-button {
3740 position: relative;
3741 overflow: hidden;
3742 padding: 12px 28px;
3743 border-radius: 980px;
3744 background: rgba(255, 255, 255, 0.25);
3745 backdrop-filter: blur(10px);
3746 color: white;
3747 border: 1px solid rgba(255, 255, 255, 0.3);
3748 font-size: 15px;
3749 font-weight: 600;
3750 letter-spacing: -0.2px;
3751 cursor: pointer;
3752 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
3753 white-space: nowrap;
3754 transition: all 0.3s;
3755}
3756
3757.send-button::before {
3758 content: "";
3759 position: absolute;
3760 inset: -40%;
3761 background: radial-gradient(
3762 120% 60% at 0% 0%,
3763 rgba(255, 255, 255, 0.35),
3764 transparent 60%
3765 );
3766 opacity: 0;
3767 transform: translateX(-30%) translateY(-10%);
3768 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3769 pointer-events: none;
3770}
3771
3772.send-button:hover {
3773 background: rgba(255, 255, 255, 0.35);
3774 transform: translateY(-2px);
3775 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
3776}
3777
3778.send-button:hover::before {
3779 opacity: 1;
3780 transform: translateX(30%) translateY(10%);
3781}
3782
3783.send-button.loading .send-label {
3784 opacity: 0;
3785}
3786
3787/* Progress bar */
3788.send-progress {
3789 position: absolute;
3790 left: 0;
3791 top: 0;
3792 height: 100%;
3793 width: 0%;
3794 background: linear-gradient(
3795 90deg,
3796 rgba(255,255,255,0.25),
3797 rgba(255,255,255,0.6),
3798 rgba(255,255,255,0.25)
3799 );
3800 transition: width 0.2s ease;
3801 pointer-events: none;
3802}
3803
3804/* Loading state */
3805.send-button.loading {
3806 cursor: default;
3807 animation: none;
3808 transform: none;
3809}
3810
3811/* Responsive Design */
3812@media (max-width: 768px) {
3813 .top-nav {
3814 padding: 12px 16px;
3815 }
3816
3817 .nav-button,
3818 .book-button {
3819 padding: 8px 16px;
3820 font-size: 14px;
3821 }
3822
3823 .page-title {
3824 font-size: 16px;
3825 }
3826
3827 .notes-container {
3828 padding: 16px 12px;
3829 }
3830
3831 .note-bubble {
3832 max-width: 85%;
3833 padding: 12px 16px;
3834 }
3835
3836 .input-bar {
3837 padding: 16px;
3838 }
3839
3840 .input-controls {
3841 flex-direction: column;
3842 align-items: stretch;
3843 }
3844
3845 .input-options {
3846 flex-direction: column;
3847 align-items: flex-start;
3848 gap: 10px;
3849 }
3850
3851 .send-button {
3852 width: 100%;
3853 }
3854
3855 textarea {
3856 font-size: 16px;
3857 height: 60px;
3858 }
3859}
3860
3861@media (max-width: 480px) {
3862 .nav-left {
3863 width: 100%;
3864 flex-direction: column;
3865 }
3866
3867 .nav-button,
3868 .book-button {
3869 width: 100%;
3870 justify-content: center;
3871 }
3872}
3873 html, body {
3874 background: #736fe6;
3875 margin: 0;
3876 padding: 0;
3877 }
3878 .editor-open-btn {
3879 width: 44px; height: 44px;
3880 border-radius: 50%;
3881 border: 1px solid rgba(255, 255, 255, 0.3);
3882 background: rgba(255, 255, 255, 0.2);
3883 backdrop-filter: blur(10px);
3884 color: white; font-size: 18px;
3885 cursor: pointer; transition: all 0.3s;
3886 display: flex; align-items: center; justify-content: center;
3887 flex-shrink: 0;
3888}
3889
3890.editor-open-btn:hover {
3891 background: rgba(255, 255, 255, 0.35);
3892 transform: translateY(-2px);
3893 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3894}
3895 @media (max-width: 768px) {
3896 .input-controls {
3897 flex-direction: row;
3898 align-items: center;
3899 flex-wrap: nowrap;
3900 }
3901
3902 .input-options {
3903 flex-direction: row;
3904 align-items: center;
3905 gap: 8px;
3906 flex: 1;
3907 min-width: 0;
3908 }
3909
3910 .input-options input[type="file"] {
3911 max-width: 140px;
3912 font-size: 0;
3913 }
3914
3915 .input-options input[type="file"]::file-selector-button {
3916 margin-right: 0;
3917 padding: 8px 12px;
3918 font-size: 12px;
3919 }
3920
3921 .send-button {
3922 width: auto;
3923 padding: 10px 20px;
3924 flex-shrink: 0;
3925 }
3926}
3927 </style>
3928</head>
3929<body>
3930 <!-- Top Navigation -->
3931 <div class="top-nav">
3932 <div class="top-nav-content">
3933 <div class="nav-left">
3934 <a href="/api/v1/root/${nodeId}?token=${token}&html" class="nav-button">
3935 ← Back to Tree
3936 </a>
3937 <a href="${base}?token=${token}&html" class="nav-button">
3938 Back to Version
3939 </a>
3940 </div>
3941
3942 <div class="page-title">
3943 Notes for <a href="${base}?token=${token}&html">${escapeHtml(nodeName)} v${version}</a>
3944 </div>
3945 </div>
3946 </div>
3947
3948 <!-- Notes Container -->
3949 <div class="notes-container">
3950 <div class="notes-wrapper">
3951 <ul class="notes-list">
3952 ${notes
3953 .map((n) => {
3954 const isSelf =
3955 currentUserId && n.userId && n.userId.toString() === currentUserId;
3956 const rawPreview =
3957 n.contentType === "text"
3958 ? n.content.length > 169
3959 ? n.content.substring(0, 500) + "..."
3960 : n.content
3961 : n.content.split("/").pop();
3962 const preview = escapeHtml(rawPreview);
3963
3964 const userLabel = n.userId
3965 ? `<a href="/api/v1/user/${n.userId}?token=${token}&html">${escapeHtml(n.username ?? n.userId)}</a>`
3966 : escapeHtml(n.username ?? "Unknown user");
3967
3968 return `
3969 <li
3970 class="note-item ${isSelf ? "self" : "other"} ${
3971 n.isReflection ? "reflection" : ""
3972 }"
3973 data-note-id="${n._id}"
3974 data-node-id="${n.nodeId}"
3975 data-version="${n.version}"
3976 >
3977 <div class="note-bubble">
3978 ${
3979 n.contentType === "file"
3980 ? '<div class="file-badge">📎 File</div>'
3981 : ""
3982 }
3983 ${!isSelf ? `<div class="note-author">${userLabel}</div>` : ""}
3984 <div class="note-content">
3985 <a href="${base}/notes/${n._id}?token=${token}&html">
3986 ${preview}
3987 </a>
3988 </div>
3989 <div class="note-meta">
3990 <span>${new Date(n.createdAt).toLocaleTimeString([], {
3991 hour: "2-digit",
3992 minute: "2-digit",
3993 })}</span>
3994 <button class="delete-button" title="Delete note">✕</button>
3995 </div>
3996 </div>
3997 </li>
3998 `;
3999 })
4000 .join("")}
4001 </ul>
4002 </div>
4003 </div>
4004
4005 <!-- Input Bar -->
4006 <div class="input-bar">
4007 <form
4008 method="POST"
4009 action="/api/v1/node/${nodeId}/${version}/notes?token=${token}&html"
4010 enctype="multipart/form-data"
4011 class="input-form"
4012 id="noteForm"
4013 >
4014 <textarea
4015 name="content"
4016 rows="1"
4017 placeholder="Write a note..."
4018 id="noteTextarea"
4019 maxlength="5000"
4020 ></textarea>
4021 <div class="char-counter" id="charCounter">
4022 <span id="charCount">0</span> / 5000
4023 <span class="energy-display" id="energyDisplay"></span>
4024 </div>
4025
4026 <div class="input-controls">
4027 <div class="input-options">
4028 <input type="file" name="file" id="fileInput" />
4029 <div class="file-selected-badge" id="fileSelectedBadge">
4030 <span>📎</span>
4031 <span class="file-name" id="fileName"></span>
4032 <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">✕</button>
4033 </div>
4034 <button type="button" class="editor-open-btn" id="openEditorBtn" title="Open in Editor">✏️</button>
4035 </div>
4036 <button type="submit" class="send-button" id="sendBtn">
4037 <span class="send-label">Send</span>
4038 <span class="send-progress"></span>
4039 </button>
4040 </div>
4041 </form>
4042 </div>
4043
4044 <script>
4045 // Auto-scroll to bottom on load
4046 const container = document.querySelector('.notes-container');
4047 container.scrollTop = container.scrollHeight;
4048
4049 // Elements
4050 const form = document.getElementById('noteForm');
4051 const textarea = document.getElementById('noteTextarea');
4052 const charCounter = document.getElementById('charCounter');
4053 const charCount = document.getElementById('charCount');
4054 const energyDisplay = document.getElementById('energyDisplay');
4055 const fileInput = document.getElementById('fileInput');
4056 const fileSelectedBadge = document.getElementById('fileSelectedBadge');
4057 const fileName = document.getElementById('fileName');
4058 const clearFileBtn = document.getElementById('clearFileBtn');
4059 const sendBtn = document.getElementById('sendBtn');
4060 const progressBar = sendBtn.querySelector('.send-progress');
4061
4062 const MAX_CHARS = 5000;
4063 let hasFile = false;
4064
4065 // Auto-resize textarea
4066 textarea.addEventListener('input', function() {
4067 this.style.height = 'auto';
4068 const newHeight = Math.min(this.scrollHeight, 120);
4069 this.style.height = newHeight + 'px';
4070 this.style.overflowY = this.scrollHeight > 120 ? 'auto' : 'hidden';
4071 updateCharCounter();
4072 });
4073
4074 // Character counter with energy (1 energy per 1000 chars)
4075 function updateCharCounter() {
4076 const len = textarea.value.length;
4077 charCount.textContent = len;
4078
4079 // Styling based on remaining
4080 const remaining = MAX_CHARS - len;
4081 charCounter.classList.remove('warning', 'danger', 'disabled');
4082
4083 if (hasFile) {
4084 charCounter.classList.add('disabled');
4085 } else if (remaining <= 100) {
4086 charCounter.classList.add('danger');
4087 } else if (remaining <= 500) {
4088 charCounter.classList.add('warning');
4089 }
4090
4091 // Energy cost: 1 per 1000 chars (minimum 1 if any text)
4092 if (len > 0 && !hasFile) {
4093 const cost = Math.max(1, Math.ceil(len / 1000));
4094 energyDisplay.textContent = '⚡' + cost;
4095 energyDisplay.classList.remove('file-energy');
4096 } else if (!hasFile) {
4097 energyDisplay.textContent = '';
4098 }
4099 }
4100
4101 // File energy calculation
4102 const FILE_MIN_COST = 5;
4103 const FILE_BASE_RATE = 1.5;
4104 const FILE_MID_RATE = 3;
4105 const SOFT_LIMIT_MB = 100;
4106 const HARD_LIMIT_MB = 1024;
4107
4108 function calculateFileEnergy(sizeMB) {
4109 if (sizeMB <= SOFT_LIMIT_MB) {
4110 return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
4111 }
4112 if (sizeMB <= HARD_LIMIT_MB) {
4113 const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
4114 const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
4115 return Math.ceil(base + extra);
4116 }
4117 const base = SOFT_LIMIT_MB * FILE_BASE_RATE +
4118 (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
4119 const overGB = sizeMB - HARD_LIMIT_MB;
4120 return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
4121 }
4122
4123 // File selection - blocks text input
4124 fileInput.addEventListener('change', function() {
4125 if (this.files && this.files[0]) {
4126 const file = this.files[0];
4127 hasFile = true;
4128
4129 // Disable textarea
4130 textarea.disabled = true;
4131 textarea.value = '';
4132 textarea.placeholder = 'File selected - text disabled';
4133
4134 // Show file badge, hide file input
4135 fileInput.classList.add('hidden-input');
4136 fileSelectedBadge.classList.add('visible');
4137
4138 // Truncate filename for display
4139 let displayName = file.name;
4140 if (displayName.length > 20) {
4141 displayName = displayName.substring(0, 17) + '...';
4142 }
4143 fileName.textContent = displayName;
4144 fileSelectedBadge.title = file.name;
4145
4146 // Calculate and show energy (+1 for the note itself)
4147 const sizeMB = file.size / (1024 * 1024);
4148 const fileCost = calculateFileEnergy(sizeMB);
4149 const totalCost = fileCost + 1;
4150 energyDisplay.textContent = '~⚡' + totalCost;
4151 energyDisplay.classList.add('file-energy');
4152
4153 // Update char counter state
4154 updateCharCounter();
4155 }
4156 });
4157
4158 // Clear file selection
4159 clearFileBtn.addEventListener('click', function() {
4160 hasFile = false;
4161 fileInput.value = '';
4162 fileInput.classList.remove('hidden-input');
4163 fileSelectedBadge.classList.remove('visible');
4164
4165 // Re-enable textarea
4166 textarea.disabled = false;
4167 textarea.placeholder = 'Write a note...';
4168
4169 // Clear energy display
4170 energyDisplay.textContent = '';
4171 energyDisplay.classList.remove('file-energy');
4172
4173 updateCharCounter();
4174 });
4175
4176 // Delete note functionality
4177 document.addEventListener('click', async (e) => {
4178 if (!e.target.classList.contains('delete-button')) return;
4179
4180 const noteItem = e.target.closest('.note-item');
4181 const noteId = noteItem.dataset.noteId;
4182 const nodeId = noteItem.dataset.nodeId;
4183 const version = noteItem.dataset.version;
4184
4185 if (!confirm('Delete this note? This cannot be undone.')) return;
4186
4187 const token = new URLSearchParams(window.location.search).get('token') || '';
4188 const qs = token ? '?token=' + encodeURIComponent(token) : '';
4189
4190 try {
4191 const res = await fetch(
4192 '/api/v1/node/' + nodeId + '/' + version + '/notes/' + noteId + qs,
4193 { method: 'DELETE' }
4194 );
4195
4196 const data = await res.json();
4197 if (!data.success) throw new Error(data.error || 'Delete failed');
4198
4199 noteItem.style.opacity = '0';
4200 noteItem.style.transform = 'translateY(-10px)';
4201 setTimeout(() => noteItem.remove(), 300);
4202 } catch (err) {
4203 alert('Failed to delete: ' + (err.message || 'Unknown error'));
4204 }
4205 });
4206
4207 // Form submission with progress
4208 form.addEventListener('submit', (e) => {
4209 e.preventDefault();
4210
4211 sendBtn.classList.add('loading');
4212 sendBtn.disabled = true;
4213
4214 const formData = new FormData(form);
4215 const xhr = new XMLHttpRequest();
4216
4217 xhr.open('POST', form.action, true);
4218
4219 xhr.upload.onprogress = (e) => {
4220 if (!e.lengthComputable) return;
4221 const percent = Math.round((e.loaded / e.total) * 100);
4222 progressBar.style.width = percent + '%';
4223 };
4224
4225 xhr.onload = () => {
4226 document.location.reload();
4227 };
4228
4229 xhr.onerror = () => {
4230 alert('Send failed');
4231 sendBtn.classList.remove('loading');
4232 sendBtn.disabled = false;
4233 progressBar.style.width = '0%';
4234 };
4235
4236 xhr.send(formData);
4237 });
4238
4239 // Enter to submit
4240 textarea.addEventListener('keydown', (e) => {
4241 if (e.key === 'Enter' && !e.shiftKey) {
4242 e.preventDefault();
4243 form.requestSubmit();
4244 }
4245 });
4246
4247 // Editor button
4248 document.getElementById("openEditorBtn").addEventListener("click", function() {
4249 var token = new URLSearchParams(window.location.search).get("token") || "";
4250 var qs = token ? "?token=" + encodeURIComponent(token) + "&html" : "?html";
4251 var content = textarea.value.trim();
4252 var editorUrl = "/api/v1/node/${nodeId}/${version}/notes/editor" + qs;
4253
4254 if (content) {
4255 sessionStorage.setItem("tree-editor-draft", content);
4256 }
4257
4258 window.location.href = editorUrl;
4259 });
4260
4261 // Form reset handler
4262 form.addEventListener('reset', () => {
4263 hasFile = false;
4264 fileInput.classList.remove('hidden-input');
4265 fileSelectedBadge.classList.remove('visible');
4266 textarea.disabled = false;
4267 textarea.placeholder = 'Write a note...';
4268 energyDisplay.textContent = '';
4269 energyDisplay.classList.remove('file-energy');
4270 charCount.textContent = '0';
4271 charCounter.classList.remove('warning', 'danger', 'disabled');
4272 });
4273 </script>
4274</body>
4275</html>
4276`;
4277}
4278
4279export function renderTextNote({
4280 back,
4281 backText,
4282 userLink,
4283 editorButton,
4284 note,
4285}) {
4286 return `
4287<!DOCTYPE html>
4288<html lang="en">
4289<head>
4290 <meta charset="UTF-8">
4291 <meta name="viewport" content="width=device-width, initial-scale=1.0">
4292 <meta name="theme-color" content="#667eea">
4293 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4294<title>Note by ${escapeHtml(note.userId?.username || "User")} - TreeOS</title>
4295 <meta name="description" content="${escapeHtml((note.content || "").slice(0, 160))}" />
4296 <meta property="og:title" content="Note by ${escapeHtml(note.userId?.username || "User")} - TreeOS" />
4297 <meta property="og:description" content="${escapeHtml((note.content || "").slice(0, 160))}" />
4298 <meta property="og:type" content="article" />
4299 <meta property="og:site_name" content="TreeOS" />
4300 <meta property="og:image" content="${getLandUrl()}/tree.png" />
4301 <style>
4302 ${baseStyles}
4303 ${backNavStyles}
4304
4305 /* Note Card */
4306 .note-card {
4307 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4308 backdrop-filter: blur(22px) saturate(140%);
4309 -webkit-backdrop-filter: blur(22px) saturate(140%);
4310 border-radius: 16px;
4311 padding: 32px;
4312 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
4313 inset 0 1px 0 rgba(255, 255, 255, 0.25);
4314 border: 1px solid rgba(255, 255, 255, 0.28);
4315 position: relative;
4316 overflow: hidden;
4317 animation: fadeInUp 0.6s ease-out 0.1s both;
4318 }
4319
4320 /* User Info */
4321 .user-info {
4322 display: flex;
4323 align-items: center;
4324 gap: 8px;
4325 margin-bottom: 20px;
4326 padding-bottom: 16px;
4327 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
4328 }
4329
4330 .user-info::before {
4331 content: '👤';
4332 font-size: 18px;
4333 }
4334
4335 .user-info a {
4336 color: white;
4337 text-decoration: none;
4338 font-weight: 600;
4339 font-size: 15px;
4340 transition: all 0.2s;
4341 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4342 }
4343
4344 .user-info a:hover {
4345 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4346 transform: translateX(2px);
4347 }
4348
4349 .note-time {
4350 margin-left: auto;
4351 font-size: 13px;
4352 color: rgba(255, 255, 255, 0.6);
4353 font-weight: 400;
4354 }
4355
4356 /* Copy Button Bar */
4357 .copy-bar {
4358 display: flex;
4359 justify-content: flex-end;
4360 gap: 8px;
4361 margin-bottom: 16px;
4362 }
4363
4364 .copy-btn {
4365 background: rgba(255, 255, 255, 0.2);
4366 backdrop-filter: blur(10px);
4367 border: 1px solid rgba(255, 255, 255, 0.3);
4368 cursor: pointer;
4369 font-size: 20px;
4370 padding: 8px 12px;
4371 border-radius: 980px;
4372 transition: all 0.3s;
4373 position: relative;
4374 overflow: hidden;
4375 }
4376
4377 .copy-btn::before {
4378 content: "";
4379 position: absolute;
4380 inset: -40%;
4381 background: radial-gradient(
4382 120% 60% at 0% 0%,
4383 rgba(255, 255, 255, 0.35),
4384 transparent 60%
4385 );
4386 opacity: 0;
4387 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4388 pointer-events: none;
4389 }
4390
4391 .copy-btn:hover {
4392 background: rgba(255, 255, 255, 0.3);
4393 transform: translateY(-2px);
4394 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4395 }
4396
4397 .copy-btn:hover::before {
4398 opacity: 1;
4399 transform: translateX(30%) translateY(10%);
4400 }
4401
4402 .copy-btn:active {
4403 transform: translateY(0);
4404 }
4405
4406 #copyUrlBtn {
4407 background: rgba(255, 255, 255, 0.25);
4408 }
4409
4410 /* Note Content */
4411 pre {
4412 background: rgba(255, 255, 255, 0.3);
4413 backdrop-filter: blur(20px) saturate(150%);
4414 -webkit-backdrop-filter: blur(20px) saturate(150%);
4415 padding: 20px;
4416 border-radius: 12px;
4417 font-size: 16px;
4418 line-height: 1.7;
4419 white-space: pre-wrap;
4420 word-wrap: break-word;
4421 border: 1px solid rgba(255, 255, 255, 0.3);
4422 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
4423 color: #3d2f8f;
4424 font-weight: 600;
4425 text-shadow:
4426 0 0 10px rgba(102, 126, 234, 0.4),
4427 0 1px 3px rgba(255, 255, 255, 1);
4428 box-shadow:
4429 0 4px 20px rgba(0, 0, 0, 0.1),
4430 inset 0 1px 0 rgba(255, 255, 255, 0.4);
4431 position: relative;
4432 overflow: hidden;
4433 transition: all 0.3s ease;
4434 }
4435
4436 pre::before {
4437 content: "";
4438 position: absolute;
4439 inset: 0;
4440 background: linear-gradient(
4441 110deg,
4442 transparent 40%,
4443 rgba(255, 255, 255, 0.4),
4444 transparent 60%
4445 );
4446 opacity: 0;
4447 transform: translateX(-100%);
4448 pointer-events: none;
4449 }
4450
4451 pre:hover {
4452 border-color: rgba(255, 255, 255, 0.5);
4453 box-shadow:
4454 0 8px 32px rgba(102, 126, 234, 0.2),
4455 inset 0 1px 0 rgba(255, 255, 255, 0.6);
4456 }
4457/* Programmatic shimmer trigger */
4458pre.flash::before {
4459 opacity: 1;
4460 animation: glassShimmer 1.2s ease forwards;
4461}
4462
4463 pre:hover::before {
4464 opacity: 1;
4465 animation: glassShimmer 1.2s ease forwards;
4466 }
4467
4468 pre.copied {
4469 animation: textGlow 0.8s ease-out;
4470 }
4471
4472 @keyframes textGlow {
4473 0% {
4474 box-shadow:
4475 0 4px 20px rgba(0, 0, 0, 0.1),
4476 inset 0 1px 0 rgba(255, 255, 255, 0.4);
4477 }
4478 50% {
4479 box-shadow:
4480 0 0 40px rgba(102, 126, 234, 0.6),
4481 0 0 60px rgba(102, 126, 234, 0.4),
4482 inset 0 1px 0 rgba(255, 255, 255, 0.8);
4483 text-shadow:
4484 0 0 20px rgba(102, 126, 234, 0.8),
4485 0 0 30px rgba(102, 126, 234, 0.6),
4486 0 1px 3px rgba(255, 255, 255, 1);
4487 }
4488 100% {
4489 box-shadow:
4490 0 4px 20px rgba(0, 0, 0, 0.1),
4491 inset 0 1px 0 rgba(255, 255, 255, 0.4);
4492 }
4493 }
4494
4495 @keyframes glassShimmer {
4496 0% {
4497 opacity: 0;
4498 transform: translateX(-120%) skewX(-15deg);
4499 }
4500 50% {
4501 opacity: 1;
4502 }
4503 100% {
4504 opacity: 0;
4505 transform: translateX(120%) skewX(-15deg);
4506 }
4507 }
4508
4509 /* Responsive */
4510 @media (max-width: 640px) {
4511 body {
4512 padding: 16px;
4513 }
4514
4515 .note-card {
4516 padding: 24px 20px;
4517 }
4518
4519 pre {
4520 font-size: 17px;
4521 padding: 16px;
4522 }
4523
4524 .back-nav {
4525 flex-direction: column;
4526 }
4527
4528 .back-link {
4529 justify-content: center;
4530 }
4531 }
4532
4533 @media (min-width: 641px) and (max-width: 1024px) {
4534 .container {
4535 max-width: 700px;
4536 }
4537 }
4538 .editor-btn {
4539 text-decoration: none;
4540 display: inline-flex;
4541 align-items: center;
4542 justify-content: center;
4543}
4544
4545.editor-btn:hover {
4546 background: rgba(255, 255, 255, 0.35);
4547}
4548
4549 </style>
4550</head>
4551<body>
4552 <div class="container">
4553 <!-- Back Navigation -->
4554 <div class="back-nav">
4555 <a href="${back}" class="back-link">${backText}</a>
4556 <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">🔗</button>
4557 </div>
4558
4559 <!-- Note Card -->
4560 <div class="note-card">
4561 <div class="user-info">
4562 ${userLink}
4563 ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4564 </div>
4565<div class="copy-bar">
4566 ${editorButton}
4567 <button id="copyNoteBtn" class="copy-btn" title="Copy note">📋</button>
4568</div>
4569
4570
4571<pre id="noteContent">${escapeHtml(note.content)}</pre>
4572 </div>
4573 </div>
4574
4575 <script>
4576 const copyNoteBtn = document.getElementById("copyNoteBtn");
4577 const copyUrlBtn = document.getElementById("copyUrlBtn");
4578 const noteContent = document.getElementById("noteContent");
4579
4580 copyNoteBtn.addEventListener("click", () => {
4581 navigator.clipboard.writeText(noteContent.textContent).then(() => {
4582 copyNoteBtn.textContent = "✔️";
4583 setTimeout(() => (copyNoteBtn.textContent = "📋"), 900);
4584
4585 // text glow (already existing)
4586 noteContent.classList.add("copied");
4587 setTimeout(() => noteContent.classList.remove("copied"), 800);
4588
4589 // delayed glass shimmer (0.5s)
4590 setTimeout(() => {
4591 noteContent.classList.remove("flash"); // reset if still present
4592 void noteContent.offsetWidth; // force reflow so animation restarts
4593 noteContent.classList.add("flash");
4594
4595 setTimeout(() => {
4596 noteContent.classList.remove("flash");
4597 }, 1300); // slightly longer than animation
4598 }, 600);
4599 });
4600});
4601
4602
4603 copyUrlBtn.addEventListener("click", () => {
4604 const url = new URL(window.location.href);
4605 url.searchParams.delete('token');
4606 if (!url.searchParams.has('html')) {
4607 url.searchParams.set('html', '');
4608 }
4609 navigator.clipboard.writeText(url.toString()).then(() => {
4610 copyUrlBtn.textContent = "✔️";
4611 setTimeout(() => (copyUrlBtn.textContent = "🔗"), 900);
4612 });
4613 });
4614 </script>
4615</body>
4616</html>
4617`;
4618}
4619
4620export function renderFileNote({
4621 back,
4622 backText,
4623 userLink,
4624 note,
4625 fileName,
4626 fileUrl,
4627 mediaHtml,
4628 fileDeleted,
4629}) {
4630 return `
4631<!DOCTYPE html>
4632<html lang="en">
4633<head>
4634 <meta charset="UTF-8">
4635 <meta name="viewport" content="width=device-width, initial-scale=1.0">
4636 <meta name="theme-color" content="#667eea">
4637 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4638<title>${escapeHtml(fileName)}</title>
4639 <style>
4640 ${baseStyles}
4641 ${backNavStyles}
4642
4643 /* File Card */
4644 .file-card {
4645 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4646 backdrop-filter: blur(22px) saturate(140%);
4647 -webkit-backdrop-filter: blur(22px) saturate(140%);
4648 border-radius: 16px;
4649 padding: 32px;
4650 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
4651 inset 0 1px 0 rgba(255, 255, 255, 0.25);
4652 border: 1px solid rgba(255, 255, 255, 0.28);
4653 position: relative;
4654 overflow: hidden;
4655 animation: fadeInUp 0.6s ease-out 0.1s both;
4656 }
4657
4658 /* User Info */
4659 .user-info {
4660 display: flex;
4661 align-items: center;
4662 gap: 8px;
4663 margin-bottom: 20px;
4664 padding-bottom: 16px;
4665 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
4666 }
4667
4668 .user-info::before {
4669 content: '👤';
4670 font-size: 18px;
4671 }
4672
4673 .user-info a {
4674 color: white;
4675 text-decoration: none;
4676 font-weight: 600;
4677 font-size: 15px;
4678 transition: all 0.2s;
4679 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4680 }
4681
4682 .user-info a:hover {
4683 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4684 transform: translateX(2px);
4685 }
4686
4687 .note-time {
4688 margin-left: auto;
4689 font-size: 13px;
4690 color: rgba(255, 255, 255, 0.6);
4691 font-weight: 400;
4692 }
4693
4694 /* File Header */
4695 h1 {
4696 font-size: 24px;
4697 font-weight: 700;
4698 color: white;
4699 margin-bottom: 20px;
4700 word-break: break-word;
4701 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4702 }
4703
4704 /* Action Buttons */
4705 .action-bar {
4706 display: flex;
4707 gap: 12px;
4708 margin-bottom: 24px;
4709 flex-wrap: wrap;
4710 }
4711
4712 .download {
4713 display: inline-flex;
4714 align-items: center;
4715 gap: 8px;
4716 padding: 12px 20px;
4717 background: rgba(255, 255, 255, 0.25);
4718 backdrop-filter: blur(10px);
4719 color: white;
4720 text-decoration: none;
4721 border-radius: 980px;
4722 font-weight: 600;
4723 font-size: 15px;
4724 transition: all 0.3s;
4725 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
4726 border: 1px solid rgba(255, 255, 255, 0.3);
4727 cursor: pointer;
4728 position: relative;
4729 overflow: hidden;
4730 }
4731
4732 .download::after {
4733 content: '⬇️';
4734 font-size: 16px;
4735 margin-left: 4px;
4736 }
4737
4738 .download::before {
4739 content: "";
4740 position: absolute;
4741 inset: -40%;
4742 background: radial-gradient(
4743 120% 60% at 0% 0%,
4744 rgba(255, 255, 255, 0.35),
4745 transparent 60%
4746 );
4747 opacity: 0;
4748 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4749 pointer-events: none;
4750 }
4751
4752 .download:hover {
4753 background: rgba(255, 255, 255, 0.35);
4754 transform: translateY(-2px);
4755 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
4756 }
4757
4758 .download:hover::before {
4759 opacity: 1;
4760 transform: translateX(30%) translateY(10%);
4761 }
4762
4763 .copy-url-btn {
4764 display: inline-flex;
4765 align-items: center;
4766 gap: 8px;
4767 padding: 12px 20px;
4768 background: rgba(255, 255, 255, 0.2);
4769 backdrop-filter: blur(10px);
4770 color: white;
4771 border: 1px solid rgba(255, 255, 255, 0.3);
4772 border-radius: 980px;
4773 font-weight: 600;
4774 font-size: 15px;
4775 transition: all 0.3s;
4776 cursor: pointer;
4777 position: relative;
4778 overflow: hidden;
4779 }
4780
4781 .copy-url-btn::after {
4782 content: '🔗';
4783 font-size: 16px;
4784 margin-left: 4px;
4785 }
4786
4787 .copy-url-btn::before {
4788 content: "";
4789 position: absolute;
4790 inset: -40%;
4791 background: radial-gradient(
4792 120% 60% at 0% 0%,
4793 rgba(255, 255, 255, 0.35),
4794 transparent 60%
4795 );
4796 opacity: 0;
4797 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4798 pointer-events: none;
4799 }
4800
4801 .copy-url-btn:hover {
4802 background: rgba(255, 255, 255, 0.3);
4803 transform: translateY(-2px);
4804 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4805 }
4806
4807 .copy-url-btn:hover::before {
4808 opacity: 1;
4809 transform: translateX(30%) translateY(10%);
4810 }
4811
4812 /* Media Container */
4813 .media {
4814 margin-top: 24px;
4815 padding-top: 24px;
4816 border-top: 1px solid rgba(255, 255, 255, 0.2);
4817 }
4818
4819 .media img,
4820 .media video,
4821 .media audio {
4822 max-width: 100%;
4823 border-radius: 12px;
4824 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
4825 border: 1px solid rgba(255, 255, 255, 0.2);
4826 }
4827
4828 /* Responsive */
4829 @media (max-width: 640px) {
4830 body {
4831 padding: 16px;
4832 }
4833
4834 .file-card {
4835 padding: 24px 20px;
4836 }
4837
4838 h1 {
4839 font-size: 22px;
4840 }
4841
4842 .action-bar {
4843 flex-direction: column;
4844 }
4845
4846 .download,
4847 .copy-url-btn {
4848 padding: 12px 18px;
4849 font-size: 16px;
4850 width: 100%;
4851 justify-content: center;
4852 }
4853
4854 .back-nav {
4855 flex-direction: column;
4856 }
4857
4858 .back-link {
4859 justify-content: center;
4860 }
4861 }
4862
4863 @media (min-width: 641px) and (max-width: 1024px) {
4864 .container {
4865 max-width: 700px;
4866 }
4867 }
4868 @media (max-width: 768px) {
4869 .send-progress {
4870 animation: shimmer 1.2s infinite linear;
4871 }
4872}
4873
4874@keyframes shimmer {
4875 0% { background-position: -200px 0; }
4876 100% { background-position: 200px 0; }
4877}
4878
4879 </style>
4880</head>
4881<body>
4882 <div class="container">
4883 <!-- Back Navigation -->
4884 <div class="back-nav">
4885 <a href="${back}" class="back-link">${backText}</a>
4886 </div>
4887
4888 <!-- File Card -->
4889 <div class="file-card">
4890 <div class="user-info">
4891 ${userLink}
4892 ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4893 </div>
4894
4895<h1>${escapeHtml(fileName)}</h1>
4896
4897 ${fileDeleted ? "" : `<div class="action-bar">
4898 <a class="download" href="${fileUrl}" download>Download</a>
4899 <button id="copyUrlBtn" class="copy-url-btn">Share</button>
4900 </div>`}
4901
4902 <div class="media">
4903 ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
4904 </div>
4905 </div>
4906 </div>
4907
4908 <script>
4909 const copyUrlBtn = document.getElementById("copyUrlBtn");
4910
4911 copyUrlBtn.addEventListener("click", () => {
4912 const url = new URL(window.location.href);
4913 url.searchParams.delete('token');
4914 if (!url.searchParams.has('html')) {
4915 url.searchParams.set('html', '');
4916 }
4917 navigator.clipboard.writeText(url.toString()).then(() => {
4918 const originalText = copyUrlBtn.textContent;
4919 copyUrlBtn.textContent = "✔️ Copied!";
4920 setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
4921 });
4922 });
4923 </script>
4924</body>
4925</html>
4926`;
4927}
4928
4929export { parseBool, normalizeStatusFilters, renderBookNode };
4930
1/* ------------------------------------------------- */
2/* Public query page renderer */
3/* Lightweight chat UI for querying public trees */
4/* ------------------------------------------------- */
5/* NOTE: baseStyles not imported here. The query page */
6/* uses a dark theme (gradient #0f0c29 -> #16213e) */
7/* that diverges entirely from the purple base. Only */
8/* the * reset overlaps, which is not worth the */
9/* import + override cost. */
10
11import { escapeHtml } from "./utils.js";
12
13export function renderQueryPage({ treeName, ownerUsername, rootId, queryAvailable, isAuthenticated }) {
14 return `<!DOCTYPE html>
15<html lang="en">
16<head>
17 <meta charset="UTF-8">
18 <meta name="viewport" content="width=device-width, initial-scale=1.0">
19 <title>${escapeHtml(treeName)} - Query</title>
20 <style>
21 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22
23 body {
24 font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25 background: linear-gradient(135deg, #0f0c29 0%, #1a1a2e 40%, #16213e 100%);
26 color: #e0e0e0;
27 min-height: 100vh;
28 display: flex;
29 flex-direction: column;
30 }
31
32 .header {
33 padding: 24px 32px;
34 border-bottom: 1px solid rgba(255,255,255,0.08);
35 display: flex;
36 align-items: center;
37 gap: 16px;
38 }
39
40 .header h1 {
41 font-size: 1.3rem;
42 font-weight: 700;
43 color: #fff;
44 }
45
46 .header .meta {
47 font-size: 0.85rem;
48 color: rgba(255,255,255,0.5);
49 }
50
51 .header .badge {
52 font-size: 0.7rem;
53 padding: 3px 10px;
54 border-radius: 12px;
55 background: rgba(72,187,120,0.15);
56 color: rgba(72,187,120,0.9);
57 border: 1px solid rgba(72,187,120,0.25);
58 font-weight: 600;
59 text-transform: uppercase;
60 letter-spacing: 0.5px;
61 }
62
63 .chat-area {
64 flex: 1;
65 overflow-y: auto;
66 padding: 24px 32px;
67 display: flex;
68 flex-direction: column;
69 gap: 16px;
70 }
71
72 .message {
73 max-width: 720px;
74 width: 100%;
75 margin: 0 auto;
76 padding: 16px 20px;
77 border-radius: 16px;
78 line-height: 1.6;
79 font-size: 0.95rem;
80 }
81
82 .message.user {
83 background: rgba(88,86,214,0.15);
84 border: 1px solid rgba(88,86,214,0.25);
85 align-self: flex-end;
86 }
87
88 .message.assistant {
89 background: rgba(255,255,255,0.05);
90 border: 1px solid rgba(255,255,255,0.1);
91 }
92
93 .message.error {
94 background: rgba(255,59,48,0.1);
95 border: 1px solid rgba(255,59,48,0.25);
96 color: rgba(255,107,107,0.9);
97 }
98
99 .message p { margin: 0 0 8px; }
100 .message p:last-child { margin-bottom: 0; }
101 .message code {
102 background: rgba(255,255,255,0.1);
103 padding: 2px 6px;
104 border-radius: 4px;
105 font-size: 0.9em;
106 }
107 .message pre {
108 background: rgba(0,0,0,0.3);
109 padding: 12px 16px;
110 border-radius: 8px;
111 overflow-x: auto;
112 margin: 8px 0;
113 }
114 .message pre code {
115 background: none;
116 padding: 0;
117 }
118
119 .empty-state {
120 text-align: center;
121 color: rgba(255,255,255,0.4);
122 padding: 48px 24px;
123 font-size: 0.95rem;
124 max-width: 480px;
125 margin: auto;
126 }
127
128 .empty-state .icon {
129 font-size: 2rem;
130 margin-bottom: 12px;
131 opacity: 0.6;
132 }
133
134 .input-area {
135 padding: 16px 32px 24px;
136 border-top: 1px solid rgba(255,255,255,0.08);
137 max-width: 784px;
138 width: 100%;
139 margin: 0 auto;
140 }
141
142 .input-row {
143 display: flex;
144 gap: 12px;
145 align-items: flex-end;
146 }
147
148 .input-row textarea {
149 flex: 1;
150 resize: none;
151 padding: 12px 16px;
152 border-radius: 16px;
153 border: 1px solid rgba(255,255,255,0.15);
154 background: rgba(255,255,255,0.06);
155 color: #fff;
156 font-size: 0.95rem;
157 font-family: inherit;
158 line-height: 1.5;
159 min-height: 48px;
160 max-height: 200px;
161 outline: none;
162 transition: border-color 0.2s;
163 }
164
165 .input-row textarea:focus {
166 border-color: rgba(88,86,214,0.5);
167 }
168
169 .input-row textarea::placeholder {
170 color: rgba(255,255,255,0.3);
171 }
172
173 .input-row button {
174 padding: 12px 20px;
175 border-radius: 16px;
176 border: 1px solid rgba(72,187,120,0.4);
177 background: rgba(72,187,120,0.15);
178 color: rgba(72,187,120,0.9);
179 font-weight: 600;
180 font-size: 0.9rem;
181 cursor: pointer;
182 transition: background 0.2s;
183 white-space: nowrap;
184 }
185
186 .input-row button:hover {
187 background: rgba(72,187,120,0.25);
188 }
189
190 .input-row button:disabled {
191 opacity: 0.4;
192 cursor: not-allowed;
193 }
194
195 .footer {
196 text-align: center;
197 padding: 12px;
198 font-size: 0.75rem;
199 color: rgba(255,255,255,0.3);
200 }
201
202 .footer a {
203 color: rgba(88,86,214,0.7);
204 text-decoration: none;
205 }
206
207 .spinner {
208 display: inline-block;
209 width: 16px;
210 height: 16px;
211 border: 2px solid rgba(255,255,255,0.2);
212 border-top-color: rgba(72,187,120,0.8);
213 border-radius: 50%;
214 animation: spin 0.8s linear infinite;
215 margin-right: 8px;
216 vertical-align: middle;
217 }
218
219 @keyframes spin { to { transform: rotate(360deg); } }
220
221 .unavailable {
222 text-align: center;
223 padding: 48px 24px;
224 color: rgba(255,255,255,0.5);
225 }
226
227 .unavailable h2 {
228 font-size: 1.1rem;
229 margin-bottom: 8px;
230 color: rgba(255,255,255,0.7);
231 }
232 </style>
233</head>
234<body>
235
236 <div class="header">
237 <div>
238 <h1>${escapeHtml(treeName)}</h1>
239 <div class="meta">by ${escapeHtml(ownerUsername)}</div>
240 <a href="https://dir.treeos.ai" target="_blank" rel="noopener"
241 style="font-size:0.75rem;color:rgba(255,255,255,0.35);text-decoration:none;margin-top:4px;display:inline-block;transition:color 0.2s;"
242 onmouseover="this.style.color='rgba(255,255,255,0.7)'"
243 onmouseout="this.style.color='rgba(255,255,255,0.35)'"
244 >Canopy Directory</a>
245 </div>
246 <span class="badge">Public</span>
247 </div>
248
249 ${queryAvailable ? `
250 <div class="chat-area" id="chatArea">
251 <div class="empty-state" id="emptyState">
252 Ask the tree anything to find knowledge. Responses are read only and will not modify the tree.
253 </div>
254 </div>
255
256 <div class="input-area">
257 <div class="input-row">
258 <textarea
259 id="queryInput"
260 placeholder="Ask a question about this tree..."
261 rows="1"
262 onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendQuery();}"
263 oninput="autoResize(this)"
264 ></textarea>
265 <button id="sendBtn" onclick="sendQuery()">Ask</button>
266 </div>
267 </div>
268 ` : `
269 <div class="unavailable">
270 <h2>Query not available</h2>
271 <p>This tree does not have AI configured for public queries.${isAuthenticated ? "" : " If you have an account on another land, you can query through the CLI or API using your own AI connection."}</p>
272 </div>
273 `}
274
275 <div class="footer">
276 Powered by <a href="https://treeos.ai" target="_blank">TreeOS</a>
277 </div>
278
279 <script>
280 var ROOT_ID = "${rootId}";
281 var sending = false;
282
283 function autoResize(el) {
284 el.style.height = "auto";
285 el.style.height = Math.min(el.scrollHeight, 200) + "px";
286 }
287
288 function addMessage(role, html) {
289 var empty = document.getElementById("emptyState");
290 if (empty) empty.remove();
291
292 var area = document.getElementById("chatArea");
293 var div = document.createElement("div");
294 div.className = "message " + role;
295 div.innerHTML = html;
296 area.appendChild(div);
297 area.scrollTop = area.scrollHeight;
298 return div;
299 }
300
301 function markdownToHtml(text) {
302 if (!text) return "";
303 var BT = String.fromCharCode(96);
304 var html = text
305 .replace(/&/g, "&")
306 .replace(/</g, "<")
307 .replace(/>/g, ">");
308
309 // Code blocks (triple backtick fenced)
310 var codeBlockRe = new RegExp(BT + BT + BT + "(\\\\w*)?\\\\n([\\\\s\\\\S]*?)" + BT + BT + BT, "g");
311 html = html.replace(codeBlockRe, function(m, lang, code) {
312 return "<pre><code>" + code.trim() + "</code></pre>";
313 });
314
315 // Inline code
316 var inlineCodeRe = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
317 html = html.replace(inlineCodeRe, "<code>$1</code>");
318
319 // Bold
320 html = html.replace(/[*][*](.+?)[*][*]/g, "<strong>$1</strong>");
321
322 // Italic
323 html = html.replace(/[*](.+?)[*]/g, "<em>$1</em>");
324
325 // Paragraphs
326 html = html.split(new RegExp("\\\\n\\\\n+")).map(function(p) {
327 p = p.trim();
328 if (!p) return "";
329 if (p.startsWith("<pre>")) return p;
330 return "<p>" + p.replace(new RegExp("\\\\n", "g"), "<br>") + "</p>";
331 }).join("");
332
333 return html;
334 }
335
336 async function sendQuery() {
337 if (sending) return;
338 var input = document.getElementById("queryInput");
339 var btn = document.getElementById("sendBtn");
340 var msg = input.value.trim();
341 if (!msg) return;
342
343 sending = true;
344 btn.disabled = true;
345 btn.textContent = "...";
346 input.value = "";
347 input.style.height = "auto";
348
349 addMessage("user", "<p>" + msg.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/\\n/g,"<br>") + "</p>");
350
351 var loadingDiv = addMessage("assistant", '<span class="spinner"></span> Thinking...');
352
353 try {
354 var res = await fetch("/api/v1/root/" + ROOT_ID + "/query", {
355 method: "POST",
356 credentials: "include",
357 headers: { "Content-Type": "application/json" },
358 body: JSON.stringify({ message: msg }),
359 });
360
361 var text = await res.text();
362 var data;
363 try { data = JSON.parse(text); } catch (_) { data = {}; }
364
365 if (res.status === 429) {
366 loadingDiv.className = "message error";
367 loadingDiv.innerHTML = "<p>Rate limit reached. Please wait a few minutes before trying again.</p>";
368 } else if (!res.ok || !data.success) {
369 var errMsg = data.answer || data.error || data.message || "Error (HTTP " + res.status + ")";
370 loadingDiv.className = "message error";
371 loadingDiv.innerHTML = "<p>" + errMsg + "</p>";
372 } else {
373 loadingDiv.innerHTML = markdownToHtml(data.answer);
374 }
375 } catch (err) {
376 loadingDiv.className = "message error";
377 loadingDiv.innerHTML = "<p>Network error: " + err.message + "</p>";
378 }
379
380 sending = false;
381 btn.disabled = false;
382 btn.textContent = "Ask";
383 input.focus();
384 }
385 </script>
386
387</body>
388</html>`;
389}
390
1/* ------------------------------------------------- */
2/* HTML renderers for root.js pages */
3/* ------------------------------------------------- */
4
5import { baseStyles, backNavStyles, responsiveBase } from './baseStyles.js';
6import { escapeHtml, rainbow } from './utils.js';
7
8// ─────────────────────────────────────────────────────────────────────────
9// 1. renderRootOverview
10// ─────────────────────────────────────────────────────────────────────────
11
12export function renderRootOverview({
13 allData,
14 rootMeta,
15 ancestors,
16 isOwner,
17 isDeleted,
18 isRoot,
19 isPublicAccess,
20 queryAvailable,
21 currentUserId,
22 queryString,
23 nodeId,
24 userId,
25 token,
26 deferredItems,
27 ownerConnections,
28}) {
29 const deferredHtml = deferredItems && deferredItems.length > 0
30 ? `<ul class="deferred-list">${deferredItems.map((d) => `<li class="deferred-item"><div class="deferred-content">${escapeHtml(d.content || d.text || JSON.stringify(d.data || ""))}</div><div class="deferred-meta" style="font-size:11px;opacity:0.6;margin-top:4px;">${d.status || "pending"}${d.createdAt ? " . " + new Date(d.createdAt).toLocaleDateString() : ""}</div></li>`).join("")}</ul>`
31 : '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.5);font-size:14px;">No short-term items</div>';
32
33 let rootNameColor = "rgba(255, 255, 255, 0.4)";
34 if (isDeleted) {
35 rootNameColor = "#b00020";
36 }
37
38 const _txMeta = rootMeta?.metadata?.transactions || (rootMeta?.metadata instanceof Map ? rootMeta?.metadata?.get("transactions") : null) || {};
39 const transactionPolicy = _txMeta.policy || "OWNER_ONLY";
40
41 const renderParents = (chain) => {
42 if (!chain || chain.length === 0) return "";
43 if (chain.length === 1 && chain[0].isCurrent) return "";
44
45 let html = '<div class="breadcrumb-constellation">';
46 chain.forEach((node, idx) => {
47 const isLast = idx === chain.length - 1;
48 const color = rainbow[idx % rainbow.length];
49
50 html += `
51 <div class="breadcrumb-node ${isLast ? "current-node" : ""}" data-depth="${idx}">
52 <div class="node-connector" style="background: linear-gradient(90deg, ${rainbow[(idx - 1 + rainbow.length) % rainbow.length]}, ${color});"></div>
53 <div class="node-bubble" style="border-color: ${color}; box-shadow: 0 0 20px ${color}40;" data-node-id="${node._id}" data-is-current="${node.isCurrent ? "true" : "false"}">
54 ${
55 node.isCurrent
56 ? `<a href="/api/v1/node/${node._id}${queryString}" class="node-link current">
57 <span class="node-icon">●</span>
58 <span class="node-name">${escapeHtml(node.name)}</span>
59 <span class="node-badge">YOU ARE HERE</span>
60 </a>`
61 : `<a href="/api/v1/root/${node._id}${queryString}" class="node-link">
62 <span class="node-icon">○</span>
63 <span class="node-name">${escapeHtml(node.name)}</span>
64 <span class="depth-badge">Level ${idx + 1}</span>
65 </a>`
66 }
67 </div>
68 </div>
69 `;
70 });
71 html += "</div>";
72 return html;
73 };
74
75 const renderTree = (node, depth = 0) => {
76 const color = rainbow[depth % rainbow.length];
77 let html = `
78 <li
79 class="tree-node"
80 data-node-id="${node._id}"
81
82 style="
83 border-left: 4px solid ${color};
84 padding-left: 12px;
85 margin: 6px 0;
86 "
87 >
88
89
90 <a href="/api/v1/node/${node._id}/${0}${queryString}">
91 ${escapeHtml(node.name)}
92 </a>
93 `;
94 if (node.children && node.children.length > 0) {
95 html += `<ul>`;
96 for (const c of node.children) {
97 html += renderTree(c, depth + 1);
98 }
99 html += `</ul>`;
100 }
101 html += `</li>`;
102 return html;
103 };
104
105 const inviteFormHtml = isOwner
106 ? `
107<form
108 method="POST"
109 action="/api/v1/root/${nodeId}/invite?token=${token}&html"
110 style="display:flex; gap:8px; max-width:420px; margin-top:12px;"
111>
112 <input
113 type="text"
114 name="userReceiving"
115 placeholder="username or user@other.land.com"
116 required
117 />
118
119 <button type="submit">
120 Invite
121 </button>
122</form>
123<div style="font-size:11px; color:rgba(255,255,255,0.5); margin-top:4px;">
124 Use username@domain to invite someone from another land.
125</div>
126`
127 : ``;
128
129 const policyHtml = isOwner
130 ? `
131
132<form
133 method="POST"
134 action="/api/v1/root/${nodeId}/transaction-policy?token=${token}&html"
135 style="max-width: 420px;"
136>
137 <select
138 name="policy"
139 style="
140 width:100%;
141 padding:10px;
142 border-radius:8px;
143 border:1px solid #ccc;
144 font-size:14px;
145 "
146 >
147 <option value="OWNER_ONLY" ${
148 transactionPolicy === "OWNER_ONLY" ? "selected" : ""
149 }>
150 Owner only
151 </option>
152 <option value="ANYONE" ${transactionPolicy === "ANYONE" ? "selected" : ""}>
153 Anyone (single approval)
154 </option>
155 <option value="MAJORITY" ${
156 transactionPolicy === "MAJORITY" ? "selected" : ""
157 }>
158 Majority of root members
159 </option>
160 <option value="ALL" ${transactionPolicy === "ALL" ? "selected" : ""}>
161 All root members
162 </option>
163 </select>
164
165 <button type="submit" style="margin-top:12px;">
166 Update Policy
167 </button>
168</form>
169`
170 : ``;
171
172 const ownerHtml = rootMeta?.rootOwner
173 ? `<ul class="contributors-list">
174 <li>
175 <a href="/api/v1/user/${rootMeta.rootOwner._id}${queryString}">
176 ${escapeHtml(rootMeta.rootOwner.username)}
177 </a>
178 <span style="font-size:12px;opacity:0.7;color:white;">Owner</span>
179 </li>
180</ul>`
181 : ``;
182
183 const contributorsHtml = rootMeta?.contributors?.length
184 ? `
185<ul class="contributors-list">
186${rootMeta.contributors
187 .map((u) => {
188 const isSelf = u._id.toString() === userId?.toString();
189
190 return `
191<li>
192<a href="/api/v1/user/${u._id}${queryString}">
193 ${escapeHtml(u.username)}
194</a>
195${u.isRemote ? '<span style="font-size:11px;opacity:0.5;color:white;">(remote)</span>' : ""}
196 <div class="contributors-actions">
197 ${
198 isOwner
199 ? `
200 <form
201 method="POST"
202 action="/api/v1/root/${nodeId}/transfer-owner?token=${token}&html"
203onsubmit="return confirm('Transfer ownership to ${escapeHtml(u.username)}?')"
204 >
205 <input type="hidden" name="userReceiving" value="${u._id}" />
206 <button type="submit">Transfer</button>
207 </form>
208 `
209 : ""
210 }
211
212 ${
213 isOwner || isSelf
214 ? `
215 <form
216 method="POST"
217 action="/api/v1/root/${nodeId}/remove-user?token=${token}&html"
218 onsubmit="return confirm('${
219 isSelf ? "Leave this root?" : `Remove ${escapeHtml(u.username)} from this root?`
220 }')"
221 >
222 <input type="hidden" name="userReceiving" value="${u._id}" />
223 <button type="submit">
224 ${isSelf ? "Leave" : "Remove"}
225 </button>
226 </form>
227 `
228 : ""
229 }
230 </div>
231</li>
232`;
233 })
234 .join("")}
235</ul>
236`
237 : ``;
238
239 const retireHtml = isOwner
240 ? `
241<form
242 method="POST"
243 action="/api/v1/root/${nodeId}/retire?token=${token}&html"
244 onsubmit="return confirm('This will retire the root. Continue?')"
245 style="margin-top:12px;"
246>
247 <button
248 type="submit"
249 style="
250 padding:8px 14px;
251 border-radius:8px;
252 border:1px solid rgba(239, 68, 68, 0.5);
253 background:rgba(239, 68, 68, 0.25);
254 color:white;
255 font-weight:600;
256 cursor:pointer;
257 "
258 >
259 Retire
260 </button>
261</form>
262`
263 : "";
264
265 // Tree AI Model section
266 let treeLlmHtml = "";
267 if (isOwner && rootMeta?.rootOwner && ownerConnections) {
268 const ownerProfile = rootMeta.rootOwner;
269 const llmSlots = [
270 { key: "default", label: "Default", isDefault: true },
271 { key: "placement", label: "Placement" },
272 { key: "understanding", label: "Understanding" },
273 { key: "respond", label: "Respond" },
274 { key: "notes", label: "Notes" },
275 { key: "cleanup", label: "Cleanup" },
276 { key: "drain", label: "Drain" },
277 { key: "notification", label: "Notification" },
278 ];
279
280 function buildSlotHtml(slot) {
281 // Read from llmDefault for "default" slot, metadata.llm.slots for extension slots
282 const current = slot.key === "default"
283 ? (rootMeta.llmDefault || null)
284 : (rootMeta.metadata?.llm?.slots?.[slot.key] || (rootMeta.metadata instanceof Map ? rootMeta.metadata.get("llm")?.slots?.[slot.key] : null) || null);
285 const optHtml = ownerConnections.map(function(c) {
286 return '<div class="custom-select-option' + (current === c._id ? ' selected' : '') + '" data-value="' + c._id + '">'
287 + escapeHtml(c.name) + ' (' + escapeHtml(c.model) + ')</div>';
288 }).join('');
289 let label;
290 if (current === "none") {
291 label = 'Off (no AI)';
292 } else if (current) {
293 const m = ownerConnections.find(function(c){return c._id === current;});
294 label = m ? escapeHtml(m.name) + ' (' + escapeHtml(m.model) + ')' : 'Account default';
295 } else {
296 label = slot.isDefault ? 'Account default' : 'Use default';
297 }
298 return `<p style="font-size:0.85em;opacity:0.6;margin-bottom:4px;margin-top:10px;">${slot.label}</p>
299 <div class="custom-select" data-slot="${slot.key}" style="margin-bottom:4px;">
300 <div class="custom-select-trigger">${label}</div>
301 <div class="custom-select-options">
302 <div class="custom-select-option${!current ? ' selected' : ''}" data-value="">${slot.isDefault ? 'Account default' : 'Use default'}</div>
303 ${optHtml}
304 ${slot.isDefault ? '<div class="custom-select-option' + (current === "none" ? ' selected' : '') + '" data-value="none" style="color:rgba(255,107,107,0.8);">Off (no AI)</div>' : ''}
305 </div>
306 </div>`;
307 }
308
309 treeLlmHtml = `
310<h3>AI Models</h3>
311<p style="font-size:0.85em;opacity:0.5;margin-bottom:8px;">Set a default LLM for the tree. All modes fall back to this. Per-mode overrides below. Set default to "Off" to disable AI entirely.</p>
312${ownerConnections.length === 0
313 ? '<p style="font-size:0.85em;opacity:0.5;">No custom connections -- <a href="/api/v1/user/${ownerProfile._id}${queryString ? queryString + "&" : "?"}html" style="color:inherit;">add one on your profile</a></p>'
314 : llmSlots.map(buildSlotHtml).join('\n') + '\n <div class="llm-assign-status" style="font-size:0.8em;margin-top:4px;display:none;"></div>'
315}`;
316 }
317
318 const parentHtml = ancestors.length
319 ? renderParents([
320 ...ancestors.slice().reverse(),
321 {
322 _id: allData._id,
323 name: allData.name,
324 isCurrent: true,
325 },
326 ])
327 : ``;
328
329 const childrenInner = allData.children?.length
330 ? `<ul>${allData.children.map((c) => renderTree(c)).join("")}</ul>`
331 : ``;
332
333 const treeHtml = `
334 <ul class="tree-root" style="padding-left:0;">
335 <li class="tree-node root-entry"
336 data-node-id="${allData._id}"
337 style="border-left: 4px solid ${rootNameColor}; padding-left: 6px; margin: 6px 0;">
338 <a href="/api/v1/node/${allData._id}/0${queryString}">
339 ${escapeHtml(allData.name)}
340 </a>
341 ${childrenInner}
342 </li>
343 </ul>`;
344
345 return `
346<!DOCTYPE html>
347<html lang="en">
348<head>
349 <meta charset="UTF-8">
350 <meta name="viewport" content="width=device-width, initial-scale=1.0">
351 <meta name="theme-color" content="#667eea">
352 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
353<title>${escapeHtml(allData.name)} - TreeOS</title>
354 <style>
355 ${baseStyles}
356 ${backNavStyles}
357
358 .current {
359 color: rgb(51, 66, 85);}
360
361 /* Glass Content Cards */
362 .content-card {
363 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
364 backdrop-filter: blur(22px) saturate(140%);
365 -webkit-backdrop-filter: blur(22px) saturate(140%);
366 border-radius: 16px;
367 padding: 28px;
368 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
369 inset 0 1px 0 rgba(255, 255, 255, 0.25);
370 border: 1px solid rgba(255, 255, 255, 0.28);
371 margin-bottom: 24px;
372 animation: fadeInUp 0.6s ease-out;
373 animation-fill-mode: both;
374 position: relative;
375 }
376
377 .content-card:nth-child(2) { animation-delay: 0.1s; }
378 .content-card:nth-child(3) { animation-delay: 0.15s; }
379 .content-card:nth-child(4) { animation-delay: 0.2s; }
380 .content-card:nth-child(5) { animation-delay: 0.25s; }
381
382
383
384 .content-card::before {
385 content: "";
386 position: absolute;
387 inset: 0;
388 border-radius: inherit;
389
390 background:
391 linear-gradient(
392 180deg,
393 rgba(255,255,255,0.18),
394 rgba(255,255,255,0.05)
395 );
396
397 pointer-events: none;
398}
399
400 /* Header Section */
401 .header-section {
402 margin-bottom: 24px;
403 padding-bottom: 20px;
404 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
405 }
406
407 .section-header {
408 margin-bottom: 20px;
409 padding-bottom: 12px;
410 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
411 }
412
413 .section-header h2 {
414 margin: 0;
415 font-size: 20px;
416 font-weight: 600;
417 color: white;
418 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
419 letter-spacing: -0.3px;
420 }
421
422 .section-header h2 a {
423 color: white;
424 text-decoration: none;
425 transition: all 0.2s;
426 }
427
428 .section-header h2 a:hover {
429 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
430 }
431
432 /* ==========================================
433 CONSTELLATION BREADCRUMB NAVIGATION
434 ========================================== */
435
436.breadcrumb-constellation {
437 display: flex;
438 align-items: center;
439 gap: 0;
440 padding: 24px 16px;
441 overflow-x: auto;
442 overflow-y: hidden;
443 position: relative;
444 min-height: 100px;
445
446 /* Scrollbar styling */
447 scrollbar-width: thin;
448 scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
449}
450
451 .breadcrumb-constellation::-webkit-scrollbar {
452 height: 6px;
453 }
454
455 .breadcrumb-constellation::-webkit-scrollbar-track {
456 background: rgba(255, 255, 255, 0.1);
457 border-radius: 3px;
458 }
459
460 .breadcrumb-constellation::-webkit-scrollbar-thumb {
461 background: rgba(255, 255, 255, 0.3);
462 border-radius: 3px;
463 }
464
465 .breadcrumb-node {
466 display: flex;
467 align-items: center;
468 flex-shrink: 0;
469 animation: nodeSlideIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
470 animation-fill-mode: both;
471 }
472
473 .breadcrumb-node:nth-child(1) { animation-delay: 0s; }
474 .breadcrumb-node:nth-child(2) { animation-delay: 0.1s; }
475 .breadcrumb-node:nth-child(3) { animation-delay: 0.2s; }
476 .breadcrumb-node:nth-child(4) { animation-delay: 0.3s; }
477 .breadcrumb-node:nth-child(5) { animation-delay: 0.4s; }
478 .breadcrumb-node:nth-child(6) { animation-delay: 0.5s; }
479 .breadcrumb-node:nth-child(n+7) { animation-delay: 0.6s; }
480
481 @keyframes nodeSlideIn {
482 from {
483 opacity: 0;
484 transform: translateX(-30px) scale(0.8);
485 }
486 to {
487 opacity: 1;
488 transform: translateX(0) scale(1);
489 }
490 }
491
492 .node-connector {
493 width: 40px;
494 height: 3px;
495 border-radius: 2px;
496 position: relative;
497 overflow: hidden;
498 }
499
500 .node-connector::after {
501 content: '';
502 position: absolute;
503 top: 0;
504 left: -100%;
505 width: 100%;
506 height: 100%;
507 background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
508 animation: shimmer 2s infinite;
509 }
510
511 @keyframes shimmer {
512 to {
513 left: 100%;
514 }
515 }
516
517 .breadcrumb-node:first-child .node-connector {
518 display: none;
519 }
520
521 .node-bubble {
522 background: rgba(255, 255, 255, 0.15);
523 backdrop-filter: blur(10px);
524 border: 2px solid;
525 border-radius: 12px;
526 padding: 12px 18px;
527 position: relative;
528 transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
529 cursor: pointer;
530 }
531
532 .bubble-scroll-zone {
533 position: absolute;
534 left: 0;
535 top: 0;
536 bottom: 0;
537 width: 35%;
538 z-index: 10;
539 cursor: pointer;
540 }
541
542 .bubble-scroll-zone:hover {
543 background: rgba(255, 255, 255, 0.1);
544 border-radius: 12px 0 0 12px;
545 }
546
547 .node-bubble:hover {
548 transform: scale(1.05) translateY(-3px);
549 background: rgba(255, 255, 255, 0.25);
550 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
551 }
552
553 .current-node .node-bubble {
554 background: rgba(255, 255, 255, 0.3);
555 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
556 inset 0 0 30px rgba(255, 255, 255, 0.3);
557 animation: pulse 2s infinite;
558 }
559
560 @keyframes pulse {
561 0%, 100% {
562 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
563 inset 0 0 30px rgba(255, 255, 255, 0.3);
564 }
565 50% {
566 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3),
567 inset 0 0 40px rgba(255, 255, 255, 0.5);
568 }
569 }
570
571 .node-link {
572 display: flex;
573 align-items: center;
574 gap: 10px;
575 color: white;
576 text-decoration: none;
577 font-weight: 600;
578 font-size: 14px;
579 white-space: nowrap;
580 transition: all 0.2s;
581 }
582
583 .node-link:hover {
584 text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
585 }
586
587 .node-link.current {
588 font-weight: 700;
589 font-size: 15px;
590 }
591
592 .node-icon {
593 font-size: 20px;
594 line-height: 1;
595 transition: transform 0.3s;
596 }
597
598 .node-link:hover .node-icon {
599 transform: scale(1.3);
600 }
601
602 .current-node .node-icon {
603 animation: glow 2s infinite;
604 }
605
606 @keyframes glow {
607 0%, 100% {
608 filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.6));
609 }
610 50% {
611 filter: drop-shadow(0 0 8px rgba(255, 255, 255, 1));
612 }
613 }
614
615 .node-name {
616 max-width: 150px;
617 overflow: hidden;
618 text-overflow: ellipsis;
619 }
620
621 .node-badge {
622 background: rgba(255, 255, 255, 0.3);
623 padding: 2px 8px;
624 border-radius: 8px;
625 font-size: 9px;
626 font-weight: 700;
627 letter-spacing: 0.5px;
628 text-transform: uppercase;
629 border: 1px solid rgba(255, 255, 255, 0.4);
630 }
631
632 .depth-badge {
633 background: rgba(0, 0, 0, 0.2);
634 padding: 2px 6px;
635 border-radius: 6px;
636 font-size: 10px;
637 font-weight: 600;
638 opacity: 0.7;
639 }
640
641 /* Mobile optimization */
642 @media (max-width: 640px) {
643 .breadcrumb-constellation {
644 padding: 16px 8px;
645 min-height: 80px;
646 }
647
648 .node-connector {
649 width: 24px;
650 }
651
652 .node-bubble {
653 padding: 8px 12px;
654 }
655
656 .node-link {
657 font-size: 12px;
658 gap: 6px;
659 }
660
661 .node-icon {
662 font-size: 16px;
663 }
664
665 .node-name {
666 max-width: 100px;
667 }
668
669 .node-badge {
670 font-size: 8px;
671 padding: 1px 6px;
672 }
673 }
674
675 /* Glass Action Buttons */
676 .action-button {
677 background: rgba(255,255,255,0.22);
678 border: 1px solid rgba(255,255,255,0.28);
679 box-shadow:
680 inset 0 1px 0 rgba(255,255,255,0.35),
681 0 4px 12px rgba(0,0,0,0.12);
682}
683
684
685 .action-button::before {
686 content: "";
687 position: absolute;
688 inset: -40%;
689 background: radial-gradient(
690 120% 60% at 0% 0%,
691 rgba(255, 255, 255, 0.35),
692 transparent 60%
693 );
694 opacity: 0;
695 transform: translateX(-30%) translateY(-10%);
696 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
697 pointer-events: none;
698 }
699
700 .action-button:hover {
701 background: rgba(255, 255, 255, 0.35);
702 transform: translateY(-2px);
703 }
704
705 .action-button:hover::before {
706 opacity: 1;
707 transform: translateX(30%) translateY(10%);
708 }
709
710 .owner-info {
711 font-size: 14px;
712 color: white;
713 font-weight: 600;
714 margin-bottom: 8px;
715 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
716 }
717
718 .owner-info a {
719 color: white;
720 text-decoration: none;
721 transition: all 0.2s;
722 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
723 }
724
725 .owner-info a:hover {
726 border-bottom-color: white;
727 text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
728 }
729
730 h1 {
731 font-size: 28px;
732 margin: 12px 0;
733 font-weight: 600;
734 line-height: 1.3;
735 letter-spacing: -0.5px;
736 }
737
738 h1 a {
739 color: white;
740 text-decoration: none;
741 transition: all 0.2s;
742 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
743 }
744
745 h1 a:hover {
746 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
747 transform: translateX(4px);
748 display: inline-block;
749 }
750
751 code {
752 background: transparent;
753 padding: 0;
754 border-radius: 0;
755 font-size: 13px;
756 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
757 color: white;
758 word-break: break-all;
759 }
760
761 /* Section Headers */
762 h2 {
763 font-size: 18px;
764 margin: 24px 0 16px 0;
765 font-weight: 600;
766 color: white;
767 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
768 letter-spacing: -0.3px;
769 }
770
771 h3 {
772 font-size: 16px;
773 margin: 20px 0 12px 0;
774 font-weight: 600;
775 color: white;
776 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
777 }
778
779 /* Filter Buttons */
780 #filterButtons {
781 display: flex;
782 flex-wrap: wrap;
783 gap: 8px;
784 margin: 16px 0;
785 }
786
787 #filterButtons a {
788 display: inline-flex;
789 align-items: center;
790 padding: 8px 16px;
791 font-size: 13px;
792 border-radius: 980px;
793 color: white;
794 font-weight: 600;
795transition:
796 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
797 background-color 0.3s ease,
798 opacity 0.3s ease; text-decoration: none;
799 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
800 border: 1px solid rgba(255, 255, 255, 0.3);
801 position: relative;
802 overflow: hidden;
803 }
804
805 #filterButtons a::before {
806 content: "";
807 position: absolute;
808 inset: -40%;
809 background: radial-gradient(
810 120% 60% at 0% 0%,
811 rgba(255, 255, 255, 0.35),
812 transparent 60%
813 );
814 opacity: 0;
815 transform: translateX(-30%) translateY(-10%);
816 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
817 pointer-events: none;
818 }
819
820 #filterButtons a:hover {
821 transform: translateY(-2px);
822 }
823
824 #filterButtons a:hover::before {
825 opacity: 1;
826 transform: translateX(30%) translateY(10%);
827 }
828
829 /* Tree Structure - Keep rainbow colors */
830 ul {
831 list-style: none;
832 padding-left: 16px;
833 margin: 12px 0;
834 }
835
836 li {
837 margin: 8px 0;
838 word-wrap: break-word;
839 overflow-wrap: break-word;
840 }
841
842 li a {
843 color: white;
844 text-decoration: none;
845 font-weight: 500;
846 transition: all 0.2s;
847 position: relative;
848 display: inline-block;
849 }
850
851 li a:hover {
852 text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
853 transform: translateX(4px);
854 }
855
856 /* Parents/Children with colored borders - keep the rainbow */
857 li[style*="border-left"] {
858 padding-left: 12px !important;
859 margin: 6px 0 !important;
860 position: relative;
861 background: rgba(255, 255, 255, 0.05);
862 border-radius: 6px;
863 padding: 8px 12px !important;
864 transition: all 0.2s;
865 }
866
867 li[style*="border-left"]:hover {
868 background: rgba(255, 255, 255, 0.1);
869 transform: translateX(4px);
870 }
871
872 /* Glass Forms */
873 form {
874 margin: 16px 0;
875 }
876
877 input[type="text"],
878 select {
879 width: 100%;
880 padding: 12px 14px;
881 font-size: 15px;
882 border-radius: 10px;
883 border: 2px solid rgba(255, 255, 255, 0.3);
884 background: rgba(255, 255, 255, 0.15);
885 font-family: inherit;
886transition:
887 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
888 background-color 0.3s ease,
889 opacity 0.3s ease;
890 color: white;
891 font-weight: 500;
892 }
893
894 input[type="text"]::placeholder {
895 color: rgba(255, 255, 255, 0.5);
896 }
897
898 input[type="text"]:focus,
899 select:focus {
900 outline: none;
901 border-color: rgba(255, 255, 255, 0.6);
902 background: rgba(255, 255, 255, 0.25);
903 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
904 transform: translateY(-2px);
905 }
906
907 select option {
908 background: #667eea;
909 color: white;
910 }
911
912 button[type="submit"] {
913 padding: 10px 18px;
914 border-radius: 980px;
915 border: 1px solid rgba(255, 255, 255, 0.3);
916 background: rgba(255, 255, 255, 0.25);
917 color: white;
918 cursor: pointer;
919 font-weight: 600;
920 font-size: 14px;
921transition:
922 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
923 background-color 0.3s ease,
924 opacity 0.3s ease;
925 font-family: inherit;
926 position: relative;
927 overflow: hidden;
928 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
929 }
930
931 button[type="submit"]::before {
932 content: "";
933 position: absolute;
934 inset: -40%;
935
936 opacity: 0;
937 transform: translateX(-30%) translateY(-10%);
938 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
939 pointer-events: none;
940 }
941
942 button[type="submit"]:hover {
943 background: rgba(255, 255, 255, 0.35);
944 transform: translateY(-1px);
945 }
946
947 button[type="submit"]:hover::before {
948 opacity: 1;
949 transform: translateX(30%) translateY(10%);
950 }
951
952 /* Invite Form */
953 form[action*="/invite"] {
954 display: flex;
955 flex-direction: column;
956 gap: 10px;
957 max-width: 100%;
958 }
959
960 @media (min-width: 640px) {
961 form[action*="/invite"] {
962 flex-direction: row;
963 max-width: 500px;
964 }
965
966 form[action*="/invite"] input[type="text"] {
967 flex: 1;
968 }
969
970 form[action*="/invite"] button {
971 width: auto;
972 }
973 }
974
975 /* Contributors - Glass List Items */
976 .contributors-list {
977 list-style: none;
978 padding-left: 0;
979 }
980
981 .contributors-list li {
982 display: flex;
983 flex-direction: column;
984 gap: 8px;
985 padding: 14px 16px;
986 background: rgba(255, 255, 255, 0.12);
987 border-radius: 10px;
988 margin: 8px 0;
989 border: 1px solid rgba(255, 255, 255, 0.25);
990transition:
991 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
992 background-color 0.3s ease,
993 opacity 0.3s ease;
994 }
995
996 .contributors-list li:hover {
997 background: rgba(255, 255, 255, 0.18);
998 transform: translateX(4px);
999 }
1000
1001 @media (min-width: 640px) {
1002 .contributors-list li {
1003 flex-direction: row;
1004 align-items: center;
1005 justify-content: space-between;
1006 }
1007 }
1008
1009 .contributors-list a {
1010 font-weight: 600;
1011 color: white;
1012 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1013 }
1014
1015 .contributors-list form {
1016 display: inline-block;
1017 margin: 0;
1018 }
1019
1020 .contributors-list button {
1021 padding: 6px 12px;
1022 font-size: 13px;
1023 border-radius: 980px;
1024 border: 1px solid rgba(255, 255, 255, 0.3);
1025 cursor: pointer;
1026 transition: all 0.2s;
1027 }
1028
1029 .contributors-list button:hover {
1030 transform: translateY(-1px);
1031 }
1032
1033 .contributors-actions {
1034 display: flex;
1035 gap: 8px;
1036 flex-wrap: wrap;
1037 }
1038
1039 /* Retire Button */
1040 form[action*="/retire"] button {
1041 background: rgba(239, 68, 68, 0.3) !important;
1042 border: 1px solid rgba(239, 68, 68, 0.5) !important;
1043 }
1044
1045 form[action*="/retire"] button:hover {
1046 background: rgba(239, 68, 68, 0.5) !important;
1047 transform: translateY(-2px);
1048 }
1049
1050.glass-shadow {
1051 position: absolute;
1052 inset: 0;
1053 border-radius: inherit;
1054 box-shadow: 0 12px 32px rgba(0,0,0,0.18);
1055 opacity: 0;
1056 transition: opacity 0.3s ease;
1057 pointer-events: none;
1058}
1059 :hover > .glass-shadow {
1060 opacity: 1;
1061}
1062
1063 /* Responsive Design */
1064 @media (max-width: 640px) {
1065 ul {
1066 padding-left: 6px;
1067 }
1068}
1069 @media (max-width: 640px) {
1070 .tree-node {
1071 padding: 6px 8px;
1072 }
1073}
1074 @media (max-width: 640px) {
1075 .tree-node {
1076 margin-left: 0;
1077 }
1078
1079 li[style*="border-left"] {
1080 padding-left: 8px !important;
1081 }
1082}
1083@media (max-width: 640px) {
1084 .tree-node,
1085 .tree-node:hover,
1086 li[style*="border-left"],
1087 li[style*="border-left"]:hover {
1088 transform: none !important;
1089 }
1090}
1091@media (max-width: 640px) {
1092 li[style*="border-left"] {
1093 padding-left: 8px !important;
1094 margin-left: 0 !important;
1095 }
1096}
1097@media (hover: none) {
1098 .tree-node:hover::before {
1099 opacity: 0;
1100 transform: none;
1101 }
1102}
1103
1104 ${responsiveBase}
1105
1106 @media (max-width: 640px) {
1107 .content-card {
1108 padding: 20px;
1109 }
1110
1111 h1 {
1112 font-size: 24px;
1113 }
1114
1115 ul {
1116 padding-left: 8px;
1117 }
1118 }
1119
1120.tree-node {
1121 position: relative;
1122 padding: 8px 12px;
1123 border-radius: 8px;
1124 background: rgba(255, 255, 255, 0.12);
1125 border: 1px solid rgba(255, 255, 255, 0.22);
1126 box-shadow:
1127 inset 0 1px 0 rgba(255, 255, 255, 0.35),
1128 0 4px 12px rgba(0, 0, 0, 0.12);
1129
1130 transition:
1131 transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
1132 background-color 0.25s ease,
1133 box-shadow 0.25s ease;
1134}
1135
1136
1137
1138 /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
1139 .custom-select { position: relative; width: 100%; max-width: 360px; }
1140 .custom-select-trigger {
1141 padding: 12px 14px; font-size: 15px; border-radius: 10px;
1142 border: 2px solid rgba(255, 255, 255, 0.3);
1143 background: rgba(255, 255, 255, 0.15); color: white;
1144 cursor: pointer; display: flex; align-items: center;
1145 justify-content: space-between; gap: 8px;
1146 font-weight: 500;
1147 -webkit-user-select: none; user-select: none;
1148 transition: all 0.3s ease;
1149 }
1150 .custom-select-trigger::after { content: "▾"; font-size: 11px; opacity: 0.6; flex-shrink: 0; }
1151 .custom-select.open .custom-select-trigger {
1152 border-color: rgba(255, 255, 255, 0.6);
1153 background: rgba(255, 255, 255, 0.25);
1154 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1155 }
1156 .custom-select.open .custom-select-trigger::after { content: "▴"; }
1157 .custom-select-options {
1158 display: none; position: absolute; left: 0; right: 0;
1159 bottom: calc(100% + 4px);
1160 background: rgba(102, 126, 234, 0.95);
1161 backdrop-filter: blur(12px);
1162 border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 10px;
1163 overflow: hidden; z-index: 100; max-height: 220px; overflow-y: auto;
1164 box-shadow: 0 -4px 16px rgba(0,0,0,0.2);
1165 }
1166 .custom-select.open .custom-select-options { display: block; }
1167 .custom-select-option {
1168 padding: 10px 14px; font-size: 14px; color: rgba(255, 255, 255, 0.85);
1169 cursor: pointer; transition: background 0.15s;
1170 }
1171 .custom-select-option:hover { background: rgba(255, 255, 255, 0.15); }
1172 .custom-select-option.selected { background: rgba(255, 255, 255, 0.2); color: white; font-weight: 600; }
1173
1174 /* Root entry in tree */
1175 .root-entry {
1176 background: rgba(255, 255, 255, 0.18) !important;
1177 border: 1px solid rgba(255, 255, 255, 0.30);
1178 border-left: 4px solid !important;
1179 }
1180 .root-entry > a {
1181 font-weight: 700;
1182 font-size: 16px;
1183 }
1184
1185 /* Settings groups inside ownership card */
1186 .settings-group {
1187 padding: 16px 0;
1188 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
1189 }
1190 .settings-group:last-child {
1191 border-bottom: none;
1192 padding-bottom: 0;
1193 }
1194 .settings-group:first-child {
1195 padding-top: 0;
1196 }
1197 .settings-group h3 {
1198 margin-top: 0;
1199 margin-bottom: 12px;
1200 font-size: 15px;
1201 opacity: 0.85;
1202 }
1203 .settings-group h2 {
1204 margin-top: 0;
1205 }
1206
1207 </style>
1208</head>
1209<body>
1210 <div class="container">
1211 ${
1212 currentUserId
1213 ? `
1214 <!-- Back Navigation -->
1215 <div class="back-nav">
1216 <a href="/api/v1/user/${currentUserId}${queryString}" class="back-link">
1217 <- Back to Profile
1218 </a>
1219 <a href="/api/v1/root/${allData._id}/calendar${queryString}" class="back-link">
1220 Calendar
1221 </a>
1222 </a>
1223 <a href="/api/v1/root/${allData._id}/book${queryString}" class="back-link">
1224 Book
1225 </a>
1226 </a>
1227 <a href="/api/v1/root/${allData._id}/values${queryString}" class="back-link">
1228 Global Values
1229 </a>
1230 </a>
1231 <a href="/api/v1/root/${allData._id}/understandings${queryString}" class="back-link">
1232 Understandings
1233 </a>
1234 <a href="/api/v1/root/${allData._id}/chats${queryString}" class="back-link">
1235 AI Chats
1236 </a>
1237 </div>
1238 `
1239 : ""
1240 }
1241 <!-- Navigation Path (only if not root) -->
1242 ${
1243 ancestors.length
1244 ? `
1245 <div class="content-card">
1246 <div class="section-header">
1247 <h2>Navigation Path</h2>
1248 </div>
1249 ${parentHtml}
1250 </div>
1251 `
1252 : ""
1253 }
1254
1255 <!-- Tree Card (root + children unified) -->
1256 <div class="content-card">
1257 <div class="section-header">
1258 <h2>Tree: <a href="/api/v1/node/${allData._id}/0${queryString}">${escapeHtml(allData.name)}</a></h2>
1259 </div>
1260 <div id="filterButtons"></div>
1261 ${treeHtml}
1262 </div>
1263
1264 ${isPublicAccess ? `
1265 <div style="text-align:center;padding:16px 20px;background:rgba(72,187,120,0.1);border:1px solid rgba(72,187,120,0.25);border-radius:12px;margin:16px 0;color:rgba(255,255,255,0.8);font-size:0.9rem;">
1266 Viewing public tree${queryAvailable ? ". You can query this tree using the API." : "."}
1267 </div>
1268 ` : ""}
1269
1270 ${!isPublicAccess ? `
1271 <!-- Deferred Items (Short-Term Holdings) -->
1272 <div class="content-card">
1273 <div class="section-header">
1274 <h2>Short-Term Holdings ${deferredItems.length > 0 ? `<span style="font-size:0.7em;color:#ffb347;">(${deferredItems.length})</span>` : ""}</h2>
1275 </div>
1276 ${deferredHtml}
1277 </div>
1278 ` : ""}
1279
1280 <!-- Tree Settings Section -->
1281${
1282 !isPublicAccess && (isOwner ||
1283 rootMeta?.contributors?.some(
1284 (c) => c._id.toString() === userId?.toString(),
1285 ))
1286 ? `
1287
1288 ${isOwner ? `
1289<div class="content-card">
1290 <div class="section-header">
1291 <h2>Visibility</h2>
1292 </div>
1293 <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1294 Public trees can be browsed and queried by anyone without authentication.
1295 If an LLM is assigned to the placement slot, anonymous visitors can query the tree (you pay energy).
1296 </p>
1297 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
1298 <select id="visibilitySelect"
1299 style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
1300 background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem;min-width:140px">
1301 <option value="private" ${rootMeta.visibility || "private" === "private" ? "selected" : ""}>Private</option>
1302 <option value="public" ${rootMeta.visibility === "public" ? "selected" : ""}>Public</option>
1303 </select>
1304 <button onclick="saveVisibility()" style="padding:8px 14px;border-radius:8px;
1305 border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
1306 color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
1307 <span id="visibilityStatus" style="display:none;font-size:0.85rem"></span>
1308 </div>
1309</div>
1310
1311<div class="content-card">
1312 <div class="section-header">
1313 <h2>Tree Dream</h2>
1314 </div>
1315 <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1316 Schedule a daily maintenance cycle: cleanup, process deferred items,
1317 and update tree understanding.
1318 </p>
1319 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
1320 <input type="time" id="dreamTimeInput" value="${rootMeta.metadata?.dreams?.dreamTime || ""}"
1321 style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
1322 background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem" />
1323 <button onclick="saveDreamTime()" style="padding:8px 14px;border-radius:8px;
1324 border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
1325 color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
1326 <button onclick="clearDreamTime()" style="padding:8px 14px;border-radius:8px;
1327 border:1px solid rgba(255,107,107,0.4);background:rgba(255,107,107,0.1);
1328 color:rgba(255,107,107,0.8);cursor:pointer">Disable</button>
1329 <span id="dreamTimeStatus" style="display:none;font-size:0.85rem"></span>
1330 </div>
1331 ${rootMeta.metadata?.dreams?.lastDreamAt ? `<p style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:8px 0 0">Last dream: ${new Date(rootMeta.metadata?.dreams?.lastDreamAt).toLocaleString()}</p>` : ""}
1332</div>
1333 ` : ""}
1334
1335<div class="content-card">
1336 <div class="section-header">
1337 <h2>Team</h2>
1338 </div>
1339 ${ownerHtml}
1340 ${contributorsHtml}
1341 ${inviteFormHtml}
1342</div>
1343
1344 ${policyHtml ? `
1345<div class="content-card">
1346 <div class="section-header">
1347 <h2>Transaction Policy</h2>
1348 </div>
1349 ${policyHtml}
1350</div>` : ""}
1351
1352 ${treeLlmHtml ? `
1353<div class="content-card">
1354 <div class="section-header">
1355 <h2>Tree Models</h2>
1356 </div>
1357 ${treeLlmHtml}
1358</div>` : ""}
1359
1360 ${isOwner ? `
1361<div class="content-card">
1362 <div class="section-header">
1363 <h2>Gateway</h2>
1364 </div>
1365 <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1366 Manage output channels for this tree -- send dream summaries and notifications to Telegram, Discord, or your browser.
1367 </p>
1368 <a href="/api/v1/root/${nodeId}/gateway${queryString}"
1369 style="display:inline-block;padding:8px 16px;border-radius:8px;
1370 border:1px solid rgba(115,111,230,0.4);background:rgba(115,111,230,0.15);
1371 color:rgba(200,200,255,0.95);font-weight:600;text-decoration:none;
1372 font-size:0.9rem;cursor:pointer">
1373 Manage Channels
1374 </a>
1375</div>
1376 ` : ""}
1377
1378 ${
1379 !isOwner && userId
1380 ? `
1381<div class="content-card">
1382 <div class="section-header">
1383 <h2>Leave Tree</h2>
1384 </div>
1385 <form
1386 method="POST"
1387 action="/api/v1/root/${nodeId}/remove-user?token=${token}&html"
1388 onsubmit="return confirm('Are you sure you want to leave this tree?')"
1389 >
1390 <input type="hidden" name="userReceiving" value="${userId}" />
1391 <button
1392 type="submit"
1393 style="
1394 padding:8px 14px;
1395 border-radius:8px;
1396 border:1px solid #900;
1397 background:rgba(239, 68, 68, 0.15);
1398 color:#ff6b6b;
1399 font-weight:600;
1400 cursor:pointer;
1401 "
1402 >
1403 Leave Tree
1404 </button>
1405 </form>
1406</div>
1407 `
1408 : ""
1409 }
1410
1411 ${
1412 retireHtml
1413 ? `
1414<div class="content-card">
1415 <div class="section-header">
1416 <h2>Retire Tree</h2>
1417 </div>
1418 ${retireHtml}
1419</div>
1420 `
1421 : ""
1422 }
1423`
1424 : ""
1425}
1426
1427 </div>
1428
1429
1430<script>
1431// VISIBILITY
1432async function saveVisibility() {
1433 var select = document.getElementById("visibilitySelect");
1434 var status = document.getElementById("visibilityStatus");
1435 if (!select) return;
1436 try {
1437 var res = await fetch("/api/v1/root/${nodeId}/visibility", {
1438 method: "POST",
1439 credentials: "include",
1440 headers: { "Content-Type": "application/json" },
1441 body: JSON.stringify({ visibility: select.value }),
1442 });
1443 if (res.ok) {
1444 if (status) {
1445 status.style.display = "inline";
1446 status.style.color = "rgba(72, 187, 120, 0.9)";
1447 status.textContent = select.value === "public" ? "Now public" : "Now private";
1448 setTimeout(function() { status.style.display = "none"; }, 3000);
1449 }
1450 } else {
1451 var data = await res.json().catch(function() { return {}; });
1452 if (status) {
1453 status.style.display = "inline";
1454 status.style.color = "rgba(255, 107, 107, 0.9)";
1455 status.textContent = data.error || "Failed";
1456 }
1457 }
1458 } catch (err) {
1459 if (status) {
1460 status.style.display = "inline";
1461 status.style.color = "rgba(255, 107, 107, 0.9)";
1462 status.textContent = "Error";
1463 }
1464 }
1465}
1466
1467// DREAM TIME
1468async function saveDreamTime() {
1469 var input = document.getElementById("dreamTimeInput");
1470 var status = document.getElementById("dreamTimeStatus");
1471 try {
1472 var res = await fetch("/api/v1/root/${nodeId}/dream-time", {
1473 method: "POST",
1474 credentials: "include",
1475 headers: { "Content-Type": "application/json" },
1476 body: JSON.stringify({ dreamTime: input.value || null }),
1477 });
1478 if (res.ok) {
1479 if (status) {
1480 status.style.display = "inline";
1481 status.style.color = "rgba(72, 187, 120, 0.9)";
1482 status.textContent = input.value ? "Saved" : "Disabled";
1483 setTimeout(function() { status.style.display = "none"; }, 3000);
1484 }
1485 } else {
1486 var data = await res.json().catch(function() { return {}; });
1487 if (status) {
1488 status.style.display = "inline";
1489 status.style.color = "rgba(255, 107, 107, 0.9)";
1490 status.textContent = data.error || "Failed";
1491 }
1492 }
1493 } catch (err) {
1494 if (status) {
1495 status.style.display = "inline";
1496 status.style.color = "rgba(255, 107, 107, 0.9)";
1497 status.textContent = "Network error";
1498 }
1499 }
1500}
1501async function clearDreamTime() {
1502 document.getElementById("dreamTimeInput").value = "";
1503 saveDreamTime();
1504}
1505
1506// ROOT LLM ASSIGNMENT
1507async function assignRootLlm(slot, connId) {
1508 var statusEl = document.querySelector(".llm-assign-status");
1509 try {
1510 var res = await fetch("/api/v1/root/${nodeId}/llm-assign", {
1511 method: "POST",
1512 credentials: "include",
1513 headers: { "Content-Type": "application/json" },
1514 body: JSON.stringify({ slot: slot, connectionId: connId || null }),
1515 });
1516 if (res.ok) {
1517 if (statusEl) {
1518 statusEl.style.display = "block";
1519 statusEl.style.color = "rgba(72, 187, 120, 0.9)";
1520 statusEl.textContent = connId ? "✓ Assigned" : "✓ Using default";
1521 setTimeout(function() { statusEl.style.display = "none"; }, 3000);
1522 }
1523 } else {
1524 var data = await res.json().catch(function() { return {}; });
1525 if (statusEl) {
1526 statusEl.style.display = "block";
1527 statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1528 statusEl.textContent = "✕ " + (data.error || "Failed");
1529 }
1530 }
1531 } catch (err) {
1532 if (statusEl) {
1533 statusEl.style.display = "block";
1534 statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1535 statusEl.textContent = "✕ Network error";
1536 }
1537 }
1538}
1539
1540// CUSTOM DROPDOWN HANDLER
1541(function() {
1542 document.querySelectorAll(".custom-select").forEach(function(sel) {
1543 var trigger = sel.querySelector(".custom-select-trigger");
1544 if (!trigger) return;
1545 trigger.addEventListener("click", function(e) {
1546 e.stopPropagation();
1547 var wasOpen = sel.classList.contains("open");
1548 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1549 if (!wasOpen) sel.classList.add("open");
1550 });
1551 sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
1552 opt.addEventListener("click", function(e) {
1553 e.stopPropagation();
1554 sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
1555 opt.classList.add("selected");
1556 trigger.textContent = opt.textContent;
1557 sel.classList.remove("open");
1558 assignRootLlm(sel.getAttribute("data-slot") || "placement", opt.getAttribute("data-value"));
1559 });
1560 });
1561 });
1562 document.addEventListener("click", function() {
1563 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1564 });
1565})();
1566
1567// AUTO-SCROLL BREADCRUMB TO RIGHT ON LOAD
1568window.addEventListener('load', () => {
1569 const breadcrumb = document.querySelector('.breadcrumb-constellation');
1570 if (breadcrumb) {
1571 breadcrumb.scrollLeft = breadcrumb.scrollWidth;
1572 }
1573});
1574
1575// HORIZONTAL SCROLL WITH MOUSE WHEEL - Breadcrumb
1576const breadcrumb = document.querySelector('.breadcrumb-constellation');
1577if (breadcrumb) {
1578 breadcrumb.addEventListener('wheel', (e) => {
1579 e.preventDefault();
1580 breadcrumb.scrollLeft += e.deltaY;
1581 });
1582}
1583
1584// Breadcrumb bubble click handling - links work normally
1585document.addEventListener('click', (e) => {
1586 const link = e.target.closest('.node-link');
1587 if (link && !e.defaultPrevented) {
1588 // Just let the link work normally
1589 return;
1590 }
1591});
1592
1593// Tree node click handling (existing)
1594document.addEventListener('click', (e) => {
1595 const node = e.target.closest('.tree-node');
1596 if (!node) return;
1597
1598 // Ignore real navigation
1599 if (e.target.closest('a, button')) return;
1600
1601 const link = node.querySelector(':scope > a');
1602 if (!link) return;
1603
1604 e.preventDefault();
1605
1606 const OFFSET = 50;
1607
1608 const rect = link.getBoundingClientRect();
1609 const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1610
1611 const targetY = rect.top + scrollTop - OFFSET;
1612
1613 window.scrollTo({
1614 top: targetY,
1615 behavior: 'smooth'
1616 });
1617
1618 // Optional glow pulse
1619 link.animate(
1620 [
1621 { boxShadow: '0 0 0 rgba(255,255,255,0)' },
1622 { boxShadow: '0 0 24px rgba(255,255,255,0.6)' },
1623 { boxShadow: '0 0 0 rgba(255,255,255,0)' }
1624 ],
1625 { duration: 900, easing: 'ease-out' }
1626 );
1627});
1628</script>
1629
1630 <script>
1631 // Filter toggles
1632 const params = new URLSearchParams(window.location.search);
1633
1634 function paramIsOn(param, current) {
1635 if (current === "true") return true;
1636 if (current === "false") return false;
1637 if (param === "active" || param === "completed") return true;
1638 return false;
1639 }
1640
1641 function makeToggle(param) {
1642 const current = params.get(param);
1643 const isOn = paramIsOn(param, current);
1644 const nextValue = isOn ? "false" : "true";
1645
1646 const newParams = new URLSearchParams(params);
1647 newParams.set(param, nextValue);
1648
1649 const url = window.location.pathname + "?" + newParams.toString();
1650 const color = isOn ? "#4CAF50" : "#9E9E9E";
1651
1652 return (
1653 '<a href="' + url + '" ' +
1654 'style="background:' + color + ';">' +
1655 param +
1656 '</a>'
1657 );
1658 }
1659
1660 document.getElementById("filterButtons").innerHTML =
1661 makeToggle("active") +
1662 makeToggle("completed") +
1663 makeToggle("trimmed") +
1664 '<a href="#" id="copyNodeIdBtn" title="Copy Node ID" style="background:rgba(var(--glass-water-rgb),0.35);">📋</a>';
1665
1666 document.getElementById("copyNodeIdBtn").addEventListener("click", function(e) {
1667 e.preventDefault();
1668 navigator.clipboard.writeText("${allData._id}").then(function() {
1669 var b = document.getElementById("copyNodeIdBtn");
1670 b.textContent = "✔️";
1671 setTimeout(function() { b.textContent = "📋"; }, 900);
1672 });
1673 });
1674 </script>
1675
1676</body>
1677</html>
1678`;
1679}
1680
1681
1682// ─────────────────────────────────────────────────────────────────────────
1683// 2. renderCalendar
1684// ─────────────────────────────────────────────────────────────────────────
1685
1686export function renderCalendar({ rootId, queryString, month, year, byDay }) {
1687 return `
1688<!DOCTYPE html>
1689<html lang="en">
1690<head>
1691 <meta charset="UTF-8">
1692 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1693 <meta name="theme-color" content="#667eea">
1694 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1695 <title>Calendar</title>
1696 <style>
1697 ${baseStyles}
1698 body { color: white; }
1699 .container { max-width: 1200px; }
1700
1701 /* Glass Card Base */
1702 .glass-card {
1703 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1704 backdrop-filter: blur(22px) saturate(140%);
1705 -webkit-backdrop-filter: blur(22px) saturate(140%);
1706 border-radius: 16px;
1707 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1708 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1709 border: 1px solid rgba(255, 255, 255, 0.28);
1710 position: relative;
1711 overflow: hidden;
1712 }
1713
1714 .glass-card::before {
1715 content: "";
1716 position: absolute;
1717 inset: 0;
1718 border-radius: inherit;
1719 background: linear-gradient(
1720 180deg,
1721 rgba(255,255,255,0.18),
1722 rgba(255,255,255,0.05)
1723 );
1724 pointer-events: none;
1725 }
1726
1727 /* Header */
1728 .header {
1729 padding: 24px 28px;
1730 margin-bottom: 20px;
1731 animation: fadeInUp 0.5s ease-out;
1732 }
1733
1734 .header-top {
1735 display: flex;
1736 justify-content: space-between;
1737 align-items: center;
1738 gap: 16px;
1739 flex-wrap: wrap;
1740 }
1741
1742 ${backNavStyles}
1743
1744 .nav-controls {
1745 display: flex;
1746 align-items: center;
1747 gap: 16px;
1748 }
1749
1750 .nav-button {
1751 width: 40px;
1752 height: 40px;
1753 border-radius: 50%;
1754 border: 1px solid rgba(255, 255, 255, 0.3);
1755 background: rgba(255, 255, 255, 0.2);
1756 color: white;
1757 font-size: 18px;
1758 font-weight: 700;
1759 cursor: pointer;
1760 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1761 display: flex;
1762 align-items: center;
1763 justify-content: center;
1764 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15),
1765 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1766 }
1767
1768 .nav-button:hover {
1769 background: rgba(255, 255, 255, 0.3);
1770 transform: scale(1.1);
1771 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
1772 }
1773
1774 .month-label {
1775 font-size: 20px;
1776 font-weight: 700;
1777 color: white;
1778 min-width: 200px;
1779 text-align: center;
1780 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1781 letter-spacing: -0.3px;
1782 }
1783
1784 .clock {
1785 font-size: 14px;
1786 color: rgba(255, 255, 255, 0.9);
1787 font-weight: 500;
1788 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1789 }
1790
1791 /* Calendar Grid - Desktop */
1792 .calendar-grid {
1793 display: grid;
1794 grid-template-columns: repeat(7, 1fr);
1795 gap: 12px;
1796 padding: 24px;
1797 animation: fadeInUp 0.6s ease-out;
1798 }
1799
1800 .day-header {
1801 background: rgba(255, 255, 255, 0.2);
1802 backdrop-filter: blur(10px);
1803 border-radius: 10px;
1804 padding: 12px;
1805 text-align: center;
1806 font-weight: 700;
1807 font-size: 14px;
1808 color: white;
1809 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
1810 inset 0 1px 0 rgba(255, 255, 255, 0.3);
1811 border: 1px solid rgba(255, 255, 255, 0.25);
1812 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1813 }
1814
1815 .day-cell {
1816 background: rgba(255, 255, 255, 0.15);
1817 backdrop-filter: blur(10px);
1818 border-radius: 12px;
1819 padding: 12px;
1820 min-height: 120px;
1821 cursor: pointer;
1822 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1823 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
1824 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1825 border: 1px solid rgba(255, 255, 255, 0.2);
1826 position: relative;
1827 overflow: hidden;
1828 }
1829
1830 .day-cell::before {
1831 content: "";
1832 position: absolute;
1833 inset: 0;
1834 border-radius: inherit;
1835 background: linear-gradient(
1836 180deg,
1837 rgba(255,255,255,0.15),
1838 rgba(255,255,255,0.05)
1839 );
1840 pointer-events: none;
1841 }
1842
1843 .day-cell:hover {
1844 transform: translateY(-4px);
1845 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
1846 background: rgba(255, 255, 255, 0.25);
1847 border-color: rgba(255, 255, 255, 0.4);
1848 }
1849
1850 .day-cell.other-month {
1851 opacity: 0.4;
1852 }
1853
1854 .day-number {
1855 font-weight: 700;
1856 font-size: 16px;
1857 color: white;
1858 margin-bottom: 8px;
1859 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1860 position: relative;
1861 z-index: 1;
1862 }
1863
1864 .day-cell.today .day-number {
1865 background: rgba(255, 255, 255, 0.3);
1866 color: white;
1867 width: 32px;
1868 height: 32px;
1869 border-radius: 50%;
1870 display: flex;
1871 align-items: center;
1872 justify-content: center;
1873 font-size: 14px;
1874 box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
1875 inset 0 1px 0 rgba(255, 255, 255, 0.4);
1876 border: 2px solid rgba(255, 255, 255, 0.5);
1877 animation: pulse 2s infinite;
1878 }
1879
1880 @keyframes pulse {
1881 0%, 100% {
1882 box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
1883 inset 0 1px 0 rgba(255, 255, 255, 0.4);
1884 }
1885 50% {
1886 box-shadow: 0 0 30px rgba(255, 255, 255, 0.7),
1887 inset 0 1px 0 rgba(255, 255, 255, 0.6);
1888 }
1889 }
1890
1891 .node-item {
1892 display: block;
1893 margin: 4px 0;
1894 padding: 6px 10px;
1895 border-radius: 8px;
1896 background: rgba(255, 255, 255, 0.25);
1897 color: white;
1898 font-size: 12px;
1899 font-weight: 600;
1900 text-decoration: none;
1901 transition: all 0.2s;
1902 white-space: nowrap;
1903 overflow: hidden;
1904 text-overflow: ellipsis;
1905 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1906 border: 1px solid rgba(255, 255, 255, 0.2);
1907 position: relative;
1908 z-index: 1;
1909 }
1910
1911 .node-item:hover {
1912 background: rgba(255, 255, 255, 0.35);
1913 transform: translateX(2px);
1914 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1915 }
1916
1917 .node-count {
1918 display: inline-block;
1919 margin-top: 4px;
1920 padding: 4px 8px;
1921 background: rgba(255, 255, 255, 0.2);
1922 color: white;
1923 font-size: 11px;
1924 font-weight: 700;
1925 border-radius: 12px;
1926 border: 1px solid rgba(255, 255, 255, 0.25);
1927 position: relative;
1928 z-index: 1;
1929 }
1930
1931 /* List View - Mobile */
1932 .calendar-list {
1933 display: none;
1934 padding: 16px;
1935 gap: 12px;
1936 }
1937
1938 .list-day {
1939 background: rgba(255, 255, 255, 0.15);
1940 backdrop-filter: blur(10px);
1941 border-radius: 12px;
1942 padding: 16px;
1943 cursor: pointer;
1944 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1945 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
1946 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1947 border: 1px solid rgba(255, 255, 255, 0.2);
1948 position: relative;
1949 overflow: hidden;
1950 }
1951
1952 .list-day::before {
1953 content: "";
1954 position: absolute;
1955 inset: 0;
1956 border-radius: inherit;
1957 background: linear-gradient(
1958 180deg,
1959 rgba(255,255,255,0.15),
1960 rgba(255,255,255,0.05)
1961 );
1962 pointer-events: none;
1963 }
1964
1965 .list-day:hover {
1966 transform: translateY(-2px);
1967 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1968 background: rgba(255, 255, 255, 0.2);
1969 }
1970
1971 .list-day-header {
1972 display: flex;
1973 justify-content: space-between;
1974 align-items: center;
1975 margin-bottom: 12px;
1976 position: relative;
1977 z-index: 1;
1978 }
1979
1980 .list-day-date {
1981 font-weight: 700;
1982 font-size: 16px;
1983 color: white;
1984 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1985 }
1986
1987 .list-day-badge {
1988 padding: 4px 12px;
1989 background: rgba(255, 255, 255, 0.25);
1990 color: white;
1991 border-radius: 12px;
1992 font-size: 12px;
1993 font-weight: 700;
1994 border: 1px solid rgba(255, 255, 255, 0.3);
1995 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1996 }
1997
1998 /* Day View */
1999 .day-view {
2000 padding: 24px;
2001 animation: fadeInUp 0.6s ease-out;
2002 }
2003
2004 .hour-row {
2005 display: flex;
2006 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
2007 padding: 12px 0;
2008 min-height: 60px;
2009 transition: background 0.2s;
2010 }
2011
2012 .hour-row:hover {
2013 background: rgba(255, 255, 255, 0.08);
2014 border-radius: 8px;
2015 }
2016
2017 .hour-label {
2018 width: 80px;
2019 font-weight: 700;
2020 color: white;
2021 font-size: 14px;
2022 flex-shrink: 0;
2023 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
2024 }
2025
2026 .hour-content {
2027 flex: 1;
2028 display: flex;
2029 flex-direction: column;
2030 gap: 6px;
2031 }
2032
2033 .empty-state {
2034 text-align: center;
2035 padding: 60px 40px;
2036 color: rgba(255, 255, 255, 0.8);
2037 }
2038
2039 .empty-state-icon {
2040 font-size: 64px;
2041 margin-bottom: 16px;
2042 opacity: 0.6;
2043 }
2044
2045 /* Mobile Responsive */
2046 @media (max-width: 768px) {
2047 body {
2048 padding: 12px;
2049 }
2050
2051 .header {
2052 padding: 16px;
2053 }
2054
2055 .header-top {
2056 flex-direction: column;
2057 align-items: stretch;
2058 }
2059
2060 .nav-controls {
2061 justify-content: center;
2062 }
2063
2064 .clock {
2065 text-align: center;
2066 }
2067
2068 /* Switch to list view on mobile */
2069 .calendar-grid {
2070 display: none;
2071 }
2072
2073 .calendar-list {
2074 display: flex;
2075 flex-direction: column;
2076 }
2077
2078 .day-view {
2079 padding: 16px;
2080 }
2081
2082 .hour-label {
2083 width: 60px;
2084 font-size: 12px;
2085 }
2086
2087 .month-label {
2088 font-size: 18px;
2089 }
2090
2091 .nav-button {
2092 width: 36px;
2093 height: 36px;
2094 font-size: 16px;
2095 }
2096 }
2097 </style>
2098</head>
2099<body>
2100 <div class="container">
2101 <!-- Header -->
2102 <div class="glass-card header">
2103 <div class="header-top">
2104 <a href="/api/v1/root/${rootId}${queryString}" class="back-link" id="backLink">
2105 <- Back to Tree
2106 </a>
2107
2108 <div class="nav-controls">
2109 <button class="nav-button" id="prevMonth"><-</button>
2110 <div class="month-label" id="monthLabel"></div>
2111 <button class="nav-button" id="nextMonth">-></button>
2112 </div>
2113
2114 <div class="clock" id="clock"></div>
2115 </div>
2116 </div>
2117
2118 <!-- Calendar Container -->
2119 <div class="glass-card" id="calendarContainer"></div>
2120 </div>
2121
2122 <script>
2123 const params = new URLSearchParams(window.location.search);
2124 const dayMode = params.get("day");
2125 const calendarData = ${JSON.stringify(byDay)};
2126 const month = ${month};
2127 const year = ${year};
2128
2129 const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
2130 const dayNames = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
2131
2132 const container = document.getElementById("calendarContainer");
2133 const monthLabel = document.getElementById("monthLabel");
2134 const backLink = document.getElementById("backLink");
2135
2136 // Clock
2137 function tick() {
2138 document.getElementById("clock").textContent = new Date().toLocaleString();
2139 }
2140 tick();
2141 setInterval(tick, 1000);
2142
2143 // Format hour for day view
2144 function formatHour(h) {
2145 if (h === 0) return "12 AM";
2146 if (h < 12) return h + " AM";
2147 if (h === 12) return "12 PM";
2148 return (h - 12) + " PM";
2149 }
2150
2151 // Render Day View
2152 function renderDayView(dayKey) {
2153 monthLabel.textContent = dayKey;
2154 backLink.textContent = "<- Back to Month";
2155 backLink.onclick = (e) => {
2156 e.preventDefault();
2157 const p = new URLSearchParams(window.location.search);
2158 p.delete("day");
2159 window.location.search = p.toString();
2160 };
2161
2162 const items = (calendarData[dayKey] || []).slice().sort(
2163 (a, b) => new Date(a.schedule) - new Date(b.schedule)
2164 );
2165
2166 const byHour = {};
2167 for (const item of items) {
2168 const d = new Date(item.schedule);
2169 const h = d.getHours();
2170 if (!byHour[h]) byHour[h] = [];
2171 byHour[h].push(item);
2172 }
2173
2174 let html = '<div class="day-view">';
2175
2176 if (items.length === 0) {
2177 html += '<div class="empty-state"><div class="empty-state-icon">📅</div><div>No scheduled items for this day</div></div>';
2178 } else {
2179 for (let h = 0; h < 24; h++) {
2180 html += \`
2181 <div class="hour-row">
2182 <div class="hour-label">\${formatHour(h)}</div>
2183 <div class="hour-content">
2184 \`;
2185
2186 (byHour[h] || []).forEach(item => {
2187 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}">\${item.name}</a>\`;
2188 });
2189
2190 html += '</div></div>';
2191 }
2192 }
2193
2194 html += '</div>';
2195 container.innerHTML = html;
2196 }
2197
2198 // Render Month View
2199 function renderMonthView() {
2200 monthLabel.textContent = monthNames[month] + " " + year;
2201
2202 const firstDay = new Date(year, month, 1);
2203 const start = new Date(firstDay);
2204 start.setDate(1 - firstDay.getDay());
2205
2206 const today = new Date();
2207 const todayStr = today.toISOString().slice(0, 10);
2208
2209 const isMobile = window.innerWidth <= 768;
2210
2211 if (isMobile) {
2212 // List view for mobile
2213 let html = '<div class="calendar-list">';
2214
2215 const daysWithEvents = [];
2216 for (let i = 0; i < 42; i++) {
2217 const d = new Date(start);
2218 d.setDate(start.getDate() + i);
2219 const key = d.toISOString().slice(0, 10);
2220 const items = calendarData[key] || [];
2221
2222 if (items.length > 0 || d.getMonth() === month) {
2223 daysWithEvents.push({ date: d, key, items });
2224 }
2225 }
2226
2227 if (daysWithEvents.length === 0) {
2228 html += '<div class="empty-state"><div class="empty-state-icon">📅</div><div>No scheduled items this month</div></div>';
2229 } else {
2230 daysWithEvents.forEach(({ date, key, items }) => {
2231 const dayOfWeek = dayNames[date.getDay()];
2232 const isToday = key === todayStr;
2233
2234 html += \`
2235 <div class="list-day" onclick="goToDay('\${key}')">
2236 <div class="list-day-header">
2237 <div class="list-day-date">
2238 \${dayOfWeek}, \${monthNames[date.getMonth()]} \${date.getDate()}
2239 \${isToday ? ' <span style="text-shadow: 0 0 10px rgba(255,255,255,0.8);">✨ Today</span>' : ''}
2240 </div>
2241 \${items.length > 0 ? \`<span class="list-day-badge">\${items.length} item\${items.length !== 1 ? 's' : ''}</span>\` : ''}
2242 </div>
2243 \`;
2244
2245 if (items.length > 0) {
2246 items.slice(0, 3).forEach(item => {
2247 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
2248 });
2249
2250 if (items.length > 3) {
2251 html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
2252 }
2253 }
2254
2255 html += '</div>';
2256 });
2257 }
2258
2259 html += '</div>';
2260 container.innerHTML = html;
2261 } else {
2262 // Grid view for desktop
2263 let html = '<div class="calendar-grid">';
2264
2265 // Day headers
2266 dayNames.forEach(day => {
2267 html += \`<div class="day-header">\${day}</div>\`;
2268 });
2269
2270 // Days
2271 for (let i = 0; i < 42; i++) {
2272 const d = new Date(start);
2273 d.setDate(start.getDate() + i);
2274 const key = d.toISOString().slice(0, 10);
2275 const items = calendarData[key] || [];
2276 const isOtherMonth = d.getMonth() !== month;
2277 const isToday = key === todayStr;
2278
2279 html += \`
2280 <div class="day-cell \${isOtherMonth ? 'other-month' : ''} \${isToday ? 'today' : ''}" onclick="goToDay('\${key}')">
2281 <div class="day-number">\${d.getDate()}</div>
2282 \`;
2283
2284 items.slice(0, 3).forEach(item => {
2285 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
2286 });
2287
2288 if (items.length > 3) {
2289 html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
2290 }
2291
2292 html += '</div>';
2293 }
2294
2295 html += '</div>';
2296 container.innerHTML = html;
2297 }
2298 }
2299
2300 // Navigate to day
2301 function goToDay(key) {
2302 const p = new URLSearchParams(window.location.search);
2303 p.set("day", key);
2304 window.location.search = p.toString();
2305 }
2306
2307 // Navigation buttons
2308 document.getElementById("prevMonth").onclick = () => {
2309 const p = new URLSearchParams(window.location.search);
2310
2311 if (dayMode) {
2312 const d = new Date(dayMode);
2313 d.setDate(d.getDate() - 1);
2314 p.set("day", d.toISOString().slice(0, 10));
2315 } else {
2316 let m = month - 1;
2317 let y = year;
2318 if (m < 0) { m = 11; y--; }
2319 p.set("month", m);
2320 p.set("year", y);
2321 }
2322
2323 window.location.search = p.toString();
2324 };
2325
2326 document.getElementById("nextMonth").onclick = () => {
2327 const p = new URLSearchParams(window.location.search);
2328
2329 if (dayMode) {
2330 const d = new Date(dayMode);
2331 d.setDate(d.getDate() + 1);
2332 p.set("day", d.toISOString().slice(0, 10));
2333 } else {
2334 let m = month + 1;
2335 let y = year;
2336 if (m > 11) { m = 0; y++; }
2337 p.set("month", m);
2338 p.set("year", y);
2339 }
2340
2341 window.location.search = p.toString();
2342 };
2343
2344 // Initial render
2345 if (dayMode) {
2346 renderDayView(dayMode);
2347 } else {
2348 renderMonthView();
2349 }
2350
2351 // Re-render on resize
2352 let resizeTimer;
2353 window.addEventListener('resize', () => {
2354 clearTimeout(resizeTimer);
2355 resizeTimer = setTimeout(() => {
2356 if (!dayMode) renderMonthView();
2357 }, 250);
2358 });
2359 </script>
2360</body>
2361</html>
2362`;
2363}
2364
2365
2366// ─────────────────────────────────────────────────────────────────────────
2367// 3. renderGateway
2368// ─────────────────────────────────────────────────────────────────────────
2369
2370export function renderGateway({ rootId, rootName, queryString, channels }) {
2371 const channelRows = channels.length === 0
2372 ? '<p style="color:rgba(255,255,255,0.5);font-size:0.9rem;">No channels configured yet. Add one below.</p>'
2373 : channels.map(function(ch) {
2374 var typeBadge = ch.type === "telegram" ? "TG"
2375 : ch.type === "discord" ? "DC"
2376 : "WEB";
2377 var typeColor = ch.type === "telegram" ? "rgba(0,136,204,0.8)"
2378 : ch.type === "discord" ? "rgba(88,101,242,0.8)"
2379 : "rgba(72,187,120,0.8)";
2380 var statusDot = ch.enabled
2381 ? '<span style="color:rgba(72,187,120,0.9);">●</span>'
2382 : '<span style="color:rgba(255,107,107,0.9);">●</span>';
2383 var notifList = (ch.notificationTypes || []).join(", ");
2384 var lastDispatch = ch.lastDispatchAt
2385 ? new Date(ch.lastDispatchAt).toLocaleString()
2386 : "Never";
2387 var lastErr = ch.lastError
2388 ? '<span style="color:rgba(255,107,107,0.8);font-size:0.75rem;">' + escapeHtml(ch.lastError) + '</span>'
2389 : '';
2390
2391 var dirLabel = ch.direction === "input-output" ? "I/O"
2392 : ch.direction === "input" ? "IN"
2393 : "OUT";
2394 var modeLabel = ch.mode === "read-write" ? "CHAT"
2395 : ch.mode === "read" ? "QUERY"
2396 : "PLACE";
2397
2398 return `
2399<div class="channel-row" data-id="${ch._id}" style="
2400 background:rgba(255,255,255,0.06);border-radius:12px;padding:16px;margin-bottom:12px;
2401 border:1px solid rgba(255,255,255,0.1);position:relative;">
2402 <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
2403 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
2404 ${statusDot}
2405 <span style="font-weight:600;color:#fff;">${escapeHtml(ch.name)}</span>
2406 <span style="background:${typeColor};color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:4px;font-weight:600;">${typeBadge}</span>
2407 <span style="background:rgba(255,255,255,0.12);color:rgba(255,255,255,0.7);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${dirLabel}</span>
2408 <span style="background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${modeLabel}</span>
2409 </div>
2410 <div style="display:flex;gap:8px;">
2411 <button onclick="testChannel('${ch._id}')" style="
2412 padding:4px 12px;border-radius:6px;border:1px solid rgba(115,111,230,0.4);
2413 background:rgba(115,111,230,0.15);color:rgba(200,200,255,0.9);font-size:0.8rem;cursor:pointer;">
2414 Test</button>
2415 <button onclick="toggleChannel('${ch._id}', ${!ch.enabled})" style="
2416 padding:4px 12px;border-radius:6px;border:1px solid rgba(255,179,71,0.4);
2417 background:rgba(255,179,71,0.1);color:rgba(255,179,71,0.9);font-size:0.8rem;cursor:pointer;">
2418 ${ch.enabled ? "Disable" : "Enable"}</button>
2419 <button onclick="deleteChannel('${ch._id}')" style="
2420 padding:4px 12px;border-radius:6px;border:1px solid rgba(255,107,107,0.4);
2421 background:rgba(255,107,107,0.1);color:rgba(255,107,107,0.8);font-size:0.8rem;cursor:pointer;">
2422 Delete</button>
2423 </div>
2424 </div>
2425 <div style="margin-top:8px;font-size:0.8rem;color:rgba(255,255,255,0.5);">
2426 ${ch.config?.displayIdentifier ? escapeHtml(ch.config.displayIdentifier) + ' · ' : ''}
2427 ${notifList} · Last sent: ${lastDispatch}
2428 </div>
2429 ${lastErr ? '<div style="margin-top:4px;">' + lastErr + '</div>' : ''}
2430</div>`;
2431 }).join('\n');
2432
2433 return `
2434<!DOCTYPE html>
2435<html lang="en">
2436<head>
2437 <meta charset="UTF-8">
2438 <meta name="viewport" content="width=device-width, initial-scale=1.0">
2439 <meta name="theme-color" content="#667eea">
2440 <title>Gateway -- ${escapeHtml(rootName)}</title>
2441 <style>
2442 ${baseStyles}
2443 body { color: #fff; }
2444 .content-card {
2445 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2446 backdrop-filter: blur(22px) saturate(140%);
2447 border-radius: 16px; padding: 28px;
2448 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2449 border: 1px solid rgba(255,255,255,0.28);
2450 margin-bottom: 24px; animation: fadeInUp 0.6s ease-out both;
2451 }
2452 .section-header h2 { color: #fff; font-size: 1.3rem; font-weight: 700; margin-bottom: 16px; }
2453 .back-nav {
2454 display: flex; gap: 12px; margin-bottom: 20px; animation: fadeInUp 0.5s ease-out;
2455 }
2456 .back-nav a {
2457 background: rgba(var(--glass-water-rgb), 0.25);
2458 backdrop-filter: blur(12px); border-radius: 10px; padding: 8px 16px;
2459 color: rgba(255,255,255,0.9); text-decoration: none; font-size: 0.85rem;
2460 border: 1px solid rgba(255,255,255,0.15); font-weight: 500;
2461 }
2462 .back-nav a:hover { background: rgba(var(--glass-water-rgb), 0.35); }
2463 label { display: block; font-size: 0.85rem; color: rgba(255,255,255,0.7); margin-bottom: 4px; margin-top: 12px; }
2464 input, select {
2465 width: 100%; padding: 10px 14px; border-radius: 8px;
2466 border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
2467 color: #fff; font-size: 0.9rem; outline: none;
2468 }
2469 input::placeholder { color: rgba(255,255,255,0.7); }
2470 input:focus, select:focus { border-color: rgba(115,111,230,0.6); }
2471 select option { background: #3a3a6e; color: #fff; }
2472 .btn-primary {
2473 padding: 10px 20px; border-radius: 8px; border: 1px solid rgba(72,187,120,0.4);
2474 background: rgba(72,187,120,0.15); color: rgba(72,187,120,0.95);
2475 font-weight: 600; cursor: pointer; font-size: 0.9rem; margin-top: 16px;
2476 }
2477 .btn-primary:hover { background: rgba(72,187,120,0.25); }
2478 .checkbox-row {
2479 display: flex; align-items: center; gap: 8px; margin-top: 6px;
2480 }
2481 .checkbox-row input[type="checkbox"] { width: auto; }
2482 #gatewayStatus {
2483 display: none; font-size: 0.85rem; margin-top: 12px; padding: 8px 12px;
2484 border-radius: 8px;
2485 }
2486 </style>
2487</head>
2488<body>
2489<div class="container">
2490
2491 <div class="back-nav">
2492 <a href="/api/v1/root/${rootId}${queryString}">Back to Tree</a>
2493 </div>
2494
2495 <div class="content-card">
2496 <div class="section-header">
2497 <h2>Gateway Channels</h2>
2498 </div>
2499 <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-bottom:16px;">
2500 Output channels push notifications from this tree to external services.
2501 </p>
2502 <div id="channelList">
2503 ${channelRows}
2504 </div>
2505 </div>
2506
2507 <div class="content-card" style="animation-delay:0.1s;">
2508 <div class="section-header">
2509 <h2>Add Channel</h2>
2510 </div>
2511
2512 <label for="channelName">Channel Name</label>
2513 <input type="text" id="channelName" placeholder="e.g. My Discord Updates" maxlength="100" />
2514
2515 <label for="channelType">Type</label>
2516 <select id="channelType" onchange="updateFormFields()">
2517 <option value="telegram">Telegram</option>
2518 <option value="discord">Discord</option>
2519 <option value="webapp">Web Push (this browser)</option>
2520 </select>
2521
2522 <label for="channelDirection">Direction</label>
2523 <select id="channelDirection" onchange="updateFormFields()">
2524 <option value="output">Output (send notifications out)</option>
2525 <option value="input">Input (receive messages in)</option>
2526 <option value="input-output">Input/Output (bidirectional chat)</option>
2527 </select>
2528
2529 <label for="channelMode">Mode</label>
2530 <select id="channelMode">
2531 <option value="write">Place (scans tree, makes edits, no response)</option>
2532 <option value="read">Query (reads tree, responds, no edits)</option>
2533 <option value="read-write">Chat (reads tree, makes edits, responds)</option>
2534 </select>
2535
2536 <div id="telegramFields" style="margin-top:8px;">
2537 <label for="tgBotToken">Bot Token</label>
2538 <input type="password" id="tgBotToken" placeholder="123456:ABC-DEF..." />
2539 <label for="tgChatId">Chat ID</label>
2540 <input type="text" id="tgChatId" placeholder="-1001234567890" />
2541 </div>
2542
2543 <div id="discordOutputFields" style="display:none;">
2544 <label for="dcWebhookUrl">Webhook URL</label>
2545 <input type="password" id="dcWebhookUrl" placeholder="https://discord.com/api/webhooks/..." />
2546 </div>
2547
2548 <div id="discordInputFields" style="display:none;">
2549 <div style="background:rgba(255,255,255,0.05);border-radius:8px;padding:12px;margin-top:8px;margin-bottom:12px;border:1px solid rgba(255,255,255,0.1);">
2550 <div style="color:rgba(255,255,255,0.8);font-size:0.82rem;font-weight:600;margin-bottom:8px;">How to get your Discord bot details:</div>
2551 <ol style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:0;padding-left:18px;line-height:1.6;">
2552 <li>Go to <a href="https://discord.com/developers/applications" target="_blank" style="color:#1a1a1a;">Discord Developer Portal</a></li>
2553 <li>Create a New Application, then go to the <strong>Bot</strong> tab</li>
2554 <li>Click "Reset Token" to get your bot token and copy it</li>
2555 <li>Enable <strong>Message Content Intent</strong> under Privileged Gateway Intents</li>
2556 <li>Go to <strong>Installation</strong> tab, set integration type to <strong>Guild Install</strong></li>
2557 <li>Go to <strong>OAuth2</strong> tab, check <em>bot</em> scope, then under Bot Permissions check <strong>Read Message History</strong> and <strong>Send Messages</strong></li>
2558 <li>Copy the generated URL and open it to invite the bot to your server</li>
2559 <li>In Discord, right-click the channel you want, click "Copy Channel ID"<br/>(Enable Developer Mode in Discord Settings > Advanced if you don't see it)</li>
2560 </ol>
2561 </div>
2562 <label for="dcBotToken">Bot Token</label>
2563 <input type="password" id="dcBotToken" placeholder="Discord bot token..." />
2564 <label for="dcChannelId">Discord Channel ID</label>
2565 <input type="text" id="dcChannelId" placeholder="1234567890123456789" />
2566 <p style="color:rgba(255,179,71,0.7);font-size:0.8rem;margin-top:6px;">
2567 Discord input requires Standard, Premium, or God tier.
2568 </p>
2569 </div>
2570
2571 <div id="webappFields" style="display:none;">
2572 <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-top:12px;">
2573 Your browser will ask for notification permission when you add this channel.
2574 </p>
2575 </div>
2576
2577 <div id="outputNotifSection" style="display:none;">
2578 <label style="margin-top:16px;">Notification Types</label>
2579 <div class="checkbox-row">
2580 <input type="checkbox" id="notifSummary" checked /> <label for="notifSummary" style="margin:0;">Dream Summary</label>
2581 </div>
2582 <div class="checkbox-row">
2583 <input type="checkbox" id="notifThought" checked /> <label for="notifThought" style="margin:0;">Dream Thought</label>
2584 </div>
2585 </div>
2586
2587 <div id="inputConfigSection" style="display:none;">
2588 <label for="queueBehavior" style="margin-top:16px;">When Busy (2+ messages processing)</label>
2589 <select id="queueBehavior">
2590 <option value="respond">Respond with busy message</option>
2591 <option value="silent">Stay silent</option>
2592 </select>
2593 </div>
2594
2595 <button class="btn-primary" onclick="addChannel()">Add Channel</button>
2596 <div id="gatewayStatus"></div>
2597 </div>
2598
2599</div>
2600
2601<script>
2602var ROOT_ID = "${rootId}";
2603
2604function updateFormFields() {
2605 var type = document.getElementById("channelType").value;
2606 var direction = document.getElementById("channelDirection").value;
2607 var hasOutput = direction === "output" || direction === "input-output";
2608
2609 // Webapp can only be output
2610 var dirSelect = document.getElementById("channelDirection");
2611 var modeSelect = document.getElementById("channelMode");
2612 var modeLabel = document.querySelector('label[for="channelMode"]');
2613 if (type === "webapp") {
2614 dirSelect.value = "output";
2615 dirSelect.disabled = true;
2616 hasOutput = true;
2617 } else {
2618 dirSelect.disabled = false;
2619 }
2620
2621 // Mode only relevant for channels with input capability
2622 var hasInput = direction === "input" || direction === "input-output";
2623 modeSelect.style.display = hasInput ? "block" : "none";
2624 modeLabel.style.display = hasInput ? "block" : "none";
2625
2626 // Smart defaults per direction
2627 if (direction === "input") {
2628 modeSelect.value = "write";
2629 } else if (direction === "input-output") {
2630 modeSelect.value = "read-write";
2631 }
2632
2633 // Telegram: always show (same bot token + chat ID for input and output)
2634 document.getElementById("telegramFields").style.display = type === "telegram" ? "block" : "none";
2635
2636 // Discord: show different fields based on direction
2637 document.getElementById("discordOutputFields").style.display = (type === "discord" && !hasInput) ? "block" : "none";
2638 document.getElementById("discordInputFields").style.display = (type === "discord" && hasInput) ? "block" : "none";
2639
2640 // Webapp: only on output
2641 document.getElementById("webappFields").style.display = (type === "webapp" && hasOutput) ? "block" : "none";
2642
2643 // Notification types: only for output channels
2644 document.getElementById("outputNotifSection").style.display = hasOutput ? "block" : "none";
2645
2646 // Queue behavior: only for input channels
2647 document.getElementById("inputConfigSection").style.display = hasInput ? "block" : "none";
2648}
2649
2650function showStatus(msg, isError) {
2651 var el = document.getElementById("gatewayStatus");
2652 el.style.display = "block";
2653 el.style.background = isError ? "rgba(255,107,107,0.15)" : "rgba(72,187,120,0.15)";
2654 el.style.color = isError ? "rgba(255,107,107,0.95)" : "rgba(72,187,120,0.95)";
2655 el.textContent = msg;
2656 if (!isError) setTimeout(function() { el.style.display = "none"; }, 4000);
2657}
2658
2659async function getWebPushSubscription() {
2660 if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
2661 throw new Error("Push notifications are not supported in this browser");
2662 }
2663
2664 var permission = await Notification.requestPermission();
2665 if (permission !== "granted") {
2666 throw new Error("Notification permission denied");
2667 }
2668
2669 var reg = await navigator.serviceWorker.register("/sw.js");
2670 await navigator.serviceWorker.ready;
2671
2672 var vapidKey = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/vapid-key")
2673 .then(function(r) { return r.json(); });
2674
2675 if (!vapidKey.key) throw new Error("VAPID key not configured on server");
2676
2677 var sub = await reg.pushManager.subscribe({
2678 userVisibleOnly: true,
2679 applicationServerKey: urlBase64ToUint8Array(vapidKey.key),
2680 });
2681
2682 return sub.toJSON();
2683}
2684
2685function urlBase64ToUint8Array(base64String) {
2686 var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
2687 var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
2688 var rawData = atob(base64);
2689 var outputArray = new Uint8Array(rawData.length);
2690 for (var i = 0; i < rawData.length; i++) {
2691 outputArray[i] = rawData.charCodeAt(i);
2692 }
2693 return outputArray;
2694}
2695
2696async function addChannel() {
2697 var name = document.getElementById("channelName").value.trim();
2698 var type = document.getElementById("channelType").value;
2699 var direction = document.getElementById("channelDirection").value;
2700 var mode = document.getElementById("channelMode").value;
2701 var hasOutput = direction === "output" || direction === "input-output";
2702 var hasInput = direction === "input" || direction === "input-output";
2703
2704 if (!name) { showStatus("Please enter a channel name", true); return; }
2705
2706 var config = {};
2707
2708 try {
2709 if (type === "telegram") {
2710 // Telegram always needs bot token + chat ID
2711 var botToken = document.getElementById("tgBotToken").value.trim();
2712 var chatId = document.getElementById("tgChatId").value.trim();
2713 if (!botToken || !chatId) { showStatus("Bot token and chat ID are required", true); return; }
2714 config = { botToken: botToken, chatId: chatId };
2715 } else if (type === "discord") {
2716 if (hasInput) {
2717 // Discord input: bot token + channel ID
2718 var dcBotToken = document.getElementById("dcBotToken").value.trim();
2719 var dcChannelId = document.getElementById("dcChannelId").value.trim();
2720 if (!dcBotToken || !dcChannelId) { showStatus("Bot token and channel ID are required for Discord input", true); return; }
2721 config = { botToken: dcBotToken, discordChannelId: dcChannelId };
2722 // For input-output, optionally add webhook URL for output side
2723 if (hasOutput) {
2724 var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
2725 if (webhookUrl) config.webhookUrl = webhookUrl;
2726 }
2727 } else {
2728 // Discord output-only: webhook URL
2729 var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
2730 if (!webhookUrl) { showStatus("Webhook URL is required", true); return; }
2731 config = { webhookUrl: webhookUrl };
2732 }
2733 } else if (type === "webapp") {
2734 var subscription = await getWebPushSubscription();
2735 config = { subscription: subscription, displayIdentifier: navigator.userAgent.split(" ").pop() || "Browser" };
2736 }
2737 } catch (err) {
2738 showStatus(err.message, true);
2739 return;
2740 }
2741
2742 var notificationTypes = [];
2743 if (hasOutput) {
2744 if (document.getElementById("notifSummary").checked) notificationTypes.push("dream-summary");
2745 if (document.getElementById("notifThought").checked) notificationTypes.push("dream-thought");
2746 if (notificationTypes.length === 0 && direction === "output") { showStatus("Select at least one notification type", true); return; }
2747 }
2748
2749 var queueBehavior = hasInput ? document.getElementById("queueBehavior").value : "respond";
2750
2751 try {
2752 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels", {
2753 method: "POST",
2754 credentials: "include",
2755 headers: { "Content-Type": "application/json" },
2756 body: JSON.stringify({ name: name, type: type, direction: direction, mode: mode, config: config, notificationTypes: notificationTypes, queueBehavior: queueBehavior }),
2757 });
2758 var data = await res.json();
2759 if (!res.ok) { showStatus(data.error || "Failed to add channel", true); return; }
2760 showStatus("Channel added successfully");
2761 setTimeout(function() { location.reload(); }, 1000);
2762 } catch (err) {
2763 showStatus("Network error: " + err.message, true);
2764 }
2765}
2766
2767async function testChannel(channelId) {
2768 try {
2769 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId + "/test", {
2770 method: "POST",
2771 credentials: "include",
2772 headers: { "Content-Type": "application/json" },
2773 });
2774 var data = await res.json();
2775 if (!res.ok) { alert(data.error || "Test failed"); return; }
2776 alert("Test notification sent!");
2777 } catch (err) { alert("Network error"); }
2778}
2779
2780async function toggleChannel(channelId, enabled) {
2781 try {
2782 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
2783 method: "PUT",
2784 credentials: "include",
2785 headers: { "Content-Type": "application/json" },
2786 body: JSON.stringify({ enabled: enabled }),
2787 });
2788 if (res.ok) location.reload();
2789 else { var data = await res.json(); alert(data.error || "Failed"); }
2790 } catch (err) { alert("Network error"); }
2791}
2792
2793async function deleteChannel(channelId) {
2794 if (!confirm("Delete this channel?")) return;
2795 try {
2796 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
2797 method: "DELETE",
2798 credentials: "include",
2799 headers: { "Content-Type": "application/json" },
2800 });
2801 if (res.ok) location.reload();
2802 else { var data = await res.json(); alert(data.error || "Failed"); }
2803 } catch (err) { alert("Network error"); }
2804}
2805</script>
2806</body>
2807</html>
2808`;
2809}
2810
2811
2812// ─────────────────────────────────────────────────────────────────────────
2813// 4. renderValuesPage
2814// ─────────────────────────────────────────────────────────────────────────
2815
2816export function renderValuesPage({ nodeId, queryString, result }) {
2817 const rootNodeName = result.tree.nodeName || "Unknown";
2818
2819 const flatSummary =
2820 Object.entries(result.flat).length > 0
2821 ? Object.entries(result.flat)
2822 .sort(([, a], [, b]) => b - a)
2823 .map(
2824 ([key, value]) => `
2825 <div class="value-card">
2826 <div class="value-key">${key}</div>
2827 <div class="value-amount">${value.toLocaleString()}</div>
2828 </div>
2829 `,
2830 )
2831 .join("")
2832 : `<div class="empty-state-small">No values yet</div>`;
2833
2834 function renderTree(node, depth = 0) {
2835 const hasChildren = node.children && node.children.length > 0;
2836 const hasLocalValues =
2837 node.localValues && Object.keys(node.localValues).length > 0;
2838 const hasTotalValues =
2839 node.totalValues && Object.keys(node.totalValues).length > 0;
2840
2841 let localValuesHtml = "";
2842 if (hasLocalValues) {
2843 localValuesHtml = Object.entries(node.localValues)
2844 .map(
2845 ([k, v]) => `
2846 <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
2847 <span class="value-key-small">${k}</span>
2848 <span class="value-amount-small">${v.toLocaleString()}</span>
2849 </div>
2850 `,
2851 )
2852 .join("");
2853 }
2854
2855 let totalValuesHtml = "";
2856 if (hasTotalValues) {
2857 totalValuesHtml = Object.entries(node.totalValues)
2858 .map(
2859 ([k, v]) => `
2860 <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
2861 <span class="value-key-small">${k}</span>
2862 <span class="value-amount-small">${v.toLocaleString()}</span>
2863 </div>
2864 `,
2865 )
2866 .join("");
2867 }
2868
2869 const childrenHtml = hasChildren
2870 ? node.children.map((c) => renderTree(c, depth + 1)).join("")
2871 : "";
2872
2873 const valueCount = Math.max(
2874 Object.keys(node.localValues || {}).length,
2875 Object.keys(node.totalValues || {}).length,
2876 );
2877
2878 return `
2879 <div class="tree-node" data-depth="${depth}">
2880 <div class="tree-node-header ${hasChildren ? "has-children" : ""}">
2881 ${
2882 hasChildren
2883 ? `<button class="tree-toggle" onclick="toggleNode(this)" aria-label="Toggle children">▼</button>`
2884 : '<span class="tree-spacer"></span>'
2885 }
2886 <div class="tree-node-info">
2887 <a href="/api/v1/node/${
2888 node.nodeId
2889 }${queryString}" class="tree-node-name" title="${node.nodeName}">
2890 ${node.nodeName}
2891 </a>
2892 ${
2893 valueCount > 0
2894 ? `<span class="value-count">${valueCount} value${
2895 valueCount !== 1 ? "s" : ""
2896 }</span>`
2897 : ""
2898 }
2899 </div>
2900 </div>
2901
2902 ${
2903 hasLocalValues || hasTotalValues
2904 ? `
2905 <div class="tree-node-values local-values">
2906 ${
2907 localValuesHtml ||
2908 '<div class="empty-values">No local values</div>'
2909 }
2910 </div>
2911 <div class="tree-node-values total-values" style="display: none;">
2912 ${
2913 totalValuesHtml ||
2914 '<div class="empty-values">No total values</div>'
2915 }
2916 </div>
2917 `
2918 : ""
2919 }
2920
2921 ${
2922 hasChildren
2923 ? `
2924 <div class="tree-children">
2925 ${childrenHtml}
2926 </div>
2927 `
2928 : ""
2929 }
2930 </div>
2931 `;
2932 }
2933
2934 return `
2935<!DOCTYPE html>
2936<html lang="en">
2937<head>
2938 <meta charset="UTF-8">
2939 <meta name="viewport" content="width=device-width, initial-scale=1.0">
2940 <meta name="theme-color" content="#667eea">
2941 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2942 <title>Global Values - ${rootNodeName}</title>
2943 <style>
2944 ${baseStyles}
2945 ${backNavStyles}
2946 body { color: white; }
2947 .container { max-width: 1000px; }
2948
2949 /* Glass Card Base */
2950 .glass-card {
2951 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2952 backdrop-filter: blur(22px) saturate(140%);
2953 -webkit-backdrop-filter: blur(22px) saturate(140%);
2954 border-radius: 16px;
2955 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2956 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2957 border: 1px solid rgba(255, 255, 255, 0.28);
2958 position: relative;
2959 overflow: hidden;
2960 }
2961
2962 .glass-card::before {
2963 content: "";
2964 position: absolute;
2965 inset: 0;
2966 border-radius: inherit;
2967 background: linear-gradient(
2968 180deg,
2969 rgba(255,255,255,0.18),
2970 rgba(255,255,255,0.05)
2971 );
2972 pointer-events: none;
2973 }
2974
2975 /* Header */
2976 .header {
2977 padding: 28px;
2978 margin-bottom: 24px;
2979 animation: fadeInUp 0.6s ease-out;
2980 animation-delay: 0.1s;
2981 animation-fill-mode: both;
2982 }
2983
2984 .header h1 {
2985 font-size: 28px;
2986 font-weight: 700;
2987 color: white;
2988 margin-bottom: 8px;
2989 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2990 letter-spacing: -0.5px;
2991 }
2992
2993 .header h1::before {
2994 content: '💎 ';
2995 font-size: 26px;
2996 }
2997
2998 .header-subtitle {
2999 font-size: 14px;
3000 color: rgba(255, 255, 255, 0.85);
3001 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3002 }
3003
3004 /* Section */
3005 .section {
3006 padding: 28px;
3007 margin-bottom: 24px;
3008 animation: fadeInUp 0.6s ease-out;
3009 animation-fill-mode: both;
3010 }
3011
3012 .section:nth-child(3) { animation-delay: 0.2s; }
3013 .section:nth-child(4) { animation-delay: 0.3s; }
3014
3015 .section-title {
3016 font-size: 20px;
3017 font-weight: 600;
3018 color: white;
3019 margin-bottom: 20px;
3020 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3021 letter-spacing: -0.3px;
3022 }
3023
3024 /* Flat Summary Cards */
3025 .flat-grid {
3026 display: grid;
3027 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
3028 gap: 16px;
3029 }
3030
3031 .value-card {
3032 background: rgba(255, 255, 255, 0.15);
3033 backdrop-filter: blur(10px);
3034 padding: 20px;
3035 border-radius: 12px;
3036 border: 1px solid rgba(255, 255, 255, 0.2);
3037 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3038 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3039 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3040 position: relative;
3041 overflow: hidden;
3042 }
3043
3044 .value-card::before {
3045 content: "";
3046 position: absolute;
3047 inset: 0;
3048 border-radius: inherit;
3049 background: linear-gradient(
3050 180deg,
3051 rgba(255,255,255,0.15),
3052 rgba(255,255,255,0.05)
3053 );
3054 pointer-events: none;
3055 }
3056
3057 .value-card:hover {
3058 transform: translateY(-4px);
3059 box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
3060 background: rgba(255, 255, 255, 0.25);
3061 border-color: rgba(255, 255, 255, 0.4);
3062 }
3063
3064 .value-key {
3065 font-size: 14px;
3066 font-weight: 600;
3067 color: white;
3068 text-transform: uppercase;
3069 letter-spacing: 0.5px;
3070 margin-bottom: 8px;
3071 word-break: break-all;
3072 overflow-wrap: break-word;
3073 hyphens: auto;
3074 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3075 position: relative;
3076 z-index: 1;
3077 }
3078
3079 .value-amount {
3080 font-size: 32px;
3081 font-weight: 700;
3082 color: white;
3083 font-family: 'SF Mono', Monaco, monospace;
3084 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
3085 position: relative;
3086 z-index: 1;
3087 }
3088
3089 /* Tree View */
3090 .tree-container {
3091 position: relative;
3092 }
3093
3094 .tree-node {
3095 position: relative;
3096 margin-bottom: 4px;
3097 }
3098
3099 .tree-node-header {
3100 display: flex;
3101 align-items: center;
3102 gap: 12px;
3103 padding: 12px 16px;
3104 background: rgba(255, 255, 255, 0.12);
3105 border-radius: 8px;
3106 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3107 border: 1px solid rgba(255, 255, 255, 0.15);
3108 }
3109
3110 .tree-node-header:hover {
3111 background: rgba(255, 255, 255, 0.2);
3112 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3113 transform: translateX(4px);
3114 border-color: rgba(255, 255, 255, 0.3);
3115 }
3116
3117 .tree-toggle {
3118 width: 24px;
3119 height: 24px;
3120 background: rgba(255, 255, 255, 0.2);
3121 border: 1px solid rgba(255, 255, 255, 0.25);
3122 border-radius: 6px;
3123 cursor: pointer;
3124 display: flex;
3125 align-items: center;
3126 justify-content: center;
3127 font-size: 12px;
3128 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3129 flex-shrink: 0;
3130 color: white;
3131 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
3132 }
3133
3134 .tree-toggle:hover {
3135 background: rgba(255, 255, 255, 0.3);
3136 transform: scale(1.1);
3137 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3138 }
3139
3140 .tree-toggle.collapsed {
3141 transform: rotate(-90deg);
3142 }
3143
3144 .tree-toggle.collapsed:hover {
3145 transform: rotate(-90deg) scale(1.1);
3146 }
3147
3148 .tree-spacer {
3149 width: 24px;
3150 flex-shrink: 0;
3151 }
3152
3153 .tree-node-info {
3154 flex: 1;
3155 display: flex;
3156 align-items: center;
3157 gap: 12px;
3158 flex-wrap: wrap;
3159 min-width: 0;
3160 }
3161
3162 .tree-node-name {
3163 font-size: 15px;
3164 font-weight: 600;
3165 color: white;
3166 text-decoration: none;
3167 transition: all 0.2s;
3168 overflow: hidden;
3169 text-overflow: ellipsis;
3170 white-space: nowrap;
3171 max-width: 300px;
3172 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3173 }
3174
3175 .tree-node-name:hover {
3176 text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
3177 transform: translateX(2px);
3178 }
3179
3180 .value-count {
3181 font-size: 12px;
3182 color: white;
3183 padding: 2px 8px;
3184 background: rgba(255, 255, 255, 0.15);
3185 border-radius: 10px;
3186 border: 1px solid rgba(255, 255, 255, 0.2);
3187 flex-shrink: 0;
3188 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
3189 }
3190
3191 .tree-node-values {
3192 display: grid;
3193 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
3194 gap: 8px;
3195 margin: 12px 0 12px 36px;
3196 padding: 12px;
3197 background: rgba(255, 255, 255, 0.1);
3198 border-radius: 8px;
3199 border-left: 3px solid rgba(255, 255, 255, 0.4);
3200 }
3201
3202 .tree-node-values.total-values {
3203 border-left-color: rgba(16, 185, 129, 0.6);
3204 }
3205
3206 .node-value-item {
3207 display: flex;
3208 flex-direction: column;
3209 align-items: flex-start;
3210 gap: 4px;
3211 padding: 10px 12px;
3212 background: rgba(255, 255, 255, 0.15);
3213 border-radius: 6px;
3214 transition: all 0.2s;
3215 min-height: 60px;
3216 cursor: help;
3217 overflow: hidden;
3218 border: 1px solid rgba(255, 255, 255, 0.15);
3219 }
3220
3221 .node-value-item:hover {
3222 background: rgba(255, 255, 255, 0.25);
3223 transform: translateY(-2px);
3224 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
3225 }
3226
3227 .value-key-small {
3228 font-size: 11px;
3229 font-weight: 600;
3230 color: white;
3231 letter-spacing: 0.3px;
3232 overflow: hidden;
3233 text-overflow: ellipsis;
3234 white-space: nowrap;
3235 max-width: 100%;
3236 line-height: 1.3;
3237 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
3238 }
3239
3240 .value-amount-small {
3241 font-size: 16px;
3242 font-weight: 700;
3243 color: white;
3244 font-family: 'SF Mono', Monaco, monospace;
3245 overflow: hidden;
3246 text-overflow: ellipsis;
3247 white-space: nowrap;
3248 max-width: 100%;
3249 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3250 }
3251
3252 .empty-values {
3253 font-size: 13px;
3254 color: rgba(255, 255, 255, 0.6);
3255 font-style: italic;
3256 padding: 8px;
3257 }
3258
3259 .tree-children {
3260 margin-left: 20px;
3261 padding-left: 12px;
3262 border-left: 2px solid rgba(255, 255, 255, 0.2);
3263 margin-top: 4px;
3264 transition: all 0.3s;
3265 }
3266
3267 .tree-children.collapsed {
3268 display: none;
3269 }
3270
3271 /* Empty States */
3272 .empty-state-small {
3273 text-align: center;
3274 padding: 40px;
3275 color: rgba(255, 255, 255, 0.7);
3276 font-style: italic;
3277 }
3278
3279 /* Controls */
3280 .tree-controls {
3281 display: flex;
3282 gap: 12px;
3283 margin-bottom: 16px;
3284 flex-wrap: wrap;
3285 }
3286
3287 .btn-control {
3288 padding: 8px 16px;
3289 background: rgba(255, 255, 255, 0.15);
3290 border: 1px solid rgba(255, 255, 255, 0.2);
3291 border-radius: 980px;
3292 font-size: 14px;
3293 font-weight: 600;
3294 color: white;
3295 cursor: pointer;
3296 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3297 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3298 inset 0 1px 0 rgba(255, 255, 255, 0.2);
3299 position: relative;
3300 overflow: hidden;
3301 }
3302
3303 .btn-control::before {
3304 content: "";
3305 position: absolute;
3306 inset: -40%;
3307 background: radial-gradient(
3308 120% 60% at 0% 0%,
3309 rgba(255, 255, 255, 0.3),
3310 transparent 60%
3311 );
3312 opacity: 0;
3313 transform: translateX(-30%) translateY(-10%);
3314 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3315 pointer-events: none;
3316 }
3317
3318 .btn-control:hover {
3319 background: rgba(255, 255, 255, 0.25);
3320 transform: translateY(-2px);
3321 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
3322 }
3323
3324 .btn-control:hover::before {
3325 opacity: 1;
3326 transform: translateX(30%) translateY(10%);
3327 }
3328
3329 .btn-control.active {
3330 background: rgba(255, 255, 255, 0.3);
3331 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2),
3332 inset 0 1px 0 rgba(255, 255, 255, 0.4);
3333 }
3334
3335 .controls-group {
3336 display: flex;
3337 gap: 8px;
3338 background: rgba(255, 255, 255, 0.1);
3339 padding: 4px;
3340 border-radius: 980px;
3341 border: 1px solid rgba(255, 255, 255, 0.2);
3342 }
3343
3344 .controls-group .btn-control {
3345 border: none;
3346 background: transparent;
3347 box-shadow: none;
3348 }
3349
3350 .controls-group .btn-control:hover {
3351 background: rgba(255, 255, 255, 0.2);
3352 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
3353 }
3354
3355 .controls-group .btn-control.active {
3356 background: rgba(255, 255, 255, 0.25);
3357 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
3358 }
3359
3360 /* Responsive */
3361 @media (max-width: 640px) {
3362 body {
3363 padding: 16px;
3364 }
3365
3366 .header,
3367 .section {
3368 padding: 20px;
3369 }
3370
3371 .header h1 {
3372 font-size: 24px;
3373 }
3374
3375 .flat-grid {
3376 grid-template-columns: 1fr;
3377 }
3378
3379 .tree-children {
3380 margin-left: 20px;
3381 padding-left: 12px;
3382 }
3383
3384 .tree-node-values {
3385 margin-left: 36px;
3386 grid-template-columns: 1fr;
3387 }
3388
3389 .back-nav {
3390 flex-direction: column;
3391 }
3392
3393 .back-link {
3394 justify-content: center;
3395 }
3396
3397 .value-amount {
3398 font-size: 24px;
3399 }
3400
3401 .tree-node-name {
3402 max-width: 200px;
3403 }
3404
3405 .tree-controls {
3406 flex-direction: column;
3407 }
3408
3409 .controls-group {
3410 width: 100%;
3411 }
3412
3413 .controls-group .btn-control {
3414 flex: 1;
3415 text-align: center;
3416 }
3417 }
3418
3419 @media (min-width: 641px) and (max-width: 1024px) {
3420 .container {
3421 max-width: 800px;
3422 }
3423
3424 .flat-grid {
3425 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
3426 }
3427 }
3428 </style>
3429</head>
3430<body>
3431 <div class="container">
3432 <!-- Back Navigation -->
3433 <div class="back-nav">
3434 <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">
3435 <- Back to Tree
3436 </a>
3437 </div>
3438
3439 <!-- Header -->
3440 <div class="glass-card header">
3441 <h1>Global Values</h1>
3442 <div class="header-subtitle">Cumulative values across all nodes</div>
3443 </div>
3444
3445 <!-- Flat Summary -->
3446 <div class="glass-card section">
3447 <div class="section-title">Total Summary</div>
3448 <div class="flat-grid">
3449 ${flatSummary}
3450 </div>
3451 </div>
3452
3453 <!-- Tree View -->
3454 <div class="glass-card section">
3455 <div class="tree-controls">
3456 <div class="controls-group">
3457 <button class="btn-control active" id="showLocalBtn" onclick="showLocalValues()">
3458 Local Values
3459 </button>
3460 <button class="btn-control" id="showTotalBtn" onclick="showTotalValues()">
3461 Total Values
3462 </button>
3463 </div>
3464 <button class="btn-control" onclick="expandAll()">Expand All</button>
3465 <button class="btn-control" onclick="collapseAll()">Collapse All</button>
3466 </div>
3467 <div class="tree-container">
3468 ${renderTree(result.tree)}
3469 </div>
3470 </div>
3471 </div>
3472
3473 <script>
3474 let currentView = 'local';
3475
3476 function showLocalValues() {
3477 currentView = 'local';
3478 document.getElementById('showLocalBtn').classList.add('active');
3479 document.getElementById('showTotalBtn').classList.remove('active');
3480
3481 document.querySelectorAll('.local-values').forEach(el => {
3482 el.style.display = 'grid';
3483 });
3484 document.querySelectorAll('.total-values').forEach(el => {
3485 el.style.display = 'none';
3486 });
3487 }
3488
3489 function showTotalValues() {
3490 currentView = 'total';
3491 document.getElementById('showTotalBtn').classList.add('active');
3492 document.getElementById('showLocalBtn').classList.remove('active');
3493
3494 document.querySelectorAll('.local-values').forEach(el => {
3495 el.style.display = 'none';
3496 });
3497 document.querySelectorAll('.total-values').forEach(el => {
3498 el.style.display = 'grid';
3499 });
3500 }
3501
3502 function toggleNode(button) {
3503 button.classList.toggle('collapsed');
3504 const treeNode = button.closest('.tree-node');
3505 const children = treeNode.querySelector('.tree-children');
3506 if (children) {
3507 children.classList.toggle('collapsed');
3508 }
3509 }
3510
3511 function expandAll() {
3512 document.querySelectorAll('.tree-toggle').forEach(btn => {
3513 btn.classList.remove('collapsed');
3514 });
3515 document.querySelectorAll('.tree-children').forEach(children => {
3516 children.classList.remove('collapsed');
3517 });
3518 }
3519
3520 function collapseAll() {
3521 document.querySelectorAll('.tree-toggle').forEach(btn => {
3522 btn.classList.add('collapsed');
3523 });
3524 document.querySelectorAll('.tree-children').forEach(children => {
3525 children.classList.add('collapsed');
3526 });
3527 }
3528 </script>
3529</body>
3530</html>
3531 `;
3532}
3533
1/* ─────────────────────────────────────────────── */
2/* HTML renderers for user routes */
3/* ─────────────────────────────────────────────── */
4
5import path from "path";
6import mime from "mime-types";
7import { getLandUrl } from "../../../canopy/identity.js";
8import { getUserMeta } from "../../../core/tree/userMetadata.js";
9import { baseStyles, backNavStyles, glassHeaderStyles, glassCardStyles, emptyStateStyles, responsiveBase } from "./baseStyles.js";
10import {
11 esc, escapeHtml, truncate, formatTime, formatDuration,
12 actionColorClass, actionColorHex, actionLabel,
13 renderMedia as _renderMedia,
14 groupIntoChains, modeLabel, sourceLabel,
15} from "./utils.js";
16
17// user.js always renders immediately (no lazy loading)
18const renderMedia = (fileUrl, mimeType) => _renderMedia(fileUrl, mimeType, { lazy: false });
19
20
21// ═══════════════════════════════════════════════════════════════════
22// 1. Profile Page - GET /user/:userId
23// ═══════════════════════════════════════════════════════════════════
24export function renderUserProfile({ userId, user, roots, profileType, energy, extraEnergy, queryString, resetTimeLabel, storageUsedKB }) {
25 const safeUsername = escapeHtml(user.username);
26 return (`
27<!DOCTYPE html>
28<html lang="en">
29<head>
30 <meta charset="UTF-8">
31 <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
32 <meta name="theme-color" content="#667eea">
33 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
34 <title>@${safeUsername} — Profile</title>
35 <style>
36${baseStyles}
37${responsiveBase}
38
39 html { overflow-y: auto; height: 100%; }
40
41 /* Glass Card Base */
42 .glass-card {
43 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
44 backdrop-filter: blur(22px) saturate(140%);
45 -webkit-backdrop-filter: blur(22px) saturate(140%);
46 border-radius: 16px;
47 padding: 32px;
48 margin-bottom: 24px;
49 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
50 inset 0 1px 0 rgba(255, 255, 255, 0.25);
51 border: 1px solid rgba(255, 255, 255, 0.28);
52 position: relative;
53 overflow: hidden;
54 animation: fadeInUp 0.6s ease-out both;
55 }
56
57 /* Header Section */
58 .header {
59 animation-delay: 0.1s;
60 }
61
62 .user-info h1 {
63 font-size: 32px;
64 font-weight: 700;
65 color: white;
66 margin-bottom: 16px;
67 letter-spacing: -0.5px;
68 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
69 }
70
71 .user-info h1::before {
72 content: '👤 ';
73 font-size: 28px;
74 }
75
76 /* User Meta Info */
77 .user-meta {
78 display: flex;
79 gap: 12px;
80 align-items: center;
81 margin-bottom: 16px;
82 flex-wrap: wrap;
83 }
84.send-button.loading {
85 pointer-events: none;
86 opacity: 0.9;
87}
88
89.send-progress {
90 position: absolute;
91 left: 0;
92 top: 0;
93 height: 100%;
94 width: 0%;
95 background: linear-gradient(
96 90deg,
97 rgba(255,255,255,0.25),
98 rgba(255,255,255,0.6),
99 rgba(255,255,255,0.25)
100 );
101 transition: width 0.2s ease;
102 pointer-events: none;
103}
104
105 .plan-badge {
106 padding: 8px 16px;
107 border-radius: 980px;
108 font-weight: 600;
109 font-size: 13px;
110 display: inline-flex;
111 align-items: center;
112 gap: 6px;
113 background: rgba(255, 255, 255, 0.9);
114 color: #667eea;
115 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
116 }
117.plan-basic {
118 background: rgba(255, 255, 255, 0.9);
119 color: #64748b;
120}
121
122/* STANDARD */
123.plan-standard {
124 background: linear-gradient(135deg, #60a5fa, #2563eb);
125 color: white;
126}
127
128/* PREMIUM */
129.plan-premium {
130 background: linear-gradient(135deg, #a855f7, #7c3aed);
131 color: white;
132}
133
134/* GOD ✨ */
135.plan-god {
136 background: linear-gradient(
137 135deg,
138 #facc15,
139 #f59e0b,
140 #eab308
141 );
142 color: #3a2e00;
143 text-shadow: 0 1px 1px rgba(255, 255, 255, 0.6);
144 box-shadow:
145 0 0 20px rgba(250, 204, 21, 0.6),
146 0 6px 24px rgba(234, 179, 8, 0.5);
147 border: 1px solid rgba(255, 215, 0, 0.9);
148}
149 .meta-item {
150 display: inline-flex;
151 align-items: center;
152 gap: 6px;
153 padding: 8px 14px;
154 background: rgba(255, 255, 255, 0.2);
155 backdrop-filter: blur(10px);
156 border-radius: 980px;
157 font-size: 13px;
158 font-weight: 500;
159 color: white;
160 border: 1px solid rgba(255, 255, 255, 0.3);
161 }
162
163 .storage-toggle-btn {
164 padding: 2px 8px;
165 margin-left: 4px;
166 border-radius: 6px;
167 border: 1px solid rgba(255, 255, 255, 0.4);
168 background: rgba(255, 255, 255, 0.2);
169 font-size: 11px;
170 font-weight: 600;
171 cursor: pointer;
172 transition: all 0.2s;
173 color: white;
174 }
175
176 .storage-toggle-btn:hover {
177 background: rgba(255, 255, 255, 0.3);
178 transform: scale(1.05);
179 }
180
181 .logout-btn {
182 padding: 8px 16px;
183 border-radius: 980px;
184 border: 1px solid rgba(255, 255, 255, 0.3);
185 background: rgba(239, 68, 68, 0.3);
186 backdrop-filter: blur(10px);
187 color: white;
188 font-weight: 600;
189 font-size: 13px;
190 cursor: pointer;
191 transition: all 0.3s;
192 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
193 }
194
195 .logout-btn:hover {
196 background: rgba(239, 68, 68, 0.5);
197 transform: translateY(-2px);
198 box-shadow: 0 6px 20px rgba(239, 68, 68, 0.3);
199 }
200
201 .header { position: relative; }
202
203 .basic-btn {
204 position: absolute;
205 top: 16px;
206 right: 16px;
207 padding: 8px 16px;
208 border-radius: 980px;
209 border: 1px solid rgba(255, 255, 255, 0.3);
210 background: rgba(16, 185, 129, 0.3);
211 backdrop-filter: blur(10px);
212 color: white;
213 font-weight: 600;
214 font-size: 13px;
215 cursor: pointer;
216 transition: all 0.3s;
217 box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
218 text-decoration: none;
219 }
220
221 .basic-btn:hover {
222 background: rgba(16, 185, 129, 0.5);
223 transform: translateY(-2px);
224 box-shadow: 0 6px 20px rgba(16, 185, 129, 0.3);
225 }
226
227 /* User ID */
228 .user-id-container {
229 display: flex;
230 align-items: center;
231 gap: 8px;
232 padding: 12px 16px;
233 background: rgba(255, 255, 255, 0.15);
234 backdrop-filter: blur(10px);
235 border-radius: 10px;
236 margin-top: 12px;
237 border: 1px solid rgba(255, 255, 255, 0.2);
238 }
239
240 .user-id-container code {
241 flex: 1;
242 background: transparent;
243 padding: 0;
244 font-size: 13px;
245 font-family: 'SF Mono', Monaco, monospace;
246 color: white;
247 font-weight: 600;
248 word-break: break-all;
249 }
250
251 #copyNodeIdBtn {
252 background: rgba(255, 255, 255, 0.2);
253 border: 1px solid rgba(255, 255, 255, 0.3);
254 cursor: pointer;
255 padding: 6px 10px;
256 border-radius: 6px;
257 font-size: 16px;
258 transition: all 0.2s;
259 flex-shrink: 0;
260 }
261
262 #copyNodeIdBtn:hover {
263 background: rgba(255, 255, 255, 0.3);
264 transform: scale(1.1);
265 }
266
267 /* Raw Ideas Capture - Enhanced with glow */
268 .raw-ideas-section {
269 animation-delay: 0.2s;
270 box-shadow:
271 0 20px 60px rgba(16, 185, 129, 0.3),
272 0 0 40px rgba(16, 185, 129, 0.2),
273 inset 0 1px 0 rgba(255, 255, 255, 0.25);
274 }
275
276 .raw-ideas-section::after {
277 content: '';
278 position: absolute;
279 top: -50%;
280 left: -50%;
281 width: 200%;
282 height: 200%;
283 background: radial-gradient(
284 circle,
285 rgba(16, 185, 129, 0.15) 0%,
286 transparent 70%
287 );
288 animation: pulse 8s ease-in-out infinite;
289 pointer-events: none;
290 }
291
292 @keyframes pulse {
293 0%, 100% {
294 transform: scale(1) rotate(0deg);
295 opacity: 0.5;
296 }
297 50% {
298 transform: scale(1.1) rotate(180deg);
299 opacity: 0.8;
300 }
301 }
302
303 .raw-ideas-section h2 {
304 font-size: 20px;
305 font-weight: 600;
306 color: white;
307 margin-bottom: 20px;
308 position: relative;
309 z-index: 1;
310 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2),
311 0 0 20px rgba(16, 185, 129, 0.4);
312 }
313
314 .raw-ideas-section h2::before {
315 content: '💡 ';
316 font-size: 20px;
317 }
318
319 .raw-idea-form {
320 display: flex;
321 flex-direction: column;
322 gap: 16px;
323 position: relative;
324 z-index: 1;
325 }
326
327 #rawIdeaInput {
328 width: 100%;
329 padding: 16px 20px;
330 font-size: 16px;
331 line-height: 1.6;
332 border-radius: 12px;
333 border: 2px solid rgba(255, 255, 255, 0.4);
334 background: rgba(255, 255, 255, 0.25);
335 backdrop-filter: blur(20px) saturate(150%);
336 -webkit-backdrop-filter: blur(20px) saturate(150%);
337 font-family: inherit;
338 resize: vertical;
339 min-height: 80px;
340 max-height: 400px;
341 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
342 box-shadow:
343 0 4px 20px rgba(0, 0, 0, 0.15),
344 inset 0 1px 0 rgba(255, 255, 255, 0.4);
345 color: #5044c9;
346 font-weight: 600;
347 text-shadow:
348 0 0 12px rgba(102, 126, 234, 0.7),
349 0 0 20px rgba(102, 126, 234, 0.4),
350 0 1px 3px rgba(255, 255, 255, 1),
351 0 2px 8px rgba(80, 68, 201, 0.5);
352 letter-spacing: 0.3px;
353 }
354
355 #rawIdeaInput:focus {
356 outline: none;
357 border-color: rgba(102, 126, 234, 0.6);
358 backdrop-filter: blur(28px) saturate(170%);
359 -webkit-backdrop-filter: blur(28px) saturate(170%);
360 box-shadow:
361 0 0 0 4px rgba(102, 126, 234, 0.25),
362 0 0 40px rgba(102, 126, 234, 0.5),
363 0 8px 30px rgba(102, 126, 234, 0.3),
364 inset 0 1px 0 rgba(255, 255, 255, 0.6);
365 transform: translateY(-2px);
366 }
367
368 #rawIdeaInput:focus::placeholder {
369 color: rgba(80, 68, 201, 0.4);
370 }
371
372 #rawIdeaInput::placeholder {
373 color: rgba(80, 68, 201, 0.4);
374 font-weight: 400;
375 text-shadow: 0 0 6px rgba(102, 126, 234, 0.25);
376 }
377
378 #rawIdeaInput:disabled {
379 opacity: 0.4;
380 cursor: not-allowed;
381 background: rgba(255, 255, 255, 0.1);
382 transform: none;
383 }
384
385 #rawIdeaInput:disabled::placeholder {
386 color: rgba(80, 68, 201, 0.25);
387 }
388
389 /* Character counter */
390 .char-counter {
391 display: flex;
392 justify-content: flex-end;
393 align-items: center;
394 margin-top: -8px;
395 margin-bottom: 8px;
396 font-size: 12px;
397 color: rgba(255, 255, 255, 0.6);
398 font-weight: 500;
399 transition: color 0.2s;
400 }
401
402 .char-counter.warning {
403 color: rgba(255, 193, 7, 0.9);
404 }
405
406 .char-counter.danger {
407 color: rgba(239, 68, 68, 0.9);
408 font-weight: 600;
409 }
410
411 .char-counter.disabled {
412 opacity: 0.4;
413 }
414
415 /* Energy display */
416 .energy-display {
417 display: inline-flex;
418 align-items: center;
419 gap: 4px;
420 margin-left: 10px;
421 padding: 2px 8px;
422 background: rgba(255, 215, 79, 0.2);
423 border: 1px solid rgba(255, 215, 79, 0.3);
424 border-radius: 10px;
425 font-size: 11px;
426 font-weight: 600;
427 color: rgba(255, 215, 79, 1);
428 transition: all 0.2s;
429 }
430
431 .energy-display:empty {
432 display: none;
433 }
434
435 .energy-display.file-energy {
436 background: rgba(255, 220, 100, 0.9);
437 border-color: rgba(255, 200, 50, 1);
438 color: #1a1a1a;
439 font-size: 13px;
440 font-weight: 700;
441 padding: 4px 12px;
442 box-shadow: 0 2px 8px rgba(255, 200, 50, 0.4);
443 }
444
445 /* File selected badge */
446 .file-selected-badge {
447 display: none;
448 align-items: center;
449 gap: 6px;
450 padding: 6px 12px;
451 background: rgba(255, 255, 255, 0.15);
452 border: 1px solid rgba(255, 255, 255, 0.25);
453 border-radius: 20px;
454 font-size: 12px;
455 font-weight: 500;
456 color: white;
457 }
458
459 .file-selected-badge.visible {
460 display: inline-flex;
461 }
462
463 .file-selected-badge .file-name {
464 max-width: 120px;
465 overflow: hidden;
466 text-overflow: ellipsis;
467 white-space: nowrap;
468 }
469
470 .file-selected-badge .clear-file {
471 background: rgba(255, 255, 255, 0.2);
472 border: none;
473 border-radius: 50%;
474 width: 18px;
475 height: 18px;
476 display: flex;
477 align-items: center;
478 justify-content: center;
479 cursor: pointer;
480 font-size: 10px;
481 color: white;
482 transition: all 0.2s;
483 }
484
485 .file-selected-badge .clear-file:hover {
486 background: rgba(239, 68, 68, 0.4);
487 }
488
489 .form-actions {
490 display: flex;
491 justify-content: space-between;
492 align-items: center;
493 gap: 12px;
494 flex-wrap: wrap;
495 }
496
497 .file-input-wrapper {
498 flex: 1;
499 min-width: 180px;
500 display: flex;
501 align-items: center;
502 gap: 8px;
503 }
504
505 input[type="file"] {
506 font-size: 13px;
507 color: rgba(255, 255, 255, 0.9);
508 cursor: pointer;
509 }
510
511 input[type="file"]::file-selector-button {
512 padding: 8px 16px;
513 border-radius: 980px;
514 border: 1px solid rgba(255, 255, 255, 0.3);
515 background: rgba(255, 255, 255, 0.2);
516 backdrop-filter: blur(10px);
517 color: white;
518 cursor: pointer;
519 font-size: 13px;
520 font-weight: 600;
521 transition: all 0.2s;
522 margin-right: 10px;
523 }
524
525 input[type="file"]::file-selector-button:hover {
526 background: rgba(255, 255, 255, 0.3);
527 transform: translateY(-1px);
528 }
529
530 input[type="file"].hidden-input {
531 display: none;
532 }
533
534 .send-button {
535 padding: 14px 32px;
536 font-size: 16px;
537 font-weight: 600;
538 border-radius: 980px;
539 border: 1px solid rgba(255, 255, 255, 0.3);
540 background: rgba(16, 185, 129, 0.9);
541 backdrop-filter: blur(10px);
542 color: white;
543 cursor: pointer;
544 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
545 box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
546 white-space: nowrap;
547 position: relative;
548 overflow: hidden;
549 }
550
551 .send-button::before {
552 content: "";
553 position: absolute;
554 inset: -40%;
555 background: radial-gradient(
556 120% 60% at 0% 0%,
557 rgba(255, 255, 255, 0.35),
558 transparent 60%
559 );
560 opacity: 0;
561 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
562 pointer-events: none;
563 }
564
565 .send-button:hover {
566 background: rgba(16, 185, 129, 1);
567 transform: translateY(-2px);
568 box-shadow: 0 6px 25px rgba(16, 185, 129, 0.5);
569 }
570
571 .send-button:hover::before {
572 opacity: 1;
573 transform: translateX(30%) translateY(10%);
574 }
575
576 /* Navigation Section */
577 .nav-section {
578 animation-delay: 0.3s;
579 }
580
581 .nav-section h2 {
582 font-size: 18px;
583 font-weight: 600;
584 color: white;
585 margin-bottom: 20px;
586 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
587 }
588
589 .nav-links {
590 list-style: none;
591 display: grid;
592 grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
593 gap: 12px;
594 }
595
596 .nav-links a {
597 display: block;
598 padding: 14px 18px;
599 background: rgba(255, 255, 255, 0.2);
600 backdrop-filter: blur(10px);
601 border-radius: 980px;
602 color: white;
603 text-decoration: none;
604 font-weight: 600;
605 font-size: 14px;
606 transition: all 0.3s;
607 border: 1px solid rgba(255, 255, 255, 0.3);
608 text-align: center;
609 position: relative;
610 overflow: hidden;
611 }
612
613 .nav-links a::before {
614 content: "";
615 position: absolute;
616 inset: -40%;
617 background: linear-gradient(
618 120deg,
619 transparent 40%,
620 rgba(255, 255, 255, 0.25),
621 transparent 60%
622 );
623 opacity: 0;
624 transform: translateX(-100%);
625 transition: opacity 0.3s, transform 0.6s;
626 }
627
628 .nav-links a:hover {
629 background: rgba(255, 255, 255, 0.3);
630 transform: translateY(-2px);
631 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
632 }
633
634 .nav-links a:hover::before {
635 opacity: 1;
636 transform: translateX(100%);
637 }
638
639 /* Roots Section */
640 .roots-section {
641 animation-delay: 0.4s;
642 }
643
644 .roots-section h2 {
645 font-size: 20px;
646 font-weight: 600;
647 color: white;
648 margin-bottom: 20px;
649 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
650 }
651
652 .roots-section h2::before {
653 content: '🌳 ';
654 font-size: 20px;
655 }
656
657 .roots-list {
658 list-style: none;
659 margin-bottom: 24px;
660 }
661
662 .roots-list li {
663 margin-bottom: 10px;
664 }
665
666 .roots-list a {
667 display: block;
668 padding: 14px 18px;
669 background: rgba(255, 255, 255, 0.2);
670 backdrop-filter: blur(10px);
671 border-radius: 12px;
672 color: white;
673 text-decoration: none;
674 font-weight: 500;
675 font-size: 15px;
676 transition: all 0.3s;
677 border: 1px solid rgba(255, 255, 255, 0.25);
678 position: relative;
679 overflow: hidden;
680 }
681
682 .roots-list a::before {
683 content: "";
684 position: absolute;
685 inset: -40%;
686 background: linear-gradient(
687 120deg,
688 transparent 40%,
689 rgba(255, 255, 255, 0.25),
690 transparent 60%
691 );
692 opacity: 0;
693 transform: translateX(-100%);
694 transition: opacity 0.3s, transform 0.6s;
695 }
696
697 .roots-list a:hover {
698 background: rgba(255, 255, 255, 0.3);
699 transform: translateX(4px);
700 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
701 }
702
703 .roots-list a:hover::before {
704 opacity: 1;
705 transform: translateX(100%);
706 }
707
708 .roots-list em {
709 color: rgba(255, 255, 255, 0.7);
710 font-style: italic;
711 display: block;
712 padding: 20px;
713 text-align: center;
714 }
715
716 /* Create Root Form */
717 .create-root-form {
718 display: flex;
719 gap: 12px;
720 align-items: stretch;
721 }
722
723 .create-root-form input[type="text"] {
724 flex: 1;
725 padding: 14px 18px;
726 font-size: 15px;
727 border-radius: 12px;
728 border: 2px solid rgba(255, 255, 255, 0.3);
729 background: rgba(255, 255, 255, 0.9);
730 font-family: inherit;
731 transition: all 0.2s;
732 }
733
734 .create-root-form input[type="text"]:focus {
735 outline: none;
736 border-color: white;
737 background: white;
738 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.2),
739 0 4px 20px rgba(0, 0, 0, 0.1);
740 }
741
742 .create-root-button {
743 padding: 14px 20px;
744 font-size: 24px;
745 line-height: 1;
746 border-radius: 12px;
747 border: 1px solid rgba(255, 255, 255, 0.3);
748 background: rgba(255, 255, 255, 0.25);
749 backdrop-filter: blur(10px);
750 color: white;
751 cursor: pointer;
752 transition: all 0.3s;
753 font-weight: 300;
754 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
755 position: relative;
756 overflow: hidden;
757 }
758
759 .create-root-button::before {
760 content: "";
761 position: absolute;
762 inset: -40%;
763 background: radial-gradient(
764 120% 60% at 0% 0%,
765 rgba(255, 255, 255, 0.35),
766 transparent 60%
767 );
768 opacity: 0;
769 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
770 pointer-events: none;
771 }
772
773 .create-root-button:hover {
774 background: rgba(255, 255, 255, 0.35);
775 transform: scale(1.05) translateY(-2px);
776 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
777 }
778
779 .create-root-button:hover::before {
780 opacity: 1;
781 transform: translateX(30%) translateY(10%);
782 }
783
784 /* Responsive Design */
785
786
787 a {
788text-decoration: none;
789 color: inherit;}
790 </style>
791</head>
792<body>
793 <div class="container">
794 <!-- Header -->
795 <div class="glass-card header">
796 <a href="/chat" target="_top" class="basic-btn">Back to Basic Chat</a>
797 <div class="user-info">
798 <a href="/api/v1/user/${userId}/energy${queryString}">
799 <h1>@${safeUsername}</h1> </a>
800
801 <div class="user-meta">
802 <a href="/api/v1/user/${userId}/energy${queryString}">
803 <span class="plan-badge plan-${profileType}">
804 ${profileType === "god" ? "👑 " : ""}
805 ${profileType.charAt(0).toUpperCase() + profileType.slice(1)} Plan
806</span></a>
807
808 <span class="meta-item">
809 <a href="/api/v1/user/${userId}/energy${queryString}">⚡ ${(energy?.amount ?? 0) + (extraEnergy?.amount ?? 0)} · resets ${resetTimeLabel}
810</a>
811 </span>
812
813 <span class="meta-item">
814 💾 <span id="storageValue"></span>
815 <button
816 id="storageToggle"
817 class="storage-toggle-btn"
818 data-storage-kb="${storageUsedKB}"
819 >
820 MB
821 </button>
822 used
823 </span>
824
825 <button id="logoutBtn" class="logout-btn">
826 Log out
827 </button>
828 </div>
829
830 <div class="user-id-container">
831 <code id="nodeIdCode">${user._id}</code>
832 <button id="copyNodeIdBtn" title="Copy ID">📋</button>
833 </div>
834 </div>
835 </div>
836
837 <!-- Raw Ideas Capture -->
838 <div class="glass-card raw-ideas-section">
839 <h2>Capture a Raw Idea</h2>
840 <form
841 method="POST"
842 action="/api/v1/user/${userId}/raw-ideas${queryString}"
843 enctype="multipart/form-data"
844 class="raw-idea-form"
845 id="rawIdeaForm"
846 >
847 <textarea
848 name="content"
849 placeholder="What's on your mind?"
850 id="rawIdeaInput"
851 rows="1"
852 maxlength="5000"
853 autofocus
854 ></textarea>
855
856 <div class="char-counter" id="charCounter">
857 <span id="charCount">0</span> / 5000
858 <span class="energy-display" id="energyDisplay"></span>
859 </div>
860
861 <div class="form-actions">
862 <div class="file-input-wrapper">
863 <input type="file" name="file" id="fileInput" />
864 <div class="file-selected-badge" id="fileSelectedBadge">
865 <span>📎</span>
866 <span class="file-name" id="fileName"></span>
867 <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">✕</button>
868 </div>
869 </div>
870 <button type="submit" class="send-button" title="Save raw idea" id="rawIdeaSendBtn">
871 <span class="send-label">Send</span>
872 <span class="send-progress"></span>
873 </button>
874 </div>
875 </form>
876 </div>
877
878 <!-- Navigation Links -->
879 <div class="glass-card nav-section">
880 <h2>Quick Links</h2>
881 <ul class="nav-links">
882 <li><a href="/api/v1/user/${userId}/raw-ideas${queryString}">Raw Ideas</a></li>
883 <li><a href="/api/v1/user/${userId}/chats${queryString}">AI Chats</a></li>
884
885 <li><a href="/api/v1/user/${userId}/notes${queryString}">Notes</a></li>
886 <li><a href="/api/v1/user/${userId}/tags${queryString}">Mail</a></li>
887 <li><a href="/api/v1/user/${userId}/contributions${queryString}">Contributions</a></li>
888 <li><a href="/api/v1/user/${userId}/notifications${queryString}">Notifications</a></li>
889 <li><a href="/api/v1/user/${userId}/invites${queryString}">Invites</a></li>
890 <li><a href="/api/v1/user/${userId}/deleted${queryString}">Deleted</a></li>
891 <li><a href="/api/v1/user/${userId}/api-keys${queryString}">API Keys</a></li>
892 <li><a href="/api/v1/user/${userId}/sharetoken${queryString}">Share Token</a></li>
893 </ul>
894 </div>
895
896 <!-- Roots Section -->
897 <div class="glass-card roots-section">
898 <h2>My Roots</h2>
899 ${
900 roots.length > 0
901 ? `
902 <ul class="roots-list">
903 ${roots
904 .map(
905 (r) => `
906 <li>
907 <a href="/api/v1/root/${r._id}${queryString}">
908 ${escapeHtml(r.name || "Untitled")}
909 </a>
910 </li>
911 `,
912 )
913 .join("")}
914 </ul>
915 `
916 : `<ul class="roots-list"><li><em>No roots yet — create your first one below!</em></li></ul>`
917 }
918
919 <form
920 method="POST"
921 action="/api/v1/user/${userId}/createRoot${queryString}"
922 class="create-root-form"
923 >
924 <input
925 type="text"
926 name="name"
927 placeholder="New root name..."
928 required
929 />
930 <button type="submit" class="create-root-button" title="Create root">
931 +
932 </button>
933 </form>
934 </div>
935 </div>
936
937 <script>
938 // Copy ID functionality
939 document.getElementById("copyNodeIdBtn").addEventListener("click", () => {
940 const code = document.getElementById("nodeIdCode");
941 const btn = document.getElementById("copyNodeIdBtn");
942
943 navigator.clipboard.writeText(code.textContent).then(() => {
944 btn.textContent = "✔️";
945 setTimeout(() => (btn.textContent = "📋"), 1000);
946 });
947 });
948
949 // Storage toggle
950 (() => {
951 const toggleBtn = document.getElementById("storageToggle");
952 const valueEl = document.getElementById("storageValue");
953 const storageKB = Number(toggleBtn.dataset.storageKb || 0);
954 let unit = "MB";
955
956 function render() {
957 if (unit === "MB") {
958 const mb = storageKB / 1024;
959 valueEl.textContent = mb.toFixed(mb < 10 ? 2 : 1);
960 toggleBtn.textContent = "MB";
961 } else {
962 const gb = storageKB / (1024 * 1024);
963 valueEl.textContent = gb.toFixed(gb < 1 ? 3 : 2);
964 toggleBtn.textContent = "GB";
965 }
966 }
967
968 toggleBtn.addEventListener("click", () => {
969 unit = unit === "GB" ? "MB" : "GB";
970 render();
971 });
972
973 render();
974 })();
975
976 // Logout
977 document.getElementById("logoutBtn").addEventListener("click", async () => {
978 try {
979 await fetch("/api/v1/logout", {
980 method: "POST",
981 credentials: "include",
982 });
983 window.top.location.href = "/login";
984 } catch (err) {
985 console.error("Logout failed", err);
986 alert("Logout failed. Please try again.");
987 }
988 });
989
990 // Elements
991 const form = document.getElementById('rawIdeaForm');
992 const textarea = document.getElementById('rawIdeaInput');
993 const charCounter = document.getElementById('charCounter');
994 const charCount = document.getElementById('charCount');
995 const energyDisplay = document.getElementById('energyDisplay');
996 const fileInput = document.getElementById('fileInput');
997 const fileSelectedBadge = document.getElementById('fileSelectedBadge');
998 const fileName = document.getElementById('fileName');
999 const clearFileBtn = document.getElementById('clearFileBtn');
1000 const sendBtn = document.getElementById('rawIdeaSendBtn');
1001 const progressBar = sendBtn.querySelector('.send-progress');
1002
1003 const MAX_CHARS = 5000;
1004 let hasFile = false;
1005
1006 // Auto-resize textarea
1007 function autoResize() {
1008 textarea.style.height = 'auto';
1009 const maxHeight = 400;
1010 const newHeight = Math.min(textarea.scrollHeight, maxHeight);
1011 textarea.style.height = newHeight + 'px';
1012 textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
1013 updateCharCounter();
1014 }
1015
1016 textarea.addEventListener('input', autoResize);
1017 autoResize();
1018
1019 // Character counter with energy (1 per 1000 chars)
1020 function updateCharCounter() {
1021 const len = textarea.value.length;
1022 charCount.textContent = len;
1023
1024 const remaining = MAX_CHARS - len;
1025 charCounter.classList.remove('warning', 'danger', 'disabled');
1026
1027 if (hasFile) {
1028 charCounter.classList.add('disabled');
1029 } else if (remaining <= 100) {
1030 charCounter.classList.add('danger');
1031 } else if (remaining <= 500) {
1032 charCounter.classList.add('warning');
1033 }
1034
1035 // Energy cost: 1 per 1000 chars (minimum 1 if any text)
1036 if (len > 0 && !hasFile) {
1037 const cost = Math.max(1, Math.ceil(len / 1000));
1038 energyDisplay.textContent = '⚡' + cost;
1039 energyDisplay.classList.remove('file-energy');
1040 } else if (!hasFile) {
1041 energyDisplay.textContent = '';
1042 }
1043 }
1044
1045 // File energy calculation
1046 const FILE_MIN_COST = 5;
1047 const FILE_BASE_RATE = 1.5;
1048 const FILE_MID_RATE = 3;
1049 const SOFT_LIMIT_MB = 100;
1050 const HARD_LIMIT_MB = 1024;
1051
1052 function calculateFileEnergy(sizeMB) {
1053 if (sizeMB <= SOFT_LIMIT_MB) {
1054 return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
1055 }
1056 if (sizeMB <= HARD_LIMIT_MB) {
1057 const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
1058 const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
1059 return Math.ceil(base + extra);
1060 }
1061 const base = SOFT_LIMIT_MB * FILE_BASE_RATE +
1062 (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
1063 const overGB = sizeMB - HARD_LIMIT_MB;
1064 return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
1065 }
1066
1067 // File selection - blocks text input
1068 fileInput.addEventListener('change', function() {
1069 if (this.files && this.files[0]) {
1070 const file = this.files[0];
1071 hasFile = true;
1072
1073 // Disable textarea
1074 textarea.disabled = true;
1075 textarea.value = '';
1076 textarea.placeholder = 'File selected - text disabled';
1077
1078 // Show file badge, hide file input
1079 fileInput.classList.add('hidden-input');
1080 fileSelectedBadge.classList.add('visible');
1081
1082 // Truncate filename
1083 let displayName = file.name;
1084 if (displayName.length > 20) {
1085 displayName = displayName.substring(0, 17) + '...';
1086 }
1087 fileName.textContent = displayName;
1088 fileSelectedBadge.title = file.name;
1089
1090 // Calculate and show energy (+1 for the note itself)
1091 const sizeMB = file.size / (1024 * 1024);
1092 const fileCost = calculateFileEnergy(sizeMB);
1093 const totalCost = fileCost + 1;
1094 energyDisplay.textContent = '~⚡' + totalCost;
1095 energyDisplay.classList.add('file-energy');
1096
1097 updateCharCounter();
1098 }
1099 });
1100
1101 // Clear file selection
1102 clearFileBtn.addEventListener('click', function() {
1103 hasFile = false;
1104 fileInput.value = '';
1105 fileInput.classList.remove('hidden-input');
1106 fileSelectedBadge.classList.remove('visible');
1107
1108 textarea.disabled = false;
1109 textarea.placeholder = "What's on your mind?";
1110
1111 energyDisplay.textContent = '';
1112 energyDisplay.classList.remove('file-energy');
1113
1114 updateCharCounter();
1115 });
1116
1117 // Submit with Enter (desktop only)
1118 textarea.addEventListener("keydown", (e) => {
1119 const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
1120 if (!isMobile && e.key === "Enter" && !e.shiftKey) {
1121 e.preventDefault();
1122 form.requestSubmit();
1123 }
1124 });
1125
1126 // Form submission with progress + cancel
1127 let activeXhr = null;
1128
1129 sendBtn.addEventListener('click', (e) => {
1130 if (activeXhr) {
1131 e.preventDefault();
1132 activeXhr.abort();
1133 activeXhr = null;
1134 sendBtn.classList.remove('loading');
1135 sendBtn.querySelector('.send-label').textContent = 'Send';
1136 progressBar.style.width = '0%';
1137 return;
1138 }
1139 });
1140
1141 form.addEventListener('submit', (e) => {
1142 e.preventDefault();
1143
1144 sendBtn.classList.add('loading');
1145 sendBtn.querySelector('.send-label').textContent = 'Cancel';
1146 progressBar.style.width = '15%';
1147
1148 const formData = new FormData(form);
1149 const xhr = new XMLHttpRequest();
1150 activeXhr = xhr;
1151
1152 xhr.open('POST', form.action, true);
1153
1154 xhr.upload.onprogress = (e) => {
1155 if (!e.lengthComputable) return;
1156 const realPercent = (e.loaded / e.total) * 100;
1157 const lagged = Math.min(90, Math.round(realPercent * 0.8));
1158 progressBar.style.width = lagged + '%';
1159 };
1160
1161 xhr.onload = () => {
1162 activeXhr = null;
1163 if (xhr.status >= 200 && xhr.status < 300) {
1164 progressBar.style.width = '100%';
1165 setTimeout(() => document.location.reload(), 150);
1166 } else {
1167 fail();
1168 }
1169 };
1170
1171 xhr.onerror = fail;
1172 xhr.onabort = () => {
1173 activeXhr = null;
1174 };
1175
1176 function fail() {
1177 activeXhr = null;
1178 var msg = 'Send failed';
1179 try {
1180 var body = JSON.parse(xhr.responseText);
1181 if (body.error) msg = body.error;
1182 } catch(e) {}
1183 alert(msg);
1184 sendBtn.classList.remove('loading');
1185 sendBtn.querySelector('.send-label').textContent = 'Send';
1186 progressBar.style.width = '0%';
1187 }
1188
1189 xhr.send(formData);
1190 });
1191
1192 // Form reset handler
1193 form.addEventListener('reset', () => {
1194 hasFile = false;
1195 fileInput.classList.remove('hidden-input');
1196 fileSelectedBadge.classList.remove('visible');
1197 textarea.disabled = false;
1198 textarea.placeholder = "What's on your mind?";
1199 energyDisplay.textContent = '';
1200 energyDisplay.classList.remove('file-energy');
1201 charCount.textContent = '0';
1202 charCounter.classList.remove('warning', 'danger', 'disabled');
1203 });
1204 </script>
1205</body>
1206</html>
1207`);
1208}
1209
1210// ═══════════════════════════════════════════════════════════════════
1211// 2. Notes Page - GET /user/:userId/notes
1212// ═══════════════════════════════════════════════════════════════════
1213export function renderUserNotes({ userId, user, notes, processedNotes, query, token }) {
1214 const tokenQS = token ? `?token=${token}&html` : `?html`;
1215 return (`
1216<!DOCTYPE html>
1217<html lang="en">
1218<head>
1219 <meta charset="UTF-8">
1220 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1221 <meta name="theme-color" content="#667eea">
1222 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1223<title>${escapeHtml(user.username)} — Notes</title>
1224 <style>
1225${baseStyles}
1226${backNavStyles}
1227${glassCardStyles}
1228${emptyStateStyles}
1229${responsiveBase}
1230
1231
1232/* Glass Header Section */
1233.header {
1234 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1235 backdrop-filter: blur(22px) saturate(140%);
1236 -webkit-backdrop-filter: blur(22px) saturate(140%);
1237 border-radius: 16px;
1238 padding: 32px;
1239 margin-bottom: 24px;
1240 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1241 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1242 border: 1px solid rgba(255, 255, 255, 0.28);
1243 color: white;
1244 animation: fadeInUp 0.6s ease-out 0.1s both;
1245}
1246
1247.header h1 {
1248 font-size: 28px;
1249 font-weight: 600;
1250 color: white;
1251 margin-bottom: 8px;
1252 line-height: 1.3;
1253 letter-spacing: -0.5px;
1254 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1255}
1256
1257.header h1 a {
1258 color: white;
1259 text-decoration: none;
1260 border-bottom: 1px solid rgba(255, 255, 255, 0.3);
1261 transition: all 0.2s;
1262}
1263
1264.header h1 a:hover {
1265 border-bottom-color: white;
1266 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1267}
1268
1269.header-subtitle {
1270 font-size: 14px;
1271 color: rgba(255, 255, 255, 0.9);
1272 margin-bottom: 20px;
1273 font-weight: 400;
1274}
1275
1276/* Glass Search Form */
1277.search-form {
1278 display: flex;
1279 gap: 10px;
1280 flex-wrap: wrap;
1281}
1282
1283.search-form input[type="text"] {
1284 flex: 1;
1285 min-width: 200px;
1286 padding: 12px 16px;
1287 font-size: 16px;
1288 border-radius: 12px;
1289 border: 2px solid rgba(255, 255, 255, 0.3);
1290 background: rgba(255, 255, 255, 0.2);
1291 backdrop-filter: blur(20px) saturate(150%);
1292 -webkit-backdrop-filter: blur(20px) saturate(150%);
1293 font-family: inherit;
1294 color: white;
1295 font-weight: 500;
1296 transition: all 0.3s;
1297 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
1298 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1299}
1300
1301.search-form input[type="text"]::placeholder {
1302 color: rgba(255, 255, 255, 0.6);
1303}
1304
1305.search-form input[type="text"]:focus {
1306 outline: none;
1307 border-color: rgba(255, 255, 255, 0.6);
1308 background: rgba(255, 255, 255, 0.3);
1309 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
1310 0 8px 30px rgba(0, 0, 0, 0.15),
1311 inset 0 1px 0 rgba(255, 255, 255, 0.4);
1312 transform: translateY(-2px);
1313}
1314
1315
1316.search-form button {
1317 position: relative;
1318 overflow: hidden;
1319 padding: 12px 28px;
1320 font-size: 15px;
1321 font-weight: 600;
1322 letter-spacing: -0.2px;
1323 border-radius: 980px;
1324 border: 1px solid rgba(255, 255, 255, 0.3);
1325 background: rgba(255, 255, 255, 0.25);
1326 backdrop-filter: blur(10px);
1327 color: white;
1328 cursor: pointer;
1329 transition: all 0.3s;
1330 font-family: inherit;
1331 white-space: nowrap;
1332 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1333}
1334
1335.search-form button::before {
1336 content: "";
1337 position: absolute;
1338 inset: -40%;
1339 background: radial-gradient(
1340 120% 60% at 0% 0%,
1341 rgba(255, 255, 255, 0.35),
1342 transparent 60%
1343 );
1344 opacity: 0;
1345 transform: translateX(-30%) translateY(-10%);
1346 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1347 pointer-events: none;
1348}
1349
1350.search-form button:hover {
1351 background: rgba(255, 255, 255, 0.35);
1352 transform: translateY(-2px);
1353 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1354}
1355
1356.search-form button:hover::before {
1357 opacity: 1;
1358 transform: translateX(30%) translateY(10%);
1359}
1360
1361/* Card Actions (Edit + Delete buttons) */
1362.card-actions {
1363 position: absolute;
1364 top: 20px;
1365 right: 20px;
1366 display: flex;
1367 gap: 8px;
1368 z-index: 10;
1369}
1370
1371.edit-button,
1372.delete-button {
1373 background: rgba(255, 255, 255, 0.2);
1374 border: 1px solid rgba(255, 255, 255, 0.3);
1375 border-radius: 50%;
1376 width: 32px;
1377 height: 32px;
1378 display: flex;
1379 align-items: center;
1380 justify-content: center;
1381 font-size: 16px;
1382 cursor: pointer;
1383 color: white;
1384 padding: 0;
1385 line-height: 1;
1386 opacity: 0.8;
1387 transition: all 0.3s;
1388 text-decoration: none;
1389}
1390
1391.edit-button:hover {
1392 opacity: 1;
1393 background: rgba(72, 187, 178, 0.4);
1394 border-color: rgba(72, 187, 178, 0.6);
1395 transform: scale(1.1);
1396 box-shadow: 0 4px 12px rgba(72, 187, 178, 0.3);
1397}
1398
1399.delete-button:hover {
1400 opacity: 1;
1401 background: rgba(239, 68, 68, 0.4);
1402 border-color: rgba(239, 68, 68, 0.6);
1403 transform: scale(1.1);
1404 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
1405}
1406
1407
1408.note-author {
1409 font-weight: 600;
1410 color: white;
1411 font-size: 13px;
1412 margin-bottom: 6px;
1413 opacity: 0.9;
1414 letter-spacing: -0.2px;
1415 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1416}
1417
1418.note-link {
1419 color: white;
1420 text-decoration: none;
1421 font-size: 15px;
1422 line-height: 1.6;
1423 display: block;
1424 word-wrap: break-word;
1425 transition: all 0.2s;
1426 font-weight: 400;
1427}
1428
1429.note-link:hover {
1430 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1431}
1432
1433.file-badge {
1434 display: inline-block;
1435 padding: 4px 10px;
1436 background: rgba(255, 255, 255, 0.25);
1437 color: white;
1438 border-radius: 6px;
1439 font-size: 11px;
1440 font-weight: 600;
1441 margin-right: 8px;
1442 border: 1px solid rgba(255, 255, 255, 0.3);
1443 text-transform: uppercase;
1444 letter-spacing: 0.5px;
1445}
1446
1447/* Responsive Design */
1448@media (max-width: 640px) {
1449 body {
1450 padding: 16px;
1451 }
1452
1453 .header {
1454 padding: 24px 20px;
1455 }
1456
1457 .header h1 {
1458 font-size: 24px;
1459 }
1460
1461 .search-form {
1462 flex-direction: column;
1463 }
1464
1465 .search-form input[type="text"] {
1466 width: 100%;
1467 min-width: 0;
1468 font-size: 16px;
1469 }
1470
1471 .search-form button {
1472 width: 100%;
1473 }
1474
1475
1476 .card-actions {
1477 top: 16px;
1478 right: 16px;
1479 gap: 6px;
1480 }
1481
1482 .edit-button,
1483 .delete-button {
1484 width: 28px;
1485 height: 28px;
1486 font-size: 14px;
1487 }
1488
1489}
1490
1491
1492 </style>
1493</head>
1494<body>
1495 <div class="container">
1496 <!-- Back Navigation -->
1497 <div class="back-nav">
1498 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
1499 ← Back to Profile
1500 </a>
1501 </div>
1502
1503 <!-- Header Section -->
1504 <div class="header">
1505 <h1>
1506 Notes by
1507<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
1508 </h1>
1509 <div class="header-subtitle">
1510 View and manage your last 200notes across every tree
1511 </div>
1512
1513 <!-- Search Form -->
1514 <form method="GET" action="/api/v1/user/${userId}/notes" class="search-form">
1515 <input type="hidden" name="token" value="${token}">
1516 <input type="hidden" name="html" value="">
1517 <input
1518 type="text"
1519 name="q"
1520 placeholder="Search notes..."
1521value="${escapeHtml(query)}"
1522 />
1523 <button type="submit">Search</button>
1524 </form>
1525 </div>
1526
1527 <!-- Notes List -->
1528 ${
1529 notes.length > 0
1530 ? `
1531 <ul class="notes-list">
1532 ${processedNotes.join("")}
1533 </ul>
1534 `
1535 : `
1536 <div class="empty-state">
1537 <div class="empty-state-icon">📝</div>
1538 <div class="empty-state-text">No notes yet</div>
1539 <div class="empty-state-subtext">
1540 ${
1541 query.trim() !== ""
1542 ? "Try a different search term"
1543 : "Notes will appear here as you create them"
1544 }
1545 </div>
1546 </div>
1547 `
1548 }
1549 </div>
1550
1551 <script>
1552 document.addEventListener("click", async (e) => {
1553 if (!e.target.classList.contains("delete-button")) return;
1554
1555 const card = e.target.closest(".note-card");
1556 const noteId = card.dataset.noteId;
1557 const nodeId = card.dataset.nodeId;
1558 const version = card.dataset.version;
1559
1560 // Debug: log what we're trying to delete
1561
1562 if (!noteId || !nodeId || !version) {
1563 alert("Error: Missing note data. Please refresh and try again.");
1564 return;
1565 }
1566
1567 if (!confirm("Delete this note? This cannot be undone.")) return;
1568
1569 const token = new URLSearchParams(window.location.search).get("token") || "";
1570 const qs = token ? "?token=" + encodeURIComponent(token) : "";
1571
1572 try {
1573 const url = "/api/v1/" + nodeId + "/" + version + "/notes/" + noteId + qs;
1574
1575 const res = await fetch(url, { method: "DELETE" });
1576
1577 const data = await res.json();
1578 if (!data.success) throw new Error(data.error || "Delete failed");
1579
1580 // Fade out animation
1581 card.style.transition = "all 0.3s ease";
1582 card.style.opacity = "0";
1583 card.style.transform = "translateX(-20px)";
1584 setTimeout(() => card.remove(), 300);
1585 } catch (err) {
1586 alert("Failed to delete: " + (err.message || "Unknown error"));
1587 }
1588 });
1589 </script>
1590</body>
1591</html>
1592`);
1593}
1594
1595// ═══════════════════════════════════════════════════════════════════
1596// 3. Tags/Mail Page - GET /user/:userId/tags
1597// ═══════════════════════════════════════════════════════════════════
1598export async function renderUserTags({ userId, user, notes, getNodeName, token }) {
1599 const tokenQS = token ? `?token=${token}&html` : `?html`;
1600 return (`
1601<!DOCTYPE html>
1602<html lang="en">
1603<head>
1604 <meta charset="UTF-8">
1605 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1606 <meta name="theme-color" content="#667eea">
1607 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1608 <title>${escapeHtml(user.username)} — Mail</title>
1609 <style>
1610${baseStyles}
1611${backNavStyles}
1612${glassHeaderStyles}
1613${glassCardStyles}
1614${emptyStateStyles}
1615${responsiveBase}
1616
1617.header-subtitle {
1618 margin-bottom: 0;
1619}
1620
1621
1622.note-author {
1623 font-weight: 600;
1624 color: white;
1625 font-size: 15px;
1626 margin-bottom: 8px;
1627 display: block;
1628 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1629}
1630
1631.note-author a {
1632 color: white;
1633 text-decoration: none;
1634 transition: all 0.2s;
1635}
1636
1637.note-author a:hover {
1638 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1639}
1640
1641.note-link {
1642 color: white;
1643 text-decoration: none;
1644 font-size: 15px;
1645 line-height: 1.6;
1646 display: block;
1647 word-wrap: break-word;
1648 transition: all 0.2s;
1649 font-weight: 400;
1650}
1651
1652.note-link:hover {
1653 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1654}
1655
1656.file-badge {
1657 display: inline-block;
1658 padding: 4px 10px;
1659 background: rgba(255, 255, 255, 0.25);
1660 color: white;
1661 border-radius: 6px;
1662 font-size: 11px;
1663 font-weight: 600;
1664 margin-right: 8px;
1665 border: 1px solid rgba(255, 255, 255, 0.3);
1666 text-transform: uppercase;
1667 letter-spacing: 0.5px;
1668}
1669
1670/* Responsive Design */
1671
1672 </style>
1673</head>
1674<body>
1675 <div class="container">
1676 <!-- Back Navigation -->
1677 <div class="back-nav">
1678 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
1679 ← Back to Profile
1680 </a>
1681 </div>
1682
1683 <!-- Header Section -->
1684 <div class="header">
1685 <h1>
1686 Mail for
1687 <a href="/api/v1/user/${userId}${tokenQS}">@${escapeHtml(user.username)}</a>
1688 ${
1689 notes.length > 0
1690 ? `<span class="message-count">${notes.length}</span>`
1691 : ""
1692 }
1693 </h1>
1694 <div class="header-subtitle">Notes where others have mentioned you</div>
1695 </div>
1696
1697 <!-- Notes List -->
1698 ${
1699 notes.length > 0
1700 ? `
1701 <ul class="notes-list">
1702 ${await Promise.all(
1703 notes.map(async (n) => {
1704 const nodeName = await getNodeName(n.nodeId);
1705 const preview =
1706 n.contentType === "text"
1707 ? n.content.length > 120
1708 ? n.content.substring(0, 120) + "…"
1709 : n.content
1710 : n.content.split("/").pop();
1711
1712 const author = n.userId.username || n.userId._id;
1713
1714 return `
1715 <li class="note-card">
1716 <div class="note-content">
1717 <div class="note-author">
1718 <a href="/api/v1/user/${n.userId._id}${tokenQS}">
1719 ${escapeHtml(author)}
1720 </a>
1721 </div>
1722 <a href="/api/v1/node/${n.nodeId}/${n.version}/notes/${
1723 n._id
1724 }${tokenQS}" class="note-link">
1725 ${
1726 n.contentType === "file"
1727 ? `<span class="file-badge">FILE</span>`
1728 : ""
1729 }${escapeHtml(preview)}
1730 </a>
1731 </div>
1732
1733 <div class="note-meta">
1734 ${new Date(n.createdAt).toLocaleString()}
1735 <span class="meta-separator">•</span>
1736 <a href="/api/v1/node/${n.nodeId}/${n.version}${tokenQS}">
1737 ${escapeHtml(nodeName)} v${n.version}
1738 </a>
1739 <span class="meta-separator">•</span>
1740 <a href="/api/v1/node/${n.nodeId}/${n.version}/notes${tokenQS}">
1741 View Notes
1742 </a>
1743 </div>
1744 </li>
1745 `;
1746 }),
1747 ).then((results) => results.join(""))}
1748 </ul>
1749 `
1750 : `
1751 <div class="empty-state">
1752 <div class="empty-state-icon">📬</div>
1753 <div class="empty-state-text">No messages yet</div>
1754 <div class="empty-state-subtext">
1755 Notes where you're mentioned will appear here
1756 </div>
1757 </div>
1758 `
1759 }
1760 </div>
1761</body>
1762</html>
1763`);
1764}
1765
1766// ═══════════════════════════════════════════════════════════════════
1767// 4. User Contributions Page - GET /user/:userId/contributions
1768// ═══════════════════════════════════════════════════════════════════
1769export async function renderUserContributions({ userId, contributions, username, getNodeName, token }) {
1770 const tokenQS = token ? `?token=${token}&html` : `?html`;
1771
1772 const link = (id, label) =>
1773 id
1774 ? `<a href="/api/v1/node/${id}${tokenQS}">${label || `<code>${esc(id)}</code>`}</a>`
1775 : `<code>unknown</code>`;
1776
1777 const nodeLink = (id, name, version) => {
1778 if (!id) return `<code>unknown node</code>`;
1779 const v = version != null ? `/${version}` : "";
1780 const display = name || id;
1781 return `<a href="/api/v1/node/${id}${v}${tokenQS}"><code>${esc(display)}</code></a>`;
1782 };
1783
1784 const userTag = (u) => {
1785 if (!u) return `<code>unknown user</code>`;
1786 if (typeof u === "object" && u.username)
1787 return `<a href="/api/v1/user/${u._id}${tokenQS}"><code>${esc(u.username)}</code></a>`;
1788 if (typeof u === "string")
1789 return `<a href="/api/v1/user/${u}${tokenQS}"><code>${esc(u)}</code></a>`;
1790 return `<code>unknown user</code>`;
1791 };
1792
1793 const kvMap = (data) => {
1794 if (!data) return "";
1795 const entries =
1796 data instanceof Map
1797 ? [...data.entries()]
1798 : typeof data === "object"
1799 ? Object.entries(data)
1800 : [];
1801 if (entries.length === 0) return "";
1802 return entries
1803 .map(
1804 ([k, v]) =>
1805 `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
1806 )
1807 .join(" ");
1808 };
1809
1810 /* ─────────────────────────────────────────────── */
1811 /* ACTION RENDERER */
1812 /* ─────────────────────────────────────────────── */
1813
1814 const renderAction = (rawC, nodeName) => {
1815 // Merge extensionData into contribution so action renderers work
1816 const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
1817 const nId = c.nodeId?._id || c.nodeId;
1818 const v = Number(c.nodeVersion ?? 0);
1819 const nLink = nodeLink(nId, nodeName, v);
1820
1821 switch (c.action) {
1822 case "create":
1823 return `Created ${nLink}`;
1824
1825 case "editStatus":
1826 return `Marked ${nLink} as <code>${esc(c.statusEdited)}</code>`;
1827
1828 case "editValue":
1829 return `Adjusted values on ${nLink} ${kvMap(c.valueEdited)}`;
1830
1831 case "prestige":
1832 return `Prestiged ${nLink} to a new version`;
1833
1834 case "trade":
1835 return `Traded on ${nLink}`;
1836
1837 case "delete":
1838 return `Deleted ${nLink}`;
1839
1840 case "invite": {
1841 const ia = c.inviteAction || {};
1842 const target = userTag(ia.receivingId);
1843 const labels = {
1844 invite: `Invited ${target} to collaborate on`,
1845 acceptInvite: `Accepted an invitation on`,
1846 denyInvite: `Declined an invitation on`,
1847 removeContributor: `Removed ${target} from`,
1848 switchOwner: `Transferred ownership of`,
1849 };
1850 const suffix = ia.action === "switchOwner" ? ` to ${target}` : "";
1851 return `${labels[ia.action] || "Updated collaboration on"} ${nLink}${suffix}`;
1852 }
1853
1854 case "editSchedule": {
1855 const s = c.scheduleEdited || {};
1856 const parts = [];
1857 if (s.date)
1858 parts.push(
1859 `date to <code>${new Date(s.date).toLocaleString()}</code>`,
1860 );
1861 if (s.reeffectTime != null)
1862 parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
1863 return parts.length
1864 ? `Set ${parts.join(" and ")} on ${nLink}`
1865 : `Updated the schedule on ${nLink}`;
1866 }
1867
1868 case "editGoal":
1869 return `Set new goals on ${nLink} ${kvMap(c.goalEdited)}`;
1870
1871 case "transaction": {
1872 const tm = c.transactionMeta;
1873 if (!tm) return `Recorded a transaction on ${nLink}`;
1874 const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
1875 const counterparty = tm.counterpartyNodeId
1876 ? ` with ${link(tm.counterpartyNodeId)}`
1877 : "";
1878 const sent = kvMap(tm.valuesSent);
1879 const recv = kvMap(tm.valuesReceived);
1880 let flow = "";
1881 if (sent) flow += ` — sent ${sent}`;
1882 if (recv) flow += `${sent ? "," : " —"} received ${recv}`;
1883 return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)}) on ${nLink}${counterparty}${flow}`;
1884 }
1885
1886 case "note": {
1887 const na = c.noteAction || {};
1888
1889 let verb;
1890 switch (na.action) {
1891 case "add":
1892 verb = "Added a note to";
1893 break;
1894 case "edit":
1895 verb = "Edited a note in";
1896 break;
1897 case "remove":
1898 verb = "Removed a note from";
1899 break;
1900 default:
1901 verb = "Updated a note in";
1902 }
1903
1904 const noteRef = na.noteId
1905 ? ` <a href="/api/v1/node/${nId}/${v}/notes/${na.noteId}${tokenQS}"><code>${esc(na.noteId)}</code></a>`
1906 : "";
1907
1908 return `${verb} ${nLink}${noteRef}`;
1909 }
1910
1911 case "updateParent": {
1912 const up = c.updateParent || {};
1913 const from = up.oldParentId
1914 ? link(up.oldParentId)
1915 : `<code>none</code>`;
1916 const to = up.newParentId
1917 ? link(up.newParentId)
1918 : `<code>none</code>`;
1919 return `Moved ${nLink} from ${from} to ${to}`;
1920 }
1921
1922 case "editScript": {
1923 const es = c.editScript || {};
1924 return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code> on ${nLink}`;
1925 }
1926
1927 case "executeScript": {
1928 const xs = c.executeScript || {};
1929 const icon = xs.success ? "✅" : "❌";
1930 let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code> on ${nLink}`;
1931 if (xs.error) text += ` — <code>${esc(xs.error)}</code>`;
1932 return text;
1933 }
1934
1935 case "updateChildNode": {
1936 const uc = c.updateChildNode || {};
1937 return uc.action === "added"
1938 ? `Added ${link(uc.childId)} as a child of ${nLink}`
1939 : `Removed child ${link(uc.childId)} from ${nLink}`;
1940 }
1941
1942 case "editNameNode": {
1943 const en = c.editNameNode || {};
1944 return `Renamed ${nLink} from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
1945 }
1946
1947 case "rawIdea": {
1948 const ri = c.rawIdeaAction || {};
1949 const ideaRef = `<a href="/api/v1/user/${userId}/raw-ideas/${ri.rawIdeaId}${tokenQS}"><code>${esc(ri.rawIdeaId)}</code></a>`;
1950 if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
1951 if (ri.action === "delete")
1952 return `Discarded raw idea <code>${esc(ri.rawIdeaId)}</code>`;
1953 if (ri.action === "placed") {
1954 const target = ri.targetNodeId ? link(ri.targetNodeId) : nLink;
1955 return `Placed raw idea ${ideaRef} into ${target}`;
1956 }
1957 if (ri.action === "aiStarted")
1958 return `AI began processing raw idea ${ideaRef}`;
1959 if (ri.action === "aiFailed")
1960 return `AI failed to place raw idea ${ideaRef}`;
1961 return `Updated raw idea ${ideaRef}`;
1962 }
1963
1964 case "branchLifecycle": {
1965 const bl = c.branchLifecycle || {};
1966 if (bl.action === "retired") {
1967 let text = `Retired branch ${nLink}`;
1968 if (bl.fromParentId) text += ` from ${link(bl.fromParentId)}`;
1969 return text;
1970 }
1971 if (bl.action === "revived") {
1972 let text = `Revived branch ${nLink}`;
1973 if (bl.toParentId) text += ` under ${link(bl.toParentId)}`;
1974 return text;
1975 }
1976 return `Revived ${nLink} as a new root`;
1977 }
1978
1979 case "purchase": {
1980 const pm = c.purchaseMeta || {};
1981 const parts = [];
1982 if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
1983 if (pm.energyAmount)
1984 parts.push(`<code>${pm.energyAmount}</code> energy`);
1985 const price = pm.totalCents
1986 ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
1987 : "";
1988 return parts.length
1989 ? `Purchased ${parts.join(" and ")}${price}`
1990 : `Made a purchase${price}`;
1991 }
1992
1993 case "understanding": {
1994 const um = c.understandingMeta || {};
1995 const rootNode = um.rootNodeId || nId;
1996 const runId = um.understandingRunId;
1997
1998 if (um.stage === "createRun") {
1999 const runLink =
2000 runId && rootNode
2001 ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${tokenQS}"><code>${esc(runId)}</code></a>`
2002 : `<code>unknown run</code>`;
2003 let text = `Started understanding run ${runLink}`;
2004 if (rootNode) text += ` on ${link(rootNode)}`;
2005 if (um.nodeCount != null)
2006 text += ` spanning <code>${um.nodeCount}</code> nodes`;
2007 if (um.perspective) text += ` — "${esc(um.perspective)}"`;
2008 return text;
2009 }
2010
2011 if (um.stage === "processStep") {
2012 const uNodeId = um.understandingNodeId;
2013 const uNodeLink =
2014 uNodeId && runId && rootNode
2015 ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${tokenQS}"><code>${esc(uNodeId)}</code></a>`
2016 : uNodeId
2017 ? `<code>${esc(uNodeId)}</code>`
2018 : `<code>unknown</code>`;
2019 let text = `Understanding encoded ${uNodeLink}`;
2020 if (um.mode)
2021 text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
2022 if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
2023 return text;
2024 }
2025
2026 return `Understanding activity on ${nLink}`;
2027 }
2028
2029 default:
2030 return `<code>${esc(c.action)}</code> on ${nLink}`;
2031 }
2032 };
2033
2034 const items = await Promise.all(
2035 contributions.map(async (c) => {
2036 const nId = c.nodeId?._id || c.nodeId;
2037 const nodeName = nId ? await getNodeName(nId) : null;
2038 const time = new Date(c.date).toLocaleString();
2039 const actionHtml = renderAction(c, nodeName);
2040 const colorClass = actionColorClass(c.action);
2041
2042 const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
2043 const energyBadge =
2044 c.energyUsed != null && c.energyUsed > 0
2045 ? `<span class="badge badge-energy">⚡ ${c.energyUsed}</span>`
2046 : "";
2047
2048 return `
2049 <li class="note-card ${colorClass}">
2050 <div class="note-content">
2051 <div class="contribution-action">${actionHtml}</div>
2052 </div>
2053 <div class="note-meta">
2054 ${time}
2055 ${aiBadge}${energyBadge}
2056 <span class="meta-separator">·</span>
2057 <code class="contribution-id">${esc(c._id)}</code>
2058 </div>
2059 </li>`;
2060 }),
2061 );
2062
2063 /* ─────────────────────────────────────────────── */
2064 /* HTML SHELL */
2065 /* ─────────────────────────────────────────────── */
2066
2067 return (`
2068<!DOCTYPE html>
2069<html lang="en">
2070<head>
2071 <meta charset="UTF-8">
2072 <meta name="viewport" content="width=device-width, initial-scale=1.0">
2073 <meta name="theme-color" content="#667eea">
2074 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2075 <title>${esc(username)} — Contributions</title>
2076 <style>
2077${baseStyles}
2078${backNavStyles}
2079${glassHeaderStyles}
2080${glassCardStyles}
2081${emptyStateStyles}
2082${responsiveBase}
2083
2084.header-subtitle {
2085 margin-bottom: 16px;
2086}
2087
2088
2089.nav-links {
2090 display: flex; flex-wrap: wrap; gap: 8px;
2091}
2092
2093.nav-links a {
2094 display: inline-block;
2095 padding: 6px 14px;
2096 background: rgba(255,255,255,0.18);
2097 color: white; border-radius: 980px;
2098 font-size: 13px; font-weight: 600;
2099 text-decoration: none;
2100 border: 1px solid rgba(255,255,255,0.25);
2101 transition: all 0.2s;
2102}
2103
2104.nav-links a:hover {
2105 background: rgba(255,255,255,0.32);
2106 transform: translateY(-1px);
2107}
2108
2109.contribution-action {
2110 font-size: 15px; line-height: 1.6;
2111 color: white; font-weight: 400;
2112 word-wrap: break-word;
2113}
2114
2115.contribution-action a {
2116 color: white; text-decoration: none;
2117 border-bottom: 1px solid rgba(255,255,255,0.3);
2118 transition: all 0.2s;
2119}
2120
2121.contribution-action a:hover {
2122 border-bottom-color: white;
2123 text-shadow: 0 0 12px rgba(255,255,255,0.8);
2124}
2125
2126.contribution-action code {
2127 background: rgba(255,255,255,0.18);
2128 padding: 2px 7px; border-radius: 5px;
2129 font-size: 13px;
2130 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
2131 border: 1px solid rgba(255,255,255,0.15);
2132}
2133
2134.contribution-id {
2135 background: rgba(255,255,255,0.12);
2136 padding: 2px 6px; border-radius: 4px;
2137 font-size: 11px;
2138 font-family: 'SF Mono', 'Fira Code', monospace;
2139 color: rgba(255,255,255,0.6);
2140 border: 1px solid rgba(255,255,255,0.1);
2141}
2142
2143/* ── Badges ─────────────────────────────────────── */
2144
2145.badge {
2146 display: inline-flex; align-items: center;
2147 padding: 3px 10px; border-radius: 980px;
2148 font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
2149 border: 1px solid rgba(255,255,255,0.2);
2150}
2151
2152.badge-ai {
2153 background: rgba(255,200,50,0.35);
2154 color: #fff;
2155 text-shadow: 0 1px 2px rgba(0,0,0,0.2);
2156}
2157
2158.badge-energy {
2159 background: rgba(100,220,255,0.3);
2160 color: #fff;
2161 text-shadow: 0 1px 2px rgba(0,0,0,0.2);
2162}
2163
2164/* ── KV Chips ───────────────────────────────────── */
2165
2166.kv-chip {
2167 display: inline-block;
2168 padding: 2px 8px;
2169 background: rgba(255,255,255,0.15);
2170 border-radius: 6px; font-size: 12px;
2171 margin: 2px 2px;
2172 border: 1px solid rgba(255,255,255,0.15);
2173}
2174
2175.kv-chip code {
2176 background: none !important;
2177 border: none !important;
2178 padding: 0 !important;
2179 font-weight: 600;
2180}
2181
2182/* ── Responsive ─────────────────────────────────── */
2183
2184
2185 </style>
2186</head>
2187<body>
2188 <div class="container">
2189 <div class="back-nav">
2190 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
2191 </div>
2192
2193 <div class="header">
2194 <h1>
2195 Contributions by
2196 <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
2197 ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
2198 </h1>
2199 <div class="header-subtitle">Activity & change history</div>
2200
2201 </div>
2202
2203 ${
2204 items.length
2205 ? `<ul class="notes-list">${items.join("")}</ul>`
2206 : `
2207 <div class="empty-state">
2208 <div class="empty-state-icon">📊</div>
2209 <div class="empty-state-text">No contributions yet</div>
2210 <div class="empty-state-subtext">Contributions and activity will appear here</div>
2211 </div>`
2212 }
2213 </div>
2214
2215
2216</body>
2217</html>
2218`);
2219}
2220
2221// ═══════════════════════════════════════════════════════════════════
2222// 5. Reset Password Page (expired) - GET /user/reset-password/:token
2223// ═══════════════════════════════════════════════════════════════════
2224export function renderResetPasswordExpired() {
2225 return (`
2226<!DOCTYPE html>
2227<html lang="en">
2228<head>
2229 <meta charset="UTF-8">
2230 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
2231 <meta name="theme-color" content="#736fe6">
2232 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2233 <title>TreeOS - Link Expired</title>
2234 <style>
2235${baseStyles}
2236
2237body {
2238 display: flex;
2239 flex-direction: column;
2240 align-items: center;
2241 justify-content: center;
2242}
2243
2244
2245 @keyframes fadeInDown {
2246 from { opacity: 0; transform: translateY(-30px); }
2247 to { opacity: 1; transform: translateY(0); }
2248 }
2249
2250 @keyframes slideUp {
2251 from { opacity: 0; transform: translateY(30px); }
2252 to { opacity: 1; transform: translateY(0); }
2253 }
2254
2255 .brand-header {
2256 position: relative; z-index: 1;
2257 margin-bottom: 32px; text-align: center;
2258 animation: fadeInDown 0.8s ease-out;
2259 }
2260
2261 .brand-logo {
2262 font-size: 80px; margin-bottom: 16px; display: inline-block;
2263 filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
2264 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
2265 }
2266
2267 @keyframes grow {
2268 0%, 100% { transform: scale(1); }
2269 50% { transform: scale(1.06); }
2270 }
2271
2272 .brand-title {
2273 font-size: 56px; font-weight: 600; color: white;
2274 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2275 letter-spacing: -1.5px; margin-bottom: 8px;
2276 }
2277
2278 .container {
2279 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2280 backdrop-filter: blur(22px) saturate(140%);
2281 -webkit-backdrop-filter: blur(22px) saturate(140%);
2282 padding: 48px;
2283 border-radius: 16px;
2284 width: 100%; max-width: 460px;
2285 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2286 border: 1px solid rgba(255,255,255,0.28);
2287 text-align: center;
2288 position: relative; z-index: 1;
2289 animation: slideUp 0.6s ease-out 0.2s both;
2290 }
2291
2292 h2 {
2293 font-size: 32px; font-weight: 600; color: white;
2294 margin-bottom: 12px;
2295 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2296 }
2297
2298 .subtitle {
2299 font-size: 15px; color: rgba(255,255,255,0.85);
2300 margin-bottom: 24px; line-height: 1.5;
2301 }
2302
2303 .back-btn {
2304 display: inline-block;
2305 width: 100%;
2306 padding: 14px;
2307 margin-top: 16px;
2308 border-radius: 980px;
2309 border: 1px solid rgba(255,255,255,0.3);
2310 background: rgba(255,255,255,0.25);
2311 backdrop-filter: blur(10px);
2312 color: white;
2313 font-size: 16px; font-weight: 600;
2314 cursor: pointer;
2315 transition: all 0.3s;
2316 text-decoration: none;
2317 text-align: center;
2318 font-family: inherit;
2319 }
2320
2321 .back-btn:hover {
2322 background: rgba(255,255,255,0.35);
2323 transform: translateY(-2px);
2324 box-shadow: 0 6px 20px rgba(0,0,0,0.18);
2325 }
2326
2327 @media (max-width: 640px) {
2328 .brand-logo { font-size: 64px; }
2329 .brand-title { font-size: 42px; }
2330 .container { padding: 32px 24px; }
2331 h2 { font-size: 28px; }
2332 }
2333 </style>
2334</head>
2335<body>
2336 <div class="brand-header">
2337 <a href="/" style="text-decoration: none;">
2338 <div class="brand-logo">🌳</div>
2339 <h1 class="brand-title">TreeOS</h1>
2340 </a>
2341 </div>
2342
2343 <div class="container">
2344 <h2>Link Expired</h2>
2345 <p class="subtitle">This reset link is invalid or has expired. Please request a new password reset.</p>
2346 <a href="/login" class="back-btn">← Back to Login</a>
2347 </div>
2348</body>
2349</html>
2350 `);
2351}
2352
2353// Reset Password Form Page
2354export function renderResetPasswordForm({ token }) {
2355 return (`
2356<!DOCTYPE html>
2357<html lang="en">
2358<head>
2359 <meta charset="UTF-8">
2360 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
2361 <meta name="theme-color" content="#736fe6">
2362 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2363 <title>TreeOS - Reset Password</title>
2364 <style>
2365${baseStyles}
2366
2367body {
2368 display: flex;
2369 flex-direction: column;
2370 align-items: center;
2371 justify-content: center;
2372}
2373
2374
2375 @keyframes fadeInDown {
2376 from { opacity: 0; transform: translateY(-30px); }
2377 to { opacity: 1; transform: translateY(0); }
2378 }
2379
2380 @keyframes slideUp {
2381 from { opacity: 0; transform: translateY(30px); }
2382 to { opacity: 1; transform: translateY(0); }
2383 }
2384
2385 @keyframes spin { to { transform: rotate(360deg); } }
2386
2387 .brand-header {
2388 position: relative; z-index: 1;
2389 margin-bottom: 32px; text-align: center;
2390 animation: fadeInDown 0.8s ease-out;
2391 }
2392
2393 .brand-logo {
2394 font-size: 80px; margin-bottom: 16px; display: inline-block;
2395 filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
2396 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
2397 }
2398
2399 @keyframes grow {
2400 0%, 100% { transform: scale(1); }
2401 50% { transform: scale(1.06); }
2402 }
2403
2404 .brand-title {
2405 font-size: 56px; font-weight: 600; color: white;
2406 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2407 letter-spacing: -1.5px; margin-bottom: 8px;
2408 }
2409
2410 .container {
2411 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2412 backdrop-filter: blur(22px) saturate(140%);
2413 -webkit-backdrop-filter: blur(22px) saturate(140%);
2414 padding: 48px;
2415 border-radius: 16px;
2416 width: 100%; max-width: 460px;
2417 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2418 border: 1px solid rgba(255,255,255,0.28);
2419 text-align: center;
2420 position: relative; z-index: 1;
2421 animation: slideUp 0.6s ease-out 0.2s both;
2422 }
2423
2424 h2 {
2425 font-size: 32px; font-weight: 600; color: white;
2426 margin-bottom: 8px;
2427 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2428 }
2429
2430 .subtitle {
2431 font-size: 15px; color: rgba(255,255,255,0.85);
2432 margin-bottom: 32px; line-height: 1.5;
2433 }
2434
2435 .input-group {
2436 margin-bottom: 16px;
2437 text-align: left;
2438 }
2439
2440 label {
2441 display: block;
2442 font-size: 14px; font-weight: 600; color: white;
2443 margin-bottom: 8px;
2444 text-shadow: 0 1px 3px rgba(0,0,0,0.2);
2445 }
2446
2447 input {
2448 width: 100%;
2449 padding: 14px 18px;
2450 border-radius: 12px;
2451 border: 2px solid rgba(255,255,255,0.3);
2452 font-size: 16px;
2453 transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
2454 background: rgba(255,255,255,0.15);
2455 backdrop-filter: blur(20px) saturate(150%);
2456 -webkit-backdrop-filter: blur(20px) saturate(150%);
2457 font-family: inherit;
2458 color: white;
2459 font-weight: 500;
2460 box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
2461 }
2462
2463 input:focus {
2464 outline: none;
2465 border-color: rgba(255,255,255,0.6);
2466 background: rgba(255,255,255,0.25);
2467 box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
2468 transform: translateY(-2px);
2469 }
2470
2471 input::placeholder {
2472 color: rgba(255,255,255,0.5);
2473 font-weight: 400;
2474 }
2475
2476 input.error {
2477 border-color: rgba(239,68,68,0.6);
2478 background: rgba(239,68,68,0.1);
2479 }
2480
2481 .password-hint {
2482 font-size: 12px;
2483 color: rgba(255,255,255,0.7);
2484 margin-top: 6px;
2485 text-align: left;
2486 }
2487
2488 button {
2489 width: 100%;
2490 padding: 16px;
2491 margin-top: 8px;
2492 border-radius: 980px;
2493 border: 1px solid rgba(255,255,255,0.3);
2494 background: rgba(255,255,255,0.25);
2495 backdrop-filter: blur(10px);
2496 color: white;
2497 font-size: 16px; font-weight: 600;
2498 cursor: pointer;
2499 transition: all 0.3s;
2500 box-shadow: 0 4px 12px rgba(0,0,0,0.12);
2501 font-family: inherit;
2502 position: relative;
2503 overflow: hidden;
2504 }
2505
2506 button:hover {
2507 background: rgba(255,255,255,0.35);
2508 transform: translateY(-2px);
2509 box-shadow: 0 6px 20px rgba(0,0,0,0.18);
2510 }
2511
2512 button:active { transform: translateY(0); }
2513
2514 button.loading {
2515 color: transparent;
2516 pointer-events: none;
2517 }
2518
2519 button.loading::after {
2520 content: '';
2521 position: absolute;
2522 width: 20px; height: 20px;
2523 top: 50%; left: 50%;
2524 margin-left: -10px; margin-top: -10px;
2525 border: 3px solid rgba(255,255,255,0.3);
2526 border-radius: 50%;
2527 border-top-color: white;
2528 animation: spin 0.8s linear infinite;
2529 }
2530
2531 .message {
2532 margin-top: 16px;
2533 padding: 12px 16px;
2534 border-radius: 10px;
2535 font-size: 14px; font-weight: 600;
2536 text-align: left;
2537 display: none;
2538 }
2539
2540 .error-message {
2541 color: white;
2542 background: rgba(239,68,68,0.3);
2543 backdrop-filter: blur(10px);
2544 border: 1px solid rgba(239,68,68,0.4);
2545 }
2546
2547 .success-message {
2548 color: white;
2549 background: rgba(16,185,129,0.3);
2550 backdrop-filter: blur(10px);
2551 border: 1px solid rgba(16,185,129,0.4);
2552 }
2553
2554 .message.show { display: block; }
2555
2556 .back-btn {
2557 display: inline-block;
2558 width: 100%;
2559 padding: 12px;
2560 margin-top: 16px;
2561 border-radius: 980px;
2562 border: 1px solid rgba(255,255,255,0.3);
2563 background: rgba(255,255,255,0.15);
2564 color: white;
2565 font-size: 15px; font-weight: 600;
2566 cursor: pointer;
2567 transition: all 0.3s;
2568 text-decoration: none;
2569 text-align: center;
2570 }
2571
2572 .back-btn:hover {
2573 background: rgba(255,255,255,0.25);
2574 transform: translateY(-2px);
2575 }
2576
2577 @media (max-width: 640px) {
2578 .brand-logo { font-size: 64px; }
2579 .brand-title { font-size: 42px; }
2580 .container { padding: 32px 24px; }
2581 h2 { font-size: 28px; }
2582 input { font-size: 16px; }
2583 }
2584 </style>
2585</head>
2586<body>
2587 <div class="brand-header">
2588 <a href="/" style="text-decoration: none;">
2589 <div class="brand-logo">🌳</div>
2590 <h1 class="brand-title">TreeOS</h1>
2591 </a>
2592 </div>
2593
2594 <div class="container">
2595 <h2>Reset Password</h2>
2596 <p class="subtitle">Enter your new password below</p>
2597
2598 <form id="resetForm">
2599 <div class="input-group">
2600 <label for="password">New Password</label>
2601 <input
2602 type="password"
2603 id="password"
2604 placeholder="Enter new password"
2605 required
2606 autocomplete="new-password"
2607 />
2608 <div class="password-hint">Must be at least 8 characters</div>
2609 </div>
2610
2611 <div class="input-group">
2612 <label for="confirm">Confirm Password</label>
2613 <input
2614 type="password"
2615 id="confirm"
2616 placeholder="Confirm new password"
2617 required
2618 autocomplete="new-password"
2619 />
2620 </div>
2621
2622 <button type="submit" id="resetBtn">Reset Password</button>
2623 </form>
2624
2625 <div id="errorMessage" class="message error-message"></div>
2626 <div id="successMessage" class="message success-message">
2627 ✓ Password reset successful! Redirecting to login...
2628 </div>
2629
2630 <a href="/login" class="back-btn">← Back to Login</a>
2631 </div>
2632
2633 <script>
2634 const form = document.getElementById("resetForm");
2635 const passwordInput = document.getElementById("password");
2636 const confirmInput = document.getElementById("confirm");
2637 const errorEl = document.getElementById("errorMessage");
2638 const successEl = document.getElementById("successMessage");
2639 const btn = document.getElementById("resetBtn");
2640
2641 confirmInput.addEventListener("input", () => {
2642 if (confirmInput.value && passwordInput.value !== confirmInput.value) {
2643 confirmInput.classList.add("error");
2644 } else {
2645 confirmInput.classList.remove("error");
2646 }
2647 });
2648
2649 form.addEventListener("submit", async (e) => {
2650 e.preventDefault();
2651
2652 const password = passwordInput.value;
2653 const confirm = confirmInput.value;
2654
2655 errorEl.classList.remove("show");
2656 successEl.classList.remove("show");
2657 passwordInput.classList.remove("error");
2658 confirmInput.classList.remove("error");
2659
2660 if (password.length < 8) {
2661 errorEl.textContent = "Password must be at least 8 characters.";
2662 errorEl.classList.add("show");
2663 passwordInput.classList.add("error");
2664 passwordInput.focus();
2665 return;
2666 }
2667
2668 if (password !== confirm) {
2669 errorEl.textContent = "Passwords do not match.";
2670 errorEl.classList.add("show");
2671 confirmInput.classList.add("error");
2672 confirmInput.focus();
2673 return;
2674 }
2675
2676 btn.classList.add("loading");
2677 btn.disabled = true;
2678
2679 try {
2680 const res = await fetch("/api/v1/user/reset-password", {
2681 method: "POST",
2682 headers: { "Content-Type": "application/json" },
2683 body: JSON.stringify({ token: "${token}", password }),
2684 });
2685
2686 const data = await res.json();
2687
2688 if (!res.ok) {
2689 errorEl.textContent = data.message || "Reset failed. Please try again.";
2690 errorEl.classList.add("show");
2691 btn.classList.remove("loading");
2692 btn.disabled = false;
2693 return;
2694 }
2695
2696 successEl.classList.add("show");
2697 form.style.display = "none";
2698
2699 setTimeout(() => {
2700 window.location.href = "/login";
2701 }, 2000);
2702
2703 } catch (err) {
2704 errorEl.textContent = "An error occurred. Please try again.";
2705 errorEl.classList.add("show");
2706 btn.classList.remove("loading");
2707 btn.disabled = false;
2708 }
2709 });
2710 </script>
2711</body>
2712</html>
2713 `);
2714}
2715
2716// ═══════════════════════════════════════════════════════════════════
2717// 6. Reset Password POST responses
2718// ═══════════════════════════════════════════════════════════════════
2719export function renderResetPasswordMismatch({ token }) {
2720 return (`
2721 <html><body style="font-family:sans-serif; padding:20px;">
2722 <h2>Passwords Do Not Match</h2>
2723 <p><a href="/api/v1/user/reset-password/${token}">Try Again</a></p>
2724 </body></html>
2725 `);
2726}
2727
2728export function renderResetPasswordInvalid() {
2729 return (`
2730 <html><body style="font-family:sans-serif; padding:20px;">
2731 <h2>Reset Link Expired or Invalid</h2>
2732 <p>Please request a new password reset.</p>
2733 </body></html>
2734 `);
2735}
2736
2737export function renderResetPasswordSuccess() {
2738 return (`
2739 <html><body style="font-family:sans-serif; padding:20px;">
2740 <h2>Password Reset Successfully</h2>
2741 <p>You can now log in with your new password.</p>
2742 </body></html>
2743 `);
2744}
2745
2746// ═══════════════════════════════════════════════════════════════════
2747// 7. Raw Ideas List - GET /user/:userId/raw-ideas
2748// ═══════════════════════════════════════════════════════════════════
2749export function renderRawIdeasList({ userId, user, rawIdeas, query, statusFilter, tabs, tabUrl, token, AUTO_PLACE_ELIGIBLE }) {
2750 const tokenQS = token ? `?token=${token}&html` : `?html`;
2751 return (`
2752<!DOCTYPE html>
2753<html lang="en">
2754<head>
2755 <meta charset="UTF-8">
2756 <meta name="viewport" content="width=device-width, initial-scale=1.0">
2757 <meta name="theme-color" content="#667eea">
2758 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2759<title>${escapeHtml(user.username)} -- Raw Ideas</title>
2760 <style>
2761${baseStyles}
2762${backNavStyles}
2763${glassHeaderStyles}
2764${emptyStateStyles}
2765${responsiveBase}
2766
2767.header-subtitle {
2768 margin-bottom: 20px;
2769}
2770
2771
2772.auto-place-row {
2773 display: flex;
2774 align-items: center;
2775 justify-content: space-between;
2776 gap: 14px;
2777 margin: 16px 0 0;
2778 padding: 14px 16px;
2779 background: rgba(255, 255, 255, 0.06);
2780 border: 1px solid rgba(255, 255, 255, 0.12);
2781 border-radius: 12px;
2782}
2783.auto-place-label {
2784 font-size: 14px;
2785 font-weight: 600;
2786 color: rgba(255, 255, 255, 0.8);
2787}
2788.auto-place-hint {
2789 font-size: 11px;
2790 color: rgba(255, 255, 255, 0.45);
2791 margin-top: 2px;
2792}
2793.auto-place-toggle {
2794 position: relative;
2795 width: 54px; height: 28px;
2796 border-radius: 999px;
2797 background: rgba(255, 255, 255, 0.2);
2798 border: 1px solid rgba(255, 255, 255, 0.3);
2799 backdrop-filter: blur(18px);
2800 cursor: pointer;
2801 transition: all 0.25s ease;
2802 flex-shrink: 0;
2803}
2804.auto-place-toggle.active {
2805 background: rgba(72, 187, 178, 0.45);
2806 box-shadow: 0 0 16px rgba(72, 187, 178, 0.35);
2807}
2808.auto-place-toggle.muted {
2809 opacity: 0.4;
2810 cursor: not-allowed;
2811}
2812.auto-place-toggle-knob {
2813 position: absolute;
2814 top: 4px; left: 4px;
2815 width: 20px; height: 20px;
2816 border-radius: 50%;
2817 background: white;
2818 transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
2819}
2820.auto-place-toggle.active .auto-place-toggle-knob {
2821 left: 28px;
2822}
2823
2824@keyframes waterDrift {
2825 0% { transform: translateY(-1px); }
2826 100% { transform: translateY(1px); }
2827}
2828
2829/* Glass Search Form */
2830.search-form {
2831 display: flex;
2832 gap: 10px;
2833 flex-wrap: wrap;
2834}
2835
2836.search-form input[type="text"] {
2837 flex: 1;
2838 min-width: 200px;
2839 padding: 12px 16px;
2840 font-size: 16px;
2841 border-radius: 12px;
2842 border: 2px solid rgba(255, 255, 255, 0.3);
2843 background: rgba(255, 255, 255, 0.2);
2844 backdrop-filter: blur(20px) saturate(150%);
2845 -webkit-backdrop-filter: blur(20px) saturate(150%);
2846 font-family: inherit;
2847 color: white;
2848 font-weight: 500;
2849 transition: all 0.3s;
2850 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
2851 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2852}
2853
2854.search-form input[type="text"]::placeholder {
2855 color: rgba(255, 255, 255, 0.65);
2856}
2857
2858.search-form input[type="text"]:focus {
2859 outline: none;
2860 border-color: rgba(255, 255, 255, 0.6);
2861 background: rgba(255, 255, 255, 0.3);
2862 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
2863 0 8px 30px rgba(0, 0, 0, 0.15),
2864 inset 0 1px 0 rgba(255, 255, 255, 0.4);
2865 transform: translateY(-2px);
2866}
2867
2868.search-form button {
2869 position: relative;
2870 overflow: hidden;
2871 padding: 12px 28px;
2872 font-size: 15px;
2873 font-weight: 600;
2874 letter-spacing: -0.2px;
2875 border-radius: 980px;
2876 border: 1px solid rgba(255, 255, 255, 0.3);
2877 background: rgba(255, 255, 255, 0.25);
2878 backdrop-filter: blur(10px);
2879 color: white;
2880 cursor: pointer;
2881 transition: all 0.3s;
2882 font-family: inherit;
2883 white-space: nowrap;
2884 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
2885}
2886
2887.search-form button::before {
2888 content: "";
2889 position: absolute;
2890 inset: -40%;
2891 background: radial-gradient(
2892 120% 60% at 0% 0%,
2893 rgba(255, 255, 255, 0.35),
2894 transparent 60%
2895 );
2896 opacity: 0;
2897 transform: translateX(-30%) translateY(-10%);
2898 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2899 pointer-events: none;
2900}
2901
2902.search-form button:hover {
2903 background: rgba(255, 255, 255, 0.35);
2904 transform: translateY(-2px);
2905 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
2906}
2907
2908.search-form button:hover::before {
2909 opacity: 1;
2910 transform: translateX(30%) translateY(10%);
2911}
2912
2913/* Glass Ideas List */
2914.ideas-list {
2915 list-style: none;
2916 display: flex;
2917 flex-direction: column;
2918 gap: 16px;
2919}
2920
2921.idea-card {
2922 position: relative;
2923 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2924 backdrop-filter: blur(22px) saturate(140%);
2925 -webkit-backdrop-filter: blur(22px) saturate(140%);
2926 border-radius: 16px;
2927 padding: 24px;
2928 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
2929 inset 0 1px 0 rgba(255, 255, 255, 0.25);
2930 border: 1px solid rgba(255, 255, 255, 0.28);
2931 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2932 color: white;
2933 overflow: hidden;
2934 animation: fadeInUp 0.5s ease-out both;
2935}
2936
2937.ideas-list {
2938 animation: fadeInUp 0.6s ease-out 0.2s both;
2939}
2940
2941.idea-card:nth-child(1) { animation-delay: 0.25s; }
2942.idea-card:nth-child(2) { animation-delay: 0.3s; }
2943.idea-card:nth-child(3) { animation-delay: 0.35s; }
2944.idea-card:nth-child(4) { animation-delay: 0.4s; }
2945.idea-card:nth-child(5) { animation-delay: 0.45s; }
2946.idea-card:nth-child(n+6) { animation-delay: 0.5s; }
2947
2948
2949.idea-card::before {
2950 content: "";
2951 position: absolute;
2952 inset: -40%;
2953 background: radial-gradient(
2954 120% 60% at 0% 0%,
2955 rgba(255, 255, 255, 0.35),
2956 transparent 60%
2957 );
2958 opacity: 0;
2959 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2960 pointer-events: none;
2961}
2962
2963.idea-card:hover {
2964 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
2965 transform: translateY(-2px);
2966 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
2967}
2968
2969.idea-card:hover::before {
2970 opacity: 1;
2971 transform: translateX(30%) translateY(10%);
2972}
2973
2974.delete-button {
2975 position: absolute;
2976 top: 20px;
2977 right: 20px;
2978 background: rgba(255, 255, 255, 0.2);
2979 border: 1px solid rgba(255, 255, 255, 0.3);
2980 border-radius: 50%;
2981 width: 32px;
2982 height: 32px;
2983 display: flex;
2984 align-items: center;
2985 justify-content: center;
2986 font-size: 18px;
2987 cursor: pointer;
2988 color: white;
2989 padding: 0;
2990 line-height: 1;
2991 opacity: 0.8;
2992 transition: all 0.3s;
2993 z-index: 10;
2994}
2995
2996.delete-button:hover {
2997 opacity: 1;
2998 background: rgba(239, 68, 68, 0.4);
2999 border-color: rgba(239, 68, 68, 0.6);
3000 transform: scale(1.1);
3001 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
3002}
3003
3004.idea-content {
3005 padding-right: 48px;
3006 margin-bottom: 16px;
3007}
3008
3009.idea-link {
3010 color: white;
3011 text-decoration: none;
3012 font-size: 16px;
3013 line-height: 1.6;
3014 display: block;
3015 word-wrap: break-word;
3016 transition: all 0.2s;
3017 font-weight: 400;
3018}
3019
3020.idea-link:hover {
3021 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3022}
3023
3024.file-badge {
3025 display: inline-block;
3026 padding: 4px 10px;
3027 background: rgba(255, 255, 255, 0.25);
3028 color: white;
3029 border-radius: 6px;
3030 font-size: 11px;
3031 font-weight: 600;
3032 margin-right: 8px;
3033 border: 1px solid rgba(255, 255, 255, 0.3);
3034 text-transform: uppercase;
3035 letter-spacing: 0.5px;
3036}
3037
3038/* Subtle Transfer Form */
3039.transfer-form {
3040 display: flex;
3041 gap: 8px;
3042 margin-top: 16px;
3043 padding-top: 16px;
3044 border-top: 1px solid rgba(255, 255, 255, 0.15);
3045 flex-wrap: wrap;
3046 align-items: center;
3047}
3048
3049.transfer-form input[type="text"] {
3050 flex: 1;
3051 min-width: 180px;
3052 padding: 10px 14px;
3053 font-size: 14px;
3054 border-radius: 10px;
3055 border: 1px solid rgba(255, 255, 255, 0.25);
3056 background: rgba(255, 255, 255, 0.15);
3057 backdrop-filter: blur(10px);
3058 -webkit-backdrop-filter: blur(10px);
3059 font-family: inherit;
3060 color: white;
3061 font-weight: 500;
3062 transition: all 0.3s;
3063 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
3064}
3065
3066.transfer-form input[type="text"]::placeholder {
3067 color: rgba(255, 255, 255, 0.5);
3068 font-size: 13px;
3069}
3070
3071.transfer-form input[type="text"]:focus {
3072 outline: none;
3073 border-color: rgba(255, 255, 255, 0.4);
3074 background: rgba(255, 255, 255, 0.2);
3075 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
3076 inset 0 1px 0 rgba(255, 255, 255, 0.2);
3077}
3078
3079.transfer-form button {
3080 padding: 10px 18px;
3081 font-size: 13px;
3082 font-weight: 600;
3083 border-radius: 980px;
3084 border: 1px solid rgba(255, 255, 255, 0.25);
3085 background: rgba(255, 255, 255, 0.15);
3086 backdrop-filter: blur(10px);
3087 color: white;
3088 cursor: pointer;
3089 transition: all 0.3s;
3090 font-family: inherit;
3091 white-space: nowrap;
3092 opacity: 0.85;
3093 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
3094}
3095
3096.transfer-form button:hover {
3097 background: rgba(255, 255, 255, 0.25);
3098 opacity: 1;
3099 transform: translateY(-1px);
3100 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3101 inset 0 1px 0 rgba(255, 255, 255, 0.2);
3102}
3103
3104/* Metadata */
3105.idea-meta {
3106 margin-top: 12px;
3107 font-size: 12px;
3108 color: rgba(255, 255, 255, 0.75);
3109 display: flex;
3110 align-items: center;
3111 gap: 6px;
3112}
3113
3114/* Status badges */
3115.status-badge {
3116 display: inline-block;
3117 margin-left: 10px;
3118 padding: 2px 8px;
3119 border-radius: 20px;
3120 font-size: 11px;
3121 font-weight: 600;
3122 vertical-align: middle;
3123 letter-spacing: 0.3px;
3124}
3125.status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); }
3126.status-badge--processing { background: rgba(255,200,0,0.25); color: #ffe066; }
3127.status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; }
3128.status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; }
3129
3130/* Placed / stuck notices */
3131.placed-notice .chat-link {
3132 color: #7effc0;
3133 opacity: 0.8;
3134 text-decoration: underline;
3135 white-space: nowrap;
3136}
3137.placed-notice .chat-link:hover { opacity: 1; }
3138.placed-notice {
3139 margin-top: 12px;
3140 padding: 10px 14px;
3141 background: rgba(50,220,120,0.15);
3142 border-radius: 10px;
3143 font-size: 13px;
3144 color: #7effc0;
3145 border: 1px solid rgba(50,220,120,0.25);
3146}
3147.stuck-notice {
3148 margin-top: 12px;
3149 margin-bottom: 8px;
3150 padding: 10px 14px;
3151 background: rgba(255,140,0,0.15);
3152 border-radius: 10px;
3153 font-size: 13px;
3154 color: #ffcf7e;
3155 border: 1px solid rgba(255,140,0,0.25);
3156}
3157.processing-notice {
3158 margin-top: 12px;
3159 padding: 10px 14px;
3160 background: rgba(255,200,0,0.12);
3161 border-radius: 10px;
3162 font-size: 13px;
3163 color: #ffe066;
3164 border: 1px solid rgba(255,200,0,0.2);
3165}
3166
3167/* Status filter tabs */
3168.filter-tabs {
3169 display: flex;
3170 gap: 6px;
3171 flex-wrap: wrap;
3172 margin-top: 14px;
3173}
3174.filter-tab {
3175 padding: 5px 14px;
3176 border-radius: 980px;
3177 font-size: 12px;
3178 font-weight: 600;
3179 text-decoration: none;
3180 color: rgba(255,255,255,0.6);
3181 background: rgba(255,255,255,0.08);
3182 border: 1px solid rgba(255,255,255,0.12);
3183 transition: all 0.2s;
3184 letter-spacing: 0.3px;
3185}
3186.filter-tab:hover { background: rgba(255,255,255,0.16); color: rgba(255,255,255,0.9); }
3187.filter-tab--active { background: rgba(255,255,255,0.22); color: white; border-color: rgba(255,255,255,0.35); }
3188
3189/* Auto-place button */
3190.auto-place-btn {
3191 margin-top: 14px;
3192 padding: 10px 20px;
3193 font-size: 13px;
3194 font-weight: 600;
3195 border-radius: 980px;
3196 border: 1px solid rgba(255,255,255,0.3);
3197 background: rgba(255,255,255,0.18);
3198 backdrop-filter: blur(10px);
3199 color: white;
3200 cursor: pointer;
3201 transition: all 0.3s;
3202 font-family: inherit;
3203}
3204.auto-place-btn:hover {
3205 background: rgba(255,255,255,0.28);
3206 transform: translateY(-1px);
3207}
3208.auto-place-btn:disabled {
3209 opacity: 0.5;
3210 cursor: not-allowed;
3211 transform: none;
3212}
3213
3214/* Responsive Design */
3215@media (max-width: 640px) {
3216 body {
3217 padding: 16px;
3218 }
3219
3220 .search-form {
3221 flex-direction: column;
3222 }
3223
3224 .search-form input[type="text"] {
3225 width: 100%;
3226 min-width: 0;
3227 font-size: 16px;
3228 }
3229
3230 .search-form button {
3231 width: 100%;
3232 }
3233
3234 .idea-card {
3235 padding: 20px 16px;
3236 }
3237
3238 .delete-button {
3239 top: 16px;
3240 right: 16px;
3241 width: 28px;
3242 height: 28px;
3243 font-size: 16px;
3244 }
3245
3246 .transfer-form {
3247 flex-direction: column;
3248 }
3249
3250 .transfer-form input[type="text"] {
3251 width: 100%;
3252 min-width: 0;
3253 }
3254
3255 .transfer-form button {
3256 width: 100%;
3257 }
3258
3259}
3260
3261
3262 </style>
3263</head>
3264<body>
3265 <div class="container">
3266 <!-- Back Navigation -->
3267 <div class="back-nav">
3268 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
3269 ← Back to Profile
3270 </a>
3271 </div>
3272
3273 <!-- Header Section -->
3274 <div class="header">
3275 <h1>
3276 Raw Ideas for
3277<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
3278 </h1>
3279 <div class="header-subtitle">
3280These will be placed onto your trees automatically while you dream (Standard+ plans)</div>
3281
3282 <div class="auto-place-row">
3283 <div>
3284 <div class="auto-place-label">Auto-place ideas</div>
3285 <div class="auto-place-hint">${
3286 AUTO_PLACE_ELIGIBLE.includes(user.profileType)
3287 ? "Pending ideas are placed automatically every 15 minutes while you're offline."
3288 : "Available on Standard, Premium, and God plans."
3289 }</div>
3290 </div>
3291 <div
3292 id="autoPlaceToggle"
3293 class="auto-place-toggle${getUserMeta(user, "rawIdeas")?.autoPlace !== false ? " active" : ""}${!AUTO_PLACE_ELIGIBLE.includes(user.profileType) ? " muted" : ""}"
3294 onclick="${AUTO_PLACE_ELIGIBLE.includes(user.profileType) ? "toggleAutoPlace()" : ""}"
3295 >
3296 <div class="auto-place-toggle-knob"></div>
3297 </div>
3298 </div>
3299
3300 <!-- Search Form -->
3301 <form method="GET" action="/api/v1/user/${userId}/raw-ideas" class="search-form">
3302 <input type="hidden" name="token" value="${token}">
3303 <input type="hidden" name="html" value="">
3304 ${statusFilter !== "pending" ? `<input type="hidden" name="status" value="${statusFilter}">` : ""}
3305 <input
3306 type="text"
3307 name="q"
3308 placeholder="Search raw ideas..."
3309 value="${query.replace(/"/g, """)}"
3310 />
3311 <button type="submit">Search</button>
3312 </form>
3313
3314 <!-- Status Filter Tabs -->
3315 <div class="filter-tabs">
3316 ${tabs.map((t) => `<a href="${tabUrl(t.key)}" class="filter-tab${statusFilter === t.key ? " filter-tab--active" : ""}">${t.label}</a>`).join("")}
3317 </div>
3318 </div>
3319
3320 <!-- Raw Ideas List -->
3321 ${
3322 rawIdeas.length > 0
3323 ? `
3324 <ul class="ideas-list">
3325 ${rawIdeas
3326 .map(
3327 (r) => `
3328 <li class="idea-card idea-card--${r.status || "pending"}" data-raw-idea-id="${r._id}" data-status="${r.status || "pending"}">
3329 ${!r.status || r.status === "pending" || r.status === "stuck" ? `<button class="delete-button" title="Delete raw idea">✕</button>` : ""}
3330
3331 <div class="idea-content">
3332 <a
3333 href="/api/v1/user/${userId}/raw-ideas/${r._id}${tokenQS}"
3334 class="idea-link"
3335 >
3336 ${
3337 r.contentType === "file"
3338 ? `<span class="file-badge">FILE</span>${escapeHtml(r.content)}`
3339 : escapeHtml(r.content)
3340 }
3341 </a>
3342 <span class="status-badge status-badge--${r.status || "pending"}">
3343 ${r.status === "processing" ? "⏳ processing" : r.status === "succeeded" ? "✓ placed by AI" : r.status === "stuck" ? "⚠ stuck" : r.status === "deleted" ? "deleted" : "pending"}
3344 </span>
3345 </div>
3346
3347 ${
3348 r.status === "succeeded"
3349 ? `
3350 <div class="placed-notice">Placed automatically by AI${r.placedAt ? ` on ${new Date(r.placedAt).toLocaleString()}` : ""}.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>
3351 `
3352 : r.status === "processing"
3353 ? `
3354 <div class="processing-notice">Being processed by AI — please wait.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>
3355 `
3356 : r.status === "deleted"
3357 ? ``
3358 : `
3359 ${r.status === "stuck" ? `<div class="stuck-notice">Auto-placement failed — place manually below.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>` : ""}
3360
3361 ${
3362 (!r.status || r.status === "pending") && r.contentType !== "file"
3363 ? `
3364 <button
3365 class="auto-place-btn"
3366 data-raw-idea-id="${r._id}"
3367 data-token="${token}"
3368 data-user-id="${userId}"
3369 >✨ Auto-place</button>
3370 `
3371 : ""
3372 }
3373
3374 <form
3375 method="POST"
3376 action="/api/v1/user/${userId}/raw-ideas/${
3377 r._id
3378 }/transfer?token=${token}&html"
3379 class="transfer-form"
3380 >
3381 <input
3382 type="text"
3383 name="nodeId"
3384 placeholder="Target node ID"
3385 required
3386 />
3387 <button type="submit">Transfer to Node</button>
3388 </form>
3389 `
3390 }
3391
3392 <div class="idea-meta">
3393 ${new Date(r.createdAt).toLocaleString()}
3394 </div>
3395 </li>
3396 `,
3397 )
3398 .join("")}
3399 </ul>
3400 `
3401 : `
3402 <div class="empty-state">
3403 <div class="empty-state-icon">💭</div>
3404 <div class="empty-state-text">No ${statusFilter === "pending" ? "" : statusFilter + " "}raw ideas</div>
3405 <div class="empty-state-subtext">
3406 ${
3407 query.trim() !== ""
3408 ? "Try a different search term"
3409 : statusFilter === "pending"
3410 ? "Start capturing your ideas from the user page"
3411 : "Nothing here yet"
3412 }
3413 </div>
3414 </div>
3415 `
3416 }
3417 </div>
3418
3419 <script>
3420 const urlToken = new URLSearchParams(window.location.search).get("token") || "";
3421 const tokenQs = urlToken ? "?token=" + encodeURIComponent(urlToken) : "";
3422
3423 // Auto-refresh if any card is processing
3424 if (document.querySelector(".idea-card[data-status='processing']")) {
3425 setTimeout(() => window.location.reload(), 3000);
3426 }
3427
3428 document.addEventListener("click", async function(e) {
3429 // ── Delete ──────────────────────────────────────────────────────────
3430 const deleteBtn = e.target.closest(".delete-button");
3431 if (deleteBtn) {
3432 e.preventDefault();
3433 e.stopPropagation();
3434
3435 const card = deleteBtn.closest(".idea-card");
3436 if (!card) return;
3437 const rawIdeaId = card.dataset.rawIdeaId;
3438
3439 if (!confirm("Delete this raw idea? This cannot be undone.")) return;
3440
3441 try {
3442 const res = await fetch(
3443 "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + tokenQs,
3444 { method: "DELETE" }
3445 );
3446 const data = await res.json();
3447 if (!data.success) throw new Error(data.error || "Delete failed");
3448
3449 card.style.transition = "all 0.3s ease";
3450 card.style.opacity = "0";
3451 card.style.transform = "translateX(-20px)";
3452 setTimeout(() => card.remove(), 300);
3453 } catch (err) {
3454 alert("Failed to delete: " + (err.message || "Unknown error"));
3455 }
3456 return;
3457 }
3458
3459 // ── Auto-place ───────────────────────────────────────────────────────
3460 const autoBtn = e.target.closest(".auto-place-btn");
3461 if (autoBtn) {
3462 e.preventDefault();
3463 const rawIdeaId = autoBtn.dataset.rawIdeaId;
3464 const card = autoBtn.closest(".idea-card");
3465
3466 autoBtn.disabled = true;
3467 autoBtn.textContent = "⏳ Starting…";
3468
3469 try {
3470 const res = await fetch(
3471 "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + "/place" + tokenQs,
3472 { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: "user" }) }
3473 );
3474 if (res.status === 202) {
3475 card.dataset.status = "processing";
3476 // Update badge
3477 const badge = card.querySelector(".status-badge");
3478 if (badge) { badge.className = "status-badge status-badge--processing"; badge.textContent = "⏳ processing"; }
3479 autoBtn.textContent = "⏳ Processing…";
3480 // Reload after 4s to show result
3481 setTimeout(() => window.location.reload(), 4000);
3482 } else {
3483 const data = await res.json().catch(() => ({}));
3484 autoBtn.disabled = false;
3485 autoBtn.textContent = "✨ Auto-place";
3486 alert(data.error || "Could not start orchestration");
3487 }
3488 } catch (err) {
3489 autoBtn.disabled = false;
3490 autoBtn.textContent = "✨ Auto-place";
3491 alert("Error: " + (err.message || "Unknown"));
3492 }
3493 return;
3494 }
3495 }, true);
3496
3497 async function toggleAutoPlace() {
3498 var toggle = document.getElementById("autoPlaceToggle");
3499 if (!toggle || toggle.classList.contains("muted")) return;
3500 var isActive = toggle.classList.contains("active");
3501 var newEnabled = !isActive;
3502 toggle.classList.toggle("active");
3503 try {
3504 var res = await fetch(
3505 "/api/v1/user/${userId}/raw-ideas/auto-place" + tokenQs,
3506 { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: newEnabled }) }
3507 );
3508 var data = await res.json();
3509 if (!data.success) {
3510 toggle.classList.toggle("active");
3511 alert(data.error || "Failed to toggle");
3512 }
3513 } catch (err) {
3514 toggle.classList.toggle("active");
3515 alert("Error: " + (err.message || "Unknown"));
3516 }
3517 }
3518 </script>
3519</body>
3520</html>
3521`);
3522}
3523
3524// ═══════════════════════════════════════════════════════════════════
3525// 8a. Single Raw Idea (text) - GET /user/:userId/raw-ideas/:rawIdeaId
3526// ═══════════════════════════════════════════════════════════════════
3527export function renderRawIdeaText({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
3528 const tokenQS = token ? `?token=${token}&html` : `?html`;
3529 return (`
3530<!DOCTYPE html>
3531<html lang="en">
3532<head>
3533 <meta charset="UTF-8">
3534 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3535 <meta name="theme-color" content="#667eea">
3536 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3537 <title>Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS</title>
3538 <meta name="description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
3539 <meta property="og:title" content="Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS" />
3540 <meta property="og:description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
3541 <meta property="og:type" content="article" />
3542 <meta property="og:site_name" content="TreeOS" />
3543 <meta property="og:image" content="${getLandUrl()}/tree.png" />
3544 <style>
3545${baseStyles}
3546${backNavStyles}
3547${responsiveBase}
3548
3549
3550 /* Raw Idea Card */
3551 .raw-idea-card {
3552 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3553 backdrop-filter: blur(22px) saturate(140%);
3554 -webkit-backdrop-filter: blur(22px) saturate(140%);
3555 border-radius: 16px;
3556 padding: 32px;
3557 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
3558 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3559 border: 1px solid rgba(255, 255, 255, 0.28);
3560 position: relative;
3561 overflow: hidden;
3562 animation: fadeInUp 0.6s ease-out 0.1s both;
3563 }
3564
3565 /* User Info */
3566 .user-info {
3567 display: flex;
3568 align-items: center;
3569 gap: 8px;
3570 margin-bottom: 20px;
3571 padding-bottom: 16px;
3572 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
3573 }
3574
3575 .user-info::before {
3576 content: '💡';
3577 font-size: 18px;
3578 }
3579
3580 .user-info a {
3581 color: white;
3582 text-decoration: none;
3583 font-weight: 600;
3584 font-size: 15px;
3585 transition: all 0.2s;
3586 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3587 }
3588
3589 .user-info a:hover {
3590 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3591 transform: translateX(2px);
3592 }
3593
3594 .note-time {
3595 margin-left: auto;
3596 font-size: 13px;
3597 color: rgba(255, 255, 255, 0.6);
3598 font-weight: 400;
3599 }
3600
3601 /* Status badge */
3602 .status-row {
3603 display: flex;
3604 align-items: center;
3605 gap: 10px;
3606 margin-bottom: 16px;
3607 flex-wrap: wrap;
3608 }
3609 .status-badge {
3610 display: inline-block;
3611 padding: 4px 12px;
3612 border-radius: 20px;
3613 font-size: 12px;
3614 font-weight: 600;
3615 letter-spacing: 0.03em;
3616 }
3617 .status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
3618 .status-badge--processing{ background: rgba(255,200,0,0.25); color: #ffe066; border: 1px solid rgba(255,200,0,0.3); }
3619 .status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; border: 1px solid rgba(50,220,120,0.3); }
3620 .status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; border: 1px solid rgba(255,140,0,0.3); }
3621 .status-badge--deleted { background: rgba(255,80,80,0.2); color: #ff9ea0; border: 1px solid rgba(255,80,80,0.25); }
3622 .ai-chat-link {
3623 display: inline-block;
3624 padding: 4px 12px;
3625 border-radius: 20px;
3626 font-size: 12px;
3627 font-weight: 600;
3628 background: rgba(255,255,255,0.15);
3629 color: rgba(255,255,255,0.9);
3630 border: 1px solid rgba(255,255,255,0.25);
3631 text-decoration: none;
3632 transition: background 0.2s;
3633 }
3634 .ai-chat-link:hover { background: rgba(255,255,255,0.25); }
3635
3636 /* Copy Button Bar */
3637 .copy-bar {
3638 display: flex;
3639 justify-content: flex-end;
3640 gap: 8px;
3641 margin-bottom: 16px;
3642 }
3643
3644 .copy-btn {
3645 background: rgba(255, 255, 255, 0.2);
3646 backdrop-filter: blur(10px);
3647 border: 1px solid rgba(255, 255, 255, 0.3);
3648 cursor: pointer;
3649 font-size: 20px;
3650 padding: 8px 12px;
3651 border-radius: 980px;
3652 transition: all 0.3s;
3653 position: relative;
3654 overflow: hidden;
3655 }
3656
3657 .copy-btn::before {
3658 content: "";
3659 position: absolute;
3660 inset: -40%;
3661 background: radial-gradient(
3662 120% 60% at 0% 0%,
3663 rgba(255, 255, 255, 0.35),
3664 transparent 60%
3665 );
3666 opacity: 0;
3667 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3668 pointer-events: none;
3669 }
3670
3671 .copy-btn:hover {
3672 background: rgba(255, 255, 255, 0.3);
3673 transform: translateY(-2px);
3674 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3675 }
3676
3677 .copy-btn:hover::before {
3678 opacity: 1;
3679 transform: translateX(30%) translateY(10%);
3680 }
3681
3682 .copy-btn:active {
3683 transform: translateY(0);
3684 }
3685
3686 #copyUrlBtn {
3687 background: rgba(255, 255, 255, 0.25);
3688 }
3689
3690 /* Raw Idea Content */
3691 pre {
3692 background: rgba(255, 255, 255, 0.3);
3693 backdrop-filter: blur(20px) saturate(150%);
3694 -webkit-backdrop-filter: blur(20px) saturate(150%);
3695 padding: 20px;
3696 border-radius: 12px;
3697 font-size: 16px;
3698 line-height: 1.7;
3699 white-space: pre-wrap;
3700 word-wrap: break-word;
3701 border: 1px solid rgba(255, 255, 255, 0.3);
3702 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
3703 color: #3d2f8f;
3704 font-weight: 600;
3705 text-shadow:
3706 0 0 10px rgba(102, 126, 234, 0.4),
3707 0 1px 3px rgba(255, 255, 255, 1);
3708 box-shadow:
3709 0 4px 20px rgba(0, 0, 0, 0.1),
3710 inset 0 1px 0 rgba(255, 255, 255, 0.4);
3711 position: relative;
3712 overflow: hidden;
3713 transition: all 0.3s ease;
3714 }
3715
3716 pre::before {
3717 content: "";
3718 position: absolute;
3719 inset: 0;
3720 background: linear-gradient(
3721 110deg,
3722 transparent 40%,
3723 rgba(255, 255, 255, 0.4),
3724 transparent 60%
3725 );
3726 opacity: 0;
3727 transform: translateX(-100%);
3728 pointer-events: none;
3729 }
3730
3731 pre:hover {
3732 border-color: rgba(255, 255, 255, 0.5);
3733 box-shadow:
3734 0 8px 32px rgba(102, 126, 234, 0.2),
3735 inset 0 1px 0 rgba(255, 255, 255, 0.6);
3736 }
3737
3738 pre.flash::before {
3739 opacity: 1;
3740 animation: glassShimmer 1.2s ease forwards;
3741 }
3742
3743 pre:hover::before {
3744 opacity: 1;
3745 animation: glassShimmer 1.2s ease forwards;
3746 }
3747
3748 pre.copied {
3749 animation: textGlow 0.8s ease-out;
3750 }
3751
3752 @keyframes textGlow {
3753 0% {
3754 box-shadow:
3755 0 4px 20px rgba(0, 0, 0, 0.1),
3756 inset 0 1px 0 rgba(255, 255, 255, 0.4);
3757 }
3758 50% {
3759 box-shadow:
3760 0 0 40px rgba(102, 126, 234, 0.6),
3761 0 0 60px rgba(102, 126, 234, 0.4),
3762 inset 0 1px 0 rgba(255, 255, 255, 0.8);
3763 text-shadow:
3764 0 0 20px rgba(102, 126, 234, 0.8),
3765 0 0 30px rgba(102, 126, 234, 0.6),
3766 0 1px 3px rgba(255, 255, 255, 1);
3767 }
3768 100% {
3769 box-shadow:
3770 0 4px 20px rgba(0, 0, 0, 0.1),
3771 inset 0 1px 0 rgba(255, 255, 255, 0.4);
3772 }
3773 }
3774
3775 @keyframes glassShimmer {
3776 0% {
3777 opacity: 0;
3778 transform: translateX(-120%) skewX(-15deg);
3779 }
3780 50% {
3781 opacity: 1;
3782 }
3783 100% {
3784 opacity: 0;
3785 transform: translateX(120%) skewX(-15deg);
3786 }
3787 }
3788
3789 /* Responsive */
3790
3791
3792 </style>
3793</head>
3794<body>
3795 <div class="container">
3796 <!-- Back Navigation -->
3797 <div class="back-nav">
3798<a href="${back}" class="back-link">${backText}</a>
3799 <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">🔗</button>
3800 </div>
3801
3802 <!-- Raw Idea Card -->
3803 <div class="raw-idea-card">
3804 <div class="user-info">
3805 ${userLink}
3806 ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
3807 </div>
3808
3809 ${
3810 hasToken
3811 ? `<div class="status-row">
3812 <span class="status-badge status-badge--${rawIdea.status || "pending"}">
3813 ${rawIdea.status === "processing" ? "⏳ processing" : rawIdea.status === "succeeded" ? "✓ placed by AI" : rawIdea.status === "stuck" ? "⚠ stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
3814 </span>
3815 ${rawIdea.aiSessionId && (rawIdea.status === "succeeded" || rawIdea.status === "stuck" || rawIdea.status === "processing") ? `<a class="ai-chat-link" href="/api/v1/user/${userId}/chats?sessionId=${rawIdea.aiSessionId}&token=${token}&html">View AI chat →</a>` : ""}
3816 </div>`
3817 : ""
3818 }
3819
3820 <div class="copy-bar">
3821 <button id="copyBtn" class="copy-btn" title="Copy raw idea">📋</button>
3822 </div>
3823
3824 <pre id="content">${escapeHtml(rawIdea.content)}</pre>
3825 </div>
3826 </div>
3827
3828 <script>
3829 const copyBtn = document.getElementById("copyBtn");
3830 const copyUrlBtn = document.getElementById("copyUrlBtn");
3831 const content = document.getElementById("content");
3832
3833 copyBtn.addEventListener("click", () => {
3834 navigator.clipboard.writeText(content.textContent).then(() => {
3835 copyBtn.textContent = "✔️";
3836 setTimeout(() => (copyBtn.textContent = "📋"), 900);
3837
3838 content.classList.add("copied");
3839 setTimeout(() => content.classList.remove("copied"), 800);
3840
3841 setTimeout(() => {
3842 content.classList.remove("flash");
3843 void content.offsetWidth;
3844 content.classList.add("flash");
3845 setTimeout(() => content.classList.remove("flash"), 1300);
3846 }, 600);
3847 });
3848 });
3849
3850 copyUrlBtn.addEventListener("click", () => {
3851 const url = new URL(window.location.href);
3852 url.searchParams.delete('token');
3853 if (!url.searchParams.has('html')) {
3854 url.searchParams.set('html', '');
3855 }
3856 navigator.clipboard.writeText(url.toString()).then(() => {
3857 copyUrlBtn.textContent = "✔️";
3858 setTimeout(() => (copyUrlBtn.textContent = "🔗"), 900);
3859 });
3860 });
3861 </script>
3862</body>
3863</html>
3864`);
3865}
3866
3867// ═══════════════════════════════════════════════════════════════════
3868// 8b. Single Raw Idea (file) - GET /user/:userId/raw-ideas/:rawIdeaId
3869// ═══════════════════════════════════════════════════════════════════
3870export function renderRawIdeaFile({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
3871 const tokenQS = token ? `?token=${token}&html` : `?html`;
3872 const fileDeleted = rawIdea.content === "File was deleted";
3873 const fileUrl = fileDeleted ? "" : `/api/v1/uploads/${rawIdea.content}`;
3874 const filePath = fileDeleted
3875 ? ""
3876 : path.join(process.cwd(), "uploads", rawIdea.content);
3877 const mimeType = fileDeleted
3878 ? ""
3879 : mime.lookup(filePath) || "application/octet-stream";
3880 const mediaHtml = fileDeleted ? "" : renderMedia(fileUrl, mimeType);
3881 const fileName = fileDeleted ? "File was deleted" : rawIdea.content;
3882 return (`
3883<!DOCTYPE html>
3884<html lang="en">
3885<head>
3886 <meta charset="UTF-8">
3887 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3888 <meta name="theme-color" content="#667eea">
3889 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3890 <title>${escapeHtml(fileName)}</title>
3891 <style>
3892${baseStyles}
3893${backNavStyles}
3894${responsiveBase}
3895
3896
3897 /* File Card */
3898 .file-card {
3899 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3900 backdrop-filter: blur(22px) saturate(140%);
3901 -webkit-backdrop-filter: blur(22px) saturate(140%);
3902 border-radius: 16px;
3903 padding: 32px;
3904 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
3905 inset 0 1px 0 rgba(255, 255, 255, 0.25);
3906 border: 1px solid rgba(255, 255, 255, 0.28);
3907 position: relative;
3908 overflow: hidden;
3909 animation: fadeInUp 0.6s ease-out 0.1s both;
3910 }
3911
3912 /* User Info */
3913 .user-info {
3914 display: flex;
3915 align-items: center;
3916 gap: 8px;
3917 margin-bottom: 20px;
3918 padding-bottom: 16px;
3919 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
3920 }
3921
3922 .user-info::before {
3923 content: '👤';
3924 font-size: 18px;
3925 }
3926
3927 .user-info a {
3928 color: white;
3929 text-decoration: none;
3930 font-weight: 600;
3931 font-size: 15px;
3932 transition: all 0.2s;
3933 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3934 }
3935
3936 .user-info a:hover {
3937 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3938 transform: translateX(2px);
3939 }
3940
3941 .note-time {
3942 margin-left: auto;
3943 font-size: 13px;
3944 color: rgba(255, 255, 255, 0.6);
3945 font-weight: 400;
3946 }
3947
3948 /* File Header */
3949 h1 {
3950 font-size: 24px;
3951 font-weight: 700;
3952 color: white;
3953 margin-bottom: 20px;
3954 word-break: break-word;
3955 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3956 }
3957
3958 /* Action Buttons */
3959 .action-bar {
3960 display: flex;
3961 gap: 12px;
3962 margin-bottom: 24px;
3963 flex-wrap: wrap;
3964 }
3965
3966 .download {
3967 display: inline-flex;
3968 align-items: center;
3969 gap: 8px;
3970 padding: 12px 20px;
3971 background: rgba(255, 255, 255, 0.25);
3972 backdrop-filter: blur(10px);
3973 color: white;
3974 text-decoration: none;
3975 border-radius: 980px;
3976 font-weight: 600;
3977 font-size: 15px;
3978 transition: all 0.3s;
3979 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
3980 border: 1px solid rgba(255, 255, 255, 0.3);
3981 cursor: pointer;
3982 position: relative;
3983 overflow: hidden;
3984 }
3985
3986 .download::after {
3987 content: '⬇️';
3988 font-size: 16px;
3989 margin-left: 4px;
3990 }
3991
3992 .download::before {
3993 content: "";
3994 position: absolute;
3995 inset: -40%;
3996 background: radial-gradient(
3997 120% 60% at 0% 0%,
3998 rgba(255, 255, 255, 0.35),
3999 transparent 60%
4000 );
4001 opacity: 0;
4002 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4003 pointer-events: none;
4004 }
4005
4006 .download:hover {
4007 background: rgba(255, 255, 255, 0.35);
4008 transform: translateY(-2px);
4009 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
4010 }
4011
4012 .download:hover::before {
4013 opacity: 1;
4014 transform: translateX(30%) translateY(10%);
4015 }
4016
4017 .copy-url-btn {
4018 display: inline-flex;
4019 align-items: center;
4020 gap: 8px;
4021 padding: 12px 20px;
4022 background: rgba(255, 255, 255, 0.2);
4023 backdrop-filter: blur(10px);
4024 color: white;
4025 border: 1px solid rgba(255, 255, 255, 0.3);
4026 border-radius: 980px;
4027 font-weight: 600;
4028 font-size: 15px;
4029 transition: all 0.3s;
4030 cursor: pointer;
4031 position: relative;
4032 overflow: hidden;
4033 }
4034
4035 .copy-url-btn::after {
4036 content: '🔗';
4037 font-size: 16px;
4038 margin-left: 4px;
4039 }
4040
4041 .copy-url-btn::before {
4042 content: "";
4043 position: absolute;
4044 inset: -40%;
4045 background: radial-gradient(
4046 120% 60% at 0% 0%,
4047 rgba(255, 255, 255, 0.35),
4048 transparent 60%
4049 );
4050 opacity: 0;
4051 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4052 pointer-events: none;
4053 }
4054
4055 .copy-url-btn:hover {
4056 background: rgba(255, 255, 255, 0.3);
4057 transform: translateY(-2px);
4058 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4059 }
4060
4061 .copy-url-btn:hover::before {
4062 opacity: 1;
4063 transform: translateX(30%) translateY(10%);
4064 }
4065
4066 /* Media Container */
4067 .media {
4068 margin-top: 24px;
4069 padding-top: 24px;
4070 border-top: 1px solid rgba(255, 255, 255, 0.2);
4071 }
4072
4073 .media img,
4074 .media video,
4075 .media audio {
4076 max-width: 100%;
4077 border-radius: 12px;
4078 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
4079 border: 1px solid rgba(255, 255, 255, 0.2);
4080 }
4081
4082 /* Responsive */
4083
4084
4085 .status-row {
4086 display: flex;
4087 align-items: center;
4088 gap: 10px;
4089 margin-bottom: 16px;
4090 flex-wrap: wrap;
4091 }
4092 .status-badge {
4093 display: inline-block;
4094 padding: 4px 12px;
4095 border-radius: 20px;
4096 font-size: 12px;
4097 font-weight: 600;
4098 letter-spacing: 0.03em;
4099 }
4100 .status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
4101 .status-badge--processing{ background: rgba(255,200,0,0.25); color: #ffe066; border: 1px solid rgba(255,200,0,0.3); }
4102 .status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; border: 1px solid rgba(50,220,120,0.3); }
4103 .status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; border: 1px solid rgba(255,140,0,0.3); }
4104 .status-badge--deleted { background: rgba(255,80,80,0.2); color: #ff9ea0; border: 1px solid rgba(255,80,80,0.25); }
4105
4106 </style>
4107</head>
4108<body>
4109 <div class="container">
4110 <!-- Back Navigation -->
4111 <div class="back-nav">
4112<a href="${back}" class="back-link">${backText}</a>
4113 </div>
4114
4115 <!-- File Card -->
4116 <div class="file-card">
4117 <div class="user-info">
4118 ${userLink}
4119 ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4120 </div>
4121
4122 ${
4123 hasToken
4124 ? `<div class="status-row">
4125 <span class="status-badge status-badge--${rawIdea.status || "pending"}">
4126 ${rawIdea.status === "processing" ? "⏳ processing" : rawIdea.status === "succeeded" ? "✓ placed by AI" : rawIdea.status === "stuck" ? "⚠ stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
4127 </span>
4128 </div>`
4129 : ""
4130 }
4131
4132 <h1>${escapeHtml(fileName)}</h1>
4133
4134 ${
4135 fileDeleted
4136 ? ""
4137 : `<div class="action-bar">
4138 <a class="download" href="${fileUrl}" download>Download</a>
4139 <button id="copyUrlBtn" class="copy-url-btn">Share</button>
4140 </div>`
4141 }
4142
4143 <div class="media">
4144 ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
4145 </div>
4146 </div>
4147 </div>
4148
4149 <script>
4150 const copyUrlBtn = document.getElementById("copyUrlBtn");
4151
4152 copyUrlBtn.addEventListener("click", () => {
4153 const url = new URL(window.location.href);
4154 url.searchParams.delete('token');
4155 if (!url.searchParams.has('html')) {
4156 url.searchParams.set('html', '');
4157 }
4158 navigator.clipboard.writeText(url.toString()).then(() => {
4159 const originalText = copyUrlBtn.textContent;
4160 copyUrlBtn.textContent = "✔️ Copied!";
4161 setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
4162 });
4163 });
4164 </script>
4165</body>
4166</html>
4167`);
4168}
4169
4170// ═══════════════════════════════════════════════════════════════════
4171// 9. Invites Page - GET /user/:userId/invites
4172// ═══════════════════════════════════════════════════════════════════
4173export function renderInvites({ userId, invites, token }) {
4174 const tokenQS = token ? `?token=${token}&html` : `?html`;
4175 return (`
4176<!DOCTYPE html>
4177<html lang="en">
4178<head>
4179 <meta charset="UTF-8">
4180 <meta name="viewport" content="width=device-width, initial-scale=1.0">
4181 <meta name="theme-color" content="#667eea">
4182 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4183 <title>Invites</title>
4184 <style>
4185${baseStyles}
4186${backNavStyles}
4187${glassHeaderStyles}
4188${emptyStateStyles}
4189${responsiveBase}
4190
4191.header-subtitle {
4192 margin-bottom: 0;
4193}
4194
4195
4196@keyframes waterDrift {
4197 0% { transform: translateY(-1px); }
4198 100% { transform: translateY(1px); }
4199}
4200
4201/* Glass Invites List */
4202.invites-list {
4203 list-style: none;
4204 display: flex;
4205 flex-direction: column;
4206 gap: 16px;
4207}
4208
4209.invite-card {
4210 position: relative;
4211 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4212 backdrop-filter: blur(22px) saturate(140%);
4213 -webkit-backdrop-filter: blur(22px) saturate(140%);
4214 border-radius: 16px;
4215 padding: 24px;
4216 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4217 inset 0 1px 0 rgba(255, 255, 255, 0.25);
4218 border: 1px solid rgba(255, 255, 255, 0.28);
4219 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
4220 color: white;
4221 overflow: hidden;
4222
4223 /* Start hidden for lazy loading */
4224 opacity: 0;
4225 transform: translateY(30px);
4226}
4227
4228/* When item becomes visible */
4229.invite-card.visible {
4230 animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
4231}
4232
4233.invite-card::before {
4234 content: "";
4235 position: absolute;
4236 inset: -40%;
4237 background: radial-gradient(
4238 120% 60% at 0% 0%,
4239 rgba(255, 255, 255, 0.35),
4240 transparent 60%
4241 );
4242 opacity: 0;
4243 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4244 pointer-events: none;
4245}
4246
4247.invite-card:hover {
4248 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
4249 transform: translateY(-2px);
4250 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
4251}
4252
4253.invite-card:hover::before {
4254 opacity: 1;
4255 transform: translateX(30%) translateY(10%);
4256}
4257
4258.invite-text {
4259 font-size: 16px;
4260 line-height: 1.6;
4261 color: white;
4262 margin-bottom: 16px;
4263 font-weight: 400;
4264}
4265
4266.invite-text strong {
4267 font-weight: 600;
4268 color: white;
4269 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
4270}
4271
4272.invite-actions {
4273 display: flex;
4274 gap: 10px;
4275 flex-wrap: wrap;
4276}
4277
4278.invite-actions form {
4279 margin: 0;
4280}
4281
4282.accept-button,
4283.decline-button {
4284 position: relative;
4285 overflow: hidden;
4286 padding: 10px 20px;
4287 border-radius: 980px;
4288 font-weight: 600;
4289 font-size: 14px;
4290 cursor: pointer;
4291 transition: all 0.3s;
4292 font-family: inherit;
4293 border: 1px solid rgba(255, 255, 255, 0.3);
4294}
4295
4296.accept-button {
4297 background: rgba(255, 255, 255, 0.3);
4298 backdrop-filter: blur(10px);
4299 color: white;
4300 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4301}
4302
4303.accept-button::before {
4304 content: "";
4305 position: absolute;
4306 inset: -40%;
4307 background: radial-gradient(
4308 120% 60% at 0% 0%,
4309 rgba(255, 255, 255, 0.35),
4310 transparent 60%
4311 );
4312 opacity: 0;
4313 transform: translateX(-30%) translateY(-10%);
4314 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4315 pointer-events: none;
4316}
4317
4318.accept-button:hover {
4319 background: rgba(255, 255, 255, 0.4);
4320 transform: translateY(-2px);
4321 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
4322}
4323
4324.accept-button:hover::before {
4325 opacity: 1;
4326 transform: translateX(30%) translateY(10%);
4327}
4328
4329.decline-button {
4330 background: rgba(255, 255, 255, 0.15);
4331 backdrop-filter: blur(10px);
4332 color: white;
4333 opacity: 0.85;
4334}
4335
4336.decline-button:hover {
4337 background: rgba(239, 68, 68, 0.3);
4338 border-color: rgba(239, 68, 68, 0.5);
4339 opacity: 1;
4340 transform: translateY(-1px);
4341 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
4342}
4343
4344/* Responsive Design */
4345@media (max-width: 640px) {
4346 body {
4347 padding: 16px;
4348 }
4349
4350 .invite-card {
4351 padding: 20px 16px;
4352 }
4353
4354 .invite-actions {
4355 flex-direction: column;
4356 }
4357
4358 .accept-button,
4359 .decline-button {
4360 width: 100%;
4361 }
4362
4363}
4364
4365
4366 </style>
4367</head>
4368<body>
4369 <div class="container">
4370 <!-- Back Navigation -->
4371 <div class="back-nav">
4372 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
4373 ← Back to Profile
4374 </a>
4375 </div>
4376
4377 <!-- Header -->
4378 <div class="header">
4379 <h1>Invites</h1>
4380 <div class="header-subtitle">Join other people's trees</div>
4381 </div>
4382
4383 <!-- Invites Section -->
4384 ${
4385 invites.length > 0
4386 ? `
4387 <ul class="invites-list">
4388 ${invites
4389 .map(
4390 (i) => `
4391 <li class="invite-card">
4392 <div class="invite-text">
4393 <strong>${i.userInviting.username}${i.userInviting.isRemote && i.userInviting.homeLand ? "@" + i.userInviting.homeLand : ""}</strong>
4394 invited you to
4395 <strong>${i.rootId.name}${i.userInviting.isRemote && i.userInviting.homeLand ? " on " + i.userInviting.homeLand : ""}</strong>
4396 </div>
4397
4398 <div class="invite-actions">
4399 <form
4400 method="POST"
4401 action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}"
4402 >
4403 <input type="hidden" name="accept" value="true" />
4404 <button type="submit" class="accept-button">Accept</button>
4405 </form>
4406
4407 <form
4408 method="POST"
4409 action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}"
4410 >
4411 <input type="hidden" name="accept" value="false" />
4412 <button type="submit" class="decline-button">Decline</button>
4413 </form>
4414 </div>
4415 </li>
4416 `,
4417 )
4418 .join("")}
4419 </ul>
4420 `
4421 : `
4422 <div class="empty-state">
4423 <div class="empty-state-icon">📬</div>
4424 <div class="empty-state-text">No pending invites</div>
4425 </div>
4426 `
4427 }
4428 </div>
4429
4430 <script>
4431 // Intersection Observer for lazy loading animations
4432 const observerOptions = {
4433 root: null,
4434 rootMargin: '50px',
4435 threshold: 0.1
4436 };
4437
4438 const observer = new IntersectionObserver((entries) => {
4439 entries.forEach((entry, index) => {
4440 if (entry.isIntersecting) {
4441 // Add a small stagger delay based on order
4442 setTimeout(() => {
4443 entry.target.classList.add('visible');
4444 }, index * 50); // 50ms stagger between items
4445
4446 // Stop observing once animated
4447 observer.unobserve(entry.target);
4448 }
4449 });
4450 }, observerOptions);
4451
4452 // Observe all invite cards
4453 document.querySelectorAll('.invite-card').forEach(card => {
4454 observer.observe(card);
4455 });
4456 </script>
4457</body>
4458</html>
4459`);
4460}
4461
4462// ═══════════════════════════════════════════════════════════════════
4463// 10. Deleted Branches Page - GET /user/:userId/deleted
4464// ═══════════════════════════════════════════════════════════════════
4465export function renderDeletedBranches({ userId, user, deleted, token }) {
4466 const tokenQS = token ? `?token=${token}&html` : `?html`;
4467 return (`
4468<!DOCTYPE html>
4469<html lang="en">
4470<head>
4471 <meta charset="UTF-8">
4472 <meta name="viewport" content="width=device-width, initial-scale=1.0">
4473 <meta name="theme-color" content="#667eea">
4474 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4475 <title>${user.username} — Deleted Branches</title>
4476 <style>
4477${baseStyles}
4478${backNavStyles}
4479${glassHeaderStyles}
4480${emptyStateStyles}
4481${responsiveBase}
4482
4483.header-subtitle {
4484 margin-bottom: 0;
4485}
4486
4487
4488@keyframes waterDrift {
4489 0% { transform: translateY(-1px); }
4490 100% { transform: translateY(1px); }
4491}
4492
4493/* Glass Deleted List */
4494.deleted-list {
4495 list-style: none;
4496 display: flex;
4497 flex-direction: column;
4498 gap: 16px;
4499}
4500
4501.deleted-card {
4502 position: relative;
4503 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4504 backdrop-filter: blur(22px) saturate(140%);
4505 -webkit-backdrop-filter: blur(22px) saturate(140%);
4506 border-radius: 16px;
4507 padding: 24px;
4508 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4509 inset 0 1px 0 rgba(255, 255, 255, 0.25);
4510 border: 1px solid rgba(255, 255, 255, 0.28);
4511 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
4512 color: white;
4513 overflow: hidden;
4514
4515 /* Start hidden for lazy loading */
4516 opacity: 0;
4517 transform: translateY(30px);
4518}
4519
4520/* When item becomes visible */
4521.deleted-card.visible {
4522 animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
4523}
4524
4525.deleted-card::before {
4526 content: "";
4527 position: absolute;
4528 inset: -40%;
4529 background: radial-gradient(
4530 120% 60% at 0% 0%,
4531 rgba(255, 255, 255, 0.35),
4532 transparent 60%
4533 );
4534 opacity: 0;
4535 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4536 pointer-events: none;
4537}
4538
4539.deleted-card:hover {
4540 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
4541 transform: translateY(-2px);
4542 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
4543}
4544
4545.deleted-card:hover::before {
4546 opacity: 1;
4547 transform: translateX(30%) translateY(10%);
4548}
4549
4550.deleted-info {
4551 margin-bottom: 16px;
4552}
4553
4554.deleted-name {
4555 font-size: 18px;
4556 font-weight: 600;
4557 margin-bottom: 6px;
4558}
4559
4560.deleted-name a {
4561 color: white;
4562 text-decoration: none;
4563 transition: all 0.2s;
4564}
4565
4566.deleted-name a:hover {
4567 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4568}
4569
4570.deleted-id {
4571 font-size: 12px;
4572 color: rgba(255, 255, 255, 0.75);
4573 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
4574 letter-spacing: -0.3px;
4575}
4576
4577/* Revival Forms */
4578.revival-section {
4579 display: flex;
4580 flex-direction: column;
4581 gap: 10px;
4582 padding-top: 16px;
4583 border-top: 1px solid rgba(255, 255, 255, 0.15);
4584}
4585
4586.revive-as-root-form button {
4587 position: relative;
4588 overflow: hidden;
4589 padding: 12px 24px;
4590 border-radius: 980px;
4591 border: 1px solid rgba(255, 255, 255, 0.3);
4592 background: rgba(255, 255, 255, 0.3);
4593 backdrop-filter: blur(10px);
4594 color: white;
4595 cursor: pointer;
4596 font-weight: 600;
4597 font-size: 14px;
4598 transition: all 0.3s;
4599 font-family: inherit;
4600 width: 100%;
4601 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4602}
4603
4604.revive-as-root-form button::before {
4605 content: "";
4606 position: absolute;
4607 inset: -40%;
4608 background: radial-gradient(
4609 120% 60% at 0% 0%,
4610 rgba(255, 255, 255, 0.35),
4611 transparent 60%
4612 );
4613 opacity: 0;
4614 transform: translateX(-30%) translateY(-10%);
4615 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4616 pointer-events: none;
4617}
4618
4619.revive-as-root-form button:hover {
4620 background: rgba(255, 255, 255, 0.4);
4621 transform: translateY(-2px);
4622 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
4623}
4624
4625.revive-as-root-form button:hover::before {
4626 opacity: 1;
4627 transform: translateX(30%) translateY(10%);
4628}
4629
4630.revive-into-branch-form {
4631 display: flex;
4632 gap: 8px;
4633 flex-wrap: wrap;
4634 align-items: center;
4635}
4636
4637.revive-into-branch-form input[type="text"] {
4638 flex: 1;
4639 min-width: 180px;
4640 padding: 10px 14px;
4641 font-size: 14px;
4642 border-radius: 10px;
4643 border: 1px solid rgba(255, 255, 255, 0.25);
4644 background: rgba(255, 255, 255, 0.15);
4645 backdrop-filter: blur(10px);
4646 -webkit-backdrop-filter: blur(10px);
4647 font-family: inherit;
4648 color: white;
4649 font-weight: 500;
4650 transition: all 0.3s;
4651 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
4652}
4653
4654.revive-into-branch-form input[type="text"]::placeholder {
4655 color: rgba(255, 255, 255, 0.5);
4656 font-size: 13px;
4657}
4658
4659.revive-into-branch-form input[type="text"]:focus {
4660 outline: none;
4661 border-color: rgba(255, 255, 255, 0.4);
4662 background: rgba(255, 255, 255, 0.2);
4663 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
4664 inset 0 1px 0 rgba(255, 255, 255, 0.2);
4665}
4666
4667.revive-into-branch-form button {
4668 padding: 10px 18px;
4669 font-size: 13px;
4670 font-weight: 600;
4671 border-radius: 980px;
4672 border: 1px solid rgba(255, 255, 255, 0.25);
4673 background: rgba(255, 255, 255, 0.15);
4674 backdrop-filter: blur(10px);
4675 color: white;
4676 cursor: pointer;
4677 transition: all 0.3s;
4678 font-family: inherit;
4679 white-space: nowrap;
4680 opacity: 0.85;
4681 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
4682}
4683
4684.revive-into-branch-form button:hover {
4685 background: rgba(255, 255, 255, 0.25);
4686 opacity: 1;
4687 transform: translateY(-1px);
4688 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
4689 inset 0 1px 0 rgba(255, 255, 255, 0.2);
4690}
4691
4692/* Responsive Design */
4693
4694 </style>
4695</head>
4696<body>
4697 <div class="container">
4698 <!-- Back Navigation -->
4699 <div class="back-nav">
4700 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
4701 ← Back to Profile
4702 </a>
4703 </div>
4704
4705 <!-- Header Section -->
4706 <div class="header">
4707 <h1>
4708 Deleted Branches for
4709 <a href="/api/v1/user/${userId}${tokenQS}">${user.username}</a>
4710 </h1>
4711 <div class="header-subtitle">
4712 Recover deleted trees and branches as new trees or merge them into existing ones.
4713 </div>
4714 </div>
4715
4716 <!-- Deleted Items List -->
4717 ${
4718 deleted.length > 0
4719 ? `
4720 <ul class="deleted-list">
4721 ${deleted
4722 .map(
4723 ({ _id, name }) => `
4724 <li class="deleted-card">
4725 <div class="deleted-info">
4726 <div class="deleted-name">
4727 <a href="/api/v1/root/${_id}${tokenQS}">
4728 ${name || "Untitled"}
4729 </a>
4730 </div>
4731 <div class="deleted-id">${_id}</div>
4732 </div>
4733
4734 <div class="revival-section">
4735 <!-- Revive as Root -->
4736 <form
4737 method="POST"
4738 action="/api/v1/user/${userId}/deleted/${_id}/reviveAsRoot?token=${token}&html"
4739 class="revive-as-root-form"
4740 >
4741 <button type="submit">Revive as Root</button>
4742 </form>
4743
4744 <!-- Revive into Branch -->
4745 <form
4746 method="POST"
4747 action="/api/v1/user/${userId}/deleted/${_id}/revive?token=${token}&html"
4748 class="revive-into-branch-form"
4749 >
4750 <input
4751 type="text"
4752 name="targetParentId"
4753 placeholder="Target parent node ID"
4754 required
4755 />
4756 <button type="submit">Revive into Branch</button>
4757 </form>
4758 </div>
4759 </li>
4760 `,
4761 )
4762 .join("")}
4763 </ul>
4764 `
4765 : `
4766 <div class="empty-state">
4767 <div class="empty-state-icon">🗑️</div>
4768 <div class="empty-state-text">No deleted branches</div>
4769 <div class="empty-state-subtext">
4770 Deleted branches will appear here and can be revived
4771 </div>
4772 </div>
4773 `
4774 }
4775 </div>
4776
4777 <script>
4778 // Intersection Observer for lazy loading animations
4779 const observerOptions = {
4780 root: null,
4781 rootMargin: '50px',
4782 threshold: 0.1
4783 };
4784
4785 const observer = new IntersectionObserver((entries) => {
4786 entries.forEach((entry, index) => {
4787 if (entry.isIntersecting) {
4788 // Add a small stagger delay based on order
4789 setTimeout(() => {
4790 entry.target.classList.add('visible');
4791 }, index * 50); // 50ms stagger between items
4792
4793 // Stop observing once animated
4794 observer.unobserve(entry.target);
4795 }
4796 });
4797 }, observerOptions);
4798
4799 // Observe all deleted cards
4800 document.querySelectorAll('.deleted-card').forEach(card => {
4801 observer.observe(card);
4802 });
4803 </script>
4804</body>
4805</html>
4806`);
4807}
4808
4809// ═══════════════════════════════════════════════════════════════════
4810// 11a. API Key Created Page - POST /user/:userId/api-keys
4811// ═══════════════════════════════════════════════════════════════════
4812export function renderApiKeyCreated({ userId, safeName, rawKey, token }) {
4813 const tokenQS = token ? `?token=${token}&html` : `?html`;
4814
4815 return (`
4816<!DOCTYPE html>
4817<html lang="en">
4818<head>
4819 <meta charset="UTF-8">
4820 <meta name="viewport" content="width=device-width, initial-scale=1.0">
4821 <meta name="theme-color" content="#667eea">
4822 <title>API Key Created</title>
4823 <style>
4824${baseStyles}
4825${backNavStyles}
4826
4827.container { max-width: 600px; margin: 0 auto; position: relative; z-index: 1; }
4828
4829.card {
4830 position: relative;
4831 background: rgba(115,111,230,var(--glass-alpha));
4832 backdrop-filter: blur(22px) saturate(140%);
4833 -webkit-backdrop-filter: blur(22px) saturate(140%);
4834 border-radius: 20px; padding: 40px;
4835 box-shadow: 0 20px 60px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.25);
4836 border: 1px solid rgba(255,255,255,0.28);
4837 color: white; animation: fadeInUp 0.6s ease-out 0.1s both;
4838}
4839.card-title {
4840 font-size: 22px; font-weight: 700; margin-bottom: 6px;
4841 letter-spacing: -0.3px;
4842}
4843.card-name {
4844 font-size: 14px; color: rgba(255,255,255,0.6); margin-bottom: 20px;
4845}
4846.warning {
4847 display: flex; align-items: center; gap: 10px;
4848 padding: 12px 16px; margin-bottom: 20px;
4849 background: rgba(255,179,71,0.15); border: 1px solid rgba(255,179,71,0.3);
4850 border-radius: 10px; font-size: 13px; font-weight: 500;
4851 color: rgba(255,220,150,0.95); line-height: 1.5;
4852}
4853.key-block {
4854 position: relative;
4855 background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.15);
4856 border-radius: 10px; padding: 16px 60px 16px 16px;
4857 font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
4858 font-size: 14px; color: rgba(255,255,255,0.95);
4859 word-break: break-all; line-height: 1.6;
4860 margin-bottom: 24px;
4861}
4862.copy-btn {
4863 position: absolute; top: 10px; right: 10px;
4864 background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25);
4865 border-radius: 8px; padding: 8px 14px;
4866 color: white; font-size: 12px; font-weight: 600;
4867 cursor: pointer; transition: all 0.2s;
4868 backdrop-filter: blur(10px);
4869}
4870.copy-btn:hover {
4871 background: rgba(255,255,255,0.25); transform: translateY(-1px);
4872}
4873.copy-btn.copied {
4874 background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.4);
4875}
4876@media (max-width: 640px) {
4877 body { padding: 16px; }
4878 .card { padding: 28px 20px; }
4879
4880}
4881 </style>
4882</head>
4883<body>
4884 <div class="container">
4885 <div class="back-nav">
4886 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link"><- Back to Profile</a>
4887 <a href="/api/v1/user/${userId}/api-keys${tokenQS}" class="back-link">API Keys</a>
4888 </div>
4889
4890 <div class="card">
4891 <div class="card-title">API Key Created</div>
4892 <div class="card-name">${esc(safeName)}</div>
4893
4894 <div class="warning">
4895 This key will only be shown once. Copy it now and store it securely.
4896 </div>
4897
4898 <div class="key-block" id="keyBlock">
4899 ${esc(rawKey)}
4900 <button class="copy-btn" id="copyBtn" onclick="copyKey()">📋</button>
4901 </div>
4902 </div>
4903 </div>
4904
4905 <script>
4906 function copyKey() {
4907 var block = document.getElementById("keyBlock");
4908 var btn = document.getElementById("copyBtn");
4909 var key = block.textContent.replace(btn.textContent, "").trim();
4910 navigator.clipboard.writeText(key).then(function() {
4911 var btn = document.getElementById("copyBtn");
4912 btn.textContent = "\u2705";
4913 btn.classList.add("copied");
4914 setTimeout(function() {
4915 btn.textContent = "\uD83D\uDCCB";
4916 btn.classList.remove("copied");
4917 }, 2000);
4918 });
4919 }
4920 </script>
4921</body>
4922</html>
4923`);
4924}
4925
4926// ═══════════════════════════════════════════════════════════════════
4927// 11b. API Keys List Page - GET /user/:userId/api-keys
4928// ═══════════════════════════════════════════════════════════════════
4929export function renderApiKeysList({ userId, user, apiKeys, token, errorParam }) {
4930 const tokenQS = token ? `?token=${token}&html` : `?html`;
4931 return (`
4932<!DOCTYPE html>
4933<html lang="en">
4934<head>
4935 <meta charset="UTF-8" />
4936 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4937 <meta name="theme-color" content="#667eea">
4938 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4939 <title>${user.username} — API Keys</title>
4940 <style>
4941${baseStyles}
4942${backNavStyles}
4943${glassHeaderStyles}
4944${emptyStateStyles}
4945${responsiveBase}
4946
4947.header-subtitle {
4948 margin-bottom: 0;
4949}
4950
4951
4952@keyframes waterDrift {
4953 0% { transform: translateY(-1px); }
4954 100% { transform: translateY(1px); }
4955}
4956
4957/* Create Form Card */
4958.create-card {
4959 position: relative;
4960 overflow: hidden;
4961 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4962 backdrop-filter: blur(22px) saturate(140%);
4963 -webkit-backdrop-filter: blur(22px) saturate(140%);
4964 border-radius: 16px;
4965 padding: 24px;
4966 margin-bottom: 24px;
4967 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4968 inset 0 1px 0 rgba(255, 255, 255, 0.25);
4969 border: 1px solid rgba(255, 255, 255, 0.28);
4970 color: white;
4971}
4972
4973.create-card::before {
4974 content: "";
4975 position: absolute;
4976 inset: -40%;
4977 background: radial-gradient(
4978 120% 60% at 0% 0%,
4979 rgba(255, 255, 255, 0.35),
4980 transparent 60%
4981 );
4982 opacity: 0;
4983 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4984 pointer-events: none;
4985}
4986
4987.create-card:hover::before {
4988 opacity: 1;
4989 transform: translateX(30%) translateY(10%);
4990}
4991
4992.create-form {
4993 display: flex;
4994 gap: 10px;
4995 flex-wrap: wrap;
4996 margin-bottom: 12px;
4997}
4998
4999.create-form input {
5000 flex: 1;
5001 min-width: 200px;
5002 padding: 12px 16px;
5003 font-size: 15px;
5004 border-radius: 12px;
5005 border: 1px solid rgba(255, 255, 255, 0.25);
5006 background: rgba(255, 255, 255, 0.15);
5007 backdrop-filter: blur(10px);
5008 -webkit-backdrop-filter: blur(10px);
5009 font-family: inherit;
5010 color: white;
5011 font-weight: 500;
5012 transition: all 0.3s;
5013 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
5014}
5015
5016.create-form input::placeholder {
5017 color: rgba(255, 255, 255, 0.5);
5018}
5019
5020.create-form input:focus {
5021 outline: none;
5022 border-color: rgba(255, 255, 255, 0.4);
5023 background: rgba(255, 255, 255, 0.2);
5024 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
5025 inset 0 1px 0 rgba(255, 255, 255, 0.2);
5026}
5027
5028.create-form button {
5029 position: relative;
5030 overflow: hidden;
5031 padding: 12px 24px;
5032 font-size: 15px;
5033 font-weight: 600;
5034 border-radius: 980px;
5035 border: 1px solid rgba(255, 255, 255, 0.3);
5036 background: rgba(255, 255, 255, 0.3);
5037 backdrop-filter: blur(10px);
5038 color: white;
5039 cursor: pointer;
5040 transition: all 0.3s;
5041 font-family: inherit;
5042 white-space: nowrap;
5043 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5044}
5045
5046.create-form button::before {
5047 content: "";
5048 position: absolute;
5049 inset: -40%;
5050 background: radial-gradient(
5051 120% 60% at 0% 0%,
5052 rgba(255, 255, 255, 0.35),
5053 transparent 60%
5054 );
5055 opacity: 0;
5056 transform: translateX(-30%) translateY(-10%);
5057 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5058 pointer-events: none;
5059}
5060
5061.create-form button:hover {
5062 background: rgba(255, 255, 255, 0.4);
5063 transform: translateY(-2px);
5064 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
5065}
5066
5067.create-form button:hover::before {
5068 opacity: 1;
5069 transform: translateX(30%) translateY(10%);
5070}
5071
5072.create-hint {
5073 font-size: 13px;
5074 color: rgba(255, 255, 255, 0.75);
5075}
5076
5077/* API Keys List */
5078.keys-list {
5079 display: flex;
5080 flex-direction: column;
5081 gap: 16px;
5082}
5083
5084.key-card {
5085 position: relative;
5086 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5087 backdrop-filter: blur(22px) saturate(140%);
5088 -webkit-backdrop-filter: blur(22px) saturate(140%);
5089 border-radius: 16px;
5090 padding: 24px;
5091 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
5092 inset 0 1px 0 rgba(255, 255, 255, 0.25);
5093 border: 1px solid rgba(255, 255, 255, 0.28);
5094 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
5095 color: white;
5096 overflow: hidden;
5097
5098 /* Start hidden for lazy loading */
5099 opacity: 0;
5100 transform: translateY(30px);
5101}
5102
5103/* Active keys get green glass tint */
5104.key-card.active {
5105 background: rgba(76, 175, 80, 0.2);
5106 border-color: rgba(76, 175, 80, 0.35);
5107}
5108
5109.key-card.active::after {
5110 content: "";
5111 position: absolute;
5112 inset: 0;
5113 background: radial-gradient(
5114 circle at top right,
5115 rgba(76, 175, 80, 0.15),
5116 transparent 70%
5117 );
5118 pointer-events: none;
5119}
5120
5121/* When item becomes visible */
5122.key-card.visible {
5123 animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
5124}
5125
5126.key-card::before {
5127 content: "";
5128 position: absolute;
5129 inset: -40%;
5130 background: radial-gradient(
5131 120% 60% at 0% 0%,
5132 rgba(255, 255, 255, 0.35),
5133 transparent 60%
5134 );
5135 opacity: 0;
5136 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5137 pointer-events: none;
5138}
5139
5140.key-card:hover {
5141 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
5142 transform: translateY(-2px);
5143 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
5144}
5145
5146.key-card.active:hover {
5147 background: rgba(76, 175, 80, 0.28);
5148 box-shadow: 0 12px 32px rgba(76, 175, 80, 0.15);
5149}
5150
5151.key-card:hover::before {
5152 opacity: 1;
5153 transform: translateX(30%) translateY(10%);
5154}
5155
5156.key-name {
5157 font-size: 18px;
5158 font-weight: 600;
5159 color: white;
5160 margin-bottom: 12px;
5161 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5162}
5163
5164.key-meta {
5165 display: flex;
5166 flex-direction: column;
5167 gap: 6px;
5168 margin-bottom: 12px;
5169}
5170
5171.meta-item {
5172 font-size: 13px;
5173 color: rgba(255, 255, 255, 0.85);
5174 display: flex;
5175 align-items: center;
5176 gap: 8px;
5177}
5178
5179.badge {
5180 display: inline-flex;
5181 align-items: center;
5182 padding: 4px 12px;
5183 font-size: 12px;
5184 border-radius: 980px;
5185 font-weight: 600;
5186 border: 1px solid rgba(255, 255, 255, 0.3);
5187}
5188
5189.badge.active {
5190 background: rgba(76, 175, 80, 0.25);
5191 color: white;
5192 border-color: rgba(76, 175, 80, 0.4);
5193}
5194
5195.badge.revoked {
5196 background: rgba(239, 68, 68, 0.25);
5197 color: white;
5198 border-color: rgba(239, 68, 68, 0.4);
5199}
5200
5201.key-actions {
5202 margin-top: 16px;
5203 padding-top: 16px;
5204 border-top: 1px solid rgba(255, 255, 255, 0.15);
5205}
5206
5207.revoke-button {
5208 padding: 10px 20px;
5209 font-size: 14px;
5210 font-weight: 600;
5211 border-radius: 980px;
5212 border: 1px solid rgba(239, 68, 68, 0.4);
5213 background: rgba(239, 68, 68, 0.25);
5214 color: white;
5215 cursor: pointer;
5216 transition: all 0.3s;
5217 font-family: inherit;
5218}
5219
5220.revoke-button:hover {
5221 background: rgba(239, 68, 68, 0.35);
5222 border-color: rgba(239, 68, 68, 0.5);
5223 transform: translateY(-1px);
5224 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
5225}
5226
5227/* Responsive Design */
5228@media (max-width: 640px) {
5229 body {
5230 padding: 16px;
5231 }
5232
5233 .create-form {
5234 flex-direction: column;
5235 }
5236
5237 .create-form input {
5238 width: 100%;
5239 min-width: 0;
5240 }
5241
5242 .create-form button {
5243 width: 100%;
5244 }
5245
5246 .key-card {
5247 padding: 20px 16px;
5248 }
5249
5250}
5251
5252
5253 </style>
5254</head>
5255<body>
5256 <div class="container">
5257 <!-- Back Navigation -->
5258 <div class="back-nav">
5259 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
5260 ← Back to Profile
5261 </a>
5262 </div>
5263
5264 <!-- Header -->
5265 <div class="header">
5266 <h1>API Keys</h1>
5267 <div class="header-subtitle">
5268 Manage programmatic access to your account
5269 </div>
5270 </div>
5271
5272 <!-- Create API Key -->
5273 <div class="create-card">
5274 <form class="create-form" method="POST" action="/api/v1/user/${
5275 userId
5276 }/api-keys?token=${token}&html">
5277 <input type="text" name="name" placeholder="Key name (optional)" />
5278 <button type="submit">Create Key</button>
5279 </form>
5280 <div class="create-hint">
5281 You'll only see the key once after creation.
5282 </div>
5283 </div>
5284
5285 <!-- API Keys List -->
5286 ${
5287 apiKeys.length > 0
5288 ? `
5289 <div class="keys-list">
5290 ${apiKeys
5291 .map(
5292 (k) => `
5293 <div class="key-card${!k.revoked ? " active" : ""}">
5294 <div class="key-name">${k.name || "Untitled Key"}</div>
5295
5296 <div class="key-meta">
5297 <div class="meta-item">
5298 Created ${new Date(k.createdAt).toLocaleDateString()}
5299 </div>
5300 <div class="meta-item">
5301 Used ${k.usageCount} ${k.usageCount === 1 ? "time" : "times"}
5302 </div>
5303 <div class="meta-item">
5304 <span class="badge ${k.revoked ? "revoked" : "active"}">
5305 ${k.revoked ? "Revoked" : "Active"}
5306 </span>
5307 </div>
5308 </div>
5309
5310 ${
5311 !k.revoked
5312 ? `
5313 <div class="key-actions">
5314 <button class="revoke-button" data-key-id="${k._id}">
5315 Revoke Key
5316 </button>
5317 </div>
5318 `
5319 : ""
5320 }
5321 </div>
5322 `,
5323 )
5324 .join("")}
5325 </div>
5326 `
5327 : `
5328 <div class="empty-state">
5329 <div class="empty-state-icon">🔑</div>
5330 <div class="empty-state-text">No API keys yet</div>
5331 <div class="empty-state-subtext">
5332 Create one above to get started
5333 </div>
5334 </div>
5335 `
5336 }
5337 </div>
5338
5339 <script>
5340 // Intersection Observer for lazy loading animations
5341 const observerOptions = {
5342 root: null,
5343 rootMargin: '50px',
5344 threshold: 0.1
5345 };
5346
5347 const observer = new IntersectionObserver((entries) => {
5348 entries.forEach((entry, index) => {
5349 if (entry.isIntersecting) {
5350 setTimeout(() => {
5351 entry.target.classList.add('visible');
5352 }, index * 50);
5353 observer.unobserve(entry.target);
5354 }
5355 });
5356 }, observerOptions);
5357
5358 // Observe all key cards
5359 document.querySelectorAll('.key-card').forEach(card => {
5360 observer.observe(card);
5361 });
5362
5363 // Revoke button handler
5364 document.addEventListener("click", async (e) => {
5365 if (!e.target.classList.contains("revoke-button")) return;
5366
5367 const keyId = e.target.dataset.keyId;
5368
5369 if (!confirm("Revoke this API key? This cannot be undone.")) return;
5370
5371 const token = new URLSearchParams(window.location.search).get("token") || "";
5372 const qs = token ? "?token=" + encodeURIComponent(token) : "";
5373
5374 try {
5375 const res = await fetch(
5376 "/api/v1/user/${userId}/api-keys/" + keyId + qs,
5377 { method: "DELETE" }
5378 );
5379
5380 const data = await res.json();
5381 if (!data.message) throw new Error("Revoke failed");
5382
5383 location.reload();
5384 } catch (err) {
5385 alert("Failed to revoke API key");
5386 }
5387 });
5388 </script>
5389</body>
5390</html>
5391`);
5392}
5393
5394// ═══════════════════════════════════════════════════════════════════
5395// 12. Share Token Page - GET /user/:userId/shareToken
5396// ═══════════════════════════════════════════════════════════════════
5397export function renderShareToken({ userId, user, token, tokenQS }) {
5398 return (`
5399<!DOCTYPE html>
5400<html lang="en">
5401<head>
5402 <meta charset="UTF-8" />
5403 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5404 <meta name="theme-color" content="#667eea">
5405 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
5406 <title>Share Token — @${user.username}</title>
5407 <style>
5408${baseStyles}
5409${responsiveBase}
5410
5411body {
5412 display: flex;
5413 align-items: center;
5414 justify-content: center;
5415}
5416
5417
5418.container {
5419 max-width: 600px;
5420 width: 100%;
5421 position: relative;
5422 z-index: 1;
5423}
5424
5425/* Glass Card */
5426.card {
5427 position: relative;
5428 overflow: hidden;
5429 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5430 backdrop-filter: blur(22px) saturate(140%);
5431 -webkit-backdrop-filter: blur(22px) saturate(140%);
5432 border-radius: 24px;
5433 padding: 48px;
5434 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2),
5435 inset 0 1px 0 rgba(255, 255, 255, 0.25);
5436 border: 1px solid rgba(255, 255, 255, 0.28);
5437 color: white;
5438 animation: fadeInUp 0.6s ease-out;
5439}
5440
5441.card::before {
5442 content: "";
5443 position: absolute;
5444 inset: -40%;
5445 background: radial-gradient(
5446 120% 60% at 0% 0%,
5447 rgba(255, 255, 255, 0.35),
5448 transparent 60%
5449 );
5450 opacity: 0;
5451 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5452 pointer-events: none;
5453}
5454
5455.card:hover::before {
5456 opacity: 1;
5457 transform: translateX(30%) translateY(10%);
5458}
5459
5460/* Header */
5461.header {
5462 text-align: center;
5463 margin-bottom: 32px;
5464}
5465
5466.icon {
5467 font-size: 64px;
5468 margin-bottom: 20px;
5469 display: inline-block;
5470 filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
5471 animation: bounce 2s infinite;
5472}
5473
5474@keyframes bounce {
5475 0%, 100% {
5476 transform: translateY(0);
5477 }
5478 50% {
5479 transform: translateY(-10px);
5480 }
5481}
5482
5483h1 {
5484 font-size: 32px;
5485 font-weight: 600;
5486 color: white;
5487 margin-bottom: 8px;
5488 letter-spacing: -0.5px;
5489 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5490}
5491
5492.username {
5493 font-size: 16px;
5494 color: rgba(255, 255, 255, 0.85);
5495 font-weight: 500;
5496}
5497
5498/* Description */
5499.description {
5500 color: rgba(255, 255, 255, 0.9);
5501 line-height: 1.6;
5502 margin-bottom: 28px;
5503 font-size: 15px;
5504 text-align: center;
5505}
5506
5507/* Welcome Box */
5508.welcome-box {
5509 background: rgba(255, 255, 255, 0.15);
5510 backdrop-filter: blur(10px);
5511 padding: 24px;
5512 border-radius: 16px;
5513 margin-bottom: 28px;
5514 border: 1px solid rgba(255, 255, 255, 0.25);
5515}
5516
5517.welcome-title {
5518 font-size: 18px;
5519 font-weight: 600;
5520 color: white;
5521 margin-bottom: 12px;
5522 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5523}
5524
5525.welcome-text {
5526 color: rgba(255, 255, 255, 0.9);
5527 line-height: 1.6;
5528 font-size: 15px;
5529}
5530
5531/* Token Section */
5532.token-section {
5533 margin-bottom: 28px;
5534}
5535
5536.token-label {
5537 font-size: 13px;
5538 font-weight: 600;
5539 color: rgba(255, 255, 255, 0.85);
5540 text-transform: uppercase;
5541 letter-spacing: 0.5px;
5542 margin-bottom: 10px;
5543}
5544
5545.token-display {
5546 display: flex;
5547 align-items: center;
5548 gap: 12px;
5549 background: rgba(255, 255, 255, 0.15);
5550 backdrop-filter: blur(10px);
5551 padding: 16px 20px;
5552 border-radius: 12px;
5553 border: 1px solid rgba(255, 255, 255, 0.25);
5554 transition: all 0.3s;
5555}
5556
5557.token-display:hover {
5558 border-color: rgba(255, 255, 255, 0.4);
5559 background: rgba(255, 255, 255, 0.2);
5560}
5561
5562.token-text {
5563 flex: 1;
5564 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
5565 font-size: 14px;
5566 color: white;
5567 word-break: break-all;
5568 font-weight: 500;
5569}
5570
5571.btn-copy {
5572 padding: 8px 16px;
5573 background: rgba(255, 255, 255, 0.25);
5574 color: white;
5575 border: 1px solid rgba(255, 255, 255, 0.3);
5576 border-radius: 980px;
5577 font-size: 13px;
5578 font-weight: 600;
5579 cursor: pointer;
5580 transition: all 0.3s;
5581 flex-shrink: 0;
5582}
5583
5584.btn-copy:hover {
5585 background: rgba(255, 255, 255, 0.35);
5586 transform: translateY(-2px);
5587 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5588}
5589
5590/* Form Section */
5591.form-section {
5592 background: rgba(255, 255, 255, 0.1);
5593 backdrop-filter: blur(10px);
5594 padding: 24px;
5595 border-radius: 16px;
5596 border: 1px solid rgba(255, 255, 255, 0.2);
5597 margin-bottom: 24px;
5598}
5599
5600.form-title {
5601 font-size: 16px;
5602 font-weight: 600;
5603 color: white;
5604 margin-bottom: 16px;
5605 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5606}
5607
5608.form-row {
5609 display: flex;
5610 gap: 12px;
5611}
5612
5613input {
5614 flex: 1;
5615 padding: 14px 18px;
5616 border-radius: 12px;
5617 border: 1px solid rgba(255, 255, 255, 0.25);
5618 font-size: 15px;
5619 font-family: 'SF Mono', Monaco, monospace;
5620 transition: all 0.3s;
5621 background: rgba(255, 255, 255, 0.15);
5622 backdrop-filter: blur(10px);
5623 color: white;
5624 font-weight: 500;
5625}
5626
5627input::placeholder {
5628 color: rgba(255, 255, 255, 0.5);
5629}
5630
5631input:focus {
5632 outline: none;
5633 border-color: rgba(255, 255, 255, 0.4);
5634 background: rgba(255, 255, 255, 0.2);
5635 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
5636}
5637
5638.btn-submit {
5639 padding: 14px 28px;
5640 border-radius: 980px;
5641 border: 1px solid rgba(255, 255, 255, 0.3);
5642 background: rgba(255, 255, 255, 0.3);
5643 backdrop-filter: blur(10px);
5644 color: white;
5645 font-weight: 600;
5646 font-size: 15px;
5647 cursor: pointer;
5648 transition: all 0.3s;
5649 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5650 flex-shrink: 0;
5651 position: relative;
5652 overflow: hidden;
5653}
5654
5655.btn-submit::before {
5656 content: "";
5657 position: absolute;
5658 inset: -40%;
5659 background: radial-gradient(
5660 120% 60% at 0% 0%,
5661 rgba(255, 255, 255, 0.35),
5662 transparent 60%
5663 );
5664 opacity: 0;
5665 transform: translateX(-30%) translateY(-10%);
5666 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5667 pointer-events: none;
5668}
5669
5670.btn-submit:hover {
5671 background: rgba(255, 255, 255, 0.4);
5672 transform: translateY(-2px);
5673 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
5674}
5675
5676.btn-submit:hover::before {
5677 opacity: 1;
5678 transform: translateX(30%) translateY(10%);
5679}
5680
5681/* Info Box */
5682.info-box {
5683 background: rgba(255, 255, 255, 0.1);
5684 backdrop-filter: blur(10px);
5685 padding: 14px 18px;
5686 border-radius: 12px;
5687 border-left: 3px solid rgba(255, 255, 255, 0.5);
5688 margin-bottom: 24px;
5689}
5690
5691.info-box-content {
5692 font-size: 14px;
5693 color: rgba(255, 255, 255, 0.85);
5694 line-height: 1.6;
5695}
5696
5697/* Back Links */
5698.back-links {
5699 display: flex;
5700 flex-direction: column;
5701 gap: 10px;
5702}
5703
5704.back-link {
5705 display: inline-flex;
5706 align-items: center;
5707 justify-content: center;
5708 gap: 8px;
5709 padding: 12px 20px;
5710 text-decoration: none;
5711 color: white;
5712 font-weight: 600;
5713 font-size: 14px;
5714 background: rgba(255, 255, 255, 0.15);
5715 backdrop-filter: blur(10px);
5716 border-radius: 980px;
5717 transition: all 0.3s;
5718 border: 1px solid rgba(255, 255, 255, 0.25);
5719}
5720
5721.back-link:hover {
5722 background: rgba(255, 255, 255, 0.25);
5723 transform: translateY(-2px);
5724 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
5725}
5726
5727/* Responsive */
5728@media (max-width: 640px) {
5729 body {
5730 padding: 16px;
5731 align-items: flex-start;
5732 padding-top: 40px;
5733 }
5734
5735 .card {
5736 padding: 32px 24px;
5737 }
5738
5739 h1 {
5740 font-size: 28px;
5741 }
5742
5743 .icon {
5744 font-size: 56px;
5745 }
5746
5747 .form-row {
5748 flex-direction: column;
5749 }
5750
5751 .btn-submit {
5752 width: 100%;
5753 }
5754
5755 .token-display {
5756 flex-direction: column;
5757 align-items: stretch;
5758 }
5759
5760 .btn-copy {
5761 width: 100%;
5762 }
5763}
5764
5765 </style>
5766</head>
5767<body>
5768 <div class="container">
5769 <div class="card">
5770 <!-- Header -->
5771 <div class="header">
5772 <div class="icon">🔐</div>
5773 <h1>Share Token</h1>
5774 <div class="username">@${user.username}</div>
5775 </div>
5776
5777 ${
5778 token
5779 ? `
5780 <!-- Existing Token View -->
5781 <div class="description">
5782 Share read-only access to your content.
5783 </div>
5784
5785 <div class="token-section">
5786 <div class="token-label">Your Token</div>
5787 <div class="token-display">
5788 <div class="token-text" id="tokenText">${token}</div>
5789 <button class="btn-copy" onclick="copyToken()">Copy</button>
5790 </div>
5791 </div>
5792
5793 <div class="info-box">
5794 <div class="info-box-content">
5795 Change your token anytime to revoke shared URL access.
5796 </div>
5797 </div>
5798
5799 <div class="form-section">
5800 <div class="form-title">Update Token</div>
5801 <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
5802 <div class="form-row">
5803 <input
5804 name="htmlShareToken"
5805 placeholder="Enter new token"
5806 required
5807 />
5808 <button type="submit" class="btn-submit">Update</button>
5809 </div>
5810 </form>
5811 </div>
5812 `
5813 : `
5814 <!-- First Time View -->
5815 <div class="welcome-box">
5816 <div class="welcome-title">Create a Share Token</div>
5817 <div class="welcome-text">
5818 Share read-only access to your trees and notes. Change it anytime to revoke old links.
5819 </div>
5820 </div>
5821
5822 <div class="form-section">
5823 <div class="form-title">Choose Your Token</div>
5824 <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
5825 <div class="form-row">
5826 <input
5827 name="htmlShareToken"
5828 placeholder="Enter a unique token"
5829 required
5830 />
5831 <button type="submit" class="btn-submit">Create</button>
5832 </div>
5833 </form>
5834 </div>
5835 `
5836 }
5837
5838 <div class="back-links">
5839 <a class="back-link" href="/api/v1/user/${userId}${tokenQS}">
5840 ← Back to Profile
5841 </a>
5842 <a class="back-link" target="_top" href="/">
5843 ← Back to ${new URL(getLandUrl()).hostname}
5844 </a>
5845 </div>
5846 </div>
5847 </div>
5848
5849 <script>
5850 function copyToken() {
5851 const tokenText = document.getElementById('tokenText').textContent;
5852 navigator.clipboard.writeText(tokenText).then(() => {
5853 const btn = document.querySelector('.btn-copy');
5854 const originalText = btn.textContent;
5855 btn.textContent = '✓ Copied';
5856 setTimeout(() => {
5857 btn.textContent = originalText;
5858 }, 2000);
5859 });
5860 }
5861 </script>
5862</body>
5863</html>
5864 `);
5865}
5866
5867// ═══════════════════════════════════════════════════════════════════
5868// 13. Energy Page - GET /user/:userId/energy
5869// ═══════════════════════════════════════════════════════════════════
5870export function renderEnergy({ userId, user, energyAmount, additionalEnergy, profileType, planExpiresAt, llmConnections, mainAssignment, rawIdeaAssignment, activeConn, hasLlm, connectionCount, isBasic, qs }) {
5871 return (`<!DOCTYPE html>
5872<html lang="en">
5873<head>
5874<meta charset="UTF-8">
5875<meta name="viewport" content="width=device-width, initial-scale=1.0">
5876<meta name="theme-color" content="#667eea">
5877<title>Energy · @${user.username}</title>
5878<style>
5879${baseStyles}
5880${backNavStyles}
5881
5882body {
5883 color: white;
5884}
5885
5886
5887 .glass-card > * {
5888 position: relative;
5889 z-index: 1;
5890 }
5891
5892
5893 /* =========================================================
5894 GLASS CARDS
5895 ========================================================= */
5896 .glass-card {
5897 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5898 backdrop-filter: blur(22px) saturate(140%);
5899 -webkit-backdrop-filter: blur(22px) saturate(140%);
5900 border-radius: 16px;
5901 padding: 28px;
5902 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
5903 inset 0 1px 0 rgba(255, 255, 255, 0.25);
5904 border: 1px solid rgba(255, 255, 255, 0.28);
5905 margin-bottom: 24px;
5906 animation: fadeInUp 0.6s ease-out both;
5907 position: relative;
5908 overflow: visible;
5909 }
5910
5911 .glass-card::before {
5912 content: "";
5913 position: absolute;
5914 inset: 0;
5915 border-radius: inherit;
5916 background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
5917 pointer-events: none;
5918 }
5919
5920 .glass-card h2 {
5921 font-size: 18px;
5922 font-weight: 600;
5923 color: white;
5924 margin-bottom: 16px;
5925 letter-spacing: -0.3px;
5926 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5927 }
5928
5929
5930 /* =========================================================
5931 ENERGY STATUS
5932 ========================================================= */
5933 .energy-grid {
5934 display: grid;
5935 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
5936 gap: 14px;
5937 }
5938
5939 .energy-stat {
5940 padding: 18px 20px;
5941 background: rgba(255, 255, 255, 0.1);
5942 border: 1px solid rgba(255, 255, 255, 0.2);
5943 border-radius: 14px;
5944 text-align: center;
5945 position: relative;
5946 overflow: hidden;
5947 }
5948
5949 .energy-stat::before {
5950 content: "";
5951 position: absolute;
5952 inset: 0;
5953 border-radius: inherit;
5954 background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent);
5955 pointer-events: none;
5956 }
5957
5958 .energy-stat-label {
5959 font-size: 12px;
5960 font-weight: 600;
5961 text-transform: uppercase;
5962 letter-spacing: 0.5px;
5963 color: rgba(255, 255, 255, 0.6);
5964 margin-bottom: 6px;
5965 }
5966
5967 .energy-stat-value {
5968 font-size: 28px;
5969 font-weight: 700;
5970 color: white;
5971 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5972 }
5973
5974 .energy-stat-sub {
5975 font-size: 12px;
5976 color: rgba(255, 255, 255, 0.5);
5977 margin-top: 4px;
5978 }
5979
5980 .energy-stat.plan-basic {
5981 background: rgba(255, 255, 255, 0.12);
5982 border-color: rgba(255, 255, 255, 0.2);
5983 }
5984
5985 .energy-stat.plan-standard {
5986 background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.2));
5987 border-color: rgba(96, 165, 250, 0.3);
5988 }
5989
5990 .energy-stat.plan-premium {
5991 background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.2));
5992 border-color: rgba(168, 85, 247, 0.3);
5993 }
5994
5995 .energy-stat.plan-god {
5996 background: linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(245, 158, 11, 0.2));
5997 border-color: rgba(250, 204, 21, 0.3);
5998 }
5999
6000 /* =========================================================
6001 PLAN CARDS
6002 ========================================================= */
6003 .plan-grid {
6004 display: grid;
6005 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
6006 gap: 14px;
6007 }
6008
6009 .plan-box {
6010 padding: 24px 20px;
6011 border-radius: 14px;
6012 text-align: center;
6013 cursor: pointer;
6014 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6015 position: relative;
6016 overflow: hidden;
6017 }
6018
6019 .plan-box::before {
6020 content: "";
6021 position: absolute;
6022 inset: 0;
6023 border-radius: inherit;
6024 pointer-events: none;
6025 transition: all 0.3s;
6026 }
6027
6028 .plan-box[data-plan="basic"] {
6029 background: rgba(255, 255, 255, 0.2);
6030 border: 2px solid rgba(255, 255, 255, 0.18);
6031 }
6032 .plan-box[data-plan="basic"]::before {
6033 background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent);
6034 }
6035
6036 .plan-box[data-plan="standard"] {
6037 background: rgba(96, 165, 250, 0.08);
6038 border: 2px solid rgba(96, 165, 250, 0.25);
6039 }
6040 .plan-box[data-plan="standard"]::before {
6041 background: linear-gradient(180deg, rgba(96, 165, 250, 0.1), transparent);
6042 }
6043
6044 .plan-box[data-plan="premium"] {
6045 background: rgba(168, 85, 247, 0.08);
6046 border: 2px solid rgba(168, 85, 247, 0.25);
6047 }
6048 .plan-box[data-plan="premium"]::before {
6049 background: linear-gradient(180deg, rgba(168, 85, 247, 0.1), transparent);
6050 }
6051
6052 .plan-box:hover:not(.disabled) {
6053 transform: translateY(-4px);
6054 box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);
6055 }
6056
6057 .plan-box[data-plan="standard"]:hover:not(.disabled) {
6058 background: rgba(96, 165, 250, 0.16);
6059 border-color: rgba(96, 165, 250, 0.4);
6060 }
6061
6062 .plan-box[data-plan="premium"]:hover:not(.disabled) {
6063 background: rgba(168, 85, 247, 0.16);
6064 border-color: rgba(168, 85, 247, 0.4);
6065 }
6066
6067 .plan-box.selected {
6068 transform: translateY(-4px);
6069 box-shadow: 0 0 0 3px rgba(72, 187, 178, 0.6), 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 30px rgba(72, 187, 178, 0.15);
6070 }
6071
6072 .plan-box[data-plan="standard"].selected {
6073 border-color: rgba(72, 187, 178, 0.9);
6074 background: rgba(96, 165, 250, 0.18);
6075 }
6076
6077 .plan-box[data-plan="premium"].selected {
6078 border-color: rgba(72, 187, 178, 0.9);
6079 background: rgba(168, 85, 247, 0.18);
6080 }
6081
6082 .plan-box.disabled {
6083 opacity: 0.65;
6084 cursor: not-allowed;
6085 }
6086
6087 .plan-box.current-plan {
6088 border-color: rgba(255, 255, 255, 0.6);
6089 border-width: 3px;
6090 box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15), 0 0 20px rgba(255, 255, 255, 0.08);
6091 }
6092
6093 .plan-name {
6094 font-size: 20px;
6095 font-weight: 700;
6096 color: white;
6097 margin-bottom: 6px;
6098 }
6099
6100 .plan-price {
6101 font-size: 24px;
6102 font-weight: 700;
6103 color: white;
6104 margin-bottom: 4px;
6105 }
6106
6107 .plan-period {
6108 font-size: 13px;
6109 color: rgba(255, 255, 255, 0.55);
6110 }
6111
6112 .plan-current-tag {
6113 display: inline-block;
6114 margin-top: 10px;
6115 padding: 4px 12px;
6116 border-radius: 980px;
6117 font-size: 11px;
6118 font-weight: 600;
6119 text-transform: uppercase;
6120 letter-spacing: 0.5px;
6121 background: rgba(255, 255, 255, 0.2);
6122 border: 1px solid rgba(255, 255, 255, 0.25);
6123 }
6124
6125 .plan-features {
6126 margin-top: 14px;
6127 display: flex;
6128 flex-direction: column;
6129 gap: 6px;
6130 }
6131
6132 .plan-feature {
6133 font-size: 13px;
6134 font-weight: 500;
6135 color: rgba(255, 255, 255, 0.75);
6136 }
6137
6138 .plan-feature.dim { color: rgba(255, 255, 255, 0.4); }
6139
6140 .plan-feature.highlight {
6141 color: rgba(72, 187, 178, 0.95);
6142 font-weight: 600;
6143 }
6144
6145 .plan-renew-note {
6146 margin-top: 14px;
6147 text-align: center;
6148 font-size: 13px;
6149 color: rgba(255, 255, 255, 0.55);
6150 font-style: italic;
6151 }
6152
6153 /* =========================================================
6154 ENERGY BUY
6155 ========================================================= */
6156 .energy-btns {
6157 display: flex;
6158 gap: 10px;
6159 flex-wrap: wrap;
6160 }
6161
6162 .energy-buy-btn {
6163 padding: 12px 20px;
6164 border-radius: 980px;
6165 border: 1px solid rgba(255, 255, 255, 0.28);
6166 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
6167 backdrop-filter: blur(22px) saturate(140%);
6168 -webkit-backdrop-filter: blur(22px) saturate(140%);
6169 color: white;
6170 font-weight: 600;
6171 font-size: 14px;
6172 font-family: inherit;
6173 cursor: pointer;
6174 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6175 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6176 inset 0 1px 0 rgba(255, 255, 255, 0.25);
6177 position: relative;
6178 overflow: hidden;
6179 }
6180
6181 .energy-buy-btn::before {
6182 content: "";
6183 position: absolute;
6184 inset: -40%;
6185 background:
6186 radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
6187 linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
6188 opacity: 0;
6189 transform: translateX(-30%) translateY(-10%);
6190 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
6191 pointer-events: none;
6192 }
6193
6194 .energy-buy-btn:hover {
6195 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
6196 transform: translateY(-2px);
6197 }
6198
6199 .energy-buy-btn:hover::before {
6200 opacity: 1;
6201 transform: translateX(30%) translateY(10%);
6202 }
6203
6204 .energy-buy-btn:active {
6205 background: rgba(var(--glass-water-rgb), 0.45);
6206 transform: translateY(0);
6207 }
6208
6209 /* =========================================================
6210 CHECKOUT
6211 ========================================================= */
6212 .checkout-summary {
6213 display: flex;
6214 flex-direction: column;
6215 gap: 10px;
6216 }
6217
6218 .checkout-row {
6219 display: flex;
6220 justify-content: space-between;
6221 align-items: center;
6222 padding: 14px 16px;
6223 background: rgba(255, 255, 255, 0.08);
6224 border: 1px solid rgba(255, 255, 255, 0.15);
6225 border-radius: 12px;
6226 transition: background 0.2s;
6227 }
6228
6229 .checkout-row:hover {
6230 background: rgba(255, 255, 255, 0.12);
6231 }
6232
6233 .checkout-row-left {
6234 display: flex;
6235 align-items: center;
6236 gap: 12px;
6237 flex: 1;
6238 min-width: 0;
6239 }
6240
6241 .checkout-row-icon {
6242 width: 36px;
6243 height: 36px;
6244 border-radius: 10px;
6245 display: flex;
6246 align-items: center;
6247 justify-content: center;
6248 font-size: 16px;
6249 flex-shrink: 0;
6250 }
6251
6252 .checkout-row-icon.plan-icon {
6253 background: rgba(168, 85, 247, 0.2);
6254 border: 1px solid rgba(168, 85, 247, 0.3);
6255 }
6256
6257 .checkout-row-icon.energy-icon {
6258 background: rgba(250, 204, 21, 0.2);
6259 border: 1px solid rgba(250, 204, 21, 0.3);
6260 }
6261
6262 .checkout-row-info {
6263 display: flex;
6264 flex-direction: column;
6265 gap: 2px;
6266 min-width: 0;
6267 }
6268
6269 .checkout-row-label {
6270 font-size: 14px;
6271 font-weight: 600;
6272 color: white;
6273 }
6274
6275 .checkout-row-desc {
6276 font-size: 12px;
6277 color: rgba(255, 255, 255, 0.5);
6278 }
6279
6280 .checkout-row-right {
6281 display: flex;
6282 align-items: center;
6283 gap: 12px;
6284 flex-shrink: 0;
6285 }
6286
6287 .checkout-row-value {
6288 font-size: 16px;
6289 font-weight: 700;
6290 color: white;
6291 }
6292
6293 .checkout-remove {
6294 width: 28px;
6295 height: 28px;
6296 border-radius: 50%;
6297 border: 1px solid rgba(255, 255, 255, 0.2);
6298 background: rgba(239, 68, 68, 0.15);
6299 color: rgba(255, 255, 255, 0.7);
6300 font-size: 14px;
6301 cursor: pointer;
6302 display: inline-flex;
6303 align-items: center;
6304 justify-content: center;
6305 transition: all 0.2s;
6306 line-height: 1;
6307 }
6308
6309 .checkout-remove:hover {
6310 background: rgba(239, 68, 68, 0.35);
6311 border-color: rgba(239, 68, 68, 0.5);
6312 color: white;
6313 }
6314
6315 .checkout-divider {
6316 height: 1px;
6317 background: rgba(255, 255, 255, 0.1);
6318 margin: 4px 0;
6319 }
6320
6321 .checkout-total {
6322 display: flex;
6323 justify-content: space-between;
6324 align-items: center;
6325 padding: 18px 20px;
6326 background: linear-gradient(135deg, rgba(72, 187, 178, 0.2), rgba(56, 163, 155, 0.15));
6327 border: 1px solid rgba(72, 187, 178, 0.35);
6328 border-radius: 14px;
6329 }
6330
6331 .checkout-total-label {
6332 font-size: 16px;
6333 font-weight: 600;
6334 color: white;
6335 }
6336
6337 .checkout-total-value {
6338 font-size: 28px;
6339 font-weight: 700;
6340 color: white;
6341 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
6342 }
6343
6344 .checkout-btn {
6345 width: 100%;
6346 padding: 18px;
6347 border-radius: 980px;
6348 border: 1px solid rgba(72, 187, 178, 0.5);
6349 background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
6350 backdrop-filter: blur(22px) saturate(140%);
6351 -webkit-backdrop-filter: blur(22px) saturate(140%);
6352 color: white;
6353 font-size: 17px;
6354 font-weight: 700;
6355 font-family: inherit;
6356 cursor: pointer;
6357 margin-top: 16px;
6358 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6359 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6360 0 0 20px rgba(72, 187, 178, 0.1),
6361 inset 0 1px 0 rgba(255, 255, 255, 0.2);
6362 position: relative;
6363 overflow: hidden;
6364 letter-spacing: -0.2px;
6365 }
6366
6367 .checkout-btn::before {
6368 content: "";
6369 position: absolute;
6370 inset: -40%;
6371 background:
6372 radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
6373 linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
6374 opacity: 0;
6375 transform: translateX(-30%) translateY(-10%);
6376 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
6377 pointer-events: none;
6378 }
6379
6380 .checkout-btn:hover {
6381 background: linear-gradient(135deg, rgba(72, 187, 178, 0.55), rgba(56, 163, 155, 0.5));
6382 transform: translateY(-2px);
6383 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18),
6384 0 0 30px rgba(72, 187, 178, 0.2);
6385 }
6386
6387 .checkout-btn:hover::before {
6388 opacity: 1;
6389 transform: translateX(30%) translateY(10%);
6390 }
6391
6392 .checkout-btn:active { transform: translateY(0); }
6393
6394 .checkout-btn:disabled {
6395 opacity: 0.4;
6396 cursor: not-allowed;
6397 transform: none;
6398 }
6399
6400 .checkout-btn:disabled:hover {
6401 background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
6402 transform: none;
6403 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
6404 }
6405
6406 .checkout-legal {
6407 text-align: center;
6408 margin-top: 14px;
6409 font-size: 12px;
6410 color: rgba(255, 255, 255, 0.45);
6411 line-height: 1.5;
6412 }
6413
6414 .checkout-legal-link {
6415 color: rgba(255, 255, 255, 0.7);
6416 text-decoration: underline;
6417 text-underline-offset: 2px;
6418 cursor: pointer;
6419 transition: color 0.2s;
6420 }
6421
6422 .checkout-legal-link:hover { color: white; }
6423
6424 .checkout-note {
6425 text-align: center;
6426 margin-top: 10px;
6427 font-size: 13px;
6428 color: rgba(255, 255, 255, 0.45);
6429 font-style: italic;
6430 }
6431
6432 .checkout-empty {
6433 text-align: center;
6434 padding: 28px 20px;
6435 color: rgba(255, 255, 255, 0.4);
6436 font-style: italic;
6437 font-size: 14px;
6438 border: 2px dashed rgba(255, 255, 255, 0.12);
6439 border-radius: 14px;
6440 }
6441
6442 /* =========================================================
6443 LLM SECTION
6444 ========================================================= */
6445 .llm-section-wrapper {
6446 position: relative;
6447 }
6448
6449 .llm-section-wrapper.locked .llm-section-content {
6450 opacity: 0.2;
6451 pointer-events: none;
6452 filter: blur(2px);
6453 }
6454
6455 .llm-upgrade-overlay {
6456 display: none;
6457 position: absolute;
6458 inset: 0;
6459 z-index: 5;
6460 border-radius: inherit;
6461 align-items: center;
6462 justify-content: center;
6463 flex-direction: column;
6464 gap: 8px;
6465 }
6466
6467 .llm-section-wrapper.locked .llm-upgrade-overlay {
6468 display: flex;
6469 }
6470
6471 .llm-upgrade-text {
6472 font-size: 16px;
6473 font-weight: 600;
6474 color: white;
6475 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
6476 }
6477
6478 .llm-upgrade-sub {
6479 font-size: 13px;
6480 color: rgba(255, 255, 255, 0.7);
6481 }
6482
6483 .llm-sub {
6484 font-size: 14px;
6485 color: rgba(255, 255, 255, 0.6);
6486 line-height: 1.5;
6487 margin-bottom: 16px;
6488 }
6489
6490 .llm-toggle-row {
6491 display: flex;
6492 align-items: center;
6493 justify-content: space-between;
6494 gap: 14px;
6495 margin-bottom: 16px;
6496 padding: 14px 16px;
6497 background: rgba(255, 255, 255, 0.06);
6498 border: 1px solid rgba(255, 255, 255, 0.12);
6499 border-radius: 12px;
6500 }
6501
6502 .llm-toggle-label {
6503 font-size: 14px;
6504 font-weight: 600;
6505 color: rgba(255, 255, 255, 0.8);
6506 }
6507
6508 .glass-toggle {
6509 position: relative;
6510 width: 54px;
6511 height: 28px;
6512 border-radius: 999px;
6513 background: rgba(255, 255, 255, 0.2);
6514 border: 1px solid rgba(255, 255, 255, 0.3);
6515 backdrop-filter: blur(18px);
6516 cursor: pointer;
6517 transition: all 0.25s ease;
6518 flex-shrink: 0;
6519 }
6520
6521 .glass-toggle.active {
6522 background: rgba(72, 187, 178, 0.45);
6523 box-shadow: 0 0 16px rgba(72, 187, 178, 0.35);
6524 }
6525
6526 .glass-toggle-knob {
6527 position: absolute;
6528 top: 4px;
6529 left: 4px;
6530 width: 20px;
6531 height: 20px;
6532 border-radius: 50%;
6533 background: white;
6534 transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
6535 }
6536
6537 .glass-toggle.active .glass-toggle-knob {
6538 left: 28px;
6539 }
6540
6541 .llm-connected-badge {
6542 display: flex;
6543 align-items: center;
6544 gap: 10px;
6545 padding: 12px 16px;
6546 background: rgba(72, 187, 120, 0.15);
6547 border: 1px solid rgba(72, 187, 120, 0.3);
6548 border-radius: 10px;
6549 margin-bottom: 16px;
6550 }
6551
6552 .llm-connected-dot {
6553 width: 8px; height: 8px;
6554 border-radius: 50%;
6555 background: rgba(72, 187, 120, 0.9);
6556 box-shadow: 0 0 8px rgba(72, 187, 120, 0.5);
6557 flex-shrink: 0;
6558 }
6559
6560 .llm-connected-text {
6561 font-size: 13px;
6562 font-weight: 600;
6563 color: rgba(72, 187, 120, 0.9);
6564 }
6565
6566 .llm-connected-detail {
6567 font-size: 12px;
6568 color: rgba(255, 255, 255, 0.45);
6569 margin-left: auto;
6570 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
6571 overflow: hidden;
6572 text-overflow: ellipsis;
6573 white-space: nowrap;
6574 max-width: 300px;
6575 }
6576
6577 .llm-fields {
6578 display: flex;
6579 flex-direction: column;
6580 gap: 12px;
6581 transition: opacity 0.3s;
6582 }
6583
6584 .llm-fields.disabled {
6585 opacity: 0.35;
6586 pointer-events: none;
6587 }
6588
6589 .llm-field-row {
6590 display: flex;
6591 flex-direction: column;
6592 gap: 4px;
6593 }
6594
6595 .llm-field-label {
6596 font-size: 12px;
6597 font-weight: 600;
6598 text-transform: uppercase;
6599 letter-spacing: 0.5px;
6600 color: rgba(255, 255, 255, 0.55);
6601 }
6602
6603 .llm-input {
6604 padding: 14px 16px;
6605 font-size: 15px;
6606 border-radius: 12px;
6607 border: 2px solid rgba(255, 255, 255, 0.3);
6608 background: rgba(255, 255, 255, 0.15);
6609 color: white;
6610 font-family: inherit;
6611 font-weight: 500;
6612 transition: all 0.2s;
6613 width: 100%;
6614 }
6615
6616 .llm-input::placeholder { color: rgba(255, 255, 255, 0.35); }
6617
6618 .llm-input:focus {
6619 outline: none;
6620 border-color: rgba(255, 255, 255, 0.6);
6621 background: rgba(255, 255, 255, 0.25);
6622 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
6623 transform: translateY(-2px);
6624 }
6625
6626 /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
6627 .custom-select {
6628 position: relative;
6629 width: 100%;
6630 }
6631 .custom-select-trigger {
6632 padding: 8px 10px;
6633 font-size: 15px;
6634 border-radius: 12px;
6635 border: 2px solid rgba(255, 255, 255, 0.3);
6636 background: rgba(255, 255, 255, 0.15);
6637 color: white;
6638 font-family: inherit;
6639 font-weight: 500;
6640 cursor: pointer;
6641 display: flex;
6642 align-items: center;
6643 justify-content: space-between;
6644 gap: 8px;
6645 transition: border-color 0.2s, background 0.2s;
6646 -webkit-user-select: none;
6647 user-select: none;
6648 }
6649 .custom-select-trigger::after {
6650 content: "▾";
6651 font-size: 12px;
6652 opacity: 0.6;
6653 flex-shrink: 0;
6654 }
6655 .custom-select.open .custom-select-trigger {
6656 border-color: rgba(255, 255, 255, 0.6);
6657 background: rgba(255, 255, 255, 0.25);
6658 }
6659 .custom-select.open .custom-select-trigger::after { content: "▴"; }
6660 .custom-select-options {
6661 display: none;
6662 position: absolute;
6663 left: 0; right: 0;
6664 bottom: calc(100% + 4px);
6665 background: rgba(30, 20, 50, 0.97);
6666 border: 1px solid rgba(255, 255, 255, 0.25);
6667 border-radius: 10px;
6668 overflow: hidden;
6669 z-index: 100;
6670 max-height: 220px;
6671 overflow-y: auto;
6672 backdrop-filter: blur(12px);
6673 -webkit-backdrop-filter: blur(12px);
6674 box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
6675 }
6676 .custom-select.open .custom-select-options { display: block; }
6677 .custom-select-option {
6678 padding: 10px 12px;
6679 font-size: 14px;
6680 color: rgba(255, 255, 255, 0.8);
6681 cursor: pointer;
6682 transition: background 0.15s;
6683 }
6684 .custom-select-option:hover,
6685 .custom-select-option:focus { background: rgba(255, 255, 255, 0.12); }
6686 .custom-select-option.selected {
6687 background: rgba(72, 187, 178, 0.2);
6688 color: white;
6689 font-weight: 600;
6690 }
6691
6692 .llm-btn-row {
6693 display: flex;
6694 gap: 12px;
6695 margin-top: 4px;
6696 }
6697
6698 .llm-save-btn,
6699 .llm-disconnect-btn {
6700 padding: 14px 24px;
6701 border-radius: 980px;
6702 border: 1px solid;
6703 color: white;
6704 font-weight: 600;
6705 font-size: 15px;
6706 font-family: inherit;
6707 cursor: pointer;
6708 transition: all 0.3s;
6709 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6710 inset 0 1px 0 rgba(255, 255, 255, 0.2);
6711 background: none;
6712 }
6713
6714 .llm-save-btn {
6715 flex: 1;
6716 border-color: rgba(72, 187, 178, 0.4);
6717 background: rgba(72, 187, 178, 0.3);
6718 }
6719
6720 .llm-save-btn:hover {
6721 background: rgba(72, 187, 178, 0.45);
6722 transform: translateY(-2px);
6723 }
6724
6725 .llm-disconnect-btn {
6726 border-color: rgba(239, 68, 68, 0.4);
6727 background: rgba(239, 68, 68, 0.25);
6728 }
6729
6730 .llm-disconnect-btn:hover {
6731 background: rgba(239, 68, 68, 0.4);
6732 transform: translateY(-2px);
6733 }
6734
6735 .llm-status {
6736 margin-top: 10px;
6737 font-size: 13px;
6738 font-weight: 600;
6739 display: none;
6740 }
6741
6742 /* =========================================================
6743 MODAL (Terms / Privacy)
6744 ========================================================= */
6745 .modal-overlay {
6746 display: none;
6747 position: fixed;
6748 inset: 0;
6749 z-index: 1000;
6750 background: rgba(0, 0, 0, 0.6);
6751 backdrop-filter: blur(8px);
6752 -webkit-backdrop-filter: blur(8px);
6753 align-items: center;
6754 justify-content: center;
6755 padding: 20px;
6756 }
6757
6758 .modal-overlay.show { display: flex; }
6759
6760 .modal-container {
6761 width: 100%;
6762 max-width: 720px;
6763 height: 85vh;
6764 height: 85dvh;
6765 background: rgba(var(--glass-water-rgb), 0.35);
6766 backdrop-filter: blur(22px) saturate(140%);
6767 -webkit-backdrop-filter: blur(22px) saturate(140%);
6768 border-radius: 20px;
6769 border: 1px solid rgba(255, 255, 255, 0.28);
6770 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
6771 display: flex;
6772 flex-direction: column;
6773 overflow: hidden;
6774 }
6775
6776 .modal-header {
6777 display: flex;
6778 align-items: center;
6779 justify-content: space-between;
6780 padding: 16px 20px;
6781 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
6782 flex-shrink: 0;
6783 }
6784
6785 .modal-title {
6786 font-size: 16px;
6787 font-weight: 600;
6788 color: white;
6789 }
6790
6791 .modal-close {
6792 width: 32px;
6793 height: 32px;
6794 border-radius: 50%;
6795 border: 1px solid rgba(255, 255, 255, 0.25);
6796 background: rgba(255, 255, 255, 0.15);
6797 color: white;
6798 font-size: 18px;
6799 cursor: pointer;
6800 display: inline-flex;
6801 align-items: center;
6802 justify-content: center;
6803 transition: background 0.2s;
6804 line-height: 1;
6805 }
6806
6807 .modal-close:hover {
6808 background: rgba(255, 255, 255, 0.3);
6809 }
6810
6811 .modal-body {
6812 flex: 1;
6813 overflow: hidden;
6814 }
6815
6816 .modal-body iframe {
6817 width: 100%;
6818 height: 100%;
6819 border: none;
6820 }
6821
6822 /* =========================================================
6823 RESPONSIVE
6824 ========================================================= */
6825 @media (max-width: 640px) {
6826 body { padding: 16px; }
6827 .container { max-width: 100%; }
6828 .glass-card { padding: 20px; }
6829
6830 .energy-grid { grid-template-columns: 1fr; }
6831 .plan-grid { grid-template-columns: 1fr; }
6832 .energy-btns { flex-direction: column; }
6833 .energy-buy-btn { width: 100%; }
6834 .llm-btn-row { flex-direction: column; }
6835 .llm-save-btn, .llm-disconnect-btn { width: 100%; text-align: center; }
6836 .llm-connected-detail { max-width: 140px; }
6837 .modal-container { height: 90vh; height: 90dvh; border-radius: 16px; }
6838 .modal-overlay { padding: 10px; }
6839 }
6840</style>
6841</head>
6842<body>
6843<div class="container">
6844
6845 <div class="back-nav">
6846 <a href="/api/v1/user/${userId}${qs}" class="back-link">← Back to Profile</a>
6847 </div>
6848
6849 <!-- Energy Status -->
6850 <div class="glass-card" style="animation-delay: 0.1s;">
6851 <h2>⚡ Energy</h2>
6852 <div class="energy-grid">
6853 <div class="energy-stat">
6854 <div class="energy-stat-label">Plan Energy</div>
6855 <div class="energy-stat-value">${energyAmount}</div>
6856 <div class="energy-stat-sub">Resets every 24 hours</div>
6857 </div>
6858 <div class="energy-stat plan-${profileType}">
6859 <div class="energy-stat-label">Current Plan</div>
6860 <div class="energy-stat-value" style="font-size: 22px; text-transform: capitalize;">${profileType}</div>
6861 ${!isBasic && planExpiresAt ? '<div class="energy-stat-sub">Expires ' + new Date(planExpiresAt).toLocaleDateString() + "</div>" : ""}
6862 </div>
6863 <div class="energy-stat">
6864 <div class="energy-stat-label">Additional Energy</div>
6865 <div class="energy-stat-value">${additionalEnergy}</div>
6866 <div class="energy-stat-sub">Used after plan energy</div>
6867 </div>
6868 </div>
6869 </div>
6870
6871 <!-- Plans -->
6872 <div class="glass-card" style="animation-delay: 0.15s;">
6873 <h2>📋 Plans</h2>
6874 <div class="plan-grid">
6875 <div class="plan-box disabled" data-plan="basic">
6876 <div class="plan-name">Basic</div>
6877 <div class="plan-price">Free</div>
6878 <div class="plan-period">350 daily energy</div>
6879 <div class="plan-features">
6880 <div class="plan-feature">No file uploads</div>
6881 <div class="plan-feature dim">Limited access</div>
6882 </div>
6883 ${profileType === "basic" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6884 </div>
6885 <div class="plan-box" data-plan="standard">
6886 <div class="plan-name">Standard</div>
6887 <div class="plan-price">$20</div>
6888 <div class="plan-period">per 30 days</div>
6889 <div class="plan-features">
6890 <div class="plan-feature">1,500 daily energy</div>
6891 <div class="plan-feature">File uploads</div>
6892 </div>
6893 ${profileType === "standard" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6894 </div>
6895 <div class="plan-box" data-plan="premium">
6896 <div class="plan-name">Premium</div>
6897 <div class="plan-price">$100</div>
6898 <div class="plan-period">per 30 days</div>
6899 <div class="plan-features">
6900 <div class="plan-feature">8,000 daily energy</div>
6901 <div class="plan-feature">Full access</div>
6902 <div class="plan-feature highlight">Offline LLM processing</div>
6903 </div>
6904 ${profileType === "premium" || profileType === "god" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6905 </div>
6906 </div>
6907 <div class="plan-renew-note" id="planNote" style="display:none;"></div>
6908 </div>
6909
6910 <!-- Buy Energy -->
6911 <div class="glass-card" style="animation-delay: 0.2s;">
6912 <h2>🔥 Additional Energy</h2>
6913 <div style="font-size: 14px; color: rgba(255,255,255,0.55); margin-bottom: 16px;">Reserve energy — only used when your plan energy runs out.</div>
6914 <div class="energy-btns" id="energyBtns">
6915 <button class="energy-buy-btn" data-amount="100">+100</button>
6916 <button class="energy-buy-btn" data-amount="500">+500</button>
6917 <button class="energy-buy-btn" data-amount="1000">+1000</button>
6918 <button class="energy-buy-btn" id="customEnergyBtn">+Custom</button>
6919 </div>
6920 <div id="energyAdded" style="margin-top: 14px; font-size: 14px; color: rgba(255,255,255,0.6); display: none;">
6921 Added: <strong id="energyAddedVal" style="color: white;"></strong>
6922 <span style="margin-left: 8px; cursor: pointer; opacity: 0.6;" onclick="resetEnergy()">✕ Clear</span>
6923 </div>
6924 </div>
6925
6926 <!-- Checkout -->
6927 <div class="glass-card" style="animation-delay: 0.25s;">
6928 <h2>💳 Checkout</h2>
6929 <div id="checkoutContent">
6930 <div class="checkout-empty">Select a plan or add energy to continue</div>
6931 </div>
6932 </div>
6933
6934 <!-- Custom LLM -->
6935 <div class="glass-card" style="animation-delay: 0.3s;">
6936 <h2>🤖 Custom LLM Endpoints <span style="opacity:0.5;font-size:0.7em">(${connectionCount}/15)</span></h2>
6937 <div class="llm-section-wrapper">
6938 <div class="llm-section-content">
6939 <div class="llm-sub">Connect your own OpenAI API-compatible LLMs. Assign them to different areas below.</div>
6940
6941 ${
6942 connectionCount > 0
6943 ? '<div style="display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap;">' +
6944 '<div style="flex:1;min-width:180px;">' +
6945 '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Profile (Chat)</label>' +
6946 '<div class="custom-select" id="llmAssignMain" data-slot="main">' +
6947 '<div class="custom-select-trigger">' +
6948 (mainAssignment
6949 ? llmConnections
6950 .filter(function (c) {
6951 return c._id === mainAssignment;
6952 })
6953 .map(function (c) {
6954 return c.name + " (" + c.model + ")";
6955 })[0] || "None selected"
6956 : "None selected") +
6957 "</div>" +
6958 '<div class="custom-select-options">' +
6959 '<div class="custom-select-option' +
6960 (!mainAssignment ? " selected" : "") +
6961 '" data-value="">None</div>' +
6962 llmConnections
6963 .map(function (c) {
6964 return (
6965 '<div class="custom-select-option' +
6966 (mainAssignment === c._id ? " selected" : "") +
6967 '" data-value="' +
6968 c._id +
6969 '">' +
6970 c.name +
6971 " (" +
6972 c.model +
6973 ")</div>"
6974 );
6975 })
6976 .join("") +
6977 "</div>" +
6978 "</div>" +
6979 "</div>" +
6980 '<div style="flex:1;min-width:180px;">' +
6981 '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Raw Ideas</label>' +
6982 '<div class="custom-select" id="llmAssignRawIdea" data-slot="rawIdea">' +
6983 '<div class="custom-select-trigger">' +
6984 (rawIdeaAssignment
6985 ? llmConnections
6986 .filter(function (c) {
6987 return c._id === rawIdeaAssignment;
6988 })
6989 .map(function (c) {
6990 return c.name + " (" + c.model + ")";
6991 })[0] || "Uses main"
6992 : "Uses main") +
6993 "</div>" +
6994 '<div class="custom-select-options">' +
6995 '<div class="custom-select-option' +
6996 (!rawIdeaAssignment ? " selected" : "") +
6997 '" data-value="">Uses main</div>' +
6998 llmConnections
6999 .map(function (c) {
7000 return (
7001 '<div class="custom-select-option' +
7002 (rawIdeaAssignment === c._id ? " selected" : "") +
7003 '" data-value="' +
7004 c._id +
7005 '">' +
7006 c.name +
7007 " (" +
7008 c.model +
7009 ")</div>"
7010 );
7011 })
7012 .join("") +
7013 "</div>" +
7014 "</div>" +
7015 "</div>" +
7016 "</div>"
7017 : ""
7018 }
7019
7020 <div id="llmConnectionsList">
7021 ${
7022 connectionCount === 0
7023 ? '<div class="llm-empty-state" style="text-align:center;padding:18px 0;opacity:0.5;">No connections yet</div>'
7024 : llmConnections
7025 .map(function (c) {
7026 return (
7027 '<div class="llm-conn-card" data-id="' +
7028 c._id +
7029 '" style="border:1px solid var(--glass-border-light);border-radius:10px;padding:12px 14px;margin-bottom:8px;background:rgba(255,255,255,0.03);">' +
7030 '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
7031 '<div style="flex:1;min-width:0;">' +
7032 '<div style="font-weight:600;font-size:0.95em;">' +
7033 c.name +
7034 "</div>" +
7035 '<div style="font-size:0.8em;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
7036 c.model +
7037 " · " +
7038 c.baseUrl +
7039 "</div>" +
7040 "</div>" +
7041 '<div style="display:flex;gap:6px;flex-shrink:0;">' +
7042 '<button class="llm-save-btn" style="font-size:0.75em;padding:4px 10px;" onclick="editConnection(\'' +
7043 c._id +
7044 "')\">Edit</button>" +
7045 '<button class="llm-disconnect-btn" style="font-size:0.75em;padding:4px 10px;" onclick="deleteConnection(\'' +
7046 c._id +
7047 "')\">Delete</button>" +
7048 "</div>" +
7049 "</div>" +
7050 "</div>"
7051 );
7052 })
7053 .join("")
7054 }
7055 </div>
7056
7057 <div id="llmAddSection" style="margin-top:12px;">
7058 <button class="llm-save-btn" id="llmAddToggle" onclick="toggleAddForm()" style="width:100%;">+ Add Connection</button>
7059 <div class="llm-fields" id="llmAddForm" style="display:none;margin-top:10px;">
7060 <div class="llm-field-row">
7061 <label class="llm-field-label">Name</label>
7062 <input type="text" class="llm-input" id="llmName" placeholder="e.g. Groq, OpenRouter" />
7063 </div>
7064 <div class="llm-field-row">
7065 <label class="llm-field-label">Endpoint URL</label>
7066 <input type="text" class="llm-input" id="llmBaseUrl" placeholder="https://api.groq.com/openai/v1/chat/completions" />
7067 </div>
7068 <div class="llm-field-row">
7069 <label class="llm-field-label">API Key</label>
7070 <input type="password" class="llm-input" id="llmApiKey" placeholder="gsk_abc123..." />
7071 </div>
7072 <div class="llm-field-row">
7073 <label class="llm-field-label">Model</label>
7074 <input type="text" class="llm-input" id="llmModel" placeholder="openai/gpt-oss-120b" />
7075 </div>
7076 <div class="llm-btn-row">
7077 <button class="llm-save-btn" onclick="addConnection()">Save Connection</button>
7078 </div>
7079 </div>
7080 </div>
7081
7082 <div id="llmEditSection" style="display:none;margin-top:12px;">
7083 <div style="font-weight:600;margin-bottom:8px;">Edit Connection</div>
7084 <input type="hidden" id="llmEditId" />
7085 <div class="llm-fields">
7086 <div class="llm-field-row">
7087 <label class="llm-field-label">Name</label>
7088 <input type="text" class="llm-input" id="llmEditName" />
7089 </div>
7090 <div class="llm-field-row">
7091 <label class="llm-field-label">Endpoint URL</label>
7092 <input type="text" class="llm-input" id="llmEditBaseUrl" />
7093 </div>
7094 <div class="llm-field-row">
7095 <label class="llm-field-label">API Key</label>
7096 <input type="password" class="llm-input" id="llmEditApiKey" placeholder="•••••••• (leave blank to keep)" />
7097 </div>
7098 <div class="llm-field-row">
7099 <label class="llm-field-label">Model</label>
7100 <input type="text" class="llm-input" id="llmEditModel" />
7101 </div>
7102 <div class="llm-btn-row">
7103 <button class="llm-save-btn" onclick="updateConnection()">Update</button>
7104 <button class="llm-disconnect-btn" onclick="cancelEdit()">Cancel</button>
7105 </div>
7106 </div>
7107 </div>
7108
7109 <div class="llm-status" id="llmStatus"></div>
7110
7111 <!-- Failover Stack -->
7112 <div style="margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);">
7113 <div style="font-weight:600;margin-bottom:8px;">Failover Stack</div>
7114 <div class="llm-sub" style="margin-bottom:10px;">If your default LLM fails (rate limit, timeout), the system tries these backups in order.</div>
7115 <div id="failoverStack" style="min-height:30px;">
7116 <div style="opacity:0.4;font-size:0.85rem;">Loading...</div>
7117 </div>
7118 <div style="margin-top:8px;display:flex;gap:8px;align-items:center;">
7119 <select id="failoverSelect" class="llm-input" style="flex:1;">
7120 <option value="">Select a backup connection...</option>
7121 ${llmConnections.map(c => '<option value="' + c._id + '">' + c.name + ' (' + c.model + ')</option>').join("")}
7122 </select>
7123 <button class="llm-save-btn" onclick="pushFailover()" style="white-space:nowrap;">Add Backup</button>
7124 </div>
7125 </div>
7126
7127 </div>
7128 </div>
7129 </div>
7130
7131</div>
7132
7133<script>
7134function loadFailoverStack() {
7135 fetch("/api/v1/user/${userId}/llm-failover${qs}", { headers: { "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") } })
7136 .then(r => r.json())
7137 .then(data => {
7138 const el = document.getElementById("failoverStack");
7139 const stack = data.stack || [];
7140 if (stack.length === 0) {
7141 el.innerHTML = '<div style="opacity:0.4;font-size:0.85rem;">No backups configured. Add connections above to build your failover stack.</div>';
7142 return;
7143 }
7144 const conns = ${JSON.stringify(llmConnections.map(c => ({ id: c._id, name: c.name, model: c.model })))};
7145 el.innerHTML = stack.map((id, i) => {
7146 const c = conns.find(x => x.id === id);
7147 const label = c ? c.name + " (" + c.model + ")" : id;
7148 return '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
7149 '<span style="opacity:0.4;font-size:0.8rem;width:20px;">' + (i+1) + '.</span>' +
7150 '<span style="flex:1;">' + label + '</span>' +
7151 '<button onclick="removeFailover(\\''+id+'\\','+i+')" style="background:none;border:none;color:rgba(255,100,100,0.7);cursor:pointer;font-size:0.8rem;">remove</button>' +
7152 '</div>';
7153 }).join("");
7154 })
7155 .catch(() => {
7156 document.getElementById("failoverStack").innerHTML = '<div style="color:rgba(255,100,100,0.7);">Failed to load</div>';
7157 });
7158}
7159
7160function pushFailover() {
7161 const select = document.getElementById("failoverSelect");
7162 const connectionId = select.value;
7163 if (!connectionId) return;
7164 fetch("/api/v1/user/${userId}/llm-failover${qs}", {
7165 method: "POST",
7166 headers: { "Content-Type": "application/json", "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") },
7167 body: JSON.stringify({ connectionId })
7168 })
7169 .then(r => r.json())
7170 .then(data => {
7171 if (data.error) return alert(data.error);
7172 select.value = "";
7173 loadFailoverStack();
7174 });
7175}
7176
7177function removeFailover(connId, index) {
7178 fetch("/api/v1/user/${userId}/llm-failover/" + encodeURIComponent(connId) + "${qs}", {
7179 method: "DELETE",
7180 headers: { "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") },
7181 })
7182 .then(r => r.json())
7183 .then(data => {
7184 if (data.error) return alert(data.error);
7185 loadFailoverStack();
7186 });
7187}
7188
7189loadFailoverStack();
7190</script>
7191
7192<!-- Terms Modal -->
7193<div class="modal-overlay" id="termsModal">
7194 <div class="modal-container">
7195 <div class="modal-header">
7196 <span class="modal-title">Terms of Service</span>
7197 <span class="modal-close" onclick="closeModal('terms')">✕</span>
7198 </div>
7199 <div class="modal-body">
7200 <iframe src="/terms" title="Terms of Service"></iframe>
7201 </div>
7202 </div>
7203</div>
7204
7205<!-- Privacy Modal -->
7206<div class="modal-overlay" id="privacyModal">
7207 <div class="modal-container">
7208 <div class="modal-header">
7209 <span class="modal-title">Privacy Policy</span>
7210 <span class="modal-close" onclick="closeModal('privacy')">✕</span>
7211 </div>
7212 <div class="modal-body">
7213 <iframe src="/privacy" title="Privacy Policy"></iframe>
7214 </div>
7215 </div>
7216</div>
7217
7218<script>
7219var userId = "${userId}";
7220var currentPlan = "${profileType === "god" ? "premium" : profileType}";
7221var PLAN_PRICE = { basic: 0, standard: 20, premium: 100 };
7222var PLAN_ORDER = ["basic", "standard", "premium"];
7223var ENERGY_RATE = 0.01;
7224
7225var state = {
7226 energyAdded: 0,
7227 selectedPlan: null
7228};
7229
7230// =====================
7231// MODAL
7232// =====================
7233function openModal(type) {
7234 var id = type === "terms" ? "termsModal" : "privacyModal";
7235 document.getElementById(id).classList.add("show");
7236 document.body.style.overflow = "hidden";
7237}
7238
7239function closeModal(type) {
7240 var id = type === "terms" ? "termsModal" : "privacyModal";
7241 document.getElementById(id).classList.remove("show");
7242 document.body.style.overflow = "";
7243}
7244
7245document.querySelectorAll(".modal-overlay").forEach(function(overlay) {
7246 overlay.addEventListener("click", function(e) {
7247 if (e.target === overlay) {
7248 overlay.classList.remove("show");
7249 document.body.style.overflow = "";
7250 }
7251 });
7252});
7253
7254document.addEventListener("keydown", function(e) {
7255 if (e.key === "Escape") {
7256 document.querySelectorAll(".modal-overlay.show").forEach(function(m) {
7257 m.classList.remove("show");
7258 });
7259 document.body.style.overflow = "";
7260 }
7261});
7262
7263// =====================
7264// URL STATE
7265// =====================
7266function readURL() {
7267 var p = new URLSearchParams(location.search);
7268 if (p.get("energy")) state.energyAdded = parseInt(p.get("energy")) || 0;
7269 if (p.get("plan") && p.get("plan") !== currentPlan) {
7270 state.selectedPlan = p.get("plan");
7271 }
7272}
7273
7274function writeURL() {
7275 var p = new URLSearchParams(location.search);
7276 p.delete("energy");
7277 p.delete("plan");
7278 if (!p.has("html")) p.set("html", "");
7279 if (state.energyAdded > 0) p.set("energy", state.energyAdded);
7280 if (state.selectedPlan) p.set("plan", state.selectedPlan);
7281 history.replaceState(null, "", "?" + p.toString());
7282}
7283
7284// =====================
7285// PLAN LOGIC
7286// =====================
7287function canSelectPlan(plan) {
7288 if (plan === "basic") return false;
7289 var cur = PLAN_ORDER.indexOf(currentPlan);
7290 var next = PLAN_ORDER.indexOf(plan);
7291 return next >= cur;
7292}
7293
7294function renderPlans() {
7295 document.querySelectorAll(".plan-box").forEach(function(box) {
7296 var plan = box.dataset.plan;
7297 var isSelected = state.selectedPlan === plan;
7298 var isCurrent = plan === currentPlan && !state.selectedPlan;
7299
7300 box.classList.toggle("selected", isSelected);
7301 box.classList.toggle("current-plan", isCurrent);
7302 box.classList.toggle("disabled", !canSelectPlan(plan));
7303 });
7304
7305 var note = document.getElementById("planNote");
7306 if (state.selectedPlan) {
7307 if (state.selectedPlan === currentPlan) {
7308 note.textContent = "Renewing " + state.selectedPlan + " for 30 more days";
7309 } else {
7310 note.textContent = "Upgrading to " + state.selectedPlan + " for 30 days";
7311 }
7312 note.style.display = "block";
7313 } else {
7314 note.style.display = "none";
7315 }
7316}
7317
7318// =====================
7319// ENERGY
7320// =====================
7321function renderEnergy() {
7322 var el = document.getElementById("energyAdded");
7323 var val = document.getElementById("energyAddedVal");
7324 if (state.energyAdded > 0) {
7325 el.style.display = "block";
7326 val.textContent = "+" + state.energyAdded + " ($" + (state.energyAdded * ENERGY_RATE).toFixed(2) + ")";
7327 } else {
7328 el.style.display = "none";
7329 }
7330}
7331
7332function resetEnergy() {
7333 state.energyAdded = 0;
7334 writeURL();
7335 renderEnergy();
7336 renderCheckout();
7337}
7338
7339function removePlan() {
7340 state.selectedPlan = null;
7341 writeURL();
7342 renderPlans();
7343 renderCheckout();
7344}
7345
7346// =====================
7347// CHECKOUT
7348// =====================
7349function renderCheckout() {
7350 var container = document.getElementById("checkoutContent");
7351 var energyCost = state.energyAdded * ENERGY_RATE;
7352 var planCost = state.selectedPlan ? (PLAN_PRICE[state.selectedPlan] || 0) : 0;
7353 var total = energyCost + planCost;
7354
7355 if (total <= 0) {
7356 container.innerHTML = '<div class="checkout-empty">Select a plan or add energy to continue</div>';
7357 return;
7358 }
7359
7360 var rows = "";
7361
7362 if (state.selectedPlan) {
7363 var label = state.selectedPlan === currentPlan
7364 ? "Renew " + state.selectedPlan
7365 : "Upgrade to " + state.selectedPlan;
7366 var desc = state.selectedPlan === currentPlan
7367 ? "+30 days added to remaining time"
7368 : "30-day plan starts immediately";
7369
7370 rows +=
7371 '<div class="checkout-row">' +
7372 '<div class="checkout-row-left">' +
7373 '<div class="checkout-row-icon plan-icon">📋</div>' +
7374 '<div class="checkout-row-info">' +
7375 '<div class="checkout-row-label">' + label + '</div>' +
7376 '<div class="checkout-row-desc">' + desc + '</div>' +
7377 '</div>' +
7378 '</div>' +
7379 '<div class="checkout-row-right">' +
7380 '<div class="checkout-row-value">$' + planCost.toFixed(2) + '</div>' +
7381 '<span class="checkout-remove" onclick="removePlan()">✕</span>' +
7382 '</div>' +
7383 '</div>';
7384 }
7385
7386 if (state.energyAdded > 0) {
7387 rows +=
7388 '<div class="checkout-row">' +
7389 '<div class="checkout-row-left">' +
7390 '<div class="checkout-row-icon energy-icon">🔥</div>' +
7391 '<div class="checkout-row-info">' +
7392 '<div class="checkout-row-label">+' + state.energyAdded + ' Additional Energy</div>' +
7393 '<div class="checkout-row-desc">Reserve — used after plan energy</div>' +
7394 '</div>' +
7395 '</div>' +
7396 '<div class="checkout-row-right">' +
7397 '<div class="checkout-row-value">$' + energyCost.toFixed(2) + '</div>' +
7398 '<span class="checkout-remove" onclick="resetEnergy()">✕</span>' +
7399 '</div>' +
7400 '</div>';
7401 }
7402
7403 container.innerHTML =
7404 '<div class="checkout-summary">' +
7405 rows +
7406 '<div class="checkout-divider"></div>' +
7407 '<div class="checkout-total">' +
7408 '<div class="checkout-total-label">Total</div>' +
7409 '<div class="checkout-total-value">$' + total.toFixed(2) + '</div>' +
7410 '</div>' +
7411 '</div>' +
7412 '<button class="checkout-btn" onclick="handleCheckout()">Pay with Stripe</button>' +
7413 '<div class="checkout-legal">' +
7414 'By purchasing, you agree to our ' +
7415 '<span class="checkout-legal-link" onclick="openModal('terms')">Terms of Service</span>' +
7416 ' and ' +
7417 '<span class="checkout-legal-link" onclick="openModal('privacy')">Privacy Policy</span>.' +
7418 '</div>' +
7419 '<div class="checkout-note">No recurring charges · No refunds · Renew manually</div>';
7420}
7421
7422// =====================
7423// STRIPE CHECKOUT
7424// =====================
7425async function handleCheckout() {
7426 var btn = document.querySelector(".checkout-btn");
7427 btn.disabled = true;
7428 btn.textContent = "Processing…";
7429
7430 try {
7431 var body = {
7432 userId: userId,
7433 energyAmount: state.energyAdded > 0 ? state.energyAdded : 0,
7434 plan: state.selectedPlan || null,
7435 currentPlan: currentPlan,
7436 };
7437
7438 var res = await fetch("/api/v1/user/" + userId + "/purchase", {
7439 method: "POST",
7440 headers: { "Content-Type": "application/json" },
7441 body: JSON.stringify(body),
7442 });
7443
7444 var data = await res.json();
7445
7446 if (data.url) {
7447 if (window.top !== window.self) {
7448 window.top.location.href = data.url;
7449 } else {
7450 window.location.href = data.url;
7451 }
7452 } else if (data.error) {
7453 alert(data.error);
7454 btn.disabled = false;
7455 btn.textContent = "Pay with Stripe";
7456 }
7457 } catch (err) {
7458 alert("Something went wrong. Please try again.");
7459 btn.disabled = false;
7460 btn.textContent = "Pay with Stripe";
7461 }
7462}
7463
7464// =====================
7465// CUSTOM LLM
7466// =====================
7467var llmConnections = ${JSON.stringify(llmConnections)};
7468
7469function showLlmStatus(msg, ok) {
7470 var el = document.getElementById("llmStatus");
7471 el.style.display = "block";
7472 el.textContent = msg;
7473 el.style.color = ok ? "rgba(72, 187, 120, 0.9)" : "rgba(255, 107, 107, 0.9)";
7474 if (ok) setTimeout(function() { el.style.display = "none"; }, 3000);
7475}
7476
7477function toggleAddForm() {
7478 var form = document.getElementById("llmAddForm");
7479 form.style.display = form.style.display === "none" ? "block" : "none";
7480 document.getElementById("llmEditSection").style.display = "none";
7481}
7482
7483async function addConnection() {
7484 var name = document.getElementById("llmName").value.trim();
7485 var baseUrl = document.getElementById("llmBaseUrl").value.trim();
7486 var apiKey = document.getElementById("llmApiKey").value.trim();
7487 var model = document.getElementById("llmModel").value.trim();
7488
7489 if (!name || !baseUrl || !apiKey || !model) {
7490 showLlmStatus("All fields are required", false);
7491 return;
7492 }
7493
7494 try {
7495 var res = await fetch("/api/v1/user/" + userId + "/custom-llm", {
7496 method: "POST",
7497 headers: { "Content-Type": "application/json" },
7498 body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
7499 });
7500 if (res.ok) {
7501 showLlmStatus("✓ Connection added", true);
7502 setTimeout(function() { location.reload(); }, 1000);
7503 } else {
7504 var data = await res.json().catch(function() { return {}; });
7505 showLlmStatus("✕ " + (data.error || "Failed to save"), false);
7506 }
7507 } catch (err) {
7508 showLlmStatus("✕ Network error", false);
7509 }
7510}
7511
7512function editConnection(connId) {
7513 var conn = llmConnections.find(function(c) { return c._id === connId; });
7514 if (!conn) return;
7515 document.getElementById("llmEditId").value = connId;
7516 document.getElementById("llmEditName").value = conn.name;
7517 document.getElementById("llmEditBaseUrl").value = conn.baseUrl;
7518 document.getElementById("llmEditApiKey").value = "";
7519 document.getElementById("llmEditModel").value = conn.model;
7520 document.getElementById("llmEditSection").style.display = "block";
7521 document.getElementById("llmAddForm").style.display = "none";
7522}
7523
7524function cancelEdit() {
7525 document.getElementById("llmEditSection").style.display = "none";
7526}
7527
7528async function updateConnection() {
7529 var connId = document.getElementById("llmEditId").value;
7530 var name = document.getElementById("llmEditName").value.trim();
7531 var baseUrl = document.getElementById("llmEditBaseUrl").value.trim();
7532 var apiKey = document.getElementById("llmEditApiKey").value.trim();
7533 var model = document.getElementById("llmEditModel").value.trim();
7534
7535 if (!baseUrl || !model) {
7536 showLlmStatus("Endpoint URL and Model are required", false);
7537 return;
7538 }
7539
7540 var payload = { baseUrl: baseUrl, model: model };
7541 if (name) payload.name = name;
7542 if (apiKey) payload.apiKey = apiKey;
7543
7544 try {
7545 var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
7546 method: "PUT",
7547 headers: { "Content-Type": "application/json" },
7548 body: JSON.stringify(payload),
7549 });
7550 if (res.ok) {
7551 showLlmStatus("✓ Connection updated", true);
7552 setTimeout(function() { location.reload(); }, 1000);
7553 } else {
7554 var data = await res.json().catch(function() { return {}; });
7555 showLlmStatus("✕ " + (data.error || "Failed to update"), false);
7556 }
7557 } catch (err) {
7558 showLlmStatus("✕ Network error", false);
7559 }
7560}
7561
7562async function deleteConnection(connId) {
7563 if (!confirm("Delete this connection? This cannot be undone.")) return;
7564 try {
7565 var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
7566 method: "DELETE",
7567 });
7568 if (res.ok) {
7569 showLlmStatus("✓ Deleted", true);
7570 setTimeout(function() { location.reload(); }, 1000);
7571 } else {
7572 showLlmStatus("✕ Failed to delete", false);
7573 }
7574 } catch (err) {
7575 showLlmStatus("✕ Network error", false);
7576 }
7577}
7578
7579async function assignSlot(slot, connId) {
7580 try {
7581 var res = await fetch("/api/v1/user/" + userId + "/llm-assign", {
7582 method: "POST",
7583 headers: { "Content-Type": "application/json" },
7584 body: JSON.stringify({ slot: slot, connectionId: connId || null }),
7585 });
7586 if (res.ok) {
7587 var label = slot === "main" ? "Chat" : "Raw Ideas";
7588 showLlmStatus(connId ? "✓ Assigned to " + label : "✓ " + label + " → default LLM", true);
7589 } else {
7590 showLlmStatus("✕ Failed to assign", false);
7591 }
7592 } catch (err) {
7593 showLlmStatus("✕ Network error", false);
7594 }
7595}
7596
7597// =====================
7598// CUSTOM DROPDOWNS
7599// =====================
7600(function() {
7601 document.querySelectorAll(".custom-select").forEach(function(sel) {
7602 var trigger = sel.querySelector(".custom-select-trigger");
7603 trigger.addEventListener("click", function(e) {
7604 e.stopPropagation();
7605 var wasOpen = sel.classList.contains("open");
7606 // close all others
7607 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
7608 if (!wasOpen) sel.classList.add("open");
7609 });
7610 sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
7611 opt.addEventListener("click", function(e) {
7612 e.stopPropagation();
7613 sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
7614 opt.classList.add("selected");
7615 trigger.textContent = opt.textContent;
7616 sel.classList.remove("open");
7617 var val = opt.getAttribute("data-value");
7618 var slot = sel.getAttribute("data-slot");
7619 if (slot) assignSlot(slot, val);
7620 });
7621 });
7622 });
7623 document.addEventListener("click", function() {
7624 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
7625 });
7626})();
7627
7628// =====================
7629// EVENTS
7630// =====================
7631document.querySelectorAll(".plan-box").forEach(function(box) {
7632 box.onclick = function() {
7633 var plan = box.dataset.plan;
7634 if (!canSelectPlan(plan)) return;
7635
7636 if (state.selectedPlan === plan) {
7637 state.selectedPlan = null;
7638 } else {
7639 state.selectedPlan = plan;
7640 }
7641
7642 writeURL();
7643 renderPlans();
7644 renderCheckout();
7645 };
7646});
7647
7648document.querySelectorAll(".energy-buy-btn:not(#customEnergyBtn)").forEach(function(btn) {
7649 btn.onclick = function() {
7650 state.energyAdded += parseInt(btn.dataset.amount);
7651 writeURL();
7652 renderEnergy();
7653 renderCheckout();
7654 };
7655});
7656
7657document.getElementById("customEnergyBtn").onclick = function() {
7658 var val = parseInt(prompt("Enter energy amount:"));
7659 if (!val || val <= 0) return;
7660 state.energyAdded += val;
7661 writeURL();
7662 renderEnergy();
7663 renderCheckout();
7664};
7665
7666// =====================
7667// INIT
7668// =====================
7669readURL();
7670renderPlans();
7671renderEnergy();
7672renderCheckout();
7673</script>
7674</body>
7675</html>`);
7676}
7677
7678// ═══════════════════════════════════════════════════════════════════
7679// 14. AI Chats Page - GET /user/:userId/chats
7680// ═══════════════════════════════════════════════════════════════════
7681export function renderChats({ userId, chats, sessions, username, token, sessionId }) {
7682 const tokenQS = token ? `?token=${token}&html` : `?html`;
7683
7684 const linkifyNodeIds = (html) =>
7685 html.replace(
7686 /Placed on node ([0-9a-f-]{36})/g,
7687 (_, id) =>
7688 `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${token}&html` : "?html"}">${id}</a>`,
7689 );
7690
7691 const formatContent = (str) => {
7692 if (!str) return "";
7693 const s = String(str).trim();
7694 if (
7695 (s.startsWith("{") && s.endsWith("}")) ||
7696 (s.startsWith("[") && s.endsWith("]"))
7697 ) {
7698 try {
7699 const parsed = JSON.parse(s);
7700 const pretty = JSON.stringify(parsed, null, 2);
7701 return `<span class="chain-json">${esc(pretty)}</span>`;
7702 } catch (_) {}
7703 }
7704 return esc(s);
7705 };
7706
7707 // ── Tree context helpers ───────────────────────────────
7708
7709 const renderTreeContext = (tc) => {
7710 if (!tc) return "";
7711 const parts = [];
7712
7713 const nodeId = tc.targetNodeId?._id || tc.targetNodeId;
7714 const nodeName = tc.targetNodeId?.name || tc.targetNodeName;
7715 if (nodeId && nodeName && typeof nodeId === "string") {
7716 parts.push(
7717 `<a href="/api/v1/node/${nodeId}${tokenQS}" class="tree-target-link">🎯 ${esc(nodeName)}</a>`,
7718 );
7719 } else if (nodeName) {
7720 parts.push(`<span class="tree-target-name">🎯 ${esc(nodeName)}</span>`);
7721 } else if (tc.targetPath) {
7722 const pathParts = tc.targetPath.split(" / ");
7723 const last = pathParts[pathParts.length - 1];
7724 parts.push(`<span class="tree-target-name">🎯 ${esc(last)}</span>`);
7725 }
7726
7727 if (tc.planStepIndex != null && tc.planTotalSteps != null) {
7728 parts.push(
7729 `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
7730 );
7731 }
7732
7733 if (tc.stepResult) {
7734 const resultClasses = {
7735 success: "badge-done",
7736 failed: "badge-stopped",
7737 skipped: "badge-skipped",
7738 pending: "badge-pending",
7739 };
7740 const resultIcons = {
7741 success: "✓",
7742 failed: "✗",
7743 skipped: "⊘",
7744 pending: "⏳",
7745 };
7746 parts.push(
7747 `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
7748 );
7749 }
7750
7751 if (parts.length === 0) return "";
7752 return `<div class="tree-context-bar">${parts.join("")}</div>`;
7753 };
7754
7755 const renderDirective = (tc) => {
7756 if (!tc?.directive) return "";
7757 return `<div class="tree-directive">${esc(tc.directive)}</div>`;
7758 };
7759
7760 const getTargetName = (tc) => {
7761 if (!tc) return null;
7762 return tc.targetNodeId?.name || tc.targetNodeName || null;
7763 };
7764
7765 const sessionGroups = sessions;
7766
7767 // ── Phase grouping ─────────────────────────────────────
7768
7769 const groupStepsIntoPhases = (steps) => {
7770 const phases = [];
7771 let currentPlan = null;
7772 for (const step of steps) {
7773 const mode = step.aiContext?.path || "";
7774 if (mode === "translator") {
7775 currentPlan = null;
7776 phases.push({ type: "translate", step });
7777 } else if (mode.startsWith("tree:orchestrator:plan:")) {
7778 currentPlan = { type: "plan", marker: step, substeps: [] };
7779 phases.push(currentPlan);
7780 } else if (mode === "tree:respond") {
7781 currentPlan = null;
7782 phases.push({ type: "respond", step });
7783 } else if (currentPlan) {
7784 currentPlan.substeps.push(step);
7785 } else {
7786 phases.push({ type: "step", step });
7787 }
7788 }
7789 return phases;
7790 };
7791
7792 // ── Model badge helper ─────────────────────────────────
7793
7794 const renderModelBadge = (chat) => {
7795 const connName = chat.llmProvider?.connectionId?.name;
7796 const model = connName || chat.llmProvider?.model;
7797 if (!model) return "";
7798 return `<span class="chain-model">${esc(model)}</span>`;
7799 };
7800
7801 // ── Render substep ─────────────────────────────────────
7802
7803 const renderSubstep = (chat) => {
7804 const duration = formatDuration(
7805 chat.startMessage?.time,
7806 chat.endMessage?.time,
7807 );
7808 const stopped = chat.endMessage?.stopped;
7809 const tc = chat.treeContext;
7810
7811 const dotClass = stopped
7812 ? "chain-dot-stopped"
7813 : tc?.stepResult === "failed"
7814 ? "chain-dot-stopped"
7815 : tc?.stepResult === "skipped"
7816 ? "chain-dot-skipped"
7817 : chat.endMessage?.time
7818 ? "chain-dot-done"
7819 : "chain-dot-pending";
7820
7821 const targetName = getTargetName(tc);
7822 const inputFull = formatContent(chat.startMessage?.content);
7823 const outputFull = formatContent(chat.endMessage?.content);
7824
7825 return `
7826 <details class="chain-substep">
7827 <summary class="chain-substep-summary">
7828 <span class="chain-dot ${dotClass}"></span>
7829 <span class="chain-step-mode">${modeLabel(chat.aiContext?.path)}</span>
7830 ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
7831 ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
7832 ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
7833 ${renderModelBadge(chat)}
7834 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7835 </summary>
7836 <div class="chain-step-body">
7837 ${renderTreeContext(tc)}
7838 ${renderDirective(tc)}
7839 <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
7840 ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
7841 </div>
7842 </details>`;
7843 };
7844
7845 // ── Render phases ──────────────────────────────────────
7846
7847 const renderPhases = (steps) => {
7848 const phases = groupStepsIntoPhases(steps);
7849 if (phases.length === 0) return "";
7850
7851 const phaseHtml = phases
7852 .map((phase) => {
7853 if (phase.type === "translate") {
7854 const s = phase.step;
7855 const tc = s.treeContext;
7856 const duration = formatDuration(
7857 s.startMessage?.time,
7858 s.endMessage?.time,
7859 );
7860 const outputFull = formatContent(s.endMessage?.content);
7861 return `
7862 <details class="chain-phase chain-phase-translate">
7863 <summary class="chain-phase-summary">
7864 <span class="chain-phase-icon">🔄</span>
7865 <span class="chain-phase-label">Translator</span>
7866 ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
7867 ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
7868 ${renderModelBadge(s)}
7869 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7870 </summary>
7871 ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
7872 </details>`;
7873 }
7874
7875 if (phase.type === "plan") {
7876 const m = phase.marker;
7877 const tc = m.treeContext;
7878 const targetName = getTargetName(tc);
7879 const hasSubsteps = phase.substeps.length > 0;
7880
7881 // Count results from substeps that have execution modes
7882 const counts = { success: 0, failed: 0, skipped: 0 };
7883 for (const sub of phase.substeps) {
7884 const r = sub.treeContext?.stepResult;
7885 if (r && counts[r] !== undefined) counts[r]++;
7886 }
7887 const countBadges = [
7888 counts.success > 0
7889 ? `<span class="badge badge-done">${counts.success} ✓</span>`
7890 : "",
7891 counts.failed > 0
7892 ? `<span class="badge badge-stopped">${counts.failed} ✗</span>`
7893 : "",
7894 counts.skipped > 0
7895 ? `<span class="badge badge-skipped">${counts.skipped} ⊘</span>`
7896 : "",
7897 ]
7898 .filter(Boolean)
7899 .join("");
7900
7901 // Use directive from treeContext if available, otherwise fall back to input
7902 const directiveText = tc?.directive || "";
7903 const inputFull = directiveText
7904 ? esc(directiveText)
7905 : formatContent(m.startMessage?.content);
7906
7907 return `
7908 <div class="chain-phase chain-phase-plan">
7909 <div class="chain-phase-header">
7910 <span class="chain-phase-icon">📋</span>
7911 <span class="chain-phase-label">${modeLabel(m.aiContext?.path)}</span>
7912 ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
7913 ${
7914 tc?.planStepIndex != null && tc?.planTotalSteps != null
7915 ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>`
7916 : ""
7917 }
7918 ${countBadges}
7919 ${renderModelBadge(m)}
7920 </div>
7921 <div class="chain-plan-directive">${inputFull}</div>
7922 ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map(renderSubstep).join("")}</div>` : ""}
7923 </div>`;
7924 }
7925
7926 if (phase.type === "respond") {
7927 const s = phase.step;
7928 const tc = s.treeContext;
7929 const duration = formatDuration(
7930 s.startMessage?.time,
7931 s.endMessage?.time,
7932 );
7933 const inputFull = formatContent(s.startMessage?.content);
7934 const outputFull = formatContent(s.endMessage?.content);
7935 return `
7936 <details class="chain-phase chain-phase-respond">
7937 <summary class="chain-phase-summary">
7938 <span class="chain-phase-icon">💬</span>
7939 <span class="chain-phase-label">${modeLabel(s.aiContext?.path)}</span>
7940 ${renderModelBadge(s)}
7941 ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7942 </summary>
7943 <div class="chain-step-body">
7944 ${renderTreeContext(tc)}
7945 ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
7946 ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
7947 </div>
7948 </details>`;
7949 }
7950
7951 return renderSubstep(phase.step);
7952 })
7953 .join("");
7954
7955 const summaryParts = phases
7956 .map((p) => {
7957 if (p.type === "translate") {
7958 const tc = p.step.treeContext;
7959 return tc?.planTotalSteps ? `🔄 ${tc.planTotalSteps}-step` : "🔄";
7960 }
7961 if (p.type === "plan") {
7962 const tc = p.marker.treeContext;
7963 const targetName = getTargetName(tc);
7964 const sub = p.substeps
7965 .map((s) => {
7966 const stc = s.treeContext;
7967 const icon =
7968 stc?.stepResult === "failed"
7969 ? "❌ "
7970 : stc?.stepResult === "skipped"
7971 ? "⊘ "
7972 : stc?.stepResult === "success"
7973 ? "✓ "
7974 : "";
7975 return `${icon}${modeLabel(s.aiContext?.path)}`;
7976 })
7977 .join(" → ");
7978 const label = targetName ? `📋 ${esc(targetName)}` : "📋";
7979 return sub ? `${label}: ${sub}` : label;
7980 }
7981 if (p.type === "respond") return "💬";
7982 return modeLabel(p.step?.aiContext?.path);
7983 })
7984 .join(" ");
7985
7986 return `
7987 <details class="chain-dropdown">
7988 <summary class="chain-summary">
7989 ${phases.length} phase${phases.length !== 1 ? "s" : ""}
7990 <span class="chain-modes">${summaryParts}</span>
7991 </summary>
7992 <div class="chain-phases">${phaseHtml}</div>
7993 </details>`;
7994 };
7995
7996 // ── Render chain ───────────────────────────────────────
7997
7998 const renderChain = (chain) => {
7999 const chat = chain.root;
8000 const steps = chain.steps;
8001 const duration = formatDuration(
8002 chat.startMessage?.time,
8003 chat.endMessage?.time,
8004 );
8005 const stopped = chat.endMessage?.stopped;
8006 const contribs = chat.contributions || [];
8007 const hasContribs = contribs.length > 0;
8008 const hasSteps = steps.length > 0;
8009
8010 const modelName =
8011 chat.llmProvider?.connectionId?.name ||
8012 chat.llmProvider?.model ||
8013 "unknown";
8014
8015 const tc = chat.treeContext;
8016 const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
8017 const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
8018 const treeLink =
8019 treeNodeId && treeNodeName
8020 ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">🌳 ${esc(treeNodeName)}</a>`
8021 : treeNodeName
8022 ? `<span class="tree-target-name">🌳 ${esc(treeNodeName)}</span>`
8023 : "";
8024
8025 const statusBadge = stopped
8026 ? `<span class="badge badge-stopped">Stopped</span>`
8027 : chat.endMessage?.time
8028 ? `<span class="badge badge-done">Done</span>`
8029 : `<span class="badge badge-pending">Pending</span>`;
8030
8031 const endContent = chat.endMessage?.content || "";
8032 const wasNoLlm = endContent.startsWith("No LLM connection");
8033 const wasError = endContent.startsWith("Error:");
8034 const chatEnergyUsed =
8035 wasError || wasNoLlm || chat.llmProvider?.isCustom === false ? 2 : 0;
8036 const energyBadge =
8037 chatEnergyUsed > 0
8038 ? `<span class="badge badge-energy">⚡${chatEnergyUsed}</span>`
8039 : "";
8040
8041 const contribRows = contribs
8042 .map((c) => {
8043 const nId = c.nodeId?._id || c.nodeId;
8044 const nName = c.nodeId?.name || nId || "—";
8045 const nodeRef = nId
8046 ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
8047 : `<span style="opacity:0.5">—</span>`;
8048 const aiBadge = c.wasAi
8049 ? `<span class="mini-badge mini-ai">AI</span>`
8050 : "";
8051 const cEnergyBadge =
8052 c.energyUsed > 0
8053 ? `<span class="mini-badge mini-energy">⚡${c.energyUsed}</span>`
8054 : "";
8055 const understandingLink =
8056 c.action === "understanding" &&
8057 c.understandingMeta?.understandingRunId &&
8058 c.understandingMeta?.rootNodeId
8059 ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">🧠 View run →</a>`
8060 : "";
8061 const color = actionColorHex(c.action);
8062 return `
8063 <tr class="contrib-row">
8064 <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
8065 <td>${nodeRef}</td>
8066 <td>${aiBadge}${cEnergyBadge}</td>
8067 <td class="contrib-time">${formatTime(c.date)}</td>
8068 </tr>`;
8069 })
8070 .join("");
8071
8072 const stepsHtml = hasSteps ? renderPhases(steps) : "";
8073
8074 return `
8075 <li class="note-card">
8076 <div class="chat-header">
8077 <div class="chat-header-left">
8078 <span class="chat-mode">${modeLabel(chat.aiContext?.path)}</span>
8079 ${treeLink}
8080 <span class="chat-model">${esc(modelName)}</span>
8081 </div>
8082 <div class="chat-badges">
8083 ${energyBadge}
8084 ${statusBadge}
8085 ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
8086 <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
8087 </div>
8088 </div>
8089
8090 <div class="note-content">
8091 <div class="chat-message chat-user">
8092 <span class="msg-label">You</span>
8093 <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
8094 ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
8095 </div>
8096 ${
8097 chat.endMessage?.content
8098 ? `
8099 <div class="chat-message chat-ai">
8100 <span class="msg-label">AI</span>
8101 <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content))}</div>
8102 ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
8103 </div>`
8104 : ""
8105 }
8106 </div>
8107
8108 ${stepsHtml}
8109
8110 ${
8111 hasContribs
8112 ? `
8113 <details class="contrib-dropdown">
8114 <summary class="contrib-summary">
8115 ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
8116 </summary>
8117 <div class="contrib-table-wrap">
8118 <table class="contrib-table">
8119 <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
8120 <tbody>${contribRows}</tbody>
8121 </table>
8122 </div>
8123 </details>`
8124 : ""
8125 }
8126
8127 <div class="note-meta">
8128 ${formatTime(chat.startMessage?.time)}
8129 <span class="meta-separator">·</span>
8130 <code class="contribution-id">${esc(chat._id)}</code>
8131 </div>
8132 </li>`;
8133 };
8134
8135 const renderedSections = sessionGroups
8136 .map((group) => {
8137 const chatCount = group.chatCount;
8138 const sessionTime = formatTime(group.startTime);
8139 const shortId = group.sessionId.slice(0, 8);
8140 const chains = groupIntoChains(group.chats);
8141 const chatCards = chains.map(renderChain).join("");
8142
8143 return `
8144 <div class="session-group">
8145 <div class="session-pane">
8146 <div class="session-pane-header">
8147 <div class="session-header-left">
8148 <span class="session-id">${esc(shortId)}</span>
8149 <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
8150 </div>
8151 <span class="session-time">${sessionTime}</span>
8152 </div>
8153 <ul class="notes-list">${chatCards}</ul>
8154 </div>
8155 </div>`;
8156 })
8157 .join("");
8158
8159 return (`
8160<!DOCTYPE html>
8161<html lang="en">
8162<head>
8163 <meta charset="UTF-8">
8164 <meta name="viewport" content="width=device-width, initial-scale=1.0">
8165 <meta name="theme-color" content="#667eea">
8166 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8167 <title>${esc(username)} — AI Chats</title>
8168 <style>
8169${baseStyles}
8170${backNavStyles}
8171${glassHeaderStyles}
8172${glassCardStyles}
8173${emptyStateStyles}
8174${responsiveBase}
8175
8176
8177/* ── Session Pane ───────────────────────────────── */
8178.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
8179.session-pane {
8180 background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
8181 border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
8182}
8183.session-pane-header {
8184 display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
8185 background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
8186}
8187.session-header-left { display: flex; align-items: center; gap: 10px; }
8188.session-id {
8189 font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
8190 color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
8191 border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
8192}
8193.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
8194.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
8195
8196/* ── Chat Header ────────────────────────────────── */
8197.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
8198.chat-header-left { display: flex; align-items: center; gap: 8px; }
8199.chat-mode {
8200 font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
8201 padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
8202}
8203.chat-model {
8204 font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
8205 font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
8206 text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
8207}
8208.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
8209
8210.chat-message { display: flex; gap: 10px; align-items: flex-start; }
8211.msg-label {
8212 flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
8213 letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
8214}
8215.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
8216.chat-ai .msg-label { background: rgba(100,220,255,0.25); color: white; }
8217.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
8218.msg-clamp {
8219 display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
8220 overflow: hidden; max-height: calc(1.65em * 4);
8221 transition: max-height 0.3s ease;
8222}
8223.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
8224.expand-btn {
8225 background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
8226 font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px;
8227 transition: color 0.2s;
8228}
8229.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
8230.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
8231.node-link:hover { background: rgba(50,220,120,0.3); }
8232.understanding-link {
8233 color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
8234 margin-left: 4px; transition: color 0.2s;
8235}
8236.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
8237.chat-user .msg-text { font-weight: 500; }
8238
8239/* ── Chain: outer dropdown ──────────────────────── */
8240.chain-dropdown { margin-bottom: 12px; }
8241.chain-summary {
8242 cursor: pointer; font-size: 13px; font-weight: 600;
8243 color: rgba(255,255,255,0.85); padding: 8px 14px;
8244 background: rgba(255,255,255,0.1); border-radius: 10px;
8245 border: 1px solid rgba(255,255,255,0.15);
8246 transition: all 0.2s; list-style: none;
8247 display: flex; align-items: center; gap: 8px;
8248}
8249.chain-summary::-webkit-details-marker { display: none; }
8250.chain-summary::before { content: "▶"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
8251details[open] > .chain-summary::before { transform: rotate(90deg); }
8252.chain-summary:hover { background: rgba(255,255,255,0.18); }
8253.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8254.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
8255
8256/* ── Chain: phase containers ────────────────────── */
8257.chain-phase { border-radius: 10px; overflow: hidden; }
8258.chain-phase-header {
8259 display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600;
8260 flex-wrap: wrap;
8261}
8262.chain-phase-icon { font-size: 14px; }
8263.chain-phase-label { color: rgba(255,255,255,0.85); }
8264.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
8265.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
8266.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
8267.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
8268
8269/* ── Chain: clickable summary for translate/substep details ── */
8270.chain-phase-summary, .chain-substep-summary {
8271 cursor: pointer; list-style: none;
8272 display: flex; align-items: center; gap: 8px;
8273 padding: 8px 12px;
8274 font-size: 12px; font-weight: 600;
8275 flex-wrap: wrap;
8276}
8277.chain-phase-summary::-webkit-details-marker,
8278.chain-substep-summary::-webkit-details-marker { display: none; }
8279.chain-phase-summary::before,
8280.chain-substep-summary::before {
8281 content: "▶"; font-size: 8px; color: rgba(255,255,255,0.35);
8282 transition: transform 0.15s; display: inline-block;
8283}
8284details[open] > .chain-phase-summary::before,
8285details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
8286.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
8287
8288/* ── Chain: substeps inside plan ────────────────── */
8289.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
8290.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
8291.chain-substep:hover { background: rgba(255,255,255,0.07); }
8292
8293/* ── Chain: status dot ──────────────────────────── */
8294.chain-dot {
8295 display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
8296 border: 2px solid rgba(255,255,255,0.3);
8297}
8298.chain-dot-done { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
8299.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
8300.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
8301.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
8302
8303.chain-step-mode {
8304 font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
8305 background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
8306}
8307.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
8308.chain-model {
8309 font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
8310 color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
8311 overflow: hidden; text-overflow: ellipsis; max-width: 150px;
8312}
8313
8314/* ── Chain: expanded body ───────────────────────── */
8315.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
8316
8317.chain-io-label {
8318 display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
8319 padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
8320}
8321.chain-io-in { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
8322.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
8323
8324.chain-step-input {
8325 font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
8326 word-break: break-word; white-space: pre-wrap;
8327 font-family: 'SF Mono', 'Fira Code', monospace;
8328}
8329.chain-step-output {
8330 font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
8331 margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
8332 word-break: break-word; white-space: pre-wrap;
8333 font-family: 'SF Mono', 'Fira Code', monospace;
8334}
8335.chain-json { color: rgba(255,255,255,0.8); }
8336
8337/* ── Tree Context ───────────────────────────────── */
8338.tree-context-bar {
8339 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
8340 padding: 6px 12px; margin-bottom: 6px;
8341 background: rgba(255,255,255,0.06); border-radius: 6px;
8342 font-size: 12px;
8343}
8344.tree-target-link {
8345 color: rgba(100,220,255,0.95); text-decoration: none;
8346 border-bottom: 1px solid rgba(100,220,255,0.3);
8347 font-weight: 600; font-size: 12px;
8348 transition: all 0.2s;
8349}
8350.tree-target-link:hover {
8351 border-bottom-color: rgba(100,220,255,0.8);
8352 text-shadow: 0 0 8px rgba(100,220,255,0.5);
8353}
8354.tree-target-name {
8355 color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px;
8356}
8357.tree-directive {
8358 padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
8359 line-height: 1.5; font-style: italic;
8360 border-left: 2px solid rgba(255,255,255,0.15);
8361 margin: 0 12px 8px;
8362}
8363.chain-step-counter {
8364 font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
8365 background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
8366}
8367.chain-step-target {
8368 font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
8369 max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
8370}
8371.chain-step-failed {
8372 font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
8373 background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px;
8374 letter-spacing: 0.5px;
8375}
8376.chain-step-fail-reason {
8377 font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
8378 font-style: italic; max-width: 200px; overflow: hidden;
8379 text-overflow: ellipsis; white-space: nowrap;
8380}
8381.badge-step {
8382 background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
8383 font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
8384}
8385.badge-skipped {
8386 background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7);
8387}
8388.chain-plan-summary-text {
8389 font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
8390 font-style: italic; overflow: hidden; text-overflow: ellipsis;
8391 white-space: nowrap; max-width: 300px;
8392}
8393
8394/* ── Contribution Dropdown ──────────────────────── */
8395.contrib-dropdown { margin-bottom: 12px; }
8396.contrib-summary {
8397 cursor: pointer; font-size: 13px; font-weight: 600;
8398 color: rgba(255,255,255,0.85); padding: 8px 14px;
8399 background: rgba(255,255,255,0.1); border-radius: 10px;
8400 border: 1px solid rgba(255,255,255,0.15);
8401 transition: all 0.2s; list-style: none;
8402 display: flex; align-items: center; gap: 6px;
8403}
8404.contrib-summary::-webkit-details-marker { display: none; }
8405.contrib-summary::before { content: "▶"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
8406details[open] .contrib-summary::before { transform: rotate(90deg); }
8407.contrib-summary:hover { background: rgba(255,255,255,0.18); }
8408.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
8409.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
8410.contrib-table thead th {
8411 text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
8412 letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
8413 border-bottom: 1px solid rgba(255,255,255,0.15);
8414}
8415.contrib-row td {
8416 padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
8417 color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
8418}
8419.contrib-row:last-child td { border-bottom: none; }
8420.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
8421.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
8422.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
8423.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
8424
8425/* ── Mini Badges ────────────────────────────────── */
8426.mini-badge {
8427 display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
8428 font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
8429}
8430.mini-ai { background: rgba(255,200,50,0.35); color: #fff; }
8431.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
8432
8433/* ── Badges ─────────────────────────────────────── */
8434.badge {
8435 display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
8436 font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
8437}
8438.badge-done { background: rgba(72,187,120,0.35); color: #fff; }
8439.badge-stopped { background: rgba(200,80,80,0.35); color: #fff; }
8440.badge-pending { background: rgba(255,200,50,0.3); color: #fff; }
8441.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
8442.badge-source { background: rgba(100,100,210,0.3); color: #fff; }
8443.badge-energy { background: rgba(100,220,255,0.25); color: #fff; border-color: rgba(100,220,255,0.3); }
8444
8445
8446.contribution-id {
8447 background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
8448 font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
8449 color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
8450}
8451
8452/* ── Responsive ─────────────────────────────────── */
8453
8454
8455 </style>
8456</head>
8457<body>
8458 <div class="container">
8459 <div class="back-nav">
8460 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
8461 </div>
8462
8463 <div class="header">
8464 <h1>
8465 AI Chats for
8466 <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
8467 ${chats.length > 0 ? `<span class="message-count">${chats.length}</span>` : ""}
8468 </h1>
8469 <div class="header-subtitle">Last 10 AI conversation sessions. Phases = thought process. Contributions = actions made on tree during conversation.</div>
8470 </div>
8471
8472 ${
8473 sessionGroups.length
8474 ? renderedSections
8475 : `
8476 <div class="empty-state">
8477 <div class="empty-state-icon">💬</div>
8478 <div class="empty-state-text">No AI chats yet</div>
8479 <div class="empty-state-subtext">AI conversations and their actions will appear here</div>
8480 </div>`
8481 }
8482 </div>
8483
8484 <script>
8485 var observer = new IntersectionObserver(function(entries) {
8486 entries.forEach(function(entry, index) {
8487 if (entry.isIntersecting) {
8488 setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
8489 observer.unobserve(entry.target);
8490 }
8491 });
8492 }, { root: null, rootMargin: '50px', threshold: 0.1 });
8493 document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
8494
8495 function toggleExpand(btn) {
8496 var text = btn.previousElementSibling;
8497 if (!text) return;
8498 var expanded = text.classList.toggle('expanded');
8499 btn.textContent = expanded ? 'Show less' : 'Show more';
8500 }
8501 </script>
8502</body>
8503</html>
8504`);
8505}
8506
8507// ═══════════════════════════════════════════════════════════════════
8508// 15. Notifications Page - GET /user/:userId/notifications
8509// ═══════════════════════════════════════════════════════════════════
8510export function renderNotifications({ userId, notifications, total, username, token }) {
8511 const tokenQS = token ? `?token=${token}&html` : `?html`;
8512
8513 const items = notifications
8514 .map((n) => {
8515 const icon = n.type === "dream-thought" ? "💭" : "📋";
8516 const typeLabel = n.type === "dream-thought" ? "Thought" : "Summary";
8517 const colorClass =
8518 n.type === "dream-thought" ? "glass-purple" : "glass-indigo";
8519 const date = new Date(n.createdAt).toLocaleString();
8520
8521 return `
8522 <li class="note-card ${colorClass}">
8523 <div class="note-content">
8524 <div class="contribution-action">
8525 <span style="font-size:20px;margin-right:6px">${icon}</span>
8526 ${esc(n.title)}
8527 <span class="badge badge-type">${typeLabel}</span>
8528 </div>
8529 <div style="margin-top:10px;font-size:14px;color:rgba(255,255,255,0.9);line-height:1.6;white-space:pre-wrap">${esc(n.content)}</div>
8530 </div>
8531 <div class="note-meta">
8532 ${date}
8533 </div>
8534 </li>`;
8535 })
8536 .join("");
8537
8538 return (`
8539<!DOCTYPE html>
8540<html lang="en">
8541<head>
8542 <meta charset="UTF-8">
8543 <meta name="viewport" content="width=device-width, initial-scale=1.0">
8544 <meta name="theme-color" content="#667eea">
8545 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8546 <title>${esc(username)} - Notifications</title>
8547 <style>
8548${baseStyles}
8549${backNavStyles}
8550${glassHeaderStyles}
8551${glassCardStyles}
8552${emptyStateStyles}
8553${responsiveBase}
8554
8555.header-subtitle {
8556 margin-bottom: 16px;
8557}
8558
8559
8560/* ── Badges ─────────────────────────────────────── */
8561
8562.badge {
8563 display: inline-flex; align-items: center;
8564 padding: 3px 10px; border-radius: 980px;
8565 font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
8566 border: 1px solid rgba(255,255,255,0.2);
8567}
8568
8569.badge-type {
8570 background: rgba(255,255,255,0.15);
8571 color: rgba(255,255,255,0.8);
8572 text-transform: uppercase;
8573 letter-spacing: 0.5px;
8574 font-size: 10px;
8575 margin-left: 8px;
8576}
8577
8578/* ── Responsive ─────────────────────────────────── */
8579
8580
8581 </style>
8582</head>
8583<body>
8584 <div class="container">
8585 <div class="back-nav">
8586 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
8587 </div>
8588
8589 <div class="header">
8590 <h1>
8591 Notifications for
8592 <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
8593 ${notifications.length > 0 ? `<span class="message-count">${total}</span>` : ""}
8594 </h1>
8595 <div class="header-subtitle">Dream summaries and thoughts from your trees</div>
8596 </div>
8597
8598 ${
8599 items.length
8600 ? `<ul class="notes-list">${items}</ul>`
8601 : `
8602 <div class="empty-state">
8603 <div class="empty-state-icon">🔔</div>
8604 <div class="empty-state-text">No notifications yet</div>
8605 <div class="empty-state-subtext">Dreams will generate summaries and thoughts automatically</div>
8606 </div>`
8607 }
8608 </div>
8609</body>
8610</html>`);
8611}
8612
8613// ═══════════════════════════════════════════════════════════════════
8614// Exported HTML helper functions (used by user.js for inline rendering)
8615// ═══════════════════════════════════════════════════════════════════
8616export { escapeHtml, renderMedia };
8617
1// ─────────────────────────────────────────────────
2// Shared utilities for HTML renderers
3// ─────────────────────────────────────────────────
4
5// ─── HTML escaping ───────────────────────────────
6// One definitive implementation. Handles null/undefined,
7// coerces to string, escapes all 5 dangerous characters.
8
9export function esc(str) {
10 if (str == null) return "";
11 return String(str)
12 .replace(/&/g, "&")
13 .replace(/</g, "<")
14 .replace(/>/g, ">")
15 .replace(/"/g, """)
16 .replace(/'/g, "'");
17}
18
19// Alias for files that use the longer name
20export { esc as escapeHtml };
21
22// ─── Truncation ──────────────────────────────────
23
24export function truncate(str, len = 200) {
25 if (!str) return "";
26 const clean = esc(str);
27 return clean.length > len ? clean.slice(0, len) + "..." : clean;
28}
29
30// Raw truncate (no escaping, for pre-escaped or non-HTML contexts)
31export function truncateRaw(str, len = 24) {
32 if (!str) return "";
33 return str.length > len ? str.slice(0, len) + "..." : str;
34}
35
36// ─── Time formatting ─────────────────────────────
37
38export function formatTime(d) {
39 return d ? new Date(d).toLocaleString() : "--";
40}
41
42export function formatDuration(start, end) {
43 if (!start || !end) return null;
44 const ms = new Date(end) - new Date(start);
45 if (ms < 1000) return `${ms}ms`;
46 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
47 return `${(ms / 60000).toFixed(1)}m`;
48}
49
50export function timeAgo(date) {
51 if (!date) return "never";
52 const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
53 if (seconds < 0) return "just now";
54 if (seconds < 60) return seconds + "s ago";
55 if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
56 if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
57 return Math.floor(seconds / 86400) + "d ago";
58}
59
60// ─── Rainbow depth colors ────────────────────────
61
62export const rainbow = [
63 "#ff3b30",
64 "#ff9500",
65 "#ffcc00",
66 "#34c759",
67 "#32ade6",
68 "#5856d6",
69 "#af52de",
70];
71
72// ─── Action color mappings ───────────────────────
73// Two variants: CSS class names (for glass cards) and hex (for inline styles).
74// Driven from one authoritative map so they never drift.
75
76const ACTION_COLORS = {
77 create: { cls: "glass-green", hex: "#48bb78" },
78 delete: { cls: "glass-red", hex: "#c85050" },
79 branchLifecycle: { cls: "glass-red", hex: "#c85050" },
80 editStatus: { cls: "glass-blue", hex: "#5082dc" },
81 editValue: { cls: "glass-blue", hex: "#5082dc" },
82 editGoal: { cls: "glass-blue", hex: "#5082dc" },
83 editSchedule: { cls: "glass-blue", hex: "#5082dc" },
84 editNameNode: { cls: "glass-blue", hex: "#5082dc" },
85 editScript: { cls: "glass-blue", hex: "#5082dc" },
86 executeScript: { cls: "glass-cyan", hex: "#38bdd2" },
87 prestige: { cls: "glass-gold", hex: "#c8aa32" },
88 note: { cls: "glass-purple", hex: "#9b64dc" },
89 rawIdea: { cls: "glass-purple", hex: "#9b64dc" },
90 invite: { cls: "glass-pink", hex: "#d264a0" },
91 transaction: { cls: "glass-orange", hex: "#dc8c3c" },
92 trade: { cls: "glass-orange", hex: "#dc8c3c" },
93 purchase: { cls: "glass-emerald", hex: "#34be82" },
94 updateParent: { cls: "glass-teal", hex: "#3caab4" },
95 updateChildNode: { cls: "glass-teal", hex: "#3caab4" },
96 understanding: { cls: "glass-indigo", hex: "#6464d2" },
97};
98
99const DEFAULT_ACTION = { cls: "glass-default", hex: "#736fe6" };
100
101export function actionColorClass(action) {
102 return (ACTION_COLORS[action] || DEFAULT_ACTION).cls;
103}
104
105export function actionColorHex(action) {
106 return (ACTION_COLORS[action] || DEFAULT_ACTION).hex;
107}
108
109// ─── Action labels ───────────────────────────────
110
111const ACTION_LABELS = {
112 create: "Created",
113 editStatus: "Status",
114 editValue: "Values",
115 prestige: "Prestige",
116 trade: "Trade",
117 delete: "Deleted",
118 invite: "Invite",
119 editSchedule: "Schedule",
120 editGoal: "Goal",
121 transaction: "Transaction",
122 note: "Note",
123 updateParent: "Moved",
124 editScript: "Script",
125 executeScript: "Ran script",
126 updateChildNode: "Child",
127 editNameNode: "Renamed",
128 rawIdea: "Raw idea",
129 branchLifecycle: "Branch",
130 purchase: "Purchase",
131 understanding: "Understanding",
132};
133
134export function actionLabel(action) {
135 return ACTION_LABELS[action] || action;
136}
137
138// ─── Media rendering ─────────────────────────────
139// lazy: uses data-src + lazy-media class (needs client-side IntersectionObserver)
140// immediate: uses src directly
141
142export function renderMedia(fileUrl, mimeType, { lazy = true } = {}) {
143 const srcAttr = lazy ? "data-src" : "src";
144 const cls = lazy ? ' class="lazy-media"' : "";
145 const loading = lazy ? ' loading="lazy"' : "";
146
147 if (mimeType.startsWith("image/")) {
148 return `<img ${srcAttr}="${fileUrl}"${loading}${cls} style="max-width:100%;" alt="" />`;
149 }
150 if (mimeType.startsWith("video/")) {
151 return `<video ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls} style="max-width:100%;"></video>`;
152 }
153 if (mimeType.startsWith("audio/")) {
154 return `<audio ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls}></audio>`;
155 }
156 if (mimeType === "application/pdf") {
157 return `<iframe ${srcAttr}="${fileUrl}"${loading}${cls} style="width:100%; height:90vh; border:none;"></iframe>`;
158 }
159 return "";
160}
161
162// ─── Chat chain grouping ─────────────────────────
163
164export function groupIntoChains(chats) {
165 const chainMap = new Map();
166 const chainOrder = [];
167 for (const chat of chats) {
168 const key = chat.rootChatId || chat._id;
169 if (!chainMap.has(key)) {
170 chainMap.set(key, { root: null, steps: [] });
171 chainOrder.push(key);
172 }
173 const chain = chainMap.get(key);
174 if (chat.chainIndex === 0 || chat._id === key) {
175 chain.root = chat;
176 } else {
177 chain.steps.push(chat);
178 }
179 }
180 return chainOrder
181 .map((key) => {
182 const chain = chainMap.get(key);
183 chain.steps.sort((a, b) => a.chainIndex - b.chainIndex);
184 return chain;
185 })
186 .filter((c) => c.root);
187}
188
189// ─── Mode labels ─────────────────────────────────
190
191export function modeLabel(path) {
192 if (!path) return "unknown";
193 if (path === "translator") return "Translator";
194 if (path.startsWith("tree:orchestrator:plan:")) {
195 return `Plan Step ${path.split(":")[3]}`;
196 }
197 const parts = path.split(":");
198 const labels = { home: "Home", tree: "Tree", rawIdea: "Raw Idea" };
199 const subLabels = {
200 default: "Default",
201 chat: "Chat",
202 structure: "Structure",
203 edit: "Edit",
204 be: "Be",
205 reflect: "Reflect",
206 navigate: "Navigate",
207 understand: "Understand",
208 getContext: "Context",
209 respond: "Respond",
210 notes: "Notes",
211 start: "Start",
212 chooseRoot: "Choose Root",
213 complete: "Placed",
214 stuck: "Stuck",
215 };
216 const big = labels[parts[0]] || parts[0];
217 const sub = subLabels[parts[1]] || parts[1] || "";
218 return sub ? `${big} ${sub}` : big;
219}
220
221// ─── Source labels ───────────────────────────────
222
223export function sourceLabel(src) {
224 const map = {
225 user: "User",
226 api: "API",
227 orchestrator: "Chain",
228 background: "Background",
229 script: "Script",
230 system: "System",
231 };
232 return map[src] || src;
233}
234
1import crypto from "crypto";
2import router, { pageRouter } from "./routes.js";
3import appRouter from "./app/app.js";
4import chatRouter from "./app/chat.js";
5import setupRouter from "./app/setup.js";
6import { renderLoginPage, renderRegisterPage, renderForgotPasswordPage } from "./pages.js";
7import * as renderers from "./renderers.js";
8import { resolveHtmlShareAccess } from "./shareAuth.js";
9
10// Mount app page routers onto the pageRouter so the loader wires them at /
11pageRouter.use("/", appRouter);
12pageRouter.use("/", chatRouter);
13pageRouter.use("/", setupRouter);
14
15function generateShareToken() {
16 return crypto.randomBytes(16).toString("base64url");
17}
18
19/**
20 * Register an HTML page route on the page router (mounted at /, not /api/v1).
21 * Other extensions call this to add their own server-rendered pages.
22 *
23 * @param {string} method - HTTP method: "get", "post", etc.
24 * @param {string} path - Route path, e.g. "/my-dashboard"
25 * @param {...Function} handlers - Express middleware/handler(s)
26 */
27function registerPage(method, path, ...handlers) {
28 const m = method.toLowerCase();
29 if (typeof pageRouter[m] !== "function") {
30 throw new Error(`Invalid HTTP method: ${method}`);
31 }
32 pageRouter[m](path, ...handlers);
33}
34
35export async function init(core) {
36 const User = core.models.User;
37
38 // Generate share token for new users
39 core.hooks.register("afterRegister", async ({ user }) => {
40 const freshUser = await User.findById(user._id);
41 if (!freshUser) return;
42 const { getUserMeta, setUserMeta } = await import("../../core/tree/userMetadata.js");
43 const existing = getUserMeta(freshUser, "html");
44 if (existing?.shareToken) return; // already has one
45 setUserMeta(freshUser, "html", { ...existing, shareToken: generateShareToken() });
46 await freshUser.save();
47 }, "html-rendering");
48
49 return {
50 router,
51 pageRouter,
52 exports: {
53 renderLoginPage,
54 renderRegisterPage,
55 renderForgotPasswordPage,
56 resolveHtmlShareAccess,
57 registerPage,
58 ...renderers,
59 },
60 };
61}
62
1export default {
2 name: "html-rendering",
3 version: "2.0.0",
4 description: "Server-rendered HTML pages, share token auth, and a page registration API for other extensions",
5
6 needs: {
7 models: ["User", "Node"],
8 },
9
10 optional: {},
11
12 provides: {
13 models: {},
14 routes: "./routes.js",
15 tools: false,
16 jobs: false,
17 energyActions: {},
18 sessionTypes: {},
19 env: [
20 { key: "ENABLE_FRONTEND_HTML", required: false, default: "true", description: "Enable server-rendered HTML and share token auth. Set to false to disable all ?html routes, share token access, and extension HTML renderers. API endpoints still return JSON." },
21 ],
22
23 hooks: {
24 fires: [],
25 listens: ["afterRegister"],
26 },
27
28 // Documented exports (available via getExtension("html-rendering")?.exports)
29 //
30 // Page registration:
31 // registerPage(method, path, ...handlers) - Add routes to the page router (mounted at /, not /api/v1).
32 // Other extensions use this to add their own server-rendered pages.
33 // Example: registerPage("get", "/my-dashboard", authenticate, handler)
34 //
35 // Share token auth:
36 // resolveHtmlShareAccess({ userId, nodeId, shareToken }) - Validate a share token for URL-based auth.
37 // Returns { allowed, matchedUserId, scope, ... }
38 //
39 // Render functions (60+):
40 // All render functions from html/user.js, html/node.js, html/notes.js, html/values.js, html/chat.js, html/notFound.js
41 // Examples: renderValues(), renderEnergy(), renderChat(), renderUserNotes(), renderScriptDetail(),
42 // renderBookPage(), renderSolanaWallet(), errorHtml(), parseBool(), normalizeStatusFilters()
43 //
44 // Login/register pages:
45 // renderLoginPage(), renderRegisterPage(), renderForgotPasswordPage()
46 //
47 // Usage from other extensions:
48 // import { getExtension } from "../loader.js";
49 // const html = getExtension("html-rendering")?.exports || {};
50 // if (html.renderValues) res.send(html.renderValues({ ... }));
51 //
52 // If this extension is not installed, all consuming extensions fall back to JSON responses.
53 },
54};
55
1export {
2 renderLoginPage,
3 renderRegisterPage,
4 renderForgotPasswordPage,
5} from "./html/login.js";
6
1// Barrel re-export of all HTML render functions.
2// Extensions and core routes use getExtension("html-rendering")?.exports to access these.
3
4export * from "./html/user.js";
5export * from "./html/node.js";
6export * from "./html/notes.js";
7export * from "./html/notFound.js";
8export * from "./html/chat.js";
9export * from "./html/contributions.js";
10export * from "./html/root.js";
11export * from "./html/query.js";
12
13
1import log from "../../core/log.js";
2import express from "express";
3import crypto from "crypto";
4import authenticate from "../../middleware/authenticate.js";
5import User from "../../db/models/user.js";
6import { getUserMeta, setUserMeta } from "../../core/tree/userMetadata.js";
7import {
8 renderLoginPage,
9 renderRegisterPage,
10 renderForgotPasswordPage,
11} from "./pages.js";
12import rateLimit from "express-rate-limit";
13
14const router = express.Router();
15
16const URL_SAFE_REGEX = /^[A-Za-z0-9\-_.~]+$/;
17
18const limiter = rateLimit({
19 windowMs: 15 * 60 * 1000,
20 max: 10,
21 handler: (req, res) => {
22 res.status(429).json({ message: "Too many requests. Try again later." });
23 },
24});
25
26// SET share token
27router.post("/setHTMLShareToken", authenticate, limiter, async (req, res) => {
28 try {
29 const user = await User.findById(req.userId);
30 if (!user) return res.status(404).json({ message: "User not found" });
31
32 let { htmlShareToken } = req.body;
33 if (typeof htmlShareToken !== "string") {
34 return res.status(400).json({ message: "htmlShareToken must be a string" });
35 }
36
37 htmlShareToken = htmlShareToken.trim();
38 if (htmlShareToken.length > 128 || htmlShareToken.length < 1) {
39 return res.status(400).json({ message: "htmlShareToken must be 1 to 128 characters" });
40 }
41 if (!URL_SAFE_REGEX.test(htmlShareToken)) {
42 return res.status(400).json({ message: "htmlShareToken may only contain URL-safe characters" });
43 }
44
45 setUserMeta(user, "html", { shareToken: htmlShareToken });
46 await user.save();
47
48 return res.json({ htmlShareToken });
49 } catch (err) {
50 log.error("HTML", "setHtmlShareToken error:", err.message);
51 res.status(500).json({ message: "Failed to set html share token" });
52 }
53});
54
55// VERIFY token (returns share token + user info for frontend)
56router.post("/verify-token", authenticate, async (req, res) => {
57 try {
58 const user = await User.findById(req.userId)
59 .select("metadata")
60 .lean();
61
62 const htmlMeta = getUserMeta(user, "html");
63 const HTMLShareToken = htmlMeta?.shareToken || null;
64
65 let hasLlm = false;
66 try {
67 const fullUser = await User.findById(req.userId)
68 .select("llmDefault metadata")
69 .lean();
70 if (fullUser?.llmDefault) {
71 hasLlm = true;
72 } else {
73 let CustomLlmConnection;
74 try { CustomLlmConnection = (await import("../../db/models/customLlmConnection.js")).default; } catch { }
75 if (CustomLlmConnection) {
76 const connCount = await CustomLlmConnection.countDocuments({ userId: req.userId });
77 hasLlm = connCount > 0;
78 }
79 }
80 } catch (err) {
81 log.error("HTML", "verify-token LLM check error:", err.message);
82 }
83
84 res.json({
85 userId: req.userId,
86 username: req.username,
87 HTMLShareToken,
88 hasLlm,
89 });
90 } catch (err) {
91 log.error("HTML", "verify-token error:", err.message);
92 res.status(500).json({ message: "Failed to verify token" });
93 }
94});
95
96// Page routes (mounted at / not /api/v1)
97export const pageRouter = express.Router();
98
99pageRouter.get("/login", (req, res) => {
100 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
101 return res.status(404).json({ error: "Server-rendered HTML is disabled." });
102 }
103 renderLoginPage(req, res);
104});
105
106pageRouter.get("/register", (req, res) => {
107 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
108 return res.status(404).json({ error: "Server-rendered HTML is disabled." });
109 }
110 renderRegisterPage(req, res);
111});
112
113pageRouter.get("/forgot-password", (req, res) => {
114 if (process.env.ENABLE_FRONTEND_HTML !== "true") {
115 return res.status(404).json({ error: "Server-rendered HTML is disabled." });
116 }
117 renderForgotPasswordPage(req, res);
118});
119
120export default router;
121
1// Share token authentication for HTML-rendered pages.
2// Moved from core/authenticate.js into the html-rendering extension.
3
4import log from "../../core/log.js";
5import User from "../../db/models/user.js";
6import { resolveRootNode } from "../../core/tree/treeFetch.js";
7
8export async function resolveHtmlShareAccess({ userId, nodeId, shareToken }) {
9 if (!shareToken) {
10 return { allowed: false, reason: "Missing share token" };
11 }
12
13 // CASE 1: userId-based access
14 if (userId && !nodeId) {
15 const user = await User.findOne({
16 _id: userId,
17 "metadata.html.shareToken": shareToken,
18 })
19 .select("_id username")
20 .lean()
21 .exec();
22
23 if (!user) {
24 return { allowed: false, reason: "Invalid share token" };
25 }
26
27 return {
28 allowed: true,
29 matchedUserId: user._id,
30 matchedUsername: user.username,
31 scope: "user",
32 };
33 }
34
35 // CASE 2: nodeId-based access
36 if (nodeId) {
37 const rootNode = await resolveRootNode(nodeId);
38
39 const userIds = [
40 rootNode.rootOwner,
41 ...(rootNode.contributors || []),
42 ].filter(Boolean);
43
44 if (userIds.length === 0) {
45 return { allowed: false, reason: "No users associated with root" };
46 }
47
48 const matchedUser = await User.findOne({
49 _id: { $in: userIds },
50 "metadata.html.shareToken": shareToken,
51 })
52 .select("_id username")
53 .lean()
54 .exec();
55
56 if (!matchedUser) {
57 log.debug("Auth", "ShareAuth: DENIED nodeId=%s userIds=%j tokenPrefix=%s", nodeId, userIds, shareToken?.slice(0, 6));
58 return { allowed: false, reason: "Invalid share token for node" };
59 }
60
61 return {
62 allowed: true,
63 rootId: rootNode._id.toString(),
64 matchedUserId: matchedUser._id,
65 matchedUsername: matchedUser.username,
66 scope: "node",
67 };
68 }
69
70 return {
71 allowed: false,
72 reason: "userId or nodeId is required",
73 };
74}
75