@unbrained/pm-web 1.0.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
@@ -0,0 +1,188 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // ROUTER — View switching with URL routing
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { VIEW_NAMES } from '../constants.js';
6
+ import { renderProjectsView } from './projects.js';
7
+ import { renderItemsView } from './items.js';
8
+ import { renderCreateView } from './create.js';
9
+ import { renderActivityView } from './activity.js';
10
+ import { renderSearchView } from './search.js';
11
+ import { renderStatsView } from './stats.js';
12
+ import { renderCalendarView } from './calendar.js';
13
+ import { renderContextView } from './context.js';
14
+ import { renderGraphView } from './graph.js';
15
+ import { renderSharingView } from './sharing.js';
16
+ import { renderGroupsView } from './groups.js';
17
+ import { renderHealthView } from './health.js';
18
+ import { renderDedupeAuditView } from './dedupe.js';
19
+ import { renderValidateView } from './validate.js';
20
+ import { renderSettingsView } from './settings.js';
21
+ import { renderGitHubView } from './github.js';
22
+ import { renderExportView } from './export.js';
23
+ import { renderNormalizeView } from './normalize.js';
24
+ import { renderSharedView } from './shared.js';
25
+ import { renderTemplatesView } from './templates.js';
26
+ import { renderCommentsAuditView } from './comments-audit.js';
27
+ import { renderConfigView } from './config.js';
28
+ import { renderGuideView } from './guide.js';
29
+ import { renderAdminView } from './admin.js';
30
+ import { initPlanView } from './plan.js';
31
+
32
+ // View name → URL path mapping
33
+ const VIEW_TO_PATH: Record<string, string> = {
34
+ 'projects': '/',
35
+ 'items': '/items',
36
+ 'create': '/create',
37
+ 'activity': '/activity',
38
+ 'search': '/search',
39
+ 'stats': '/stats',
40
+ 'calendar': '/calendar',
41
+ 'context': '/context',
42
+ 'graph': '/graph',
43
+ 'sharing': '/sharing',
44
+ 'groups': '/groups',
45
+ 'health': '/health',
46
+ 'dedupe': '/dedupe',
47
+ 'validate': '/validate',
48
+ 'settings': '/settings',
49
+ 'github': '/github',
50
+ 'export': '/export',
51
+ 'normalize': '/normalize',
52
+ 'shared': '/shared',
53
+ 'templates': '/templates',
54
+ 'comments-audit': '/comments-audit',
55
+ 'config': '/config',
56
+ 'guide': '/guide',
57
+ 'admin': '/admin',
58
+ 'plan': '/plan',
59
+ };
60
+
61
+ // Reverse: URL path → view name
62
+ const PATH_TO_VIEW: Record<string, string> = {};
63
+ for (const [view, path] of Object.entries(VIEW_TO_PATH)) {
64
+ PATH_TO_VIEW[path] = view;
65
+ }
66
+
67
+ // Whether pushState was just called (to ignore the resulting popstate)
68
+ let navigatingInternally = false;
69
+
70
+ // Callback invoked when the view changes — used to notify presence service
71
+ let onViewChange: ((view: string) => void) | null = null;
72
+ export function setOnViewChange(cb: (view: string) => void): void {
73
+ onViewChange = cb;
74
+ }
75
+
76
+ export function getPathForView(view: string): string {
77
+ return VIEW_TO_PATH[view] || '/';
78
+ }
79
+
80
+ export function getViewForPath(path: string): string {
81
+ // Normalize: remove trailing slash except for root
82
+ const normalized = path.replace(/\/$/, '') || '/';
83
+ // Direct match
84
+ if (PATH_TO_VIEW[normalized]) return PATH_TO_VIEW[normalized];
85
+ // Try matching first segment for deeper paths (e.g. /items/DETAIL-1 → items)
86
+ const firstSegment = '/' + normalized.slice(1).split('/')[0];
87
+ return PATH_TO_VIEW[firstSegment] || 'projects';
88
+ }
89
+
90
+ export function showView(view: string, pushState = true): void {
91
+ if (view === 'admin' && !state.user?.is_admin) {
92
+ history.replaceState({ view: 'projects' }, '', '/');
93
+ view = 'projects';
94
+ }
95
+ state.currentView = view;
96
+
97
+ // Update URL via pushState
98
+ if (pushState) {
99
+ const path = getPathForView(view);
100
+ if (window.location.pathname !== path) {
101
+ navigatingInternally = true;
102
+ history.pushState({ view }, '', path);
103
+ }
104
+ }
105
+
106
+ // Full-screen graph mode: hide sidebar
107
+ document.body.classList.toggle('graph-mode', view === 'graph');
108
+ VIEW_NAMES.forEach(v => {
109
+ const el = document.getElementById(`content-${v}`);
110
+ if (el) el.style.display = v === view ? '' : 'none';
111
+ });
112
+ document.querySelectorAll('.sidebar-item[data-view]').forEach(el => {
113
+ (el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.view === view);
114
+ });
115
+ if (view === 'projects') {
116
+ document.querySelectorAll('#sidebar-projects-section .sidebar-item').forEach((el,i) => {
117
+ (el as HTMLElement).classList.toggle('active', i===0);
118
+ });
119
+ }
120
+ switch(view) {
121
+ case 'projects': renderProjectsView(); break;
122
+ case 'items': renderItemsView(); break;
123
+ case 'create': renderCreateView(); break;
124
+ case 'activity': renderActivityView(); break;
125
+ case 'search': renderSearchView(); break;
126
+ case 'stats': renderStatsView(); break;
127
+ case 'calendar': renderCalendarView(); break;
128
+ case 'context': renderContextView(); break;
129
+ case 'graph': renderGraphView(); break;
130
+ case 'sharing': renderSharingView(); break;
131
+ case 'groups': renderGroupsView(); break;
132
+ case 'health': renderHealthView(); break;
133
+ case 'dedupe': renderDedupeAuditView(); break;
134
+ case 'validate': renderValidateView(); break;
135
+ case 'settings': renderSettingsView(); break;
136
+ case 'github': renderGitHubView(); break;
137
+ case 'export': renderExportView(); break;
138
+ case 'normalize': renderNormalizeView(); break;
139
+ case 'shared': renderSharedView(); break;
140
+ case 'templates': renderTemplatesView(); break;
141
+ case 'comments-audit': renderCommentsAuditView(); break;
142
+ case 'config': renderConfigView(); break;
143
+ case 'guide': renderGuideView(); break;
144
+ case 'admin': renderAdminView(); break;
145
+ case 'plan': void initPlanView(); break;
146
+ }
147
+ updateMobileNav(view);
148
+
149
+ // Notify presence service of view change
150
+ onViewChange?.(view);
151
+
152
+ // Scroll main content to top on view change
153
+ const mainContent = document.getElementById('main-content');
154
+ if (mainContent) mainContent.scrollTop = 0;
155
+
156
+ // Move focus to main content for accessibility
157
+ const activeArea = document.getElementById(`content-${view}`);
158
+ if (activeArea) {
159
+ activeArea.setAttribute('tabindex', '-1');
160
+ activeArea.focus({ preventScroll: true });
161
+ }
162
+ }
163
+
164
+ function updateMobileNav(view: string): void {
165
+ document.querySelectorAll('.mobile-bottom-nav-item').forEach(el => {
166
+ (el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.mobview === view);
167
+ });
168
+ const nav = document.getElementById('mobile-bottom-nav');
169
+ if (nav) {
170
+ nav.classList.toggle('visible', !!state.currentProject && view !== 'projects');
171
+ }
172
+ }
173
+
174
+ // Handle browser back/forward
175
+ function onPopState(e: PopStateEvent): void {
176
+ // Ignore if we just pushed state (some browsers fire popstate after pushState)
177
+ if (navigatingInternally) {
178
+ navigatingInternally = false;
179
+ return;
180
+ }
181
+ const view = e.state?.view || getViewForPath(window.location.pathname);
182
+ showView(view, false);
183
+ }
184
+
185
+ // Initialize popstate listener
186
+ if (typeof window !== 'undefined') {
187
+ window.addEventListener('popstate', onPopState);
188
+ }
@@ -0,0 +1,103 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // SEARCH VIEW
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { api } from '../api.js';
6
+ import { escHtml } from '../utils.js';
7
+ import { toast } from '../components/toast.js';
8
+ import { renderItemRow } from './items.js';
9
+ let searchTimer;
10
+ export function renderSearchView() {
11
+ const el = document.getElementById('content-search');
12
+ if (!el)
13
+ return;
14
+ if (!state.currentProject) {
15
+ el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>';
16
+ return;
17
+ }
18
+ const modeOpts = [
19
+ { val: 'hybrid', label: 'Hybrid', tip: 'Keyword + semantic (best)' },
20
+ { val: 'semantic', label: 'Semantic', tip: 'Ollama qwen3 embeddings' },
21
+ { val: 'keyword', label: 'Keyword', tip: 'Fast exact match' },
22
+ ];
23
+ el.innerHTML = `
24
+ <div class="page-header">
25
+ <div><div class="page-title">Search</div><div class="page-subtitle">Search items in ${escHtml(state.currentProject.name)}</div></div>
26
+ </div>
27
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap">
28
+ <div style="display:flex;gap:4px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);padding:3px">
29
+ ${modeOpts.map(m => `<button class="btn btn-sm" style="padding:4px 10px;border-radius:5px;font-size:12px;transition:var(--transition);${state.searchMode === m.val ? 'background:var(--bg-card);color:var(--text-primary)' : 'background:transparent;color:var(--text-muted)'}" onclick="window.__app.setSearchMode('${m.val}')" title="${m.tip}">${m.label}</button>`).join('')}
30
+ </div>
31
+ <button class="btn btn-secondary btn-sm" id="reindex-btn" onclick="window.__app.reindexProject()">⟳ Reindex</button>
32
+ <span style="font-size:11px;color:var(--text-muted)">Semantic powered by Ollama qwen3</span>
33
+ </div>
34
+ <div class="search-box-wrap">
35
+ <span class="search-icon">⌕</span>
36
+ <input class="search-input" id="search-query" type="text" placeholder="Search items by title, description, tags…" value="${escHtml(state.searchQuery)}" oninput="window.__app.debouncedSearch()" onkeydown="if(event.key==='Enter')window.__app.doSearch()">
37
+ </div>
38
+ <div id="search-results">
39
+ ${state.searchResults.length > 0 ? renderSearchResults() : '<div class="empty-state"><div class="empty-state-icon">⌕</div><div class="empty-state-text">Enter a query to search</div></div>'}
40
+ </div>`;
41
+ setTimeout(() => document.getElementById('search-query')?.focus(), 50);
42
+ }
43
+ export function setSearchMode(mode) {
44
+ state.searchMode = mode;
45
+ state.searchResults = [];
46
+ renderSearchView();
47
+ if (state.searchQuery)
48
+ doSearch();
49
+ }
50
+ export async function reindexProject() {
51
+ if (!state.currentProject)
52
+ return;
53
+ const btn = document.getElementById('reindex-btn');
54
+ if (!btn)
55
+ return;
56
+ btn.disabled = true;
57
+ btn.textContent = '⟳ Reindexing…';
58
+ const mode = state.searchMode;
59
+ try {
60
+ await api('POST', `/projects/${state.currentProject.id}/pm/reindex`, { mode });
61
+ toast(`Reindex complete (${mode})`, 'success');
62
+ }
63
+ catch (err) {
64
+ toast(`Reindex failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
65
+ }
66
+ finally {
67
+ btn.disabled = false;
68
+ btn.textContent = '⟳ Reindex';
69
+ }
70
+ }
71
+ export function debouncedSearch() {
72
+ clearTimeout(searchTimer);
73
+ state.searchQuery = document.getElementById('search-query')?.value || '';
74
+ searchTimer = setTimeout(doSearch, 350);
75
+ }
76
+ export async function doSearch() {
77
+ const query = (document.getElementById('search-query')?.value || '').trim();
78
+ if (!query || !state.currentProject)
79
+ return;
80
+ state.searchQuery = query;
81
+ const resultsEl = document.getElementById('search-results');
82
+ if (resultsEl)
83
+ resultsEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
84
+ try {
85
+ const data = await api('POST', `/projects/${state.currentProject.id}/pm/search`, { query, mode: state.searchMode });
86
+ state.searchResults = data.results || data.items || [];
87
+ if (resultsEl)
88
+ resultsEl.innerHTML = state.searchResults.length === 0
89
+ ? `<div class="empty-state"><div class="empty-state-text">No results for "${escHtml(query)}"</div></div>`
90
+ : renderSearchResults();
91
+ }
92
+ catch (err) {
93
+ if (resultsEl)
94
+ resultsEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
95
+ }
96
+ }
97
+ function renderSearchResults() {
98
+ if (!state.searchResults.length)
99
+ return '';
100
+ return `<div style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${state.searchResults.length} result${state.searchResults.length !== 1 ? 's' : ''}</div>
101
+ <div class="item-list">${state.searchResults.map(item => renderItemRow(item)).join('')}</div>`;
102
+ }
103
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["search.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,94 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // SEARCH VIEW
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { api } from '../api.js';
6
+ import { escHtml } from '../utils.js';
7
+ import { toast } from '../components/toast.js';
8
+ import { renderItemRow } from './items.js';
9
+
10
+ let searchTimer: ReturnType<typeof setTimeout>;
11
+
12
+ export function renderSearchView(): void {
13
+ const el = document.getElementById('content-search');
14
+ if (!el) return;
15
+ if (!state.currentProject) { el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>'; return; }
16
+ const modeOpts = [
17
+ { val: 'hybrid', label: 'Hybrid', tip: 'Keyword + semantic (best)' },
18
+ { val: 'semantic', label: 'Semantic', tip: 'Ollama qwen3 embeddings' },
19
+ { val: 'keyword', label: 'Keyword', tip: 'Fast exact match' },
20
+ ];
21
+ el.innerHTML = `
22
+ <div class="page-header">
23
+ <div><div class="page-title">Search</div><div class="page-subtitle">Search items in ${escHtml(state.currentProject.name)}</div></div>
24
+ </div>
25
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap">
26
+ <div style="display:flex;gap:4px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);padding:3px">
27
+ ${modeOpts.map(m=>`<button class="btn btn-sm" style="padding:4px 10px;border-radius:5px;font-size:12px;transition:var(--transition);${state.searchMode===m.val?'background:var(--bg-card);color:var(--text-primary)':'background:transparent;color:var(--text-muted)'}" onclick="window.__app.setSearchMode('${m.val}')" title="${m.tip}">${m.label}</button>`).join('')}
28
+ </div>
29
+ <button class="btn btn-secondary btn-sm" id="reindex-btn" onclick="window.__app.reindexProject()">⟳ Reindex</button>
30
+ <span style="font-size:11px;color:var(--text-muted)">Semantic powered by Ollama qwen3</span>
31
+ </div>
32
+ <div class="search-box-wrap">
33
+ <span class="search-icon">⌕</span>
34
+ <input class="search-input" id="search-query" type="text" placeholder="Search items by title, description, tags…" value="${escHtml(state.searchQuery)}" oninput="window.__app.debouncedSearch()" onkeydown="if(event.key==='Enter')window.__app.doSearch()">
35
+ </div>
36
+ <div id="search-results">
37
+ ${state.searchResults.length > 0 ? renderSearchResults() : '<div class="empty-state"><div class="empty-state-icon">⌕</div><div class="empty-state-text">Enter a query to search</div></div>'}
38
+ </div>`;
39
+ setTimeout(()=>document.getElementById('search-query')?.focus(),50);
40
+ }
41
+
42
+ export function setSearchMode(mode: string): void {
43
+ state.searchMode = mode;
44
+ state.searchResults = [];
45
+ renderSearchView();
46
+ if (state.searchQuery) doSearch();
47
+ }
48
+
49
+ export async function reindexProject(): Promise<void> {
50
+ if (!state.currentProject) return;
51
+ const btn = document.getElementById('reindex-btn') as HTMLButtonElement | null;
52
+ if (!btn) return;
53
+ btn.disabled = true;
54
+ btn.textContent = '⟳ Reindexing…';
55
+ const mode = state.searchMode as string;
56
+ try {
57
+ await api('POST',`/projects/${state.currentProject.id}/pm/reindex`,{mode});
58
+ toast(`Reindex complete (${mode})`, 'success');
59
+ } catch(err: unknown) {
60
+ toast(`Reindex failed: ${err instanceof Error ? err.message : String(err)}`,'error');
61
+ } finally {
62
+ btn.disabled = false;
63
+ btn.textContent = '⟳ Reindex';
64
+ }
65
+ }
66
+
67
+ export function debouncedSearch(): void {
68
+ clearTimeout(searchTimer);
69
+ state.searchQuery = (document.getElementById('search-query') as HTMLInputElement | null)?.value || '';
70
+ searchTimer = setTimeout(doSearch, 350);
71
+ }
72
+
73
+ export async function doSearch(): Promise<void> {
74
+ const query = ((document.getElementById('search-query') as HTMLInputElement | null)?.value || '').trim();
75
+ if (!query || !state.currentProject) return;
76
+ state.searchQuery = query;
77
+ const resultsEl = document.getElementById('search-results');
78
+ if (resultsEl) resultsEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
79
+ try {
80
+ const data = await api('POST',`/projects/${state.currentProject.id}/pm/search`,{query,mode:state.searchMode});
81
+ state.searchResults = (data as any).results || (data as any).items || [];
82
+ if (resultsEl) resultsEl.innerHTML = state.searchResults.length === 0
83
+ ? `<div class="empty-state"><div class="empty-state-text">No results for "${escHtml(query)}"</div></div>`
84
+ : renderSearchResults();
85
+ } catch(err: unknown) {
86
+ if (resultsEl) resultsEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
87
+ }
88
+ }
89
+
90
+ function renderSearchResults(): string {
91
+ if (!state.searchResults.length) return '';
92
+ return `<div style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${state.searchResults.length} result${state.searchResults.length!==1?'s':''}</div>
93
+ <div class="item-list">${state.searchResults.map(item=>renderItemRow(item)).join('')}</div>`;
94
+ }