Changeset 0.31.0 (#203)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -1,535 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
overrides within the editor context.
|
||||
========================================== */
|
||||
|
||||
/* ── Surface Shell ─────────────────────────── */
|
||||
|
||||
.surface-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Topbar ────────────────────────────────── */
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.editor-topbar-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-3, #777);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-topbar-back:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-topbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.editor-topbar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Workspace Selector ────────────────────── */
|
||||
|
||||
.editor-ws-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn:hover {
|
||||
border-color: var(--border, #2a2a2e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown.open { display: block; }
|
||||
|
||||
.editor-ws-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item.active {
|
||||
color: var(--accent, #b38a4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-topbar-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-topbar-branch-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--purple, #a078ff);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────── */
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Bootstrap (no workspace) ──────────────── */
|
||||
|
||||
.editor-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-bootstrap-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Workspace list in bootstrap */
|
||||
.editor-bootstrap-ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-item:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
}
|
||||
|
||||
/* User menu in editor topbar — uses user-menu.css base styles.
|
||||
No overrides needed: user-menu.css defaults to drop-down (topbar) mode.
|
||||
Sidebar context flipped via .sidebar parent selector in user-menu.css. */
|
||||
|
||||
/* ── FileTree overrides (in editor context) ── */
|
||||
|
||||
.surface-editor .file-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row.active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git status indicators */
|
||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
||||
|
||||
/* Context menu */
|
||||
.file-tree-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.file-tree-ctx-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-ctx-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── CodeEditor overrides ──────────────────── */
|
||||
|
||||
.surface-editor .code-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
height: 32px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.surface-editor .code-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #777);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
||||
|
||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.surface-editor .code-editor-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
margin-left: 4px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab-close:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-welcome {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-textarea-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Tabbed assist pane overrides ──────────── */
|
||||
|
||||
.surface-editor .pane-tabbed {
|
||||
border-left: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
/* ChatPane in editor tabbed pane */
|
||||
.surface-editor .chat-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Notes in editor pane */
|
||||
.surface-editor .note-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .note-editor-list-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Compact notes toolbar for narrow pane */
|
||||
.surface-editor .notes-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .notes-toolbar .btn-small {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-search-row {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-row {
|
||||
padding: 2px 8px 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-select {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
|
||||
.note-panel-root { height: 100%; display: flex; flex-direction: column; }
|
||||
.side-panel-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--text-3); font-size: 13px;
|
||||
@@ -68,10 +69,10 @@
|
||||
.workspace-secondary .notes-content-input, .side-panel .notes-content-input {
|
||||
min-height: 150px;
|
||||
}
|
||||
.workspace-secondary #notesListView, .side-panel #notesListView {
|
||||
#notesListView {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
.workspace-secondary #notesEditorView, .side-panel #notesEditorView {
|
||||
#notesEditorView {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
|
||||
@@ -304,8 +305,10 @@
|
||||
.notes-selection-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-dim); border-bottom: 1px solid var(--border);
|
||||
background: var(--accent-dim); border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
position: sticky; bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.notes-select-all {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
||||
@@ -404,3 +404,53 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
||||
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
|
||||
|
||||
/* ── SDK Primitives (sw.menu, sw.dropdown, sw.toolbar, sw.tabs) ── */
|
||||
|
||||
/* Menu flyout — reusable dropdown attached to any anchor */
|
||||
.sw-menu-wrap { position: relative; display: inline-flex; }
|
||||
.sw-menu-flyout {
|
||||
display: none; position: absolute; right: 0; left: auto; z-index: 200;
|
||||
background: var(--bg-elevated, #42424e); border: 1px solid var(--border-elevated, #555);
|
||||
border-radius: var(--radius-lg, 8px); padding: 4px; min-width: 170px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
}
|
||||
.sw-menu-flyout[data-position="down"] { top: 100%; margin-top: 4px; }
|
||||
.sw-menu-flyout[data-position="up"] { bottom: 100%; margin-bottom: 4px; top: auto; }
|
||||
.sw-menu-flyout.open { display: block; }
|
||||
.sw-menu-flyout .flyout-item,
|
||||
.sw-menu-flyout .flyout-divider,
|
||||
.sw-menu-flyout .flyout-danger { /* inherits from user-menu.css flyout-item styles */ }
|
||||
|
||||
/* Dropdown — styled native select */
|
||||
.sw-dropdown {
|
||||
background: var(--bg-raised, #222); color: var(--text, #eee);
|
||||
border: 1px solid var(--border, #2e2e35); border-radius: var(--radius, 6px);
|
||||
padding: 6px 10px; font-size: 13px; font-family: inherit;
|
||||
cursor: pointer; min-width: 100px;
|
||||
}
|
||||
.sw-dropdown:hover { border-color: var(--border-light, #444); }
|
||||
.sw-dropdown:focus { outline: none; border-color: var(--accent, #b38a4e); }
|
||||
|
||||
/* Toolbar — horizontal button row */
|
||||
.sw-toolbar {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Tabs — tabbed content container */
|
||||
.sw-tabs { display: flex; flex-direction: column; height: 100%; }
|
||||
.sw-tabs-bar {
|
||||
display: flex; gap: 0; border-bottom: 1px solid var(--border, #2e2e35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw-tab-btn {
|
||||
padding: 8px 16px; background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-2, #999); cursor: pointer; font-size: 12px; font-weight: 600;
|
||||
font-family: inherit; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.sw-tab-btn:hover { color: var(--text, #eee); }
|
||||
.sw-tab-btn--active { color: var(--accent, #b38a4e); border-bottom-color: var(--accent, #b38a4e); }
|
||||
.sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.sw-tab-panel { height: 100%; overflow-y: auto; }
|
||||
|
||||
|
||||
@@ -76,14 +76,15 @@
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-raised, #1a1a1e);
|
||||
border: 1px solid var(--border-light, #333);
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
background: var(--bg-elevated, #42424e);
|
||||
border: 1px solid var(--border-elevated, #555);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
padding: 4px;
|
||||
min-width: 170px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-flyout.open {
|
||||
@@ -134,9 +135,10 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Context (flyout pops UP) ──────── */
|
||||
/* When user-menu is inside .sidebar, flip the flyout direction */
|
||||
/* ── Flyout Direction: UP ──────────────────── */
|
||||
/* data-flyout="up" on .user-menu-wrap (or legacy .sidebar parent) */
|
||||
|
||||
.user-menu-wrap[data-flyout="up"] .user-flyout,
|
||||
.sidebar .user-menu-wrap .user-flyout {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
--bg-surface: #18181b;
|
||||
--bg-raised: #222227;
|
||||
--bg-hover: #2a2a30;
|
||||
--bg-elevated: #42424e;
|
||||
--border: #2e2e35;
|
||||
--border-elevated: #555560;
|
||||
--border-light: #3a3a42;
|
||||
--text: #e8e8ed;
|
||||
--text-2: #9898a8;
|
||||
@@ -75,7 +77,9 @@
|
||||
--bg-surface: #ffffff;
|
||||
--bg-raised: #eff0f3;
|
||||
--bg-hover: #e5e6eb;
|
||||
--bg-elevated: #ffffff;
|
||||
--border: #d5d6dc;
|
||||
--border-elevated: #c0c0c8;
|
||||
--border-light: #c2c3cb;
|
||||
--text: #1a1a2e;
|
||||
--text-2: #555770;
|
||||
|
||||
@@ -146,6 +146,53 @@ const ChatPane = {
|
||||
},
|
||||
|
||||
active() { return this.primary; },
|
||||
|
||||
// v0.31.0: Self-mount — creates DOM + binds in one call.
|
||||
mount(container, opts) {
|
||||
const pfx = opts.id || 'cp';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-pane';
|
||||
el.id = pfx + 'ChatPane';
|
||||
el.innerHTML =
|
||||
'<div class="chat-pane-header" id="' + pfx + 'ChatHeader" style="display:none;">' +
|
||||
'<div class="chat-pane-header-left">' +
|
||||
'<select class="chat-pane-chat-select" id="' + pfx + 'ChatSelect" title="Switch chat">' +
|
||||
'<option value="">New conversation</option>' +
|
||||
'</select>' +
|
||||
'<button class="chat-pane-new-btn" id="' + pfx + 'ChatNewBtn" title="New chat">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-header-right">' +
|
||||
'<div class="chat-pane-model-sel" id="' + pfx + 'ModelSel"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-messages" id="' + pfx + 'ChatMessages"></div>' +
|
||||
'<div class="chat-pane-input-bar">' +
|
||||
'<div class="chat-pane-input-wrap">' +
|
||||
'<div class="chat-pane-input" id="' + pfx + 'ChatInput"></div>' +
|
||||
'<button class="chat-pane-send" id="' + pfx + 'SendBtn" title="Send">' +
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>' +
|
||||
'</svg>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-toolbar" id="' + pfx + 'Toolbar"></div>' +
|
||||
'</div>';
|
||||
container.appendChild(el);
|
||||
// ChatPane.create expects element refs, not ID prefix
|
||||
const instance = this.create({
|
||||
id: pfx,
|
||||
messagesEl: document.getElementById(pfx + 'ChatMessages'),
|
||||
inputEl: document.getElementById(pfx + 'ChatInput'),
|
||||
sendBtnEl: document.getElementById(pfx + 'SendBtn'),
|
||||
modelSelEl: document.getElementById(pfx + 'ModelSel'),
|
||||
toolbarEl: document.getElementById(pfx + 'Toolbar'),
|
||||
channelId: opts.channelId || null,
|
||||
standalone: opts.standalone !== false,
|
||||
});
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -289,6 +289,36 @@ const CodeEditor = {
|
||||
CodeEditor._register(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
// v0.31.0: Self-mount — creates DOM + binds in one call.
|
||||
mount(container, opts) {
|
||||
const pfx = opts.id || 'ce';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'code-editor';
|
||||
el.id = pfx + 'CodeEditor';
|
||||
el.innerHTML =
|
||||
'<div class="code-editor-tabs" id="' + pfx + 'EditorTabs"></div>' +
|
||||
'<div class="code-editor-content" id="' + pfx + 'EditorContent">' +
|
||||
'<div class="code-editor-welcome" id="' + pfx + 'EditorWelcome">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">' +
|
||||
'<div style="text-align:center;">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>' +
|
||||
'<p style="margin:0;font-size:14px;">Open a file to start editing</p>' +
|
||||
'<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="code-editor-statusbar" id="' + pfx + 'EditorStatus">' +
|
||||
'<span id="' + pfx + 'EditorStatusFile"></span>' +
|
||||
'<span id="' + pfx + 'EditorStatusLang"></span>' +
|
||||
'<span id="' + pfx + 'EditorStatusBranch"></span>' +
|
||||
'</div>';
|
||||
container.appendChild(el);
|
||||
const instance = this.create(opts);
|
||||
instance.bind();
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Editor Surface (v0.25.0)
|
||||
// ==========================================
|
||||
// Uses PaneContainer to mount a three-pane layout and moves
|
||||
// server-rendered component partials into pane slots.
|
||||
// Components are rendered by Go templates into #editorComponents
|
||||
// (hidden) — this JS moves them into the right pane slots.
|
||||
// ==========================================
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.__SURFACE__ !== 'editor') return;
|
||||
|
||||
const pageData = window.__PAGE_DATA__ || {};
|
||||
const wsId = pageData.WorkspaceID;
|
||||
const wsName = pageData.WorkspaceName || 'Workspace';
|
||||
|
||||
// v0.28.0: UserMenu hydration moved to base.html universal init.
|
||||
|
||||
// Wire workspace selector
|
||||
_initWsSelector(wsId);
|
||||
|
||||
// No workspace — show bootstrap
|
||||
if (!wsId) {
|
||||
_showBootstrap();
|
||||
return;
|
||||
}
|
||||
|
||||
_mountEditor(wsId, wsName);
|
||||
});
|
||||
|
||||
// ── Workspace Selector ──────────────────
|
||||
|
||||
function _initWsSelector(currentWsId) {
|
||||
const btn = document.getElementById('editorWsSelectorBtn');
|
||||
const dropdown = document.getElementById('editorWsDropdown');
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown.classList.toggle('open');
|
||||
if (isOpen) _loadWsDropdown(currentWsId);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
|
||||
});
|
||||
|
||||
// New workspace button
|
||||
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
|
||||
dropdown.classList.remove('open');
|
||||
const name = window.prompt('Workspace name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const userId = sw.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (newId) {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadWsDropdown(currentWsId) {
|
||||
const listEl = document.getElementById('editorWsList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap (no workspace) ────────────
|
||||
|
||||
function _showBootstrap() {
|
||||
const body = document.getElementById('editorBody');
|
||||
const bootstrap = document.getElementById('editorBootstrap');
|
||||
if (body) body.style.display = 'none';
|
||||
if (bootstrap) bootstrap.style.display = '';
|
||||
|
||||
_loadBootstrapList();
|
||||
|
||||
const btn = document.getElementById('editorBootstrapBtn');
|
||||
const input = document.getElementById('editorBootstrapName');
|
||||
if (!btn || !input) return;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = input.value.trim() || 'workspace';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating…';
|
||||
try {
|
||||
const userId = sw.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (!newId) throw new Error('No workspace ID returned');
|
||||
// Navigate to the new workspace
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Workspace';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadBootstrapList() {
|
||||
const listEl = document.getElementById('editorBootstrapList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-bootstrap-ws-item';
|
||||
item.innerHTML =
|
||||
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mount Pane Layout ───────────────────
|
||||
|
||||
function _mountEditor(wsId, wsName) {
|
||||
const body = document.getElementById('editorBody');
|
||||
if (!body || typeof PaneContainer === 'undefined') {
|
||||
console.error('[EditorSurface] Missing body or PaneContainer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mount the 'editor' preset: files | editor | <chat, notes>
|
||||
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
|
||||
if (!layout) return;
|
||||
|
||||
// ── Move server-rendered components into pane slots ──
|
||||
// Components were rendered by Go templates into #editorComponents (hidden).
|
||||
// We move them into the pane slots created by PaneContainer.
|
||||
|
||||
const components = document.getElementById('editorComponents');
|
||||
|
||||
// File Tree → files pane
|
||||
const filesPaneInfo = layout._panes.get('files');
|
||||
const fileTreeEl = document.getElementById('edFileTree');
|
||||
if (filesPaneInfo?.el && fileTreeEl) {
|
||||
filesPaneInfo.el.appendChild(fileTreeEl);
|
||||
}
|
||||
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
|
||||
onSelect: (path) => codeEditor.openFile(path),
|
||||
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
|
||||
onNewFile: () => _createNewFile(wsId, fileTree),
|
||||
});
|
||||
filesPaneInfo.component = fileTree;
|
||||
fileTree.bind();
|
||||
|
||||
// Code Editor → editor pane
|
||||
const editorPaneInfo = layout._panes.get('editor');
|
||||
const codeEditorEl = document.getElementById('edCodeEditor');
|
||||
if (editorPaneInfo?.el && codeEditorEl) {
|
||||
editorPaneInfo.el.appendChild(codeEditorEl);
|
||||
}
|
||||
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
|
||||
onSave: () => fileTree.refresh(),
|
||||
onActivate: (path) => fileTree.setActiveFile(path),
|
||||
});
|
||||
editorPaneInfo.component = codeEditor;
|
||||
codeEditor.bind();
|
||||
|
||||
// Assist pane (tabbed: chat + notes)
|
||||
const assistPaneInfo = layout._panes.get('assist');
|
||||
if (assistPaneInfo?.tabs) {
|
||||
// Chat tab — move server-rendered ChatPane partial
|
||||
const chatPanel = assistPaneInfo.getTabPanel('chat');
|
||||
const chatPaneEl = document.getElementById('edChatPane');
|
||||
if (chatPanel && chatPaneEl) {
|
||||
chatPanel.appendChild(chatPaneEl);
|
||||
// Create ChatPane instance from the server-rendered mount points
|
||||
if (typeof ChatPane !== 'undefined') {
|
||||
const chatPane = ChatPane.create({
|
||||
id: 'ed',
|
||||
messagesEl: document.getElementById('edChatMessages'),
|
||||
inputEl: document.getElementById('edChatInput'),
|
||||
sendBtnEl: document.getElementById('edSendBtn'),
|
||||
modelSelEl: document.getElementById('edModelSel'),
|
||||
toolbarEl: document.getElementById('edToolbar'),
|
||||
standalone: true,
|
||||
});
|
||||
chatPane.showWelcome();
|
||||
_initAssistChat(chatPane, codeEditor);
|
||||
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
|
||||
if (chatTab) chatTab.instance = chatPane;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes tab — move server-rendered NoteEditor partial
|
||||
const notesPanel = assistPaneInfo.getTabPanel('notes');
|
||||
const noteEditorEl = document.getElementById('edNotesNoteEditor');
|
||||
if (notesPanel && noteEditorEl) {
|
||||
notesPanel.appendChild(noteEditorEl);
|
||||
if (typeof NoteEditor !== 'undefined') {
|
||||
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
|
||||
noteEditor.bind();
|
||||
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
|
||||
if (notesTab) {
|
||||
notesTab.instance = noteEditor;
|
||||
// Lazy-load on first tab click
|
||||
notesTab.btn.addEventListener('click', () => {
|
||||
if (!notesTab._loaded) {
|
||||
notesTab._loaded = true;
|
||||
noteEditor.loadNotes();
|
||||
noteEditor.loadFolders();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the hidden container
|
||||
if (components) components.remove();
|
||||
|
||||
// Git branch
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
|
||||
// Toolbar
|
||||
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
|
||||
fileTree.refresh();
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fileTree.refresh();
|
||||
|
||||
console.log('[EditorSurface] Mounted for workspace', wsId);
|
||||
}
|
||||
|
||||
// ── File Operations ─────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete ' + path + '?')
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.deleteWorkspaceFile(wsId, path);
|
||||
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
|
||||
fileTree.refresh();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewFile(wsId, fileTree) {
|
||||
const name = window.prompt('File name (e.g. src/main.go):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await API.writeWorkspaceFile(wsId, name.trim(), '');
|
||||
fileTree.refresh();
|
||||
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Branch ──────────────────────────
|
||||
|
||||
async function _refreshGitBranch(wsId, codeEditor) {
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitBranches(wsId);
|
||||
const branch = resp.current || null;
|
||||
if (branch) {
|
||||
const badge = document.getElementById('editorBranchBadge');
|
||||
const name = document.getElementById('editorBranchName');
|
||||
if (badge) badge.style.display = '';
|
||||
if (name) name.textContent = branch;
|
||||
}
|
||||
if (codeEditor) codeEditor.setBranch(branch);
|
||||
} catch (_) {
|
||||
// Git not configured — hide branch badge
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
|
||||
// ── Assist Chat (channel-based, standalone) ──
|
||||
// Full chat pane for the editor: model selector, chat history,
|
||||
// new chat, SSE streaming. Uses real channels/messages API.
|
||||
// No dependency on chat.js — fully standalone.
|
||||
|
||||
function _initAssistChat(chatPane, codeEditor) {
|
||||
const inputEl = chatPane.inputEl;
|
||||
const sendBtn = chatPane.sendBtnEl;
|
||||
const headerEl = document.getElementById('edChatHeader');
|
||||
const selectEl = document.getElementById('edChatSelect');
|
||||
const newBtnEl = document.getElementById('edChatNewBtn');
|
||||
const modelSelEl = document.getElementById('edModelSel');
|
||||
if (!inputEl) return;
|
||||
|
||||
// Show the header bar
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
|
||||
// State
|
||||
let _channelId = null;
|
||||
let _messages = [];
|
||||
let _sending = false;
|
||||
let _abortController = null;
|
||||
let _selectedModel = App.settings?.model || '';
|
||||
|
||||
// ── Model Selector ──────────────────────
|
||||
async function _initModelSelector() {
|
||||
if (!modelSelEl) return;
|
||||
// Ensure models are loaded
|
||||
if (!App.models?.length && typeof fetchModels === 'function') {
|
||||
await fetchModels();
|
||||
}
|
||||
const models = App.models || [];
|
||||
if (!models.length) return;
|
||||
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'chat-pane-model-select';
|
||||
sel.title = 'Select model';
|
||||
|
||||
models.forEach(m => {
|
||||
if (m.hidden) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const prefix = m.isPersona ? '🎭 ' : '';
|
||||
opt.textContent = prefix + (m.name || m.id);
|
||||
if (m.id === _selectedModel || m.personaId === _selectedModel) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
sel.addEventListener('change', () => {
|
||||
_selectedModel = sel.value;
|
||||
});
|
||||
|
||||
modelSelEl.innerHTML = '';
|
||||
modelSelEl.appendChild(sel);
|
||||
}
|
||||
|
||||
// ── Chat History Selector ───────────────
|
||||
async function _loadChatHistory() {
|
||||
if (!selectEl) return;
|
||||
try {
|
||||
const resp = await API.listChannels(1, 20, 'direct');
|
||||
const channels = resp.data || resp || [];
|
||||
selectEl.innerHTML = '<option value="">New conversation</option>';
|
||||
channels.forEach(ch => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch.id;
|
||||
opt.textContent = ch.title || 'Untitled';
|
||||
if (ch.id === _channelId) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[AssistChat] Failed to load history:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectEl) {
|
||||
selectEl.addEventListener('change', () => {
|
||||
const id = selectEl.value;
|
||||
if (id) {
|
||||
_switchToChannel(id);
|
||||
} else {
|
||||
_newChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newBtnEl) {
|
||||
newBtnEl.addEventListener('click', _newChat);
|
||||
}
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
async function _switchToChannel(channelId) {
|
||||
_channelId = channelId;
|
||||
_messages = [];
|
||||
chatPane.clear();
|
||||
chatPane.appendTyping();
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
|
||||
const msgs = resp.data || resp || [];
|
||||
_messages = msgs;
|
||||
chatPane.removeTyping();
|
||||
_renderAllMessages();
|
||||
} catch (e) {
|
||||
chatPane.removeTyping();
|
||||
_appendSystemMsg('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _newChat() {
|
||||
_channelId = null;
|
||||
_messages = [];
|
||||
chatPane.showWelcome();
|
||||
if (selectEl) selectEl.value = '';
|
||||
}
|
||||
|
||||
// ── Create Textarea ─────────────────────
|
||||
const ta = document.createElement('textarea');
|
||||
ta.placeholder = 'Ask about your code…';
|
||||
ta.rows = 1;
|
||||
inputEl.appendChild(ta);
|
||||
|
||||
ta.addEventListener('input', () => {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
// ── Send ────────────────────────────────
|
||||
async function sendMessage() {
|
||||
const text = ta.value.trim();
|
||||
if (!text || _sending) return;
|
||||
|
||||
_sending = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
ta.value = '';
|
||||
ta.style.height = 'auto';
|
||||
|
||||
// Create channel if new conversation
|
||||
if (!_channelId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
|
||||
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
|
||||
_channelId = resp.id;
|
||||
// Update dropdown
|
||||
_loadChatHistory();
|
||||
} catch (e) {
|
||||
_appendSystemMsg('Failed to create chat: ' + e.message);
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Render user message
|
||||
_messages.push({ role: 'user', content: text });
|
||||
_renderAllMessages();
|
||||
|
||||
// Build context from active file
|
||||
const fileCtx = _getFileContext(codeEditor);
|
||||
|
||||
// Stream the completion
|
||||
try {
|
||||
_abortController = new AbortController();
|
||||
|
||||
// If we have file context, prepend it to the message
|
||||
const content = fileCtx
|
||||
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
|
||||
: text;
|
||||
|
||||
const resp = await API.streamCompletion(
|
||||
_channelId, content, _selectedModel,
|
||||
_abortController.signal
|
||||
);
|
||||
|
||||
await _handleStream(resp, chatPane);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
_appendSystemMsg('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_abortController = null;
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// ── SSE Stream Handler ──────────────────
|
||||
async function _handleStream(response, pane) {
|
||||
const messagesEl = pane.messagesEl;
|
||||
if (!messagesEl) return;
|
||||
|
||||
// Create assistant bubble
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg__content';
|
||||
div.appendChild(contentEl);
|
||||
messagesEl.appendChild(div);
|
||||
|
||||
let content = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentEl.textContent = content;
|
||||
}
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
} catch (_) { /* skip unparseable lines */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
content += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
}
|
||||
|
||||
div.classList.remove('chat-msg--streaming');
|
||||
_messages.push({ role: 'assistant', content });
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────
|
||||
function _renderAllMessages() {
|
||||
if (!chatPane.messagesEl) return;
|
||||
chatPane.messagesEl.innerHTML = '';
|
||||
_messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'chat-msg__content';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
|
||||
} else {
|
||||
content.textContent = m.content || '';
|
||||
}
|
||||
div.appendChild(content);
|
||||
chatPane.messagesEl.appendChild(div);
|
||||
});
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _appendSystemMsg(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--system';
|
||||
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + esc(text) + '</div>';
|
||||
chatPane.messagesEl?.appendChild(div);
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _getFileContext(editor) {
|
||||
if (!editor) return null;
|
||||
try {
|
||||
const openFiles = editor.getOpenFiles();
|
||||
if (!openFiles.length) return null;
|
||||
// Find active tab
|
||||
const activeTab = document.querySelector('.code-editor-tab.active');
|
||||
const path = activeTab?.dataset?.path || openFiles[0];
|
||||
// Get content from the editor instances
|
||||
const inst = CodeEditor._instances?.get('ed');
|
||||
if (inst?._files) {
|
||||
const file = inst._files.get(path);
|
||||
if (file?.view) {
|
||||
const content = file.view.state.doc.toString();
|
||||
return { path, content: content.slice(0, 4000) };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────
|
||||
// Defer model selector and chat history loading until app common init
|
||||
// completes (fetchModels, auth, etc.). The sb:ready event fires from
|
||||
// startApp() after App.models is populated.
|
||||
function _deferredInit() {
|
||||
_initModelSelector();
|
||||
_loadChatHistory();
|
||||
}
|
||||
|
||||
if (App.models?.length) {
|
||||
// Already loaded (unlikely but possible)
|
||||
_deferredInit();
|
||||
} else {
|
||||
document.addEventListener('sb:ready', _deferredInit, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,27 @@ const FileTree = {
|
||||
FileTree._register(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
// v0.31.0: Self-mount — creates DOM + binds in one call.
|
||||
mount(container, opts) {
|
||||
const pfx = opts.id || 'ft';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'file-tree';
|
||||
el.id = pfx + 'FileTree';
|
||||
el.innerHTML =
|
||||
'<div class="file-tree-header" id="' + pfx + 'TreeHeader">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
|
||||
'<span class="file-tree-title">Files</span>' +
|
||||
'<button class="icon-btn" id="' + pfx + 'TreeNewFile" title="New file">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="file-tree-items" id="' + pfx + 'TreeItems"></div>';
|
||||
container.appendChild(el);
|
||||
const instance = this.create(opts);
|
||||
instance.bind();
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — NoteEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Component wrapper for notes list + editor.
|
||||
// Hydrates the server-rendered note-editor.html partial.
|
||||
// Can run standalone (in editor tabbed pane) or coexist with
|
||||
// the existing notes.js panel system (on chat/notes surfaces).
|
||||
//
|
||||
// Usage:
|
||||
// const notes = NoteEditor.create({
|
||||
// id: 'main', // matches template prefix
|
||||
// onOpenGraph: () => { ... },
|
||||
// });
|
||||
// notes.bind();
|
||||
// await notes.loadNotes();
|
||||
// notes.destroy();
|
||||
//
|
||||
// The existing notes.js + PanelRegistry integration continues to
|
||||
// work on the chat surface. This component is for NEW mount points
|
||||
// (editor pane, future notes-studio layout).
|
||||
//
|
||||
// Exports: window.NoteEditor
|
||||
|
||||
|
||||
const NoteEditor = {
|
||||
...createComponentRegistry('NoteEditor'),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'main';
|
||||
const instance = componentMixin({
|
||||
id: pfx,
|
||||
rootEl: document.getElementById(pfx + 'NoteEditor'),
|
||||
listViewEl: document.getElementById(pfx + 'NotesListView'),
|
||||
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
|
||||
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
|
||||
listEl: document.getElementById(pfx + 'NotesList'),
|
||||
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
|
||||
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
|
||||
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
|
||||
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
|
||||
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
|
||||
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
|
||||
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
|
||||
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
|
||||
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
|
||||
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
|
||||
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
|
||||
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
|
||||
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
|
||||
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
|
||||
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
|
||||
onOpenGraph: opts.onOpenGraph || null,
|
||||
_editingNoteId: null,
|
||||
_currentNote: null,
|
||||
_sort: 'updated_desc',
|
||||
_cmEditor: null,
|
||||
|
||||
// ── View Switching ───────────────────
|
||||
|
||||
showList() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = '';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
this._destroyCmEditor();
|
||||
},
|
||||
|
||||
showEditor() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = '';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
},
|
||||
|
||||
showGraph() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = '';
|
||||
},
|
||||
|
||||
// ── Note List ───────────────────────
|
||||
|
||||
async loadNotes(folder, searchQuery) {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let notes;
|
||||
if (searchQuery) {
|
||||
const resp = await API.searchNotes(searchQuery);
|
||||
notes = resp.data || [];
|
||||
} else {
|
||||
const folderVal = folder || this.folderFilterEl?.value || '';
|
||||
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
|
||||
notes = resp.data || [];
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
this.listEl.innerHTML = '<div class="empty-hint">' +
|
||||
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = '';
|
||||
const self = this;
|
||||
notes.forEach(note => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'note-item';
|
||||
item.dataset.noteId = note.id;
|
||||
const title = note.title || 'Untitled';
|
||||
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
|
||||
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
|
||||
item.innerHTML =
|
||||
'<div class="note-content-col">' +
|
||||
'<div class="note-title">' + esc(title) + '</div>' +
|
||||
'<div class="note-preview">' + esc(preview) + '</div>' +
|
||||
'<div class="note-date">' + esc(date) + '</div>' +
|
||||
'</div>';
|
||||
item.addEventListener('click', () => self.openNote(note.id));
|
||||
self.listEl.appendChild(item);
|
||||
});
|
||||
} catch (e) {
|
||||
this.listEl.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadFolders() {
|
||||
if (!this.folderFilterEl) return;
|
||||
try {
|
||||
const resp = await API.listNoteFolders();
|
||||
const folders = resp.data || resp || [];
|
||||
// Keep "All folders" option, rebuild the rest
|
||||
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = f;
|
||||
this.folderFilterEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── Note CRUD ───────────────────────
|
||||
|
||||
async openNote(noteId) {
|
||||
this.showEditor();
|
||||
if (noteId) {
|
||||
this._editingNoteId = noteId;
|
||||
try {
|
||||
this._currentNote = await API.getNote(noteId);
|
||||
this._populateFields(this._currentNote);
|
||||
this._showReadMode();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
this.showList();
|
||||
}
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
this._showEditMode();
|
||||
if (this.titleEl) this.titleEl.focus();
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
const title = this.titleEl?.value?.trim() || '';
|
||||
const content = this._getContent();
|
||||
const folder = this.folderEl?.value?.trim() || '';
|
||||
const tagsStr = this.tagsEl?.value || '';
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
if (!title && !content) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._editingNoteId) {
|
||||
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
|
||||
} else {
|
||||
const resp = await API.createNote(title, content, folder, tags);
|
||||
this._editingNoteId = resp.id || resp.data?.id;
|
||||
}
|
||||
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
|
||||
this._showReadMode();
|
||||
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteNote() {
|
||||
if (!this._editingNoteId) return;
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete this note?')
|
||||
: window.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API.deleteNote(this._editingNoteId);
|
||||
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this.showList();
|
||||
this.loadNotes();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Read / Edit / Preview Modes ─────
|
||||
|
||||
_showReadMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = 'none';
|
||||
if (this.readModeEl) this.readModeEl.style.display = '';
|
||||
|
||||
const n = this._currentNote;
|
||||
if (!n) return;
|
||||
|
||||
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
|
||||
if (this.readMetaEl) {
|
||||
const parts = [];
|
||||
if (n.folder_path) parts.push('📁 ' + n.folder_path);
|
||||
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
|
||||
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
|
||||
this.readMetaEl.textContent = parts.join(' · ');
|
||||
}
|
||||
if (this.readContentEl) {
|
||||
if (typeof marked !== 'undefined') {
|
||||
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
|
||||
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
|
||||
} else {
|
||||
this.readContentEl.textContent = n.content || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Show edit/delete buttons for read mode
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
this._setBtn('NoteCancelEditBtn', false);
|
||||
this._setBtn('NoteDeleteBtn', true);
|
||||
},
|
||||
|
||||
_showEditMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = '';
|
||||
if (this.readModeEl) this.readModeEl.style.display = 'none';
|
||||
|
||||
this._setBtn('NoteEditBtn', false);
|
||||
this._setBtn('NotePreviewBtn', true);
|
||||
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
|
||||
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
|
||||
},
|
||||
|
||||
_showPreview() {
|
||||
// Toggle between edit and read-mode preview
|
||||
if (this.readModeEl?.style.display === 'none') {
|
||||
// Show preview of current edits
|
||||
const tempNote = {
|
||||
title: this.titleEl?.value || '',
|
||||
content: this._getContent(),
|
||||
folder_path: this.folderEl?.value || '',
|
||||
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
};
|
||||
const saved = this._currentNote;
|
||||
this._currentNote = tempNote;
|
||||
this._showReadMode();
|
||||
this._currentNote = saved;
|
||||
// Swap buttons for preview-of-edits state
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
} else {
|
||||
this._showEditMode();
|
||||
}
|
||||
},
|
||||
|
||||
// ── CM6 Editor Management ───────────
|
||||
|
||||
_getContent() {
|
||||
if (this._cmEditor) return this._cmEditor.getValue();
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
_setContent(text) {
|
||||
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
|
||||
|
||||
// Lazy-init CM6
|
||||
if (this.contentContainerEl && window.CM?.noteEditor) {
|
||||
this.contentContainerEl.innerHTML = '';
|
||||
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onLink: (title) => this._navigateToLink(title),
|
||||
linkCompleter: async (query) => {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(query, 8);
|
||||
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Textarea fallback
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
},
|
||||
|
||||
_destroyCmEditor() {
|
||||
if (this._cmEditor?.destroy) this._cmEditor.destroy();
|
||||
this._cmEditor = null;
|
||||
},
|
||||
|
||||
async _navigateToLink(title) {
|
||||
try {
|
||||
const resp = await API.searchNotes(title);
|
||||
const notes = resp.data || [];
|
||||
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
this.openNote(match.id);
|
||||
} else {
|
||||
// Create new note with this title
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = title;
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ── Field Helpers ───────────────────
|
||||
|
||||
_populateFields(note) {
|
||||
if (this.titleEl) this.titleEl.value = note.title || '';
|
||||
if (this.folderEl) this.folderEl.value = note.folder_path || '';
|
||||
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
|
||||
this._setContent(note.content || '');
|
||||
},
|
||||
|
||||
_clearFields() {
|
||||
if (this.titleEl) this.titleEl.value = '';
|
||||
if (this.folderEl) this.folderEl.value = '';
|
||||
if (this.tagsEl) this.tagsEl.value = '';
|
||||
this._setContent('');
|
||||
},
|
||||
|
||||
_setBtn(suffix, show) {
|
||||
const el = document.getElementById(pfx + suffix);
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
const $ = (id) => document.getElementById(pfx + id);
|
||||
const self = this;
|
||||
|
||||
// Toolbar
|
||||
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
|
||||
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
|
||||
this._on($('NotesGraphBtn'), 'click', () => {
|
||||
if (self.onOpenGraph) self.onOpenGraph();
|
||||
});
|
||||
|
||||
// Editor header
|
||||
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
|
||||
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
|
||||
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
|
||||
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
|
||||
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
|
||||
this._on($('NoteCancelEditBtn'), 'click', () => {
|
||||
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
|
||||
else self.showList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
this._on(this.folderFilterEl, 'change', () => {
|
||||
if (self.searchEl) self.searchEl.value = '';
|
||||
self.loadNotes(self.folderFilterEl.value);
|
||||
});
|
||||
this._on(this.sortEl, 'change', () => {
|
||||
self._sort = self.sortEl.value;
|
||||
self.loadNotes();
|
||||
});
|
||||
|
||||
// Search with debounce
|
||||
let timer;
|
||||
this._on(this.searchEl, 'input', () => {
|
||||
clearTimeout(timer);
|
||||
const q = self.searchEl.value.trim();
|
||||
timer = setTimeout(() => {
|
||||
if (q.length >= 2) {
|
||||
if (self.folderFilterEl) self.folderFilterEl.value = '';
|
||||
self.loadNotes(null, q);
|
||||
} else if (q.length === 0) {
|
||||
self.loadNotes();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Daily Note ──────────────────────
|
||||
|
||||
async _openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const resp = await API.searchNotes(today);
|
||||
const notes = resp.data || [];
|
||||
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
|
||||
if (daily) {
|
||||
this.openNote(daily.id);
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = today;
|
||||
this._setContent('# ' + today + '\n\n');
|
||||
this.showEditor();
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_cleanup() {
|
||||
this._destroyCmEditor();
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
},
|
||||
}, NoteEditor);
|
||||
|
||||
NoteEditor._register(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('NoteEditor', NoteEditor);
|
||||
@@ -32,6 +32,9 @@ const NotePanel = {
|
||||
_noteEditor: null,
|
||||
_currentNote: null,
|
||||
_searchTimer: null,
|
||||
_page: 1,
|
||||
_pageSize: 50,
|
||||
_hasMore: false,
|
||||
|
||||
// ── DOM query helper ──
|
||||
$(sel) { return this.container.querySelector(sel); },
|
||||
@@ -39,33 +42,56 @@ const NotePanel = {
|
||||
|
||||
// ── Notes List ──
|
||||
|
||||
async loadNotesList(folder, searchQuery) {
|
||||
async loadNotesList(folder, searchQuery, append) {
|
||||
const list = this.$('#notesList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||
if (!append) {
|
||||
list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||
this._page = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
let data;
|
||||
let notes, isSearch = false;
|
||||
if (searchQuery) {
|
||||
data = await API.searchNotes(searchQuery);
|
||||
const results = data.data || [];
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">No results found</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = results.map(n => this._noteListItem(n, true)).join('');
|
||||
isSearch = true;
|
||||
const data = await API.searchNotes(searchQuery);
|
||||
notes = data.data || [];
|
||||
this._hasMore = false;
|
||||
} else {
|
||||
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
|
||||
data = await API.listNotes(100, 0, folderVal, '', this._notesSort);
|
||||
const notes = data.data || [];
|
||||
if (notes.length === 0) {
|
||||
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
|
||||
const offset = (this._page - 1) * this._pageSize;
|
||||
const data = await API.listNotes(this._pageSize, offset, folderVal, '', this._notesSort);
|
||||
notes = data.data || [];
|
||||
this._hasMore = notes.length >= this._pageSize;
|
||||
}
|
||||
|
||||
if (!append) list.innerHTML = '';
|
||||
|
||||
if (notes.length === 0 && !append) {
|
||||
const folderVal = this.$('#notesFolderFilter')?.value || '';
|
||||
list.innerHTML = `<div class="empty-hint">${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove old "Load more" button if present
|
||||
list.querySelector('.notes-load-more')?.remove();
|
||||
|
||||
list.insertAdjacentHTML('beforeend', notes.map(n => this._noteListItem(n, isSearch)).join(''));
|
||||
|
||||
// Pagination: "Load more" button
|
||||
if (this._hasMore) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-small notes-load-more';
|
||||
btn.textContent = 'Load more\u2026';
|
||||
btn.style.cssText = 'display:block;margin:8px auto;';
|
||||
btn.addEventListener('click', () => {
|
||||
this._page++;
|
||||
this.loadNotesList(null, null, true);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||
if (!append) list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -783,6 +809,168 @@ const NotePanel = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Self-mount: build DOM + create + wire in one call ──
|
||||
// Canonical entry point for all consumers (notes.js sidebar, editor package, sw.notes()).
|
||||
// Uses the same IDs that NotePanel.create() expects — $() is container-scoped so no collisions.
|
||||
NotePanel.mount = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'note-panel-root';
|
||||
el.innerHTML =
|
||||
'<!-- List view -->' +
|
||||
'<div id="notesListView">' +
|
||||
'<div class="notes-toolbar">' +
|
||||
'<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>' +
|
||||
'<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>' +
|
||||
'<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>' +
|
||||
'<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>' +
|
||||
'</div>' +
|
||||
'<div class="notes-search-row">' +
|
||||
'<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">' +
|
||||
'</div>' +
|
||||
'<div class="notes-filter-row">' +
|
||||
'<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>' +
|
||||
'<select id="notesSortSelect" class="notes-filter-select">' +
|
||||
'<option value="updated_desc">Last updated</option>' +
|
||||
'<option value="created_desc">Created</option>' +
|
||||
'<option value="alpha">Alphabetical</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div id="notesList" class="notes-list"></div>' +
|
||||
'<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">' +
|
||||
'<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>' +
|
||||
'<span id="notesSelectedCount">0</span> selected' +
|
||||
'<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>' +
|
||||
'<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<!-- Editor view -->' +
|
||||
'<div id="notesEditorView" style="display:none;">' +
|
||||
'<div class="notes-editor-header">' +
|
||||
'<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
|
||||
'<div style="flex:1"></div>' +
|
||||
'<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>' +
|
||||
'<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>' +
|
||||
'<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>' +
|
||||
'<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>' +
|
||||
'<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>' +
|
||||
'</div>' +
|
||||
'<div id="noteEditMode" class="notes-editor">' +
|
||||
'<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">' +
|
||||
'<div class="notes-meta-row">' +
|
||||
'<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">' +
|
||||
'<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">' +
|
||||
'</div>' +
|
||||
'<div id="noteEditorContentContainer" class="notes-content-container">' +
|
||||
'<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="noteReadMode" style="display:none;">' +
|
||||
'<div class="notes-read-view">' +
|
||||
'<h3 id="noteReadTitle" class="note-read-title"></h3>' +
|
||||
'<div id="noteReadMeta" class="note-read-meta"></div>' +
|
||||
'<div id="noteReadContent" class="note-read-content msg-content"></div>' +
|
||||
'<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="noteBacklinks" class="note-backlinks" style="display:none;">' +
|
||||
'<div class="note-backlinks-header" data-action="toggleBacklinks">' +
|
||||
'Backlinks <span id="noteBacklinksCount" class="badge">0</span>' +
|
||||
'</div>' +
|
||||
'<div id="noteBacklinksList" class="note-backlinks-list"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<!-- Graph view -->' +
|
||||
'<div id="notesGraphView" class="notes-graph-view" style="display:none;">' +
|
||||
'<div class="notes-graph-toolbar">' +
|
||||
'<button id="notesGraphBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
|
||||
'<span class="notes-graph-stats">' +
|
||||
'<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links' +
|
||||
'</span>' +
|
||||
'<div style="flex:1"></div>' +
|
||||
'<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>' +
|
||||
'<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>' +
|
||||
'</div>' +
|
||||
'<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>' +
|
||||
'</div>';
|
||||
container.appendChild(el);
|
||||
|
||||
// Create NotePanel instance — $() is container-scoped, so the IDs above "just work"
|
||||
const np = this.create({ container: el, projectId: _opts.projectId || null });
|
||||
|
||||
// ── Wire all events (same as notes.js _registerNotesPanel) ──
|
||||
const q = (sel) => el.querySelector(sel);
|
||||
|
||||
q('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
|
||||
q('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
|
||||
q('#notesGraphBtn').addEventListener('click', () => {
|
||||
np.showGraph();
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
if (_opts.onOpenGraph) _opts.onOpenGraph();
|
||||
});
|
||||
q('#notesGraphBackBtn').addEventListener('click', () => {
|
||||
np.showNotesList();
|
||||
if (typeof closeNoteGraph === 'function') closeNoteGraph();
|
||||
});
|
||||
q('#notesSelectModeBtn').addEventListener('click', () => {
|
||||
if (np._notesSelectMode) np._exitSelectMode();
|
||||
else np._enterSelectMode();
|
||||
});
|
||||
|
||||
q('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
|
||||
q('#noteSaveBtn').addEventListener('click', () => np.saveNote());
|
||||
q('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
|
||||
q('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
|
||||
q('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
|
||||
q('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
|
||||
q('#noteCancelEditBtn').addEventListener('click', () => {
|
||||
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
|
||||
else np.showNotesList();
|
||||
});
|
||||
|
||||
q('#notesFolderFilter').addEventListener('change', (e) => {
|
||||
np.$('#notesSearchInput').value = '';
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList(e.target.value);
|
||||
});
|
||||
q('#notesSortSelect').addEventListener('change', (e) => {
|
||||
np._notesSort = e.target.value;
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList();
|
||||
});
|
||||
q('#notesSelectAll').addEventListener('change', (e) => np._toggleSelectAll(e.target.checked));
|
||||
q('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
|
||||
q('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
|
||||
|
||||
let _searchTimer;
|
||||
q('#notesSearchInput').addEventListener('input', (e) => {
|
||||
clearTimeout(_searchTimer);
|
||||
const query = e.target.value.trim();
|
||||
_searchTimer = setTimeout(() => {
|
||||
np._exitSelectMode();
|
||||
if (query.length >= 2) {
|
||||
np.$('#notesFolderFilter').value = '';
|
||||
np.loadNotesList(null, query);
|
||||
} else if (query.length === 0) {
|
||||
np.loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Add showGraph view switcher
|
||||
np.showGraph = function () {
|
||||
const lv = np.$('#notesListView');
|
||||
const ev = np.$('#notesEditorView');
|
||||
const gv = np.$('#notesGraphView');
|
||||
if (lv) lv.style.display = 'none';
|
||||
if (ev) ev.style.display = 'none';
|
||||
if (gv) gv.style.display = '';
|
||||
};
|
||||
|
||||
return np;
|
||||
};
|
||||
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('NotePanel', NotePanel);
|
||||
|
||||
156
src/js/notes.js
156
src/js/notes.js
@@ -86,159 +86,23 @@ function _registerNotesPanel() {
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
|
||||
|
||||
// ── Create the panel element dynamically ──
|
||||
const el = document.createElement('div');
|
||||
el.className = 'side-panel-page';
|
||||
el.id = 'sidePanelNotes';
|
||||
el.style.display = 'none';
|
||||
el.innerHTML = `
|
||||
<!-- List view -->
|
||||
<div id="notesListView">
|
||||
<div class="notes-toolbar">
|
||||
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
|
||||
<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>
|
||||
<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>
|
||||
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>
|
||||
</div>
|
||||
<div class="notes-search-row">
|
||||
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">
|
||||
</div>
|
||||
<div class="notes-filter-row">
|
||||
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
||||
<select id="notesSortSelect" class="notes-filter-select">
|
||||
<option value="updated_desc">Last updated</option>
|
||||
<option value="created_desc">Created</option>
|
||||
<option value="alpha">Alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="notesList" class="notes-list"></div>
|
||||
<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">
|
||||
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>
|
||||
<span id="notesSelectedCount">0</span> selected
|
||||
<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
|
||||
<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editor view -->
|
||||
<div id="notesEditorView" style="display:none;">
|
||||
<div class="notes-editor-header">
|
||||
<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>
|
||||
<div style="flex:1"></div>
|
||||
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
|
||||
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
|
||||
<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
|
||||
<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
|
||||
<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
<!-- Edit mode -->
|
||||
<div id="noteEditMode" class="notes-editor">
|
||||
<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">
|
||||
<div class="notes-meta-row">
|
||||
<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
|
||||
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
|
||||
</div>
|
||||
<div id="noteEditorContentContainer" class="notes-content-container">
|
||||
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode -->
|
||||
<div id="noteReadMode" style="display:none;">
|
||||
<div class="notes-read-view">
|
||||
<h3 id="noteReadTitle" class="note-read-title"></h3>
|
||||
<div id="noteReadMeta" class="note-read-meta"></div>
|
||||
<div id="noteReadContent" class="note-read-content msg-content"></div>
|
||||
<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backlinks -->
|
||||
<div id="noteBacklinks" class="note-backlinks" style="display:none;">
|
||||
<div class="note-backlinks-header" data-action="toggleBacklinks">
|
||||
Backlinks <span id="noteBacklinksCount" class="badge">0</span>
|
||||
</div>
|
||||
<div id="noteBacklinksList" class="note-backlinks-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Graph view -->
|
||||
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
|
||||
<div class="notes-graph-toolbar">
|
||||
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">\u2190 Back</button>
|
||||
<span class="notes-graph-stats">
|
||||
<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links
|
||||
</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
|
||||
<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>
|
||||
</div>
|
||||
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
|
||||
</div>`;
|
||||
body.appendChild(el);
|
||||
// ── Create wrapper element ──
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'sidePanelNotes';
|
||||
wrapper.style.display = 'none';
|
||||
wrapper.style.height = '100%';
|
||||
body.appendChild(wrapper);
|
||||
|
||||
// ── Create NotePanel singleton ──
|
||||
const np = NotePanel.create({ container: el });
|
||||
// ── Mount NotePanel (DOM + create + wire — single source of truth) ──
|
||||
const np = NotePanel.mount(wrapper, {});
|
||||
NotePanel.primary = np;
|
||||
|
||||
// ── Wire listeners ──
|
||||
// ── Wire sidebar button ──
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
|
||||
el.querySelector('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
|
||||
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
});
|
||||
el.querySelector('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
|
||||
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
|
||||
if (typeof closeNoteGraph === 'function') closeNoteGraph();
|
||||
});
|
||||
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
|
||||
if (np._notesSelectMode) np._exitSelectMode();
|
||||
else np._enterSelectMode();
|
||||
});
|
||||
|
||||
el.querySelector('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
|
||||
el.querySelector('#noteSaveBtn').addEventListener('click', () => np.saveNote());
|
||||
el.querySelector('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
|
||||
el.querySelector('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
|
||||
el.querySelector('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
|
||||
el.querySelector('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
|
||||
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
|
||||
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
|
||||
else np.showNotesList();
|
||||
});
|
||||
|
||||
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
|
||||
np.$('#notesSearchInput').value = '';
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList(e.target.value);
|
||||
});
|
||||
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
|
||||
np._notesSort = e.target.value;
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList();
|
||||
});
|
||||
|
||||
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
|
||||
np._toggleSelectAll(e.target.checked);
|
||||
});
|
||||
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
|
||||
el.querySelector('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
|
||||
|
||||
let _notesSearchTimer;
|
||||
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
np._exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
np.$('#notesFolderFilter').value = '';
|
||||
np.loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
np.loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Register with PanelRegistry ──
|
||||
PanelRegistry.register('notes', {
|
||||
element: el,
|
||||
element: wrapper,
|
||||
label: 'Notes',
|
||||
onOpen() {
|
||||
np.loadNotesList();
|
||||
|
||||
@@ -217,6 +217,201 @@ const Switchboard = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── UI Factories ─────────────────────────────────────
|
||||
// Composable building blocks for surfaces and packages.
|
||||
// Every factory returns a plain object with the DOM element + control methods.
|
||||
|
||||
/**
|
||||
* Create a flyout menu (dropdown with action items).
|
||||
* @param {HTMLElement} anchor — element the menu attaches to
|
||||
* @param {object} opts — { items: [{label, icon?, danger?, onClick}], position: 'down'|'up' }
|
||||
* @returns {{ el, open, close, toggle, destroy }}
|
||||
*/
|
||||
sw.menu = function (anchor, opts) {
|
||||
const _opts = opts || {};
|
||||
const items = _opts.items || [];
|
||||
const pos = _opts.position || 'down';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sw-menu-wrap';
|
||||
wrap.style.position = 'relative';
|
||||
|
||||
const flyout = document.createElement('div');
|
||||
flyout.className = 'sw-menu-flyout';
|
||||
flyout.dataset.position = pos;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.divider) {
|
||||
const hr = document.createElement('hr');
|
||||
hr.className = 'flyout-divider';
|
||||
flyout.appendChild(hr);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
|
||||
btn.innerHTML = (item.icon || '') + '<span>' + (typeof esc === 'function' ? esc(item.label) : item.label) + '</span>';
|
||||
btn.addEventListener('click', () => {
|
||||
result.close();
|
||||
if (item.onClick) item.onClick();
|
||||
});
|
||||
flyout.appendChild(btn);
|
||||
});
|
||||
|
||||
wrap.appendChild(flyout);
|
||||
anchor.parentElement.insertBefore(wrap, anchor.nextSibling);
|
||||
wrap.insertBefore(anchor, flyout);
|
||||
|
||||
let _open = false;
|
||||
const result = {
|
||||
el: wrap,
|
||||
flyoutEl: flyout,
|
||||
open() { flyout.classList.add('open'); _open = true; },
|
||||
close() { flyout.classList.remove('open'); _open = false; },
|
||||
toggle() { _open ? result.close() : result.open(); },
|
||||
isOpen() { return _open; },
|
||||
destroy() { wrap.remove(); },
|
||||
};
|
||||
|
||||
anchor.addEventListener('click', (e) => { e.stopPropagation(); result.toggle(); });
|
||||
document.addEventListener('click', (e) => { if (_open && !e.target.closest('.sw-menu-wrap')) result.close(); });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a dropdown selector.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { items: [{value, label}], value?, onChange?, placeholder? }
|
||||
* @returns {{ el, getValue, setValue, setItems, destroy }}
|
||||
*/
|
||||
sw.dropdown = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const el = document.createElement('select');
|
||||
el.className = 'sw-dropdown';
|
||||
|
||||
function _render(items) {
|
||||
el.innerHTML = '';
|
||||
if (_opts.placeholder) {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = _opts.placeholder;
|
||||
ph.disabled = true;
|
||||
ph.selected = !_opts.value;
|
||||
el.appendChild(ph);
|
||||
}
|
||||
(items || []).forEach(item => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value;
|
||||
opt.textContent = item.label || item.value;
|
||||
if (item.value === _opts.value) opt.selected = true;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
_render(_opts.items);
|
||||
container.appendChild(el);
|
||||
|
||||
if (_opts.onChange) el.addEventListener('change', () => _opts.onChange(el.value));
|
||||
|
||||
return {
|
||||
el,
|
||||
getValue() { return el.value; },
|
||||
setValue(v) { el.value = v; },
|
||||
setItems(items) { const cur = el.value; _render(items); el.value = cur; },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a toolbar (horizontal button row).
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { items: [{label, icon?, title?, onClick?, id?}], class? }
|
||||
* @returns {{ el, getButton, destroy }}
|
||||
*/
|
||||
sw.toolbar = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sw-toolbar' + (_opts.class ? ' ' + _opts.class : '');
|
||||
const buttons = new Map();
|
||||
|
||||
(_opts.items || []).forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-small';
|
||||
btn.title = item.title || item.label || '';
|
||||
btn.innerHTML = (item.icon || '') + (item.label || '');
|
||||
if (item.onClick) btn.addEventListener('click', item.onClick);
|
||||
if (item.id) buttons.set(item.id, btn);
|
||||
el.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
return {
|
||||
el,
|
||||
getButton(id) { return buttons.get(id); },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tabbed content container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { tabs: [{id, label}], activeTab?, onActivate? }
|
||||
* @returns {{ el, activateTab, getPanel, activeTab, destroy }}
|
||||
*/
|
||||
sw.tabs = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const tabDefs = _opts.tabs || [];
|
||||
let _active = _opts.activeTab || (tabDefs[0]?.id);
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sw-tabs';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sw-tabs-bar';
|
||||
el.appendChild(bar);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'sw-tabs-content';
|
||||
el.appendChild(content);
|
||||
|
||||
const tabMap = new Map();
|
||||
|
||||
tabDefs.forEach(td => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sw-tab-btn' + (td.id === _active ? ' sw-tab-btn--active' : '');
|
||||
btn.textContent = td.label;
|
||||
btn.addEventListener('click', () => result.activateTab(td.id));
|
||||
bar.appendChild(btn);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sw-tab-panel';
|
||||
panel.style.display = td.id === _active ? '' : 'none';
|
||||
content.appendChild(panel);
|
||||
|
||||
tabMap.set(td.id, { btn, panel });
|
||||
});
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
const result = {
|
||||
el,
|
||||
activateTab(id) {
|
||||
_active = id;
|
||||
tabMap.forEach((t, tid) => {
|
||||
t.btn.classList.toggle('sw-tab-btn--active', tid === id);
|
||||
t.panel.style.display = tid === id ? '' : 'none';
|
||||
});
|
||||
if (_opts.onActivate) _opts.onActivate(id);
|
||||
},
|
||||
getPanel(id) { return tabMap.get(id)?.panel || null; },
|
||||
get activeTab() { return _active; },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ── Components ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -232,34 +427,15 @@ const Switchboard = {
|
||||
console.error('[Switchboard] ChatPane not available');
|
||||
return null;
|
||||
}
|
||||
const _opts = opts || {};
|
||||
// Find or create required child elements
|
||||
let messagesEl = container.querySelector('.sw-chat-messages');
|
||||
let inputEl = container.querySelector('.sw-chat-input');
|
||||
if (!messagesEl) {
|
||||
messagesEl = document.createElement('div');
|
||||
messagesEl.className = 'sw-chat-messages chat-messages';
|
||||
container.appendChild(messagesEl);
|
||||
}
|
||||
if (!inputEl) {
|
||||
inputEl = document.createElement('div');
|
||||
inputEl.className = 'sw-chat-input';
|
||||
container.appendChild(inputEl);
|
||||
}
|
||||
return ChatPane.create({
|
||||
messagesEl,
|
||||
inputEl,
|
||||
channelId: _opts.channelId || null,
|
||||
standalone: _opts.standalone !== false,
|
||||
});
|
||||
return ChatPane.mount(container, opts || {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a NotePanel instance in a container element.
|
||||
* Wraps NotePanel.create() with SDK-aware defaults.
|
||||
* Mount a NotePanel instance into a container element.
|
||||
* Builds DOM + creates + wires events in one call.
|
||||
*
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { projectId }
|
||||
* @param {object} opts — { projectId, onOpenGraph }
|
||||
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
|
||||
*/
|
||||
sw.notes = function (container, opts) {
|
||||
@@ -267,11 +443,7 @@ const Switchboard = {
|
||||
console.error('[Switchboard] NotePanel not available');
|
||||
return null;
|
||||
}
|
||||
const _opts = opts || {};
|
||||
return NotePanel.create({
|
||||
container,
|
||||
projectId: _opts.projectId || null,
|
||||
});
|
||||
return NotePanel.mount(container, opts || {});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -296,6 +468,49 @@ const Switchboard = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount a FileTree into a container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { id, workspaceId, onSelect, onDelete, onNewFile }
|
||||
* @returns {object|null} FileTree instance
|
||||
*/
|
||||
sw.fileTree = function (container, opts) {
|
||||
if (typeof FileTree === 'undefined') {
|
||||
console.error('[Switchboard] FileTree not available');
|
||||
return null;
|
||||
}
|
||||
return FileTree.mount(container, opts || {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount a CodeEditor into a container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { id, workspaceId, onSave, onActivate }
|
||||
* @returns {object|null} CodeEditor instance
|
||||
*/
|
||||
sw.codeEditor = function (container, opts) {
|
||||
if (typeof CodeEditor === 'undefined') {
|
||||
console.error('[Switchboard] CodeEditor not available');
|
||||
return null;
|
||||
}
|
||||
return CodeEditor.mount(container, opts || {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount a PaneContainer layout into a container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {string} layoutId — layout preset name (e.g. 'editor')
|
||||
* @param {object} opts — { workspaceId }
|
||||
* @returns {object|null} PaneContainer layout instance
|
||||
*/
|
||||
sw.layout = function (container, layoutId, opts) {
|
||||
if (typeof PaneContainer === 'undefined') {
|
||||
console.error('[Switchboard] PaneContainer not available');
|
||||
return null;
|
||||
}
|
||||
return PaneContainer.mount(container, layoutId, opts || {});
|
||||
};
|
||||
|
||||
// ── Workflow ─────────────────────────────────────────
|
||||
// v0.30.2: Workflow stage surface management.
|
||||
|
||||
@@ -524,36 +739,65 @@ const Switchboard = {
|
||||
_runRender(ctx) { return _runChain('render', ctx); },
|
||||
};
|
||||
|
||||
// ── UserMenu Hydration ───────────────────────────────
|
||||
// Absorbs the cs15 band-aid from base.html. Every surface
|
||||
// gets UserMenu wired up through the SDK, not through an
|
||||
// inline <script> block that duplicates logic.
|
||||
// ── UserMenu ─────────────────────────────────────────
|
||||
|
||||
const _defaultMenuHandlers = {
|
||||
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
|
||||
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
|
||||
onDebug: () => { sb.call('openDebugModal'); },
|
||||
onSignout: () => { sb.call('handleLogout'); },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount a UserMenu into a container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} [opts] — { flyout: 'down'|'up', compact, user, handlers }
|
||||
* @returns {object} UserMenu instance
|
||||
*/
|
||||
sw.userMenu = function (container, opts) {
|
||||
if (typeof UserMenu === 'undefined') {
|
||||
console.error('[Switchboard] UserMenu not available');
|
||||
return null;
|
||||
}
|
||||
const _opts = Object.assign({
|
||||
user: window.__USER__ || null,
|
||||
handlers: _defaultMenuHandlers,
|
||||
}, opts || {});
|
||||
const instance = UserMenu.mount(container, _opts);
|
||||
if (!UserMenu.primary) UserMenu.primary = instance;
|
||||
return instance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hydrate server-rendered UserMenu (non-chat surfaces).
|
||||
* Falls back to mount() if server-rendered DOM not found.
|
||||
*/
|
||||
function _hydrateUserMenu() {
|
||||
if (typeof UserMenu === 'undefined') return;
|
||||
if (UserMenu.primary) return; // already created by a surface
|
||||
if (UserMenu.primary) return;
|
||||
|
||||
// Chat surface manages its own UserMenu via UI.toggleUserMenu()
|
||||
const surface = window.__SURFACE__ || '';
|
||||
if (surface === 'chat') return;
|
||||
|
||||
// Extension surfaces: package owns its own menu via sw.userMenu().
|
||||
// Hide the server-rendered placeholder so the package can mount fresh.
|
||||
const isExtension = document.querySelector('.extension-surface') !== null;
|
||||
if (isExtension) {
|
||||
const placeholder = document.getElementById('userMenuWrap');
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-extension surfaces (admin, settings, notes): hydrate server-rendered DOM
|
||||
const btn = document.getElementById('userMenuBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const menu = UserMenu.create({ id: '' });
|
||||
UserMenu.primary = menu;
|
||||
|
||||
const user = window.__USER__ || {};
|
||||
menu.setUser(user);
|
||||
menu.showAdmin(user.role === 'admin');
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
menu.bind({
|
||||
onSettings: () => { window.location.href = BASE + '/settings'; },
|
||||
onAdmin: () => { window.location.href = BASE + '/admin'; },
|
||||
onDebug: () => { sb.call('openDebugModal'); },
|
||||
onSignout: () => { sb.call('handleLogout'); },
|
||||
});
|
||||
if (btn) {
|
||||
const menu = UserMenu.create({ id: '' });
|
||||
UserMenu.primary = menu;
|
||||
const user = window.__USER__ || {};
|
||||
menu.setUser(user);
|
||||
menu.showAdmin(user.role === 'admin');
|
||||
menu.bind(_defaultMenuHandlers);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Perform Init ─────────────────────────────────────
|
||||
|
||||
@@ -135,5 +135,62 @@ const UserMenu = {
|
||||
},
|
||||
};
|
||||
|
||||
// Self-mount: creates DOM + create + bind in one call.
|
||||
// opts.flyout: 'down' (default) or 'up' (sidebar-style)
|
||||
// opts.compact: hide name label
|
||||
// opts.user: user object for setUser()
|
||||
// opts.handlers: { onSettings, onAdmin, onDebug, onSignout }
|
||||
UserMenu.mount = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const pfx = _opts.id || 'um' + Date.now().toString(36).slice(-4);
|
||||
const flyout = _opts.flyout || 'down';
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'user-menu-wrap';
|
||||
el.id = pfx + 'userMenuWrap';
|
||||
el.dataset.flyout = flyout;
|
||||
el.innerHTML =
|
||||
'<button id="' + pfx + 'userMenuBtn" class="user-btn">' +
|
||||
'<div id="' + pfx + 'userAvatar" class="user-avatar">' +
|
||||
'<span id="' + pfx + 'avatarLetter">?</span>' +
|
||||
'</div>' +
|
||||
'<span id="' + pfx + 'userName" class="sb-label"' + (_opts.compact ? ' style="display:none"' : '') + '>User</span>' +
|
||||
'</button>' +
|
||||
'<div id="' + pfx + 'userFlyout" class="user-flyout">' +
|
||||
'<button id="' + pfx + 'menuSettings" class="flyout-item">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
|
||||
'<span>Settings</span>' +
|
||||
'</button>' +
|
||||
'<button id="' + pfx + 'menuAdmin" class="flyout-item" style="display:none;">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
|
||||
'<span>Admin</span>' +
|
||||
'</button>' +
|
||||
'<button id="' + pfx + 'menuTeamAdmin" class="flyout-item" style="display:none;">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' +
|
||||
'<span>Team Admin</span>' +
|
||||
'</button>' +
|
||||
'<button id="' + pfx + 'menuDebug" class="flyout-item" style="display:none;">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>' +
|
||||
'<span>Debug Log</span>' +
|
||||
'</button>' +
|
||||
'<hr class="flyout-divider">' +
|
||||
'<button id="' + pfx + 'menuSignout" class="flyout-item flyout-danger">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' +
|
||||
'<span>Sign Out</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
container.appendChild(el);
|
||||
|
||||
const instance = this.create({ id: pfx });
|
||||
instance.bind(_opts.handlers || {});
|
||||
|
||||
if (_opts.user) {
|
||||
instance.setUser(_opts.user);
|
||||
instance.showAdmin(_opts.user.role === 'admin');
|
||||
}
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('UserMenu', UserMenu);
|
||||
|
||||
Reference in New Issue
Block a user