synabun 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- package/skills/synabun/synabunicon.png +0 -0
|
@@ -0,0 +1,1905 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun Neural Interface — Category Sidebar
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Complete category sidebar: build/rebuild, CRUD modals,
|
|
6
|
+
// color picker, drag-reorder, category logos, visibility toggle.
|
|
7
|
+
//
|
|
8
|
+
// Events emitted:
|
|
9
|
+
// categories:changed — after any category metadata mutation (create/edit/delete/reorder)
|
|
10
|
+
// graph:refresh — request variant to re-apply graph data
|
|
11
|
+
// sidebar:rebuilt — after the sidebar DOM has been rebuilt
|
|
12
|
+
// graph:nodeThreeObject — request variant to refresh node visuals (3D only)
|
|
13
|
+
// trash:refresh — request trash badge update after category delete
|
|
14
|
+
//
|
|
15
|
+
// Events listened:
|
|
16
|
+
// sidebar:rebuild — trigger a full sidebar rebuild
|
|
17
|
+
|
|
18
|
+
import { state, emit, on } from './state.js';
|
|
19
|
+
import { KEYS, COLOR_PALETTE } from './constants.js';
|
|
20
|
+
import { storage } from './storage.js';
|
|
21
|
+
import { normalizeNodes } from './utils.js';
|
|
22
|
+
import { catColor, upgradeSelect } from './colors.js';
|
|
23
|
+
import { isGuest, hasPermission, showGuestToast } from './ui-sync.js';
|
|
24
|
+
import {
|
|
25
|
+
fetchCategories,
|
|
26
|
+
createCategory as apiCreateCategory,
|
|
27
|
+
updateCategory as apiUpdateCategory,
|
|
28
|
+
patchCategory as apiPatchCategory,
|
|
29
|
+
deleteCategory as apiDeleteCategory,
|
|
30
|
+
exportCategory as apiExportCategory,
|
|
31
|
+
uploadCategoryLogo as apiUploadCategoryLogo,
|
|
32
|
+
deleteCategoryLogo as apiDeleteCategoryLogo,
|
|
33
|
+
fetchMemories,
|
|
34
|
+
updateMemory,
|
|
35
|
+
} from './api.js';
|
|
36
|
+
|
|
37
|
+
const $ = (id) => document.getElementById(id);
|
|
38
|
+
|
|
39
|
+
// ─── Category logos — preloaded map for parent anchor nodes ──────
|
|
40
|
+
const _categoryLogos = new Map();
|
|
41
|
+
|
|
42
|
+
function preloadCategoryLogos(categories) {
|
|
43
|
+
const names = new Set(categories.map(c => c.name));
|
|
44
|
+
for (const [k] of _categoryLogos) { if (!names.has(k)) _categoryLogos.delete(k); }
|
|
45
|
+
for (const cat of categories) {
|
|
46
|
+
if (!cat.logo) continue;
|
|
47
|
+
const existing = _categoryLogos.get(cat.name);
|
|
48
|
+
if (existing && existing.src === cat.logo) continue;
|
|
49
|
+
const img = new Image();
|
|
50
|
+
const entry = { img, ready: false, src: cat.logo };
|
|
51
|
+
img.onload = () => { entry.ready = true; };
|
|
52
|
+
img.onerror = () => { _categoryLogos.delete(cat.name); };
|
|
53
|
+
img.src = cat.logo;
|
|
54
|
+
_categoryLogos.set(cat.name, entry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Expose the logo map so the 3D variant can read it for anchor textures. */
|
|
59
|
+
export { _categoryLogos as categoryLogos };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch categories from the API and populate state.
|
|
63
|
+
* This mirrors what the legacy monolithic fetchCategories() did —
|
|
64
|
+
* the api.js version is a dumb data fetcher, so we need this wrapper
|
|
65
|
+
* to fill state.allCategoryNames, state.categoryDescriptions, and
|
|
66
|
+
* state.categoryMetadata with parent/color/is_parent/logo data.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadCategories() {
|
|
69
|
+
try {
|
|
70
|
+
const data = await fetchCategories();
|
|
71
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
72
|
+
state.categoryDescriptions = {};
|
|
73
|
+
state.categoryMetadata = {};
|
|
74
|
+
data.categories.forEach(c => {
|
|
75
|
+
state.categoryDescriptions[c.name] = c.description;
|
|
76
|
+
state.categoryMetadata[c.name] = {
|
|
77
|
+
parent: c.parent,
|
|
78
|
+
color: c.color,
|
|
79
|
+
is_parent: c.is_parent,
|
|
80
|
+
logo: c.logo,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
preloadCategoryLogos(data.categories);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error('loadCategories error:', e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Graph removal scheduling ────────────────────────────────────
|
|
90
|
+
// When a category is toggled off we schedule a delayed removal so the
|
|
91
|
+
// fade-out animation plays before nodes are actually removed from the graph.
|
|
92
|
+
let graphRemovalTimer = null;
|
|
93
|
+
|
|
94
|
+
function scheduleGraphRemoval(delay = 600) {
|
|
95
|
+
emit('links:dirty'); // immediately hide links for hidden categories
|
|
96
|
+
if (graphRemovalTimer) clearTimeout(graphRemovalTimer);
|
|
97
|
+
graphRemovalTimer = setTimeout(() => {
|
|
98
|
+
graphRemovalTimer = null;
|
|
99
|
+
emit('graph:refresh');
|
|
100
|
+
}, delay);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cancelScheduledRemoval() {
|
|
104
|
+
if (graphRemovalTimer) {
|
|
105
|
+
clearTimeout(graphRemovalTimer);
|
|
106
|
+
graphRemovalTimer = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Saved category order ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function getCategoryOrder() {
|
|
113
|
+
try { return JSON.parse(storage.getItem(KEYS.CATEGORY_ORDER) || '[]'); }
|
|
114
|
+
catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveCategoryOrder(order) {
|
|
118
|
+
storage.setItem(KEYS.CATEGORY_ORDER, JSON.stringify(order));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Color picker ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function closeColorPicker() {
|
|
124
|
+
const existing = document.querySelector('.color-edit-picker');
|
|
125
|
+
if (existing) existing.remove();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function openColorPicker(cat, chipEl) {
|
|
129
|
+
if (isGuest() && !hasPermission('memories')) return;
|
|
130
|
+
closeColorPicker();
|
|
131
|
+
|
|
132
|
+
const picker = document.createElement('div');
|
|
133
|
+
picker.className = 'color-edit-picker';
|
|
134
|
+
|
|
135
|
+
const row = document.createElement('div');
|
|
136
|
+
row.className = 'color-swatch-row';
|
|
137
|
+
|
|
138
|
+
const currentColor = catColor(cat);
|
|
139
|
+
|
|
140
|
+
COLOR_PALETTE.forEach(color => {
|
|
141
|
+
const swatch = document.createElement('button');
|
|
142
|
+
swatch.className = 'color-swatch' + (color === currentColor ? ' selected' : '');
|
|
143
|
+
swatch.style.background = color;
|
|
144
|
+
swatch.addEventListener('click', (e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
applyCategoryColor(cat, color);
|
|
147
|
+
closeColorPicker();
|
|
148
|
+
});
|
|
149
|
+
row.appendChild(swatch);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
picker.appendChild(row);
|
|
153
|
+
|
|
154
|
+
// Reset to default button
|
|
155
|
+
const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
|
|
156
|
+
if (overrides[cat]) {
|
|
157
|
+
const resetBtn = document.createElement('button');
|
|
158
|
+
resetBtn.className = 'color-edit-reset';
|
|
159
|
+
resetBtn.textContent = 'Reset to default';
|
|
160
|
+
resetBtn.addEventListener('click', (e) => {
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
applyCategoryColor(cat, null);
|
|
163
|
+
closeColorPicker();
|
|
164
|
+
});
|
|
165
|
+
picker.appendChild(resetBtn);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
chipEl.after(picker);
|
|
169
|
+
|
|
170
|
+
// Close on outside click (deferred so current click doesn't trigger it)
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
function onOutsideClick(e) {
|
|
173
|
+
if (!picker.contains(e.target)) {
|
|
174
|
+
closeColorPicker();
|
|
175
|
+
document.removeEventListener('click', onOutsideClick);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
document.addEventListener('click', onOutsideClick);
|
|
179
|
+
}, 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function applyCategoryColor(cat, color) {
|
|
183
|
+
const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
|
|
184
|
+
if (color === null) {
|
|
185
|
+
delete overrides[cat];
|
|
186
|
+
} else {
|
|
187
|
+
overrides[cat] = color;
|
|
188
|
+
}
|
|
189
|
+
storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
|
|
190
|
+
|
|
191
|
+
// Rebuild sidebar to reflect new color on the dot
|
|
192
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
193
|
+
buildCategorySidebar(presentCats);
|
|
194
|
+
|
|
195
|
+
// Force graph to recreate node objects with new colors
|
|
196
|
+
emit('graph:nodeThreeObject');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Description editor (inline below chip) ─────────────────────
|
|
200
|
+
|
|
201
|
+
function closeDescEditor() {
|
|
202
|
+
const existing = document.querySelector('.category-desc-editor');
|
|
203
|
+
if (existing) existing.remove();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function openDescEditor(cat, chipEl) {
|
|
207
|
+
closeDescEditor();
|
|
208
|
+
closeColorPicker();
|
|
209
|
+
|
|
210
|
+
const editor = document.createElement('div');
|
|
211
|
+
editor.className = 'category-desc-editor';
|
|
212
|
+
const currentDesc = state.categoryDescriptions[cat] || '';
|
|
213
|
+
editor.innerHTML = `
|
|
214
|
+
<label>Description for "${cat}"</label>
|
|
215
|
+
<textarea class="desc-textarea">${currentDesc}</textarea>
|
|
216
|
+
<div class="category-desc-actions">
|
|
217
|
+
<button class="action-btn action-btn--ghost desc-cancel-btn">Cancel</button>
|
|
218
|
+
<button class="action-btn action-btn--primary desc-save-btn">Save</button>
|
|
219
|
+
</div>
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
const textarea = editor.querySelector('.desc-textarea');
|
|
223
|
+
const saveBtn = editor.querySelector('.desc-save-btn');
|
|
224
|
+
const cancelBtn = editor.querySelector('.desc-cancel-btn');
|
|
225
|
+
|
|
226
|
+
cancelBtn.addEventListener('click', () => closeDescEditor());
|
|
227
|
+
saveBtn.addEventListener('click', () => saveDescription(cat, textarea.value.trim()));
|
|
228
|
+
|
|
229
|
+
textarea.addEventListener('keydown', (e) => {
|
|
230
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
saveDescription(cat, textarea.value.trim());
|
|
233
|
+
}
|
|
234
|
+
if (e.key === 'Escape') {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
closeDescEditor();
|
|
237
|
+
}
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
});
|
|
240
|
+
textarea.addEventListener('keyup', (e) => e.stopPropagation());
|
|
241
|
+
|
|
242
|
+
chipEl.after(editor);
|
|
243
|
+
textarea.focus();
|
|
244
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function saveDescription(cat, description) {
|
|
248
|
+
if (!description) return;
|
|
249
|
+
const saveBtn = document.querySelector('.category-desc-editor .desc-save-btn');
|
|
250
|
+
if (saveBtn) { saveBtn.textContent = 'Saving...'; saveBtn.disabled = true; }
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const data = await apiPatchCategory(cat, { description });
|
|
254
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
255
|
+
state.categoryDescriptions = {};
|
|
256
|
+
data.categories.forEach(c => state.categoryDescriptions[c.name] = c.description);
|
|
257
|
+
closeDescEditor();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.error('saveDescription error:', e);
|
|
260
|
+
if (saveBtn) { saveBtn.textContent = 'Error'; saveBtn.disabled = false; }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Category change modal (move memory to different category) ───
|
|
265
|
+
|
|
266
|
+
export function openCategoryChangeModal(node) {
|
|
267
|
+
const current = node.payload.category;
|
|
268
|
+
|
|
269
|
+
const overlay = document.createElement('div');
|
|
270
|
+
overlay.className = 'tag-delete-overlay';
|
|
271
|
+
|
|
272
|
+
let listHtml = '';
|
|
273
|
+
state.allCategoryNames.forEach(cat => {
|
|
274
|
+
const desc = state.categoryDescriptions[cat] || '';
|
|
275
|
+
const isActive = cat === current;
|
|
276
|
+
listHtml += `
|
|
277
|
+
<div class="cat-modal-option${isActive ? ' active' : ''}" data-cat="${cat}">
|
|
278
|
+
<div class="cat-opt-dot" style="color:${catColor(cat)}; background:${catColor(cat)}"></div>
|
|
279
|
+
<span class="cat-opt-name">${cat}</span>
|
|
280
|
+
${desc ? `<span class="cat-opt-desc">${desc}</span>` : ''}
|
|
281
|
+
</div>`;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
overlay.innerHTML = `
|
|
285
|
+
<div class="tag-delete-modal cat-change-modal">
|
|
286
|
+
<div class="cat-change-modal-title">Move to category</div>
|
|
287
|
+
<div class="cat-change-modal-current">
|
|
288
|
+
<div class="cat-dot" style="color:${catColor(current)}; background:${catColor(current)}"></div>
|
|
289
|
+
${current}
|
|
290
|
+
</div>
|
|
291
|
+
<div class="cat-change-modal-list">${listHtml}</div>
|
|
292
|
+
</div>
|
|
293
|
+
`;
|
|
294
|
+
|
|
295
|
+
document.body.appendChild(overlay);
|
|
296
|
+
|
|
297
|
+
overlay.querySelectorAll('.cat-modal-option').forEach(opt => {
|
|
298
|
+
opt.addEventListener('click', () => {
|
|
299
|
+
const cat = opt.dataset.cat;
|
|
300
|
+
overlay.remove();
|
|
301
|
+
if (cat !== current) {
|
|
302
|
+
changeMemoryCategory(node, cat);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
overlay.addEventListener('click', (e) => {
|
|
308
|
+
if (e.target === overlay) overlay.remove();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function changeMemoryCategory(node, newCategory) {
|
|
313
|
+
const oldCategory = node.payload.category;
|
|
314
|
+
|
|
315
|
+
// Optimistic update
|
|
316
|
+
node.payload.category = newCategory;
|
|
317
|
+
|
|
318
|
+
// Update detail panel label + color
|
|
319
|
+
const color = catColor(newCategory);
|
|
320
|
+
const headerDot = $('detail-header-dot');
|
|
321
|
+
if (headerDot) { headerDot.style.color = color; headerDot.style.background = color; }
|
|
322
|
+
const label = $('detail-category-label');
|
|
323
|
+
if (label) {
|
|
324
|
+
label.textContent = newCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : '');
|
|
325
|
+
label.style.color = color;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Rebuild sidebar + graph
|
|
329
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
330
|
+
buildCategorySidebar(presentCats);
|
|
331
|
+
emit('graph:nodeThreeObject');
|
|
332
|
+
|
|
333
|
+
// Persist to server
|
|
334
|
+
try {
|
|
335
|
+
await updateMemory(node.id, { category: newCategory });
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error('changeMemoryCategory error:', e);
|
|
338
|
+
// Rollback
|
|
339
|
+
node.payload.category = oldCategory;
|
|
340
|
+
const rColor = catColor(oldCategory);
|
|
341
|
+
if (headerDot) { headerDot.style.color = rColor; headerDot.style.background = rColor; }
|
|
342
|
+
if (label) {
|
|
343
|
+
label.textContent = oldCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : '');
|
|
344
|
+
label.style.color = rColor;
|
|
345
|
+
}
|
|
346
|
+
const rCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
347
|
+
buildCategorySidebar(rCats);
|
|
348
|
+
emit('graph:nodeThreeObject');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Helper: apply API response to local state ──────────────────
|
|
353
|
+
|
|
354
|
+
function applyApiResponse(data) {
|
|
355
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
356
|
+
state.categoryDescriptions = {};
|
|
357
|
+
state.categoryMetadata = {};
|
|
358
|
+
data.categories.forEach(c => {
|
|
359
|
+
state.categoryDescriptions[c.name] = c.description;
|
|
360
|
+
state.categoryMetadata[c.name] = {
|
|
361
|
+
parent: c.parent,
|
|
362
|
+
color: c.color,
|
|
363
|
+
is_parent: c.is_parent,
|
|
364
|
+
logo: c.logo,
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
preloadCategoryLogos(data.categories);
|
|
368
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
369
|
+
buildCategorySidebar(presentCats);
|
|
370
|
+
emit('graph:refresh');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Create category ────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
async function createCategoryAction(name, description, color, parent, isParent) {
|
|
376
|
+
if (isGuest() && !hasPermission('memories')) {
|
|
377
|
+
showGuestToast('Category creation is disabled by the host');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const payload = { name, description };
|
|
381
|
+
if (parent) payload.parent = parent;
|
|
382
|
+
if (color) payload.color = color;
|
|
383
|
+
if (isParent) payload.is_parent = true;
|
|
384
|
+
|
|
385
|
+
const data = await apiCreateCategory(payload);
|
|
386
|
+
|
|
387
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
388
|
+
state.categoryDescriptions = {};
|
|
389
|
+
state.categoryMetadata = {};
|
|
390
|
+
data.categories.forEach(c => {
|
|
391
|
+
state.categoryDescriptions[c.name] = c.description;
|
|
392
|
+
state.categoryMetadata[c.name] = {
|
|
393
|
+
parent: c.parent,
|
|
394
|
+
color: c.color,
|
|
395
|
+
description: c.description,
|
|
396
|
+
is_parent: c.is_parent,
|
|
397
|
+
logo: c.logo,
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
preloadCategoryLogos(data.categories);
|
|
401
|
+
|
|
402
|
+
// Save chosen color
|
|
403
|
+
if (color) {
|
|
404
|
+
const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
|
|
405
|
+
overrides[name] = color;
|
|
406
|
+
storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Add to active set & rebuild
|
|
410
|
+
state.activeCategories.add(name);
|
|
411
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
412
|
+
buildCategorySidebar(presentCats);
|
|
413
|
+
emit('categories:changed');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── Edit category UI (modal) ────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
async function editCategoryUI(name) {
|
|
419
|
+
if (isGuest() && !hasPermission('memories')) {
|
|
420
|
+
showGuestToast('Category editing is disabled by the host');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const overlay = document.createElement('div');
|
|
424
|
+
overlay.className = 'tag-delete-overlay';
|
|
425
|
+
|
|
426
|
+
const meta = state.categoryMetadata[name] || {};
|
|
427
|
+
const currentDesc = state.categoryDescriptions[name] || '';
|
|
428
|
+
const currentParent = meta.parent || '';
|
|
429
|
+
const currentColor = meta.color || catColor(name);
|
|
430
|
+
|
|
431
|
+
// Get available parent categories (excluding this one and its descendants)
|
|
432
|
+
const getDescendants = (catName) => {
|
|
433
|
+
const directChildren = state.allCategoryNames.filter(c => {
|
|
434
|
+
const m = state.categoryMetadata[c] || {};
|
|
435
|
+
return m.parent === catName;
|
|
436
|
+
});
|
|
437
|
+
const allDescendants = [...directChildren];
|
|
438
|
+
directChildren.forEach(child => {
|
|
439
|
+
allDescendants.push(...getDescendants(child));
|
|
440
|
+
});
|
|
441
|
+
return allDescendants;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const descendants = getDescendants(name);
|
|
445
|
+
const descendantSet = new Set(descendants);
|
|
446
|
+
const availableParents = state.allCategoryNames.filter(c => c !== name && !descendantSet.has(c));
|
|
447
|
+
|
|
448
|
+
overlay.innerHTML = `
|
|
449
|
+
<div class="ecm">
|
|
450
|
+
<div class="ecm-header">
|
|
451
|
+
<span class="ecm-dot" style="background:${currentColor}"></span>
|
|
452
|
+
<span class="ecm-title">${name}</span>
|
|
453
|
+
<button class="ecm-close cat-modal-cancel">×</button>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div class="ecm-body">
|
|
457
|
+
<div class="ecm-name-row">
|
|
458
|
+
<div class="ecm-field ecm-field--name">
|
|
459
|
+
<input type="text" class="edit-cat-name ecm-input" value="${name}" placeholder="Category name">
|
|
460
|
+
</div>
|
|
461
|
+
<button class="ecm-color-btn edit-cat-color-trigger" aria-label="Pick color">
|
|
462
|
+
<span class="ecm-color-swatch" style="background:${currentColor}"></span>
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
<div class="ecm-palette" hidden>
|
|
467
|
+
<div class="ecm-palette-grid">
|
|
468
|
+
${COLOR_PALETTE.map(c => `<button class="ecm-palette-swatch${c === currentColor ? ' active' : ''}" data-color="${c}" style="background:${c}"></button>`).join('')}
|
|
469
|
+
<div class="ecm-palette-custom">
|
|
470
|
+
<span class="ecm-palette-custom-hash">#</span>
|
|
471
|
+
<input type="text" class="ecm-palette-custom-input" maxlength="6" placeholder="hex" value="${currentColor.replace('#', '')}">
|
|
472
|
+
<span class="ecm-palette-custom-preview" style="background:${currentColor}"></span>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<div class="ecm-field">
|
|
478
|
+
<label>Description</label>
|
|
479
|
+
<textarea class="edit-cat-desc ecm-input ecm-textarea" rows="4" placeholder="What this category is for...">${currentDesc}</textarea>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div class="ecm-divider"></div>
|
|
483
|
+
|
|
484
|
+
<div class="ecm-grid">
|
|
485
|
+
<div class="ecm-field">
|
|
486
|
+
<label>Parent</label>
|
|
487
|
+
<select class="edit-cat-parent ecm-input ecm-select">
|
|
488
|
+
<option value="">None (top-level)</option>
|
|
489
|
+
${availableParents.map(c => `<option value="${c}" ${c === currentParent ? 'selected' : ''}>${c}</option>`).join('')}
|
|
490
|
+
</select>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="ecm-field ecm-field--toggle edit-cat-isparent-row" ${currentParent ? 'hidden' : ''}>
|
|
493
|
+
<label class="ecm-toggle">
|
|
494
|
+
<input type="checkbox" class="edit-cat-isparent" ${meta.is_parent ? 'checked' : ''}>
|
|
495
|
+
<span class="ecm-toggle-track"><span class="ecm-toggle-thumb"></span></span>
|
|
496
|
+
<span class="ecm-toggle-label">Parent cluster</span>
|
|
497
|
+
</label>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div class="ecm-logo-wrap edit-cat-logo-row" ${!(meta.is_parent && !currentParent) ? 'hidden' : ''}>
|
|
502
|
+
<div class="ecm-logo-left">
|
|
503
|
+
${meta.logo
|
|
504
|
+
? `<img class="edit-cat-logo-preview ecm-logo-img" src="${meta.logo}?t=${Date.now()}">`
|
|
505
|
+
: `<span class="edit-cat-logo-preview ecm-logo-empty">No logo</span>`
|
|
506
|
+
}
|
|
507
|
+
</div>
|
|
508
|
+
<div class="ecm-logo-actions">
|
|
509
|
+
<label class="ecm-link-btn">
|
|
510
|
+
${meta.logo ? 'Replace' : 'Upload'}
|
|
511
|
+
<input type="file" class="edit-cat-logo-file" accept="image/*" hidden>
|
|
512
|
+
</label>
|
|
513
|
+
${meta.logo ? `<button class="ecm-link-btn ecm-link-btn--danger edit-cat-logo-remove">Remove</button>` : ''}
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div class="ecm-footer">
|
|
519
|
+
<button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
|
|
520
|
+
<button class="action-btn action-btn--primary cat-modal-confirm">Save</button>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
`;
|
|
524
|
+
|
|
525
|
+
document.body.appendChild(overlay);
|
|
526
|
+
|
|
527
|
+
const $nameInput = overlay.querySelector('.edit-cat-name');
|
|
528
|
+
const $descInput = overlay.querySelector('.edit-cat-desc');
|
|
529
|
+
const $parentSelect = overlay.querySelector('.edit-cat-parent');
|
|
530
|
+
const $isParentCheck = overlay.querySelector('.edit-cat-isparent');
|
|
531
|
+
const $isParentRow = overlay.querySelector('.edit-cat-isparent-row');
|
|
532
|
+
|
|
533
|
+
upgradeSelect($parentSelect);
|
|
534
|
+
|
|
535
|
+
// Color palette toggle + swatch selection
|
|
536
|
+
let selectedColor = currentColor;
|
|
537
|
+
const $colorBtn = overlay.querySelector('.ecm-color-btn');
|
|
538
|
+
const $colorSwatch = overlay.querySelector('.ecm-color-swatch');
|
|
539
|
+
const $headerDot = overlay.querySelector('.ecm-dot');
|
|
540
|
+
const $palette = overlay.querySelector('.ecm-palette');
|
|
541
|
+
const $paletteSwatches = overlay.querySelectorAll('.ecm-palette-swatch');
|
|
542
|
+
|
|
543
|
+
$colorBtn.addEventListener('click', () => {
|
|
544
|
+
$palette.hidden = !$palette.hidden;
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const $customInput = overlay.querySelector('.ecm-palette-custom-input');
|
|
548
|
+
const $customPreview = overlay.querySelector('.ecm-palette-custom-preview');
|
|
549
|
+
|
|
550
|
+
function applyColor(hex) {
|
|
551
|
+
selectedColor = hex;
|
|
552
|
+
$paletteSwatches.forEach(s => s.classList.toggle('active', s.dataset.color === hex));
|
|
553
|
+
if ($headerDot) $headerDot.style.background = hex;
|
|
554
|
+
if ($colorSwatch) $colorSwatch.style.background = hex;
|
|
555
|
+
if ($customPreview) $customPreview.style.background = hex;
|
|
556
|
+
if ($customInput) $customInput.value = hex.replace('#', '');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
$paletteSwatches.forEach(sw => {
|
|
560
|
+
sw.addEventListener('click', () => {
|
|
561
|
+
applyColor(sw.dataset.color);
|
|
562
|
+
$palette.hidden = true;
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Custom hex input — live preview on valid hex, apply on Enter
|
|
567
|
+
$customInput.addEventListener('input', () => {
|
|
568
|
+
const v = $customInput.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
|
|
569
|
+
$customInput.value = v;
|
|
570
|
+
if (v.length === 3 || v.length === 6) {
|
|
571
|
+
const hex = '#' + v;
|
|
572
|
+
$customPreview.style.background = hex;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
$customInput.addEventListener('keydown', (e) => {
|
|
576
|
+
if (e.key === 'Enter') {
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
const v = $customInput.value.trim();
|
|
579
|
+
if (v.length === 3 || v.length === 6) {
|
|
580
|
+
applyColor('#' + v);
|
|
581
|
+
$palette.hidden = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
e.stopPropagation();
|
|
585
|
+
});
|
|
586
|
+
$customInput.addEventListener('keyup', (e) => e.stopPropagation());
|
|
587
|
+
|
|
588
|
+
const $logoRow = overlay.querySelector('.edit-cat-logo-row');
|
|
589
|
+
const $logoFile = overlay.querySelector('.edit-cat-logo-file');
|
|
590
|
+
const $logoRemoveBtn = overlay.querySelector('.edit-cat-logo-remove');
|
|
591
|
+
|
|
592
|
+
// Show/hide is_parent checkbox and logo row based on parent selection
|
|
593
|
+
$parentSelect.addEventListener('change', () => {
|
|
594
|
+
if ($parentSelect.value) {
|
|
595
|
+
$isParentRow.hidden = true;
|
|
596
|
+
$isParentCheck.checked = false;
|
|
597
|
+
if ($logoRow) $logoRow.hidden = true;
|
|
598
|
+
} else {
|
|
599
|
+
$isParentRow.hidden = false;
|
|
600
|
+
if ($logoRow) $logoRow.hidden = !$isParentCheck.checked;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Show/hide logo row based on is_parent checkbox
|
|
605
|
+
$isParentCheck.addEventListener('change', () => {
|
|
606
|
+
if ($logoRow) $logoRow.hidden = !$isParentCheck.checked;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// Logo upload — fires immediately on file select
|
|
610
|
+
if ($logoFile) {
|
|
611
|
+
$logoFile.addEventListener('change', async () => {
|
|
612
|
+
const file = $logoFile.files[0];
|
|
613
|
+
if (!file) return;
|
|
614
|
+
const uploadLabel = $logoFile.closest('label');
|
|
615
|
+
const origText = uploadLabel.textContent.trim();
|
|
616
|
+
uploadLabel.childNodes[0].textContent = 'Uploading\u2026 ';
|
|
617
|
+
try {
|
|
618
|
+
const buf = await file.arrayBuffer();
|
|
619
|
+
const data = await apiUploadCategoryLogo(name, buf, file.type || 'image/png');
|
|
620
|
+
|
|
621
|
+
// Update preview
|
|
622
|
+
const preview = overlay.querySelector('.edit-cat-logo-preview');
|
|
623
|
+
if (preview.tagName === 'IMG') {
|
|
624
|
+
preview.src = data.logo + '?t=' + Date.now();
|
|
625
|
+
} else {
|
|
626
|
+
const img = document.createElement('img');
|
|
627
|
+
img.className = 'edit-cat-logo-preview ecm-logo-img';
|
|
628
|
+
img.src = data.logo + '?t=' + Date.now();
|
|
629
|
+
preview.replaceWith(img);
|
|
630
|
+
}
|
|
631
|
+
// Add remove button if not present
|
|
632
|
+
if (!overlay.querySelector('.edit-cat-logo-remove')) {
|
|
633
|
+
const rmBtn = document.createElement('button');
|
|
634
|
+
rmBtn.className = 'ecm-link-btn ecm-link-btn--danger edit-cat-logo-remove';
|
|
635
|
+
rmBtn.textContent = 'Remove';
|
|
636
|
+
uploadLabel.after(rmBtn);
|
|
637
|
+
attachLogoRemoveHandler(rmBtn);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (state.categoryMetadata[name]) state.categoryMetadata[name].logo = data.logo;
|
|
641
|
+
preloadCategoryLogos(data.categories);
|
|
642
|
+
uploadLabel.childNodes[0].textContent = 'Replace ';
|
|
643
|
+
// Rebuild graph to show new logo on anchor
|
|
644
|
+
emit('graph:nodeThreeObject');
|
|
645
|
+
emit('graph:refresh');
|
|
646
|
+
} catch (err) {
|
|
647
|
+
alert('Logo upload failed: ' + err.message);
|
|
648
|
+
uploadLabel.childNodes[0].textContent = origText + ' ';
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function attachLogoRemoveHandler(btn) {
|
|
654
|
+
btn.addEventListener('click', async () => {
|
|
655
|
+
btn.textContent = 'Removing\u2026';
|
|
656
|
+
btn.disabled = true;
|
|
657
|
+
try {
|
|
658
|
+
await apiDeleteCategoryLogo(name);
|
|
659
|
+
const preview = overlay.querySelector('.edit-cat-logo-preview');
|
|
660
|
+
const span = document.createElement('span');
|
|
661
|
+
span.className = 'edit-cat-logo-preview ecm-logo-empty';
|
|
662
|
+
span.textContent = 'No logo';
|
|
663
|
+
preview.replaceWith(span);
|
|
664
|
+
btn.remove();
|
|
665
|
+
if (state.categoryMetadata[name]) delete state.categoryMetadata[name].logo;
|
|
666
|
+
_categoryLogos.delete(name);
|
|
667
|
+
const uploadLabel = overlay.querySelector('.edit-cat-logo-file')?.closest('label');
|
|
668
|
+
if (uploadLabel) uploadLabel.childNodes[0].textContent = 'Upload ';
|
|
669
|
+
emit('graph:nodeThreeObject');
|
|
670
|
+
emit('graph:refresh');
|
|
671
|
+
} catch (err) {
|
|
672
|
+
alert('Failed to remove logo: ' + err.message);
|
|
673
|
+
btn.textContent = 'Remove';
|
|
674
|
+
btn.disabled = false;
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if ($logoRemoveBtn) attachLogoRemoveHandler($logoRemoveBtn);
|
|
680
|
+
|
|
681
|
+
overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
|
|
682
|
+
overlay.querySelector('.cat-modal-confirm').addEventListener('click', async () => {
|
|
683
|
+
const newName = $nameInput.value.trim();
|
|
684
|
+
const newDesc = $descInput.value.trim();
|
|
685
|
+
const newParent = $parentSelect.value;
|
|
686
|
+
const newColor = selectedColor;
|
|
687
|
+
const newIsParent = $isParentCheck.checked;
|
|
688
|
+
|
|
689
|
+
if (!newName || !newDesc) {
|
|
690
|
+
alert('Name and description are required');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const btn = overlay.querySelector('.cat-modal-confirm');
|
|
695
|
+
btn.textContent = 'Saving...';
|
|
696
|
+
btn.disabled = true;
|
|
697
|
+
|
|
698
|
+
const updates = {};
|
|
699
|
+
if (newName !== name) updates.new_name = newName;
|
|
700
|
+
if (newDesc !== currentDesc) updates.description = newDesc;
|
|
701
|
+
if (newParent !== currentParent) updates.parent = newParent;
|
|
702
|
+
if (newColor !== currentColor) updates.color = newColor;
|
|
703
|
+
if (newIsParent !== !!meta.is_parent) updates.is_parent = newIsParent;
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
const data = await apiUpdateCategory(name, updates);
|
|
707
|
+
|
|
708
|
+
// Update local state
|
|
709
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
710
|
+
state.categoryDescriptions = {};
|
|
711
|
+
state.categoryMetadata = {};
|
|
712
|
+
data.categories.forEach(c => {
|
|
713
|
+
state.categoryDescriptions[c.name] = c.description;
|
|
714
|
+
state.categoryMetadata[c.name] = { parent: c.parent, color: c.color, is_parent: c.is_parent, logo: c.logo };
|
|
715
|
+
});
|
|
716
|
+
preloadCategoryLogos(data.categories);
|
|
717
|
+
|
|
718
|
+
// Update localStorage colors
|
|
719
|
+
try {
|
|
720
|
+
const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
|
|
721
|
+
if (newColor !== currentColor) {
|
|
722
|
+
overrides[newName || name] = newColor;
|
|
723
|
+
}
|
|
724
|
+
if (newName !== name && overrides[name]) {
|
|
725
|
+
overrides[newName] = overrides[name];
|
|
726
|
+
delete overrides[name];
|
|
727
|
+
}
|
|
728
|
+
storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
|
|
729
|
+
} catch {}
|
|
730
|
+
|
|
731
|
+
// If renamed, update activeCategories
|
|
732
|
+
if (newName !== name) {
|
|
733
|
+
if (state.activeCategories.has(name)) {
|
|
734
|
+
state.activeCategories.delete(name);
|
|
735
|
+
state.activeCategories.add(newName);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
740
|
+
buildCategorySidebar(presentCats);
|
|
741
|
+
emit('graph:refresh');
|
|
742
|
+
emit('categories:changed');
|
|
743
|
+
|
|
744
|
+
overlay.remove();
|
|
745
|
+
} catch (e) {
|
|
746
|
+
alert('Error updating category: ' + e.message);
|
|
747
|
+
btn.textContent = 'Save Changes';
|
|
748
|
+
btn.disabled = false;
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ─── Delete category UI (modal) ──────────────────────────────────
|
|
756
|
+
|
|
757
|
+
function deleteCategoryUI(name, count, chipEl) {
|
|
758
|
+
if (isGuest() && !hasPermission('memories')) {
|
|
759
|
+
showGuestToast('Category deletion is disabled by the host');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const overlay = document.createElement('div');
|
|
763
|
+
overlay.className = 'tag-delete-overlay';
|
|
764
|
+
const allCats = state.allCategoryNames.filter(c => c !== name);
|
|
765
|
+
|
|
766
|
+
// Check if this category has children
|
|
767
|
+
const children = state.allCategoryNames.filter(c => {
|
|
768
|
+
const meta = state.categoryMetadata[c] || {};
|
|
769
|
+
return meta.parent === name;
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const hasChildren = children.length > 0;
|
|
773
|
+
const hasMemories = count > 0;
|
|
774
|
+
|
|
775
|
+
// Helper: build children reassignment HTML
|
|
776
|
+
const buildChildrenSelect = () => {
|
|
777
|
+
if (!hasChildren) return '';
|
|
778
|
+
const getDescendants = (catName) => {
|
|
779
|
+
const direct = state.allCategoryNames.filter(c => (state.categoryMetadata[c] || {}).parent === catName);
|
|
780
|
+
const all = [...direct];
|
|
781
|
+
direct.forEach(child => all.push(...getDescendants(child)));
|
|
782
|
+
return all;
|
|
783
|
+
};
|
|
784
|
+
const descendantSet = new Set(getDescendants(name));
|
|
785
|
+
const available = state.allCategoryNames.filter(c => c !== name && !descendantSet.has(c));
|
|
786
|
+
return `
|
|
787
|
+
<div style="font-size:var(--fs-sm);color:var(--t-secondary);margin:12px 0 6px;text-align:left">
|
|
788
|
+
${children.length} child categor${children.length === 1 ? 'y' : 'ies'}: <strong>${children.join(', ')}</strong><br>Reassign children to:
|
|
789
|
+
</div>
|
|
790
|
+
<select class="cat-modal-select-children modal-select">
|
|
791
|
+
<option value="">-- Make top-level --</option>
|
|
792
|
+
${available.map(c => `<option value="${c}">${c}</option>`).join('')}
|
|
793
|
+
</select>
|
|
794
|
+
`;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
if (!hasMemories && !hasChildren) {
|
|
798
|
+
// Simple delete — no memories, no children
|
|
799
|
+
overlay.innerHTML = `
|
|
800
|
+
<div class="tag-delete-modal">
|
|
801
|
+
<div class="tag-delete-modal-title">Delete category</div>
|
|
802
|
+
<div class="tag-delete-modal-tag">${name}</div>
|
|
803
|
+
<div class="tag-delete-modal-actions">
|
|
804
|
+
<button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
|
|
805
|
+
<button class="action-btn action-btn--danger cat-modal-confirm">Delete</button>
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
`;
|
|
809
|
+
document.body.appendChild(overlay);
|
|
810
|
+
overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
|
|
811
|
+
overlay.querySelector('.cat-modal-confirm').addEventListener('click', () => {
|
|
812
|
+
overlay.remove();
|
|
813
|
+
deleteCategoryAction(name);
|
|
814
|
+
});
|
|
815
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
816
|
+
|
|
817
|
+
} else if (!hasMemories && hasChildren) {
|
|
818
|
+
// Only children to handle, no memories
|
|
819
|
+
overlay.innerHTML = `
|
|
820
|
+
<div class="tag-delete-modal" style="min-width:340px">
|
|
821
|
+
<div class="tag-delete-modal-title">Delete category</div>
|
|
822
|
+
<div class="tag-delete-modal-tag">${name}</div>
|
|
823
|
+
${buildChildrenSelect()}
|
|
824
|
+
<div class="tag-delete-modal-actions" style="margin-top:14px">
|
|
825
|
+
<button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
|
|
826
|
+
<button class="action-btn action-btn--danger cat-modal-confirm">Delete</button>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
`;
|
|
830
|
+
document.body.appendChild(overlay);
|
|
831
|
+
const childSel = overlay.querySelector('.cat-modal-select-children');
|
|
832
|
+
if (childSel) upgradeSelect(childSel);
|
|
833
|
+
overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
|
|
834
|
+
overlay.querySelector('.cat-modal-confirm').addEventListener('click', async () => {
|
|
835
|
+
const targetChildren = overlay.querySelector('.cat-modal-select-children')?.value;
|
|
836
|
+
const btn = overlay.querySelector('.cat-modal-confirm');
|
|
837
|
+
btn.textContent = 'Deleting...';
|
|
838
|
+
btn.disabled = true;
|
|
839
|
+
await deleteCategoryAction(name, null, targetChildren);
|
|
840
|
+
overlay.remove();
|
|
841
|
+
});
|
|
842
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
843
|
+
|
|
844
|
+
} else {
|
|
845
|
+
// Has memories — show 3-option flow
|
|
846
|
+
const svgTrash = `<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-.7 12.1a2 2 0 0 1-2 1.9H7.7a2 2 0 0 1-2-1.9L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>`;
|
|
847
|
+
const svgExport = `<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
|
848
|
+
const svgMigrate = `<svg viewBox="0 0 24 24"><path d="M5 9l4-4 4 4"/><path d="M9 5v10a4 4 0 0 0 4 4h6"/><polyline points="15 15 19 19 15 23"/></svg>`;
|
|
849
|
+
|
|
850
|
+
overlay.innerHTML = `
|
|
851
|
+
<div class="tag-delete-modal" style="min-width:380px">
|
|
852
|
+
<div class="tag-delete-modal-title">Delete category</div>
|
|
853
|
+
<div class="tag-delete-modal-tag">${name}</div>
|
|
854
|
+
<div style="font-size:var(--fs-sm);color:var(--t-secondary);margin-bottom:4px">
|
|
855
|
+
${count} memor${count === 1 ? 'y' : 'ies'} in this category. What would you like to do?
|
|
856
|
+
</div>
|
|
857
|
+
<div class="cat-delete-options">
|
|
858
|
+
<div class="cat-delete-option cat-delete-option--delete" data-action="delete">
|
|
859
|
+
<div class="cat-delete-option-icon">${svgTrash}</div>
|
|
860
|
+
<div class="cat-delete-option-text">
|
|
861
|
+
<div class="cat-delete-option-label">Trash all memories</div>
|
|
862
|
+
<div class="cat-delete-option-desc">Move all ${count} memor${count === 1 ? 'y' : 'ies'} to trash</div>
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
<div class="cat-delete-option cat-delete-option--export" data-action="export">
|
|
866
|
+
<div class="cat-delete-option-icon">${svgExport}</div>
|
|
867
|
+
<div class="cat-delete-option-text">
|
|
868
|
+
<div class="cat-delete-option-label">Export as Markdown</div>
|
|
869
|
+
<div class="cat-delete-option-desc">Download all memories as .md then trash</div>
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
<div class="cat-delete-option cat-delete-option--migrate" data-action="migrate">
|
|
873
|
+
<div class="cat-delete-option-icon">${svgMigrate}</div>
|
|
874
|
+
<div class="cat-delete-option-text">
|
|
875
|
+
<div class="cat-delete-option-label">Migrate to another category</div>
|
|
876
|
+
<div class="cat-delete-option-desc">Move memories before deleting this category</div>
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
${buildChildrenSelect()}
|
|
881
|
+
<div class="cat-delete-step" id="cat-delete-step-area"></div>
|
|
882
|
+
<div class="tag-delete-modal-actions">
|
|
883
|
+
<button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
|
|
884
|
+
<button class="action-btn action-btn--danger cat-modal-confirm" disabled style="opacity:0.4">Continue</button>
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
`;
|
|
888
|
+
|
|
889
|
+
document.body.appendChild(overlay);
|
|
890
|
+
const modal = overlay.querySelector('.tag-delete-modal');
|
|
891
|
+
const stepArea = modal.querySelector('#cat-delete-step-area');
|
|
892
|
+
const confirmBtn = modal.querySelector('.cat-modal-confirm');
|
|
893
|
+
let selectedAction = null;
|
|
894
|
+
|
|
895
|
+
// Upgrade children select if present
|
|
896
|
+
const childSel3 = modal.querySelector('.cat-modal-select-children');
|
|
897
|
+
if (childSel3) upgradeSelect(childSel3);
|
|
898
|
+
|
|
899
|
+
// Option selection
|
|
900
|
+
modal.querySelectorAll('.cat-delete-option').forEach(opt => {
|
|
901
|
+
opt.addEventListener('click', () => {
|
|
902
|
+
modal.querySelectorAll('.cat-delete-option').forEach(o => o.classList.remove('selected'));
|
|
903
|
+
opt.classList.add('selected');
|
|
904
|
+
selectedAction = opt.dataset.action;
|
|
905
|
+
confirmBtn.disabled = false;
|
|
906
|
+
confirmBtn.style.opacity = '1';
|
|
907
|
+
stepArea.innerHTML = '';
|
|
908
|
+
|
|
909
|
+
if (selectedAction === 'delete') {
|
|
910
|
+
confirmBtn.textContent = 'Trash all';
|
|
911
|
+
confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
|
|
912
|
+
} else if (selectedAction === 'export') {
|
|
913
|
+
confirmBtn.textContent = 'Export & Trash';
|
|
914
|
+
confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
|
|
915
|
+
} else if (selectedAction === 'migrate') {
|
|
916
|
+
confirmBtn.textContent = 'Migrate & Delete';
|
|
917
|
+
confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
|
|
918
|
+
stepArea.innerHTML = `
|
|
919
|
+
<div style="font-size:var(--fs-sm);color:var(--t-secondary);margin:4px 0 4px;text-align:left">Move memories to:</div>
|
|
920
|
+
<select class="cat-modal-select-memories modal-select">
|
|
921
|
+
${allCats.map(c => `<option value="${c}">${c}</option>`).join('')}
|
|
922
|
+
</select>
|
|
923
|
+
`;
|
|
924
|
+
const memSel = stepArea.querySelector('.cat-modal-select-memories');
|
|
925
|
+
if (memSel) upgradeSelect(memSel);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// Cancel
|
|
931
|
+
overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
|
|
932
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
933
|
+
|
|
934
|
+
// Confirm
|
|
935
|
+
confirmBtn.addEventListener('click', async () => {
|
|
936
|
+
if (!selectedAction) return;
|
|
937
|
+
const targetChildren = hasChildren ? modal.querySelector('.cat-modal-select-children')?.value : undefined;
|
|
938
|
+
|
|
939
|
+
if (selectedAction === 'delete') {
|
|
940
|
+
confirmBtn.textContent = 'Trashing...';
|
|
941
|
+
confirmBtn.disabled = true;
|
|
942
|
+
await deleteCategoryAction(name, null, targetChildren, { deleteMemories: true });
|
|
943
|
+
overlay.remove();
|
|
944
|
+
|
|
945
|
+
} else if (selectedAction === 'export') {
|
|
946
|
+
confirmBtn.textContent = 'Exporting...';
|
|
947
|
+
confirmBtn.disabled = true;
|
|
948
|
+
try {
|
|
949
|
+
const blob = await apiExportCategory(name);
|
|
950
|
+
const url = URL.createObjectURL(blob);
|
|
951
|
+
const a = document.createElement('a');
|
|
952
|
+
a.href = url;
|
|
953
|
+
a.download = `${name}-memories.md`;
|
|
954
|
+
document.body.appendChild(a);
|
|
955
|
+
a.click();
|
|
956
|
+
a.remove();
|
|
957
|
+
URL.revokeObjectURL(url);
|
|
958
|
+
// Now trash
|
|
959
|
+
confirmBtn.textContent = 'Trashing...';
|
|
960
|
+
await deleteCategoryAction(name, null, targetChildren, { deleteMemories: true });
|
|
961
|
+
overlay.remove();
|
|
962
|
+
} catch (err) {
|
|
963
|
+
console.error('Export failed:', err);
|
|
964
|
+
alert('Export failed: ' + err.message);
|
|
965
|
+
confirmBtn.textContent = 'Export & Trash';
|
|
966
|
+
confirmBtn.disabled = false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
} else if (selectedAction === 'migrate') {
|
|
970
|
+
const targetMemories = modal.querySelector('.cat-modal-select-memories')?.value;
|
|
971
|
+
if (!targetMemories) return;
|
|
972
|
+
confirmBtn.textContent = 'Migrating...';
|
|
973
|
+
confirmBtn.disabled = true;
|
|
974
|
+
await deleteCategoryAction(name, targetMemories, targetChildren);
|
|
975
|
+
overlay.remove();
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ─── Delete category (server call + state update) ────────────────
|
|
982
|
+
|
|
983
|
+
async function deleteCategoryAction(name, reassignTo, reassignChildrenTo, options = {}) {
|
|
984
|
+
const body = {};
|
|
985
|
+
if (reassignTo) body.reassign_to = reassignTo;
|
|
986
|
+
if (reassignChildrenTo !== undefined) body.reassign_children_to = reassignChildrenTo;
|
|
987
|
+
if (options.deleteMemories) body.delete_memories = true;
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const data = await apiDeleteCategory(name, body);
|
|
991
|
+
|
|
992
|
+
state.allCategoryNames = data.categories.map(c => c.name);
|
|
993
|
+
state.categoryDescriptions = {};
|
|
994
|
+
data.categories.forEach(c => state.categoryDescriptions[c.name] = c.description);
|
|
995
|
+
// Update categoryMetadata
|
|
996
|
+
state.categoryMetadata = {};
|
|
997
|
+
data.categories.forEach(c => {
|
|
998
|
+
state.categoryMetadata[c.name] = { parent: c.parent, color: c.color, is_parent: c.is_parent, logo: c.logo };
|
|
999
|
+
});
|
|
1000
|
+
preloadCategoryLogos(data.categories);
|
|
1001
|
+
|
|
1002
|
+
// Remove color override
|
|
1003
|
+
try {
|
|
1004
|
+
const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
|
|
1005
|
+
delete overrides[name];
|
|
1006
|
+
storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
|
|
1007
|
+
} catch {}
|
|
1008
|
+
state.activeCategories.delete(name);
|
|
1009
|
+
|
|
1010
|
+
// Re-fetch memories if anything changed (reassigned or deleted)
|
|
1011
|
+
if (reassignTo || options.deleteMemories || (data.reassigned && data.reassigned > 0) || (data.deleted && data.deleted > 0)) {
|
|
1012
|
+
try {
|
|
1013
|
+
const memData = await fetchMemories();
|
|
1014
|
+
state.allNodes = normalizeNodes(memData.nodes);
|
|
1015
|
+
state.allLinks = memData.links;
|
|
1016
|
+
} catch {}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
1020
|
+
buildCategorySidebar(presentCats);
|
|
1021
|
+
emit('graph:refresh');
|
|
1022
|
+
emit('categories:changed');
|
|
1023
|
+
|
|
1024
|
+
// Update trash badge if memories were trashed
|
|
1025
|
+
if (options.deleteMemories) {
|
|
1026
|
+
emit('trash:refresh');
|
|
1027
|
+
}
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
console.error('deleteCategory error:', e);
|
|
1030
|
+
alert('Error deleting category: ' + e.message);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ═══════════════════════════════════════════
|
|
1035
|
+
// MAIN: buildCategorySidebar
|
|
1036
|
+
// ═══════════════════════════════════════════
|
|
1037
|
+
|
|
1038
|
+
let categoryDragDidMove = false;
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Rebuild the category sidebar DOM from current state.
|
|
1042
|
+
* @param {Set<string>} [presentCats] — categories that have at least one memory node.
|
|
1043
|
+
* If omitted, derived from state.allNodes.
|
|
1044
|
+
*/
|
|
1045
|
+
export function buildCategorySidebar(presentCats) {
|
|
1046
|
+
const $categoryList = $('category-list');
|
|
1047
|
+
if (!$categoryList) return;
|
|
1048
|
+
$categoryList.innerHTML = '';
|
|
1049
|
+
|
|
1050
|
+
if (!presentCats) {
|
|
1051
|
+
presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Count per category
|
|
1055
|
+
const counts = {};
|
|
1056
|
+
state.allNodes.forEach(n => {
|
|
1057
|
+
const cat = n.payload.category;
|
|
1058
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Merge present categories with all known categories (including 0-count ones)
|
|
1062
|
+
const allCats = new Set(presentCats);
|
|
1063
|
+
state.allCategoryNames.forEach(c => allCats.add(c));
|
|
1064
|
+
|
|
1065
|
+
// Group categories by parent
|
|
1066
|
+
const categoryGroups = {};
|
|
1067
|
+
const orphans = [];
|
|
1068
|
+
|
|
1069
|
+
[...allCats].forEach(cat => {
|
|
1070
|
+
const meta = state.categoryMetadata[cat] || {};
|
|
1071
|
+
if (meta.parent) {
|
|
1072
|
+
if (!categoryGroups[meta.parent]) categoryGroups[meta.parent] = [];
|
|
1073
|
+
categoryGroups[meta.parent].push(cat);
|
|
1074
|
+
} else {
|
|
1075
|
+
orphans.push(cat);
|
|
1076
|
+
}
|
|
1077
|
+
// Ensure is_parent categories always have an entry in categoryGroups (even if empty)
|
|
1078
|
+
if (meta.is_parent && !categoryGroups[cat]) {
|
|
1079
|
+
categoryGroups[cat] = [];
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// Apply saved order, then append any new categories at the end sorted by count
|
|
1084
|
+
const savedOrder = getCategoryOrder();
|
|
1085
|
+
const ordered = savedOrder.filter(c => allCats.has(c));
|
|
1086
|
+
const remaining = [...allCats].filter(c => !savedOrder.includes(c))
|
|
1087
|
+
.sort((a, b) => (counts[b] || 0) - (counts[a] || 0));
|
|
1088
|
+
const sorted = [...ordered, ...remaining];
|
|
1089
|
+
|
|
1090
|
+
// Regroup sorted: parent clusters contiguous (header + all children), orphans at end.
|
|
1091
|
+
// Prevents orphan categories from interleaving with cluster children (false 3rd nesting level).
|
|
1092
|
+
const regrouped = [];
|
|
1093
|
+
const _grouped = new Set();
|
|
1094
|
+
const _pNames = new Set();
|
|
1095
|
+
sorted.forEach(c => {
|
|
1096
|
+
const m = state.categoryMetadata[c] || {};
|
|
1097
|
+
if (m.is_parent || categoryGroups[c]) _pNames.add(c);
|
|
1098
|
+
if (m.parent) _pNames.add(m.parent);
|
|
1099
|
+
});
|
|
1100
|
+
const _pOrd = [], _pSeen = new Set();
|
|
1101
|
+
sorted.forEach(c => {
|
|
1102
|
+
const m = state.categoryMetadata[c] || {};
|
|
1103
|
+
const p = m.parent || (_pNames.has(c) ? c : null);
|
|
1104
|
+
if (p && !_pSeen.has(p)) { _pSeen.add(p); _pOrd.push(p); }
|
|
1105
|
+
});
|
|
1106
|
+
_pOrd.forEach(p => {
|
|
1107
|
+
regrouped.push(p); _grouped.add(p);
|
|
1108
|
+
sorted.forEach(c => { if ((state.categoryMetadata[c] || {}).parent === p) { regrouped.push(c); _grouped.add(c); } });
|
|
1109
|
+
});
|
|
1110
|
+
sorted.forEach(c => { if (!_grouped.has(c)) regrouped.push(c); });
|
|
1111
|
+
|
|
1112
|
+
// Track which parent clusters we've rendered
|
|
1113
|
+
const renderedClusters = new Set();
|
|
1114
|
+
|
|
1115
|
+
regrouped.forEach(cat => {
|
|
1116
|
+
const meta = state.categoryMetadata[cat] || {};
|
|
1117
|
+
|
|
1118
|
+
// If this category has a parent and we haven't rendered that cluster header yet
|
|
1119
|
+
if (meta.parent && !renderedClusters.has(meta.parent)) {
|
|
1120
|
+
const clusterHeader = document.createElement('div');
|
|
1121
|
+
clusterHeader.className = 'category-cluster-header';
|
|
1122
|
+
clusterHeader.style.borderLeft = `3px solid ${catColor(meta.parent)}`;
|
|
1123
|
+
clusterHeader.dataset.parent = meta.parent;
|
|
1124
|
+
|
|
1125
|
+
if (!state.activeCategories.has(meta.parent)) {
|
|
1126
|
+
clusterHeader.classList.add('inactive');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const parentMeta = state.categoryMetadata[meta.parent] || {};
|
|
1130
|
+
const isEphemeral = !!parentMeta._ephemeral;
|
|
1131
|
+
clusterHeader.innerHTML = `
|
|
1132
|
+
<span class="cluster-dot" style="background:${catColor(meta.parent)}; color:${catColor(meta.parent)}" data-tooltip="Color"></span>
|
|
1133
|
+
<span class="cluster-name">${meta.parent}</span>
|
|
1134
|
+
${isEphemeral
|
|
1135
|
+
? '<span style="margin-left:auto;font-size:10px;opacity:0.5;color:var(--t-muted)">bridge</span>'
|
|
1136
|
+
: `<span class="cluster-actions">
|
|
1137
|
+
<button class="cluster-edit-btn" data-tooltip="Edit">✎</button>
|
|
1138
|
+
<button class="cluster-delete-btn" data-tooltip="Delete">×</button>
|
|
1139
|
+
</span>`}
|
|
1140
|
+
`;
|
|
1141
|
+
|
|
1142
|
+
// Click on cluster header to toggle parent and all children
|
|
1143
|
+
clusterHeader.addEventListener('click', (e) => {
|
|
1144
|
+
if (categoryDragDidMove) { categoryDragDidMove = false; return; }
|
|
1145
|
+
if (e.target.closest('.cluster-actions') || e.target.closest('.cluster-dot')) return;
|
|
1146
|
+
|
|
1147
|
+
const parentCat = meta.parent;
|
|
1148
|
+
const childCategories = categoryGroups[parentCat] || [];
|
|
1149
|
+
|
|
1150
|
+
if (state.activeCategories.has(parentCat)) {
|
|
1151
|
+
state.activeCategories.delete(parentCat);
|
|
1152
|
+
childCategories.forEach(child => state.activeCategories.delete(child));
|
|
1153
|
+
clusterHeader.classList.add('inactive');
|
|
1154
|
+
childCategories.forEach(child => {
|
|
1155
|
+
const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
|
|
1156
|
+
if (childChip) childChip.classList.add('inactive');
|
|
1157
|
+
});
|
|
1158
|
+
scheduleGraphRemoval();
|
|
1159
|
+
} else {
|
|
1160
|
+
state.activeCategories.add(parentCat);
|
|
1161
|
+
childCategories.forEach(child => state.activeCategories.add(child));
|
|
1162
|
+
clusterHeader.classList.remove('inactive');
|
|
1163
|
+
childCategories.forEach(child => {
|
|
1164
|
+
const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
|
|
1165
|
+
if (childChip) childChip.classList.remove('inactive');
|
|
1166
|
+
});
|
|
1167
|
+
cancelScheduledRemoval();
|
|
1168
|
+
emit('graph:refresh');
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// Color dot click
|
|
1173
|
+
clusterHeader.querySelector('.cluster-dot').addEventListener('click', (e) => {
|
|
1174
|
+
e.stopPropagation();
|
|
1175
|
+
openColorPicker(meta.parent, clusterHeader);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Edit/Delete buttons (not present for ephemeral/bridge categories)
|
|
1179
|
+
const clusterEditBtn = clusterHeader.querySelector('.cluster-edit-btn');
|
|
1180
|
+
if (clusterEditBtn) {
|
|
1181
|
+
clusterEditBtn.addEventListener('click', (e) => {
|
|
1182
|
+
e.stopPropagation();
|
|
1183
|
+
editCategoryUI(meta.parent);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
const clusterDeleteBtn = clusterHeader.querySelector('.cluster-delete-btn');
|
|
1187
|
+
if (clusterDeleteBtn) {
|
|
1188
|
+
clusterDeleteBtn.addEventListener('click', (e) => {
|
|
1189
|
+
e.stopPropagation();
|
|
1190
|
+
const parentCount = counts[meta.parent] || 0;
|
|
1191
|
+
deleteCategoryUI(meta.parent, parentCount, clusterHeader);
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
$categoryList.appendChild(clusterHeader);
|
|
1196
|
+
renderedClusters.add(meta.parent);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// If this category is a parent (is_parent flag or has children), render its own cluster header
|
|
1200
|
+
if ((meta.is_parent || (categoryGroups[cat] && categoryGroups[cat].length > 0)) && !renderedClusters.has(cat)) {
|
|
1201
|
+
const clusterHeader = document.createElement('div');
|
|
1202
|
+
clusterHeader.className = 'category-cluster-header';
|
|
1203
|
+
clusterHeader.style.borderLeft = `3px solid ${catColor(cat)}`;
|
|
1204
|
+
clusterHeader.dataset.parent = cat;
|
|
1205
|
+
|
|
1206
|
+
if (!state.activeCategories.has(cat)) {
|
|
1207
|
+
clusterHeader.classList.add('inactive');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const childCount = (categoryGroups[cat] || []).length;
|
|
1211
|
+
const isEphemeralCat = !!(state.categoryMetadata[cat] || {})._ephemeral;
|
|
1212
|
+
clusterHeader.innerHTML = `
|
|
1213
|
+
<span class="cluster-dot" style="background:${catColor(cat)}; color:${catColor(cat)}" data-tooltip="Color"></span>
|
|
1214
|
+
<span class="cluster-name">${cat}</span>
|
|
1215
|
+
${isEphemeralCat
|
|
1216
|
+
? '<span style="margin-left:auto;font-size:10px;opacity:0.5;color:var(--t-muted)">bridge</span>'
|
|
1217
|
+
: `<span class="cluster-actions">
|
|
1218
|
+
<button class="cluster-edit-btn" data-tooltip="Edit">✎</button>
|
|
1219
|
+
<button class="cluster-delete-btn" data-tooltip="Delete">×</button>
|
|
1220
|
+
</span>`}
|
|
1221
|
+
`;
|
|
1222
|
+
|
|
1223
|
+
clusterHeader.addEventListener('click', (e) => {
|
|
1224
|
+
if (categoryDragDidMove) { categoryDragDidMove = false; return; }
|
|
1225
|
+
if (e.target.closest('.cluster-actions') || e.target.closest('.cluster-dot')) return;
|
|
1226
|
+
const childCategories = categoryGroups[cat] || [];
|
|
1227
|
+
if (state.activeCategories.has(cat)) {
|
|
1228
|
+
state.activeCategories.delete(cat);
|
|
1229
|
+
childCategories.forEach(child => state.activeCategories.delete(child));
|
|
1230
|
+
clusterHeader.classList.add('inactive');
|
|
1231
|
+
childCategories.forEach(child => {
|
|
1232
|
+
const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
|
|
1233
|
+
if (childChip) childChip.classList.add('inactive');
|
|
1234
|
+
});
|
|
1235
|
+
scheduleGraphRemoval();
|
|
1236
|
+
} else {
|
|
1237
|
+
state.activeCategories.add(cat);
|
|
1238
|
+
childCategories.forEach(child => state.activeCategories.add(child));
|
|
1239
|
+
clusterHeader.classList.remove('inactive');
|
|
1240
|
+
childCategories.forEach(child => {
|
|
1241
|
+
const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
|
|
1242
|
+
if (childChip) childChip.classList.remove('inactive');
|
|
1243
|
+
});
|
|
1244
|
+
cancelScheduledRemoval();
|
|
1245
|
+
emit('graph:refresh');
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
clusterHeader.querySelector('.cluster-dot').addEventListener('click', (e) => {
|
|
1250
|
+
e.stopPropagation();
|
|
1251
|
+
openColorPicker(cat, clusterHeader);
|
|
1252
|
+
});
|
|
1253
|
+
const editBtnStandalone = clusterHeader.querySelector('.cluster-edit-btn');
|
|
1254
|
+
if (editBtnStandalone) {
|
|
1255
|
+
editBtnStandalone.addEventListener('click', (e) => {
|
|
1256
|
+
e.stopPropagation();
|
|
1257
|
+
editCategoryUI(cat);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
const deleteBtnStandalone = clusterHeader.querySelector('.cluster-delete-btn');
|
|
1261
|
+
if (deleteBtnStandalone) {
|
|
1262
|
+
deleteBtnStandalone.addEventListener('click', (e) => {
|
|
1263
|
+
e.stopPropagation();
|
|
1264
|
+
deleteCategoryUI(cat, counts[cat] || 0, clusterHeader);
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
$categoryList.appendChild(clusterHeader);
|
|
1269
|
+
renderedClusters.add(cat);
|
|
1270
|
+
|
|
1271
|
+
// Show empty state hint if parent has no children yet
|
|
1272
|
+
if (childCount === 0) {
|
|
1273
|
+
const emptyHint = document.createElement('div');
|
|
1274
|
+
emptyHint.className = 'cluster-empty-hint';
|
|
1275
|
+
emptyHint.textContent = 'Drag categories here';
|
|
1276
|
+
$categoryList.appendChild(emptyHint);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Skip rendering chips for parent categories (they only appear as cluster headers)
|
|
1281
|
+
if (categoryGroups[cat] !== undefined) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const chip = document.createElement('div');
|
|
1286
|
+
chip.className = 'category-chip' + (meta.parent ? ' category-chip-child' : '');
|
|
1287
|
+
chip.dataset.cat = cat;
|
|
1288
|
+
|
|
1289
|
+
if (!state.activeCategories.has(cat)) {
|
|
1290
|
+
chip.classList.add('inactive');
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const isEphemeralChip = !!(meta._ephemeral || (meta.parent && (state.categoryMetadata[meta.parent] || {})._ephemeral));
|
|
1294
|
+
chip.innerHTML = `
|
|
1295
|
+
<div class="category-dot" style="color:${catColor(cat)}; background:${catColor(cat)}" data-tooltip="Color"></div>
|
|
1296
|
+
<span class="category-label">${cat}${isEphemeralChip ? '' : '<span class="cat-edit-icon" data-tooltip="Edit">✎</span>'}</span>
|
|
1297
|
+
<span class="category-count">${counts[cat] || 0}</span>
|
|
1298
|
+
${isEphemeralChip ? '' : '<button class="category-delete" data-tooltip="Delete">×</button>'}
|
|
1299
|
+
`;
|
|
1300
|
+
|
|
1301
|
+
// Color dot click -> open color picker
|
|
1302
|
+
chip.querySelector('.category-dot').addEventListener('click', (e) => {
|
|
1303
|
+
e.stopPropagation();
|
|
1304
|
+
openColorPicker(cat, chip);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
// Edit icon click -> open full category editor (not for ephemeral)
|
|
1308
|
+
const catEditIcon = chip.querySelector('.cat-edit-icon');
|
|
1309
|
+
if (catEditIcon) {
|
|
1310
|
+
catEditIcon.addEventListener('click', (e) => {
|
|
1311
|
+
e.stopPropagation();
|
|
1312
|
+
editCategoryUI(cat);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
chip.addEventListener('click', (e) => {
|
|
1317
|
+
if (categoryDragDidMove) { categoryDragDidMove = false; return; }
|
|
1318
|
+
if (e.target.closest('.category-delete')) return;
|
|
1319
|
+
if (e.target.closest('.category-dot')) return;
|
|
1320
|
+
if (e.target.closest('.cat-edit-icon')) return;
|
|
1321
|
+
if (state.activeCategories.has(cat)) {
|
|
1322
|
+
state.activeCategories.delete(cat);
|
|
1323
|
+
chip.classList.add('inactive');
|
|
1324
|
+
scheduleGraphRemoval();
|
|
1325
|
+
} else {
|
|
1326
|
+
state.activeCategories.add(cat);
|
|
1327
|
+
chip.classList.remove('inactive');
|
|
1328
|
+
cancelScheduledRemoval();
|
|
1329
|
+
emit('graph:refresh');
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const catDeleteBtn = chip.querySelector('.category-delete');
|
|
1334
|
+
if (catDeleteBtn) {
|
|
1335
|
+
catDeleteBtn.addEventListener('click', (e) => {
|
|
1336
|
+
e.stopPropagation();
|
|
1337
|
+
deleteCategoryUI(cat, counts[cat] || 0, chip);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
$categoryList.appendChild(chip);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
// Wire up drag-to-reorder
|
|
1345
|
+
initCategoryDrag();
|
|
1346
|
+
|
|
1347
|
+
emit('sidebar:rebuilt');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ═══════════════════════════════════════════
|
|
1351
|
+
// DRAG-TO-REORDER
|
|
1352
|
+
// ═══════════════════════════════════════════
|
|
1353
|
+
|
|
1354
|
+
function initCategoryDrag() {
|
|
1355
|
+
const $categoryList = $('category-list');
|
|
1356
|
+
if (!$categoryList) return;
|
|
1357
|
+
|
|
1358
|
+
let dragEl = null; // The element being dragged (chip or cluster header)
|
|
1359
|
+
let dragType = null; // 'chip' or 'cluster'
|
|
1360
|
+
let startY = 0;
|
|
1361
|
+
let startX = 0;
|
|
1362
|
+
let cloneEl = null; // Floating clone that follows cursor
|
|
1363
|
+
let initialRect = null; // Bounding rect captured before drag starts
|
|
1364
|
+
let dragElHeight = 0; // Height of dragged element for gap sizing
|
|
1365
|
+
categoryDragDidMove = false;
|
|
1366
|
+
|
|
1367
|
+
const chips = () => [...$categoryList.querySelectorAll('.category-chip')];
|
|
1368
|
+
const clusterHeaders = () => [...$categoryList.querySelectorAll('.category-cluster-header')];
|
|
1369
|
+
|
|
1370
|
+
function clearIndicators() {
|
|
1371
|
+
chips().forEach(c => c.classList.remove('drag-gap-before', 'drag-gap-after'));
|
|
1372
|
+
clusterHeaders().forEach(h => h.classList.remove('drag-over-cluster', 'drag-gap-before', 'drag-gap-after'));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Get all DOM elements belonging to a cluster (header + its child chips + empty hints)
|
|
1376
|
+
function getClusterElements(headerEl) {
|
|
1377
|
+
const els = [headerEl];
|
|
1378
|
+
let next = headerEl.nextElementSibling;
|
|
1379
|
+
while (next) {
|
|
1380
|
+
if (next.classList.contains('category-cluster-header')) break;
|
|
1381
|
+
if (next.classList.contains('category-chip') && !next.classList.contains('category-chip-child')) break;
|
|
1382
|
+
els.push(next);
|
|
1383
|
+
next = next.nextElementSibling;
|
|
1384
|
+
}
|
|
1385
|
+
return els;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function getDropTarget(y) {
|
|
1389
|
+
const headers = clusterHeaders();
|
|
1390
|
+
|
|
1391
|
+
if (dragType === 'cluster') {
|
|
1392
|
+
const dragClusterEls = new Set(getClusterElements(dragEl));
|
|
1393
|
+
|
|
1394
|
+
for (const header of headers) {
|
|
1395
|
+
if (dragClusterEls.has(header)) continue;
|
|
1396
|
+
const rect = header.getBoundingClientRect();
|
|
1397
|
+
const mid = rect.top + rect.height / 2;
|
|
1398
|
+
if (y >= rect.top - 8 && y <= rect.bottom + 8) {
|
|
1399
|
+
return { type: 'reorder-cluster', header, position: y < mid ? 'before' : 'after' };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
for (const chip of chips()) {
|
|
1404
|
+
if (chip.classList.contains('category-chip-child')) continue;
|
|
1405
|
+
if (dragClusterEls.has(chip)) continue;
|
|
1406
|
+
const rect = chip.getBoundingClientRect();
|
|
1407
|
+
const mid = rect.top + rect.height / 2;
|
|
1408
|
+
if (y >= rect.top - 4 && y <= rect.bottom + 4) {
|
|
1409
|
+
return { type: 'reorder-cluster', header: chip, position: y < mid ? 'before' : 'after' };
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Dragging a chip: check cluster headers to reparent
|
|
1417
|
+
for (const header of headers) {
|
|
1418
|
+
const rect = header.getBoundingClientRect();
|
|
1419
|
+
if (y >= rect.top - 4 && y <= rect.bottom + 20) {
|
|
1420
|
+
const clusterName = header.querySelector('.cluster-name').textContent;
|
|
1421
|
+
return { type: 'cluster', header, clusterName };
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Check empty cluster hints
|
|
1426
|
+
for (const hint of [...$categoryList.querySelectorAll('.cluster-empty-hint')]) {
|
|
1427
|
+
const rect = hint.getBoundingClientRect();
|
|
1428
|
+
if (y >= rect.top && y <= rect.bottom) {
|
|
1429
|
+
const header = hint.previousElementSibling;
|
|
1430
|
+
if (header && header.classList.contains('category-cluster-header')) {
|
|
1431
|
+
const clusterName = header.querySelector('.cluster-name').textContent;
|
|
1432
|
+
return { type: 'cluster', header, clusterName };
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Check top empty area (make top-level)
|
|
1438
|
+
const firstChip = chips()[0];
|
|
1439
|
+
const firstHeader = headers[0];
|
|
1440
|
+
const firstElement = firstChip || firstHeader;
|
|
1441
|
+
if (firstElement) {
|
|
1442
|
+
const firstRect = firstElement.getBoundingClientRect();
|
|
1443
|
+
const listRect = $categoryList.getBoundingClientRect();
|
|
1444
|
+
if (y < firstRect.top && y >= listRect.top) {
|
|
1445
|
+
return { type: 'make-toplevel' };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Reorder among sibling chips
|
|
1450
|
+
for (const chip of chips()) {
|
|
1451
|
+
if (chip === dragEl) continue;
|
|
1452
|
+
const rect = chip.getBoundingClientRect();
|
|
1453
|
+
const mid = rect.top + rect.height / 2;
|
|
1454
|
+
if (y < mid) return { type: 'reorder', chip, position: 'before' };
|
|
1455
|
+
}
|
|
1456
|
+
const last = chips().filter(c => c !== dragEl).pop();
|
|
1457
|
+
return last ? { type: 'reorder', chip: last, position: 'after' } : null;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Create a floating clone of the dragged element
|
|
1461
|
+
function createDragClone(el) {
|
|
1462
|
+
const rect = el.getBoundingClientRect();
|
|
1463
|
+
const clone = el.cloneNode(true);
|
|
1464
|
+
clone.classList.remove('inactive', 'drag-source');
|
|
1465
|
+
clone.classList.add('cat-drag-clone');
|
|
1466
|
+
clone.style.width = rect.width + 'px';
|
|
1467
|
+
clone.style.left = rect.left + 'px';
|
|
1468
|
+
clone.style.top = rect.top + 'px';
|
|
1469
|
+
clone.style.transform = 'translate(0px, 0px) scale(1.04) rotate(0.8deg)';
|
|
1470
|
+
// Strip interactive elements — clone is purely visual
|
|
1471
|
+
clone.querySelectorAll('.category-delete, .cat-edit-icon, .cluster-actions').forEach(n => n.remove());
|
|
1472
|
+
document.body.appendChild(clone);
|
|
1473
|
+
return clone;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Cleanup all drag artifacts
|
|
1477
|
+
function cleanupDrag() {
|
|
1478
|
+
if (dragEl) {
|
|
1479
|
+
dragEl.classList.remove('drag-source');
|
|
1480
|
+
if (dragType === 'cluster') {
|
|
1481
|
+
getClusterElements(dragEl).forEach(el => el.classList.remove('drag-source'));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
clearIndicators();
|
|
1485
|
+
if (cloneEl) { cloneEl.remove(); cloneEl = null; }
|
|
1486
|
+
$categoryList.classList.remove('cat-dragging');
|
|
1487
|
+
$categoryList.style.removeProperty('--drag-gap');
|
|
1488
|
+
dragEl = null;
|
|
1489
|
+
dragType = null;
|
|
1490
|
+
initialRect = null;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
$categoryList.addEventListener('pointerdown', (e) => {
|
|
1494
|
+
// Skip interactive sub-elements
|
|
1495
|
+
if (e.target.closest('.category-delete')) return;
|
|
1496
|
+
if (e.target.closest('.category-dot')) return;
|
|
1497
|
+
if (e.target.closest('.cat-edit-icon')) return;
|
|
1498
|
+
if (e.target.closest('.cluster-actions')) return;
|
|
1499
|
+
if (e.target.closest('.cluster-dot')) return;
|
|
1500
|
+
|
|
1501
|
+
const chip = e.target.closest('.category-chip');
|
|
1502
|
+
const header = e.target.closest('.category-cluster-header');
|
|
1503
|
+
const el = chip || header;
|
|
1504
|
+
if (!el) return;
|
|
1505
|
+
|
|
1506
|
+
e.preventDefault();
|
|
1507
|
+
dragEl = el;
|
|
1508
|
+
dragType = chip ? 'chip' : 'cluster';
|
|
1509
|
+
startY = e.clientY;
|
|
1510
|
+
startX = e.clientX;
|
|
1511
|
+
categoryDragDidMove = false;
|
|
1512
|
+
el.setPointerCapture(e.pointerId);
|
|
1513
|
+
|
|
1514
|
+
function onMove(ev) {
|
|
1515
|
+
if (!dragEl) return;
|
|
1516
|
+
if (!categoryDragDidMove && Math.abs(ev.clientY - startY) < 5) return;
|
|
1517
|
+
|
|
1518
|
+
if (!categoryDragDidMove) {
|
|
1519
|
+
categoryDragDidMove = true;
|
|
1520
|
+
|
|
1521
|
+
// Capture geometry before any visual changes
|
|
1522
|
+
initialRect = dragEl.getBoundingClientRect();
|
|
1523
|
+
dragElHeight = initialRect.height + 4;
|
|
1524
|
+
|
|
1525
|
+
// Create floating clone
|
|
1526
|
+
cloneEl = createDragClone(dragEl);
|
|
1527
|
+
|
|
1528
|
+
// Ghost the source element(s)
|
|
1529
|
+
dragEl.classList.add('drag-source');
|
|
1530
|
+
if (dragType === 'cluster') {
|
|
1531
|
+
getClusterElements(dragEl).forEach(el => el.classList.add('drag-source'));
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Enable gap transitions and set dynamic gap size
|
|
1535
|
+
$categoryList.classList.add('cat-dragging');
|
|
1536
|
+
$categoryList.style.setProperty('--drag-gap', dragElHeight + 'px');
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Move clone with cursor (GPU-accelerated transform)
|
|
1540
|
+
const dx = ev.clientX - startX;
|
|
1541
|
+
const dy = ev.clientY - startY;
|
|
1542
|
+
cloneEl.style.transform = `translate(${dx}px, ${dy}px) scale(1.04) rotate(0.8deg)`;
|
|
1543
|
+
|
|
1544
|
+
// Show animated gap at drop target
|
|
1545
|
+
clearIndicators();
|
|
1546
|
+
const target = getDropTarget(ev.clientY);
|
|
1547
|
+
if (target) {
|
|
1548
|
+
if (target.type === 'cluster') {
|
|
1549
|
+
target.header.classList.add('drag-over-cluster');
|
|
1550
|
+
} else if (target.type === 'reorder' || target.type === 'reorder-cluster') {
|
|
1551
|
+
const targetEl = target.chip || target.header;
|
|
1552
|
+
targetEl.classList.add(target.position === 'before' ? 'drag-gap-before' : 'drag-gap-after');
|
|
1553
|
+
} else if (target.type === 'make-toplevel') {
|
|
1554
|
+
const first = chips()[0] || clusterHeaders()[0];
|
|
1555
|
+
if (first && first !== dragEl) first.classList.add('drag-gap-before');
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function onUp(ev) {
|
|
1561
|
+
el.removeEventListener('pointermove', onMove);
|
|
1562
|
+
el.removeEventListener('pointerup', onUp);
|
|
1563
|
+
el.removeEventListener('pointercancel', onUp);
|
|
1564
|
+
|
|
1565
|
+
if (!dragEl) return;
|
|
1566
|
+
|
|
1567
|
+
const droppedEl = dragEl;
|
|
1568
|
+
const droppedType = dragType;
|
|
1569
|
+
const didMove = categoryDragDidMove;
|
|
1570
|
+
|
|
1571
|
+
if (didMove) {
|
|
1572
|
+
const target = getDropTarget(ev.clientY);
|
|
1573
|
+
|
|
1574
|
+
// ── Spring-snap drop animation ──
|
|
1575
|
+
if (cloneEl && target && initialRect) {
|
|
1576
|
+
let gapEl = null, gapPosition = null;
|
|
1577
|
+
if (target.type === 'reorder' || target.type === 'reorder-cluster') {
|
|
1578
|
+
gapEl = target.chip || target.header;
|
|
1579
|
+
gapPosition = target.position;
|
|
1580
|
+
} else if (target.type === 'make-toplevel') {
|
|
1581
|
+
gapEl = chips()[0] || clusterHeaders()[0];
|
|
1582
|
+
gapPosition = 'before';
|
|
1583
|
+
} else if (target.type === 'cluster') {
|
|
1584
|
+
gapEl = target.header;
|
|
1585
|
+
gapPosition = 'after';
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (gapEl) {
|
|
1589
|
+
const gapRect = gapEl.getBoundingClientRect();
|
|
1590
|
+
const targetTop = gapPosition === 'before'
|
|
1591
|
+
? gapRect.top - dragElHeight
|
|
1592
|
+
: gapRect.bottom;
|
|
1593
|
+
const targetDy = targetTop - initialRect.top;
|
|
1594
|
+
const targetDx = gapRect.left - initialRect.left;
|
|
1595
|
+
|
|
1596
|
+
// Animate clone to landing position with spring overshoot
|
|
1597
|
+
cloneEl.style.transition = 'transform 0.22s cubic-bezier(0.22, 1.15, 0.36, 1), opacity 0.14s ease 0.06s';
|
|
1598
|
+
cloneEl.style.transform = `translate(${targetDx}px, ${targetDy}px) scale(1) rotate(0deg)`;
|
|
1599
|
+
cloneEl.style.opacity = '0';
|
|
1600
|
+
await new Promise(r => setTimeout(r, 220));
|
|
1601
|
+
}
|
|
1602
|
+
} else if (cloneEl && initialRect) {
|
|
1603
|
+
// No valid target — animate clone back to origin
|
|
1604
|
+
cloneEl.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0, 1), opacity 0.12s ease';
|
|
1605
|
+
cloneEl.style.transform = 'translate(0px, 0px) scale(1) rotate(0deg)';
|
|
1606
|
+
cloneEl.style.opacity = '0';
|
|
1607
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ── Execute drop action ──
|
|
1611
|
+
if (target) {
|
|
1612
|
+
if (droppedType === 'cluster' && target.type === 'reorder-cluster') {
|
|
1613
|
+
const clusterEls = getClusterElements(droppedEl);
|
|
1614
|
+
const refEl = target.header;
|
|
1615
|
+
if (target.position === 'before') {
|
|
1616
|
+
for (const cel of clusterEls) {
|
|
1617
|
+
$categoryList.insertBefore(cel, refEl);
|
|
1618
|
+
}
|
|
1619
|
+
} else {
|
|
1620
|
+
let insertAfter = refEl;
|
|
1621
|
+
if (refEl.classList.contains('category-cluster-header')) {
|
|
1622
|
+
const targetClusterEls = getClusterElements(refEl);
|
|
1623
|
+
insertAfter = targetClusterEls[targetClusterEls.length - 1];
|
|
1624
|
+
}
|
|
1625
|
+
for (let i = clusterEls.length - 1; i >= 0; i--) {
|
|
1626
|
+
insertAfter.after(clusterEls[i]);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const newOrder = chips().map(c => c.dataset.cat);
|
|
1630
|
+
saveCategoryOrder(newOrder);
|
|
1631
|
+
|
|
1632
|
+
} else if (droppedType === 'chip' && target.type === 'cluster') {
|
|
1633
|
+
const draggedCat = droppedEl.dataset.cat;
|
|
1634
|
+
const newParent = target.clusterName;
|
|
1635
|
+
const currentMeta = state.categoryMetadata[draggedCat] || {};
|
|
1636
|
+
|
|
1637
|
+
if (currentMeta.parent !== newParent) {
|
|
1638
|
+
try {
|
|
1639
|
+
const data = await apiUpdateCategory(draggedCat, { parent: newParent });
|
|
1640
|
+
applyApiResponse(data);
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
console.error('Error reparenting category:', err);
|
|
1643
|
+
alert('Error reparenting category: ' + err.message);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
} else if (droppedType === 'chip' && target.type === 'make-toplevel') {
|
|
1648
|
+
const draggedCat = droppedEl.dataset.cat;
|
|
1649
|
+
const currentMeta = state.categoryMetadata[draggedCat] || {};
|
|
1650
|
+
|
|
1651
|
+
if (currentMeta.parent) {
|
|
1652
|
+
try {
|
|
1653
|
+
const data = await apiUpdateCategory(draggedCat, { parent: '' });
|
|
1654
|
+
applyApiResponse(data);
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
console.error('Error making category top-level:', err);
|
|
1657
|
+
alert('Error making category top-level: ' + err.message);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
} else if (droppedType === 'chip' && target.type === 'reorder' && target.chip !== droppedEl) {
|
|
1662
|
+
if (target.position === 'before') {
|
|
1663
|
+
$categoryList.insertBefore(droppedEl, target.chip);
|
|
1664
|
+
} else {
|
|
1665
|
+
target.chip.after(droppedEl);
|
|
1666
|
+
}
|
|
1667
|
+
const newOrder = chips().map(c => c.dataset.cat);
|
|
1668
|
+
saveCategoryOrder(newOrder);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ── Cleanup ──
|
|
1674
|
+
cleanupDrag();
|
|
1675
|
+
|
|
1676
|
+
// ── Settle animation: element pops into place ──
|
|
1677
|
+
if (didMove && droppedEl && droppedEl.parentNode) {
|
|
1678
|
+
droppedEl.classList.add('just-dropped');
|
|
1679
|
+
droppedEl.addEventListener('animationend', () => droppedEl.classList.remove('just-dropped'), { once: true });
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
el.addEventListener('pointermove', onMove);
|
|
1684
|
+
el.addEventListener('pointerup', onUp);
|
|
1685
|
+
el.addEventListener('pointercancel', onUp);
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// ═══════════════════════════════════════════
|
|
1690
|
+
// CATEGORY CREATE FORM WIRING
|
|
1691
|
+
// ═══════════════════════════════════════════
|
|
1692
|
+
|
|
1693
|
+
function initCreateForm() {
|
|
1694
|
+
const $addBtn = $('category-add-btn');
|
|
1695
|
+
const $addParentBtn = $('category-add-parent-btn');
|
|
1696
|
+
const $form = $('category-create-form');
|
|
1697
|
+
const $formTitle = $('cat-form-title');
|
|
1698
|
+
const $nameInput = $('cat-name-input');
|
|
1699
|
+
const $descInput = $('cat-desc-input');
|
|
1700
|
+
const $parentSelect = $('cat-parent-select');
|
|
1701
|
+
const $swatchRow = $('color-swatch-row');
|
|
1702
|
+
const $error = $('cat-form-error');
|
|
1703
|
+
const $createBtn = $('cat-form-create');
|
|
1704
|
+
const $cancelBtn = $('cat-form-cancel');
|
|
1705
|
+
|
|
1706
|
+
if (!$addBtn || !$form) return;
|
|
1707
|
+
|
|
1708
|
+
let selectedColor = COLOR_PALETTE[0];
|
|
1709
|
+
let formMode = 'child'; // 'child' or 'parent'
|
|
1710
|
+
|
|
1711
|
+
// Build swatches
|
|
1712
|
+
COLOR_PALETTE.forEach((color, i) => {
|
|
1713
|
+
const btn = document.createElement('button');
|
|
1714
|
+
btn.className = 'color-swatch' + (i === 0 ? ' selected' : '');
|
|
1715
|
+
btn.style.background = color;
|
|
1716
|
+
btn.addEventListener('click', () => {
|
|
1717
|
+
$swatchRow.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
|
|
1718
|
+
btn.classList.add('selected');
|
|
1719
|
+
selectedColor = color;
|
|
1720
|
+
});
|
|
1721
|
+
$swatchRow.appendChild(btn);
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
// Populate parent dropdown
|
|
1725
|
+
function populateParentDropdown() {
|
|
1726
|
+
$parentSelect.innerHTML = '<option value="">(None - standalone category)</option>';
|
|
1727
|
+
state.allCategoryNames.forEach(catName => {
|
|
1728
|
+
const opt = document.createElement('option');
|
|
1729
|
+
opt.value = catName;
|
|
1730
|
+
opt.textContent = catName;
|
|
1731
|
+
$parentSelect.appendChild(opt);
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function openForm(mode) {
|
|
1736
|
+
formMode = mode;
|
|
1737
|
+
$form.dataset.mode = mode;
|
|
1738
|
+
$form.classList.add('open');
|
|
1739
|
+
|
|
1740
|
+
if (mode === 'parent') {
|
|
1741
|
+
$formTitle.textContent = 'CREATE PARENT CATEGORY';
|
|
1742
|
+
$nameInput.placeholder = 'parent-category-name';
|
|
1743
|
+
$descInput.placeholder = 'What does this group contain?';
|
|
1744
|
+
} else {
|
|
1745
|
+
$formTitle.textContent = 'CREATE CATEGORY';
|
|
1746
|
+
$nameInput.placeholder = 'category-name';
|
|
1747
|
+
$descInput.placeholder = 'Short description...';
|
|
1748
|
+
populateParentDropdown();
|
|
1749
|
+
upgradeSelect($parentSelect);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
$nameInput.focus();
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function closeForm() {
|
|
1756
|
+
$form.classList.remove('open');
|
|
1757
|
+
$nameInput.value = '';
|
|
1758
|
+
$descInput.value = '';
|
|
1759
|
+
$parentSelect.value = '';
|
|
1760
|
+
$error.textContent = '';
|
|
1761
|
+
$createBtn.disabled = true;
|
|
1762
|
+
$nameInput.classList.remove('invalid');
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function validateForm() {
|
|
1766
|
+
const name = $nameInput.value;
|
|
1767
|
+
const desc = $descInput.value.trim();
|
|
1768
|
+
if (!name) { $error.textContent = ''; $createBtn.disabled = true; return; }
|
|
1769
|
+
if (name.length < 2) { $error.textContent = 'Min 2 characters'; $createBtn.disabled = true; return; }
|
|
1770
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) { $error.textContent = 'Lowercase, starts with letter, letters/digits/hyphens only'; $createBtn.disabled = true; $nameInput.classList.add('invalid'); return; }
|
|
1771
|
+
$nameInput.classList.remove('invalid');
|
|
1772
|
+
if (!desc) { $error.textContent = 'Description required'; $createBtn.disabled = true; return; }
|
|
1773
|
+
$error.textContent = '';
|
|
1774
|
+
$createBtn.disabled = false;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
$nameInput.addEventListener('input', () => {
|
|
1778
|
+
$nameInput.value = $nameInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
1779
|
+
validateForm();
|
|
1780
|
+
});
|
|
1781
|
+
$descInput.addEventListener('input', validateForm);
|
|
1782
|
+
|
|
1783
|
+
$addBtn.addEventListener('click', () => {
|
|
1784
|
+
if ($form.classList.contains('open')) {
|
|
1785
|
+
closeForm();
|
|
1786
|
+
} else {
|
|
1787
|
+
openForm('child');
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
$addParentBtn.addEventListener('click', () => {
|
|
1792
|
+
if ($form.classList.contains('open')) {
|
|
1793
|
+
closeForm();
|
|
1794
|
+
} else {
|
|
1795
|
+
openForm('parent');
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
$cancelBtn.addEventListener('click', () => {
|
|
1800
|
+
closeForm();
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
$createBtn.addEventListener('click', async () => {
|
|
1804
|
+
const name = $nameInput.value;
|
|
1805
|
+
const desc = $descInput.value.trim();
|
|
1806
|
+
const parent = formMode === 'child' ? ($parentSelect.value || null) : null;
|
|
1807
|
+
const isParent = formMode === 'parent';
|
|
1808
|
+
$createBtn.disabled = true;
|
|
1809
|
+
$createBtn.textContent = 'Creating...';
|
|
1810
|
+
try {
|
|
1811
|
+
await createCategoryAction(name, desc, selectedColor, parent, isParent);
|
|
1812
|
+
closeForm();
|
|
1813
|
+
} catch (e) {
|
|
1814
|
+
$error.textContent = e.message;
|
|
1815
|
+
}
|
|
1816
|
+
$createBtn.textContent = 'Create';
|
|
1817
|
+
validateForm();
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// ═══════════════════════════════════════════
|
|
1822
|
+
// SIDEBAR VISIBILITY TOGGLE + BUTTON WIRING
|
|
1823
|
+
// ═══════════════════════════════════════════
|
|
1824
|
+
|
|
1825
|
+
function initSidebarToggle() {
|
|
1826
|
+
const catSidebar = $('category-sidebar');
|
|
1827
|
+
const catToggleBtn = $('toggle-categories-btn');
|
|
1828
|
+
const catCloseBtn = $('sidebar-close');
|
|
1829
|
+
|
|
1830
|
+
if (!catSidebar) return;
|
|
1831
|
+
|
|
1832
|
+
function setCategoriesVisible(show) {
|
|
1833
|
+
catSidebar.style.display = show ? '' : 'none';
|
|
1834
|
+
if (catToggleBtn) catToggleBtn.classList.toggle('active', show);
|
|
1835
|
+
storage.setItem(KEYS.CATEGORIES_VISIBLE, show ? '1' : '0');
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Restore saved state (default: visible)
|
|
1839
|
+
const saved = storage.getItem(KEYS.CATEGORIES_VISIBLE);
|
|
1840
|
+
if (saved === '0') setCategoriesVisible(false);
|
|
1841
|
+
|
|
1842
|
+
if (catToggleBtn) {
|
|
1843
|
+
catToggleBtn.addEventListener('click', () => {
|
|
1844
|
+
const isVisible = catSidebar.style.display !== 'none';
|
|
1845
|
+
setCategoriesVisible(!isVisible);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (catCloseBtn) {
|
|
1850
|
+
catCloseBtn.addEventListener('click', () => {
|
|
1851
|
+
setCategoriesVisible(false);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ═══════════════════════════════════════════
|
|
1857
|
+
// SELECT-ALL / CLEAR-ALL BUTTONS
|
|
1858
|
+
// ═══════════════════════════════════════════
|
|
1859
|
+
|
|
1860
|
+
function initSelectAllClear() {
|
|
1861
|
+
const clearBtn = $('category-clear-btn');
|
|
1862
|
+
const selectAllBtn = $('category-select-all-btn');
|
|
1863
|
+
|
|
1864
|
+
if (clearBtn) {
|
|
1865
|
+
clearBtn.addEventListener('click', () => {
|
|
1866
|
+
state.activeCategories.clear();
|
|
1867
|
+
document.querySelectorAll('.category-chip').forEach(c => c.classList.add('inactive'));
|
|
1868
|
+
document.querySelectorAll('.category-cluster-header').forEach(c => c.classList.add('inactive'));
|
|
1869
|
+
scheduleGraphRemoval();
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (selectAllBtn) {
|
|
1874
|
+
selectAllBtn.addEventListener('click', () => {
|
|
1875
|
+
const presentCats = new Set(state.allNodes.map(n => n.payload.category));
|
|
1876
|
+
state.activeCategories = new Set([...presentCats, ...state.allCategoryNames]);
|
|
1877
|
+
document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('inactive'));
|
|
1878
|
+
document.querySelectorAll('.category-cluster-header').forEach(c => c.classList.remove('inactive'));
|
|
1879
|
+
cancelScheduledRemoval();
|
|
1880
|
+
emit('graph:refresh');
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// ═══════════════════════════════════════════
|
|
1886
|
+
// INIT — call once at boot
|
|
1887
|
+
// ═══════════════════════════════════════════
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Initialise the entire category sidebar system.
|
|
1891
|
+
* Call once after the DOM is ready and shared state is populated.
|
|
1892
|
+
*/
|
|
1893
|
+
export function initSidebar() {
|
|
1894
|
+
initSidebarToggle();
|
|
1895
|
+
initSelectAllClear();
|
|
1896
|
+
initCreateForm();
|
|
1897
|
+
|
|
1898
|
+
// Listen for rebuild requests from other modules
|
|
1899
|
+
on('sidebar:rebuild', (presentCats) => {
|
|
1900
|
+
buildCategorySidebar(presentCats);
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Re-export helpers that other modules may need
|
|
1905
|
+
export { preloadCategoryLogos, scheduleGraphRemoval, cancelScheduledRemoval, openColorPicker, editCategoryUI, deleteCategoryUI };
|