@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,926 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // APP — Main entry point
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from './state.js';
5
+ import { api } from './api.js';
6
+ import { showView, setOnViewChange } from './views/router.js';
7
+ import { loadProjects, onProjectSelect, loadItemsBadge, renderProjectsView, selectProject, deleteProject, buildCreateProjectModal, submitCreateProject, submitCreateProject2 } from './views/projects.js';
8
+ import { renderItemsView, fetchAndRenderItems, openItemDetail, switchDetailTab, addComment, addNote, appendItem, updateItem, closeItem, confirmDeleteItem, claimItem, releaseItem, startItem, pauseItem, addDep, removeDep, addLearning, addTest, addFileLink, setStatusFilter, applyItemFilters, clearFilters, showBulkUpdateModal, previewBulkUpdate, applyBulkUpdate, showBulkCloseModal, previewBulkClose, applyBulkClose, useItemAsTemplate } from './views/items.js';
9
+ import { submitCreateItem, submitCreateItemAndOpen } from './views/create.js';
10
+ import { renderActivityView } from './views/activity.js';
11
+ import { renderSearchView, setSearchMode, reindexProject, debouncedSearch, doSearch } from './views/search.js';
12
+ import { renderStatsView } from './views/stats.js';
13
+ import { renderCalendarView, calNav, showDayItems } from './views/calendar.js';
14
+ import { renderContextView } from './views/context.js';
15
+ import { renderGraphView } from './views/graph.js';
16
+ // Open graph view focused on a specific node
17
+ async function openGraphAt(nodeId) {
18
+ showView('graph');
19
+ // Give the graph view time to mount, then set selected node
20
+ setTimeout(async () => {
21
+ await renderGraphView();
22
+ // Select the node via the graph canvas
23
+ const appw = window;
24
+ appw.__graphSelectNode?.(nodeId);
25
+ }, 50);
26
+ }
27
+ import { renderSharingView, openShareModal, submitShare, removeShare } from './views/sharing.js';
28
+ import { renderGroupsView, openCreateGroupModal, submitCreateGroup, deleteGroup, openGroupDetail, inviteMember, removeMember } from './views/groups.js';
29
+ import { renderHealthView, repairItemHistory } from './views/health.js';
30
+ import { renderDedupeAuditView } from './views/dedupe.js';
31
+ import { renderValidateView } from './views/validate.js';
32
+ import { renderSettingsView, saveProfile, changePassword, saveGitHubToken, clearGitHubToken } from './views/settings.js';
33
+ import { renderGitHubView, linkGitHubRepo, unlinkGitHubRepo, loadGitHubIssues, selectAllIssues, importGitHubIssues, loadItemsForPush, selectAllPushItems, pushItemsToGitHub, updateGitHubIssue } from './views/github.js';
34
+ import { renderExportView, exportData, importData } from './views/export.js';
35
+ import { renderNormalizeView, applyNormalize } from './views/normalize.js';
36
+ import { renderSharedView } from './views/shared.js';
37
+ import { renderTemplatesView, createFromTemplate } from './views/templates.js';
38
+ import { renderCommentsAuditView } from './views/comments-audit.js';
39
+ import { renderConfigView, configAddArrayItem, configRemoveArrayItem, configSaveArray, configSaveSimple, configSaveObject, addSchemaType } from './views/config.js';
40
+ import { renderGuideView } from './views/guide.js';
41
+ import { renderAdminView, setAdminRole, adminSwitchTab, adminDeleteUser, adminDeleteProject, adminDeleteGroup, adminFilterUsers, adminFilterProjects, adminFilterAudit, adminSetPage, adminCreateGroup } from './views/admin.js';
42
+ import { switchAuthTab, submitAuth, logout, showAuth } from './views/auth.js';
43
+ import { initPlanView, openPlanDetail, openCreatePlanModal, submitCreatePlan, openAddStepModal, submitAddStep, planCompleteStep, planBlockStepPrompt, submitBlockStep, planRemoveStep, planApprove, planMaterializePrompt, submitMaterializePlan, planEditPrompt, submitEditPlan, planDeletePrompt } from './views/plan.js';
44
+ import { showModal, hideModal, createModal } from './components/modals.js';
45
+ import { toast } from './components/toast.js';
46
+ import { escHtml } from './utils.js';
47
+ // Global search modal
48
+ let globalSearchTimer;
49
+ function buildSearchModal() {
50
+ createModal('global-search-modal', 'Search', `
51
+ <div class="search-box-wrap" style="margin-bottom:12px">
52
+ <span class="search-icon">⌕</span>
53
+ <input class="search-input" id="global-search-input" type="text" placeholder="Search items…" oninput="window.__app.globalSearchDebounced()">
54
+ </div>
55
+ <div id="global-search-results">
56
+ <div class="empty-state" style="padding:24px"><div class="empty-state-text">Type to search</div></div>
57
+ </div>`, '');
58
+ }
59
+ const mobileCommandGroups = [
60
+ {
61
+ title: 'Plan and Execute',
62
+ commands: [
63
+ { view: 'items', title: 'Items', desc: 'Browse, filter, edit, and close work.', icon: '≡', requiresProject: true },
64
+ { view: 'create', title: 'Create Item', desc: 'Add tasks, features, bugs, reminders, and more.', icon: '+', requiresProject: true },
65
+ { view: 'plan', title: 'Plans', desc: 'Create and manage structured agentic plans with steps.', icon: '◧', requiresProject: true },
66
+ { view: 'calendar', title: 'Calendar', desc: 'Review deadlines, reminders, and scheduled work.', icon: '◷', requiresProject: true },
67
+ { view: 'templates', title: 'Templates', desc: 'Create from saved pm templates.', icon: '⎘', requiresProject: true },
68
+ ],
69
+ },
70
+ {
71
+ title: 'Inspect and Maintain',
72
+ commands: [
73
+ { view: 'search', title: 'Search', desc: 'Keyword, semantic, and hybrid search.', icon: '⌕', requiresProject: true },
74
+ { view: 'stats', title: 'Stats', desc: 'Counts, distributions, and project summary.', icon: '◈', requiresProject: true },
75
+ { view: 'graph', title: 'Graph', desc: 'Knowledge and dependency graph.', icon: '◎', requiresProject: true },
76
+ { view: 'health', title: 'Health', desc: 'Find stale, blocked, or weakly specified work.', icon: '♥', requiresProject: true },
77
+ { view: 'activity', title: 'Activity', desc: 'Audit recent project changes.', icon: '◎', requiresProject: true },
78
+ { view: 'dedupe', title: 'Dedupe Audit', desc: 'Find possible duplicate items.', icon: '⧖', requiresProject: true },
79
+ { view: 'validate', title: 'Validate', desc: 'Run pm validation checks.', icon: '✓', requiresProject: true },
80
+ { view: 'normalize', title: 'Normalize', desc: 'Preview and apply lifecycle cleanup.', icon: '⊞', requiresProject: true },
81
+ { view: 'comments-audit', title: 'Comments Audit', desc: 'Review latest comments across items.', icon: '💬', requiresProject: true },
82
+ ],
83
+ },
84
+ {
85
+ title: 'Collaborate and Connect',
86
+ commands: [
87
+ { view: 'sharing', title: 'Sharing', desc: 'Manage project access.', icon: '⇄', requiresProject: true },
88
+ { view: 'groups', title: 'Groups', desc: 'Create groups and manage members.', icon: '◉' },
89
+ { view: 'shared', title: 'Shared with Me', desc: 'Open projects shared by other users.', icon: '⇄' },
90
+ { view: 'github', title: 'GitHub', desc: 'Link repositories and import issues.', icon: '⊙', requiresProject: true },
91
+ ],
92
+ },
93
+ {
94
+ title: 'Project Tools',
95
+ commands: [
96
+ { view: 'projects', title: 'All Projects', desc: 'Switch or create a workspace.', icon: '⊞' },
97
+ { view: 'context', title: 'Context', desc: 'Generate agent-ready project context.', icon: '⚙', requiresProject: true },
98
+ { view: 'config', title: 'Config', desc: 'Edit project settings.', icon: '⚒', requiresProject: true },
99
+ { view: 'export', title: 'Export / Import', desc: 'Download or upload project data.', icon: '↕', requiresProject: true },
100
+ { view: 'guide', title: 'Guide', desc: 'Read pm workflow guidance.', icon: '📖', requiresProject: true },
101
+ { view: 'settings', title: 'Account Settings', desc: 'Profile, password, and GitHub token.', icon: '⚙' },
102
+ { view: 'admin', title: 'Admin', desc: 'Manage users, projects, groups, and roles.', icon: '◇' },
103
+ ],
104
+ },
105
+ ];
106
+ function buildMobileCommandSheet() {
107
+ const hasProject = !!state.currentProject;
108
+ const projectName = state.currentProject?.name || 'No project selected';
109
+ const body = `
110
+ <div class="mobile-command-intro">
111
+ <div class="mobile-command-project">
112
+ <div class="mobile-command-project-label">Current workspace</div>
113
+ <div class="mobile-command-project-name">${escHtml(projectName)}</div>
114
+ </div>
115
+ <div class="mobile-command-sync"><span class="sse-dot"></span>${hasProject ? 'Live sync' : 'Select project'}</div>
116
+ </div>
117
+ ${mobileCommandGroups.map(group => `
118
+ <div class="mobile-command-group">
119
+ <div class="mobile-command-group-title">${escHtml(group.title)}</div>
120
+ <div class="mobile-command-grid">
121
+ ${group.commands.filter(command => command.view !== 'admin' || state.user?.is_admin).map(command => {
122
+ const disabled = command.requiresProject && !hasProject;
123
+ return `
124
+ <button class="mobile-command" ${disabled ? 'disabled' : ''} onclick="window.__app.runMobileCommand('${command.view}')">
125
+ <span class="mobile-command-top">
126
+ <span class="mobile-command-icon">${escHtml(command.icon)}</span>
127
+ <span class="mobile-command-title">${escHtml(command.title)}</span>
128
+ </span>
129
+ <span class="mobile-command-desc">${escHtml(command.desc)}</span>
130
+ </button>`;
131
+ }).join('')}
132
+ </div>
133
+ </div>`).join('')}`;
134
+ createModal('mobile-command-sheet', 'More', body, '', true);
135
+ }
136
+ function openMobileCommandSheet() {
137
+ buildMobileCommandSheet();
138
+ showModal('mobile-command-sheet');
139
+ }
140
+ function runMobileCommand(view) {
141
+ hideModal('mobile-command-sheet');
142
+ showView(view);
143
+ }
144
+ function openSearchModal() {
145
+ if (!state.currentProject) {
146
+ toast('Select a project first', 'info');
147
+ return;
148
+ }
149
+ showModal('global-search-modal');
150
+ setTimeout(() => document.getElementById('global-search-input')?.focus(), 50);
151
+ }
152
+ function globalSearchDebounced() {
153
+ clearTimeout(globalSearchTimer);
154
+ globalSearchTimer = setTimeout(doGlobalSearch, 300);
155
+ }
156
+ async function doGlobalSearch() {
157
+ const query = document.getElementById('global-search-input')?.value?.trim();
158
+ if (!query || !state.currentProject)
159
+ return;
160
+ const resultsEl = document.getElementById('global-search-results');
161
+ if (resultsEl)
162
+ resultsEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
163
+ try {
164
+ const data = await api('POST', `/projects/${state.currentProject.id}/pm/search`, { query });
165
+ const results = data.results || data.items || [];
166
+ const { escHtml, typeIcon, priorityDot, statusBadge } = await import('./utils.js');
167
+ if (resultsEl)
168
+ resultsEl.innerHTML = results.length === 0
169
+ ? `<div class="empty-state" style="padding:24px"><div class="empty-state-text">No results for "${escHtml(query)}"</div></div>`
170
+ : `<div class="item-list">${results.map((item) => `
171
+ <div class="item-row" onclick="window.__app.hideModal('global-search-modal');window.__app.openItemDetail('${escHtml(item.id)}')">
172
+ ${typeIcon(item.type || '')}
173
+ <span class="item-id">${escHtml(item.id)}</span>
174
+ <span class="item-title">${escHtml(item.title)}</span>
175
+ <div class="item-meta">${priorityDot(item.priority || 5)}${statusBadge(item.status || 'draft')}</div>
176
+ </div>`).join('')}</div>`;
177
+ }
178
+ catch (err) {
179
+ if (resultsEl)
180
+ resultsEl.innerHTML = `<div class="empty-state" style="padding:24px"><div class="empty-state-text">Error: ${err instanceof Error ? err.message : String(err)}</div></div>`;
181
+ }
182
+ }
183
+ // PWA install
184
+ let deferredPrompt = null;
185
+ // Expose everything needed by inline onclick handlers via window.__app
186
+ window.__app = {
187
+ // Views
188
+ showView,
189
+ renderProjectsView,
190
+ renderItemsView,
191
+ renderActivityView,
192
+ renderSearchView,
193
+ renderStatsView,
194
+ renderCalendarView,
195
+ renderContextView,
196
+ renderGraphView,
197
+ renderSharingView,
198
+ renderGroupsView,
199
+ renderHealthView,
200
+ repairItemHistory,
201
+ renderDedupeAuditView,
202
+ renderValidateView,
203
+ renderSettingsView,
204
+ renderGitHubView,
205
+ renderExportView,
206
+ renderNormalizeView,
207
+ renderSharedView,
208
+ renderTemplatesView,
209
+ renderCommentsAuditView,
210
+ renderConfigView,
211
+ renderGuideView,
212
+ renderAdminView,
213
+ // Config
214
+ configAddArrayItem,
215
+ configRemoveArrayItem,
216
+ configSaveArray,
217
+ configSaveSimple,
218
+ configSaveObject,
219
+ addSchemaType,
220
+ setAdminRole,
221
+ // Auth
222
+ switchAuthTab,
223
+ submitAuth,
224
+ logout,
225
+ // Projects
226
+ onProjectSelect,
227
+ selectProject,
228
+ deleteProject,
229
+ submitCreateProject,
230
+ submitCreateProject2,
231
+ // Items
232
+ openItemDetail,
233
+ switchDetailTab,
234
+ addComment,
235
+ addNote,
236
+ appendItem,
237
+ updateItem,
238
+ closeItem,
239
+ confirmDeleteItem,
240
+ claimItem,
241
+ releaseItem,
242
+ startItem,
243
+ pauseItem,
244
+ addDep,
245
+ removeDep,
246
+ addLearning,
247
+ addTest,
248
+ addFileLink,
249
+ setStatusFilter,
250
+ applyItemFilters,
251
+ clearFilters,
252
+ showBulkUpdateModal,
253
+ previewBulkUpdate,
254
+ applyBulkUpdate,
255
+ showBulkCloseModal,
256
+ previewBulkClose,
257
+ applyBulkClose,
258
+ useItemAsTemplate,
259
+ // Create
260
+ submitCreateItem,
261
+ submitCreateItemAndOpen,
262
+ // Search
263
+ setSearchMode,
264
+ reindexProject,
265
+ debouncedSearch,
266
+ doSearch,
267
+ // Calendar
268
+ calNav,
269
+ showDayItems,
270
+ // Sharing
271
+ openShareModal,
272
+ submitShare,
273
+ removeShare,
274
+ // Groups
275
+ openCreateGroupModal,
276
+ submitCreateGroup,
277
+ deleteGroup,
278
+ openGroupDetail,
279
+ inviteMember,
280
+ removeMember,
281
+ // Settings
282
+ saveProfile,
283
+ changePassword,
284
+ saveGitHubToken,
285
+ clearGitHubToken,
286
+ // GitHub
287
+ linkGitHubRepo,
288
+ unlinkGitHubRepo,
289
+ loadGitHubIssues,
290
+ selectAllIssues,
291
+ importGitHubIssues,
292
+ loadItemsForPush,
293
+ selectAllPushItems,
294
+ pushItemsToGitHub,
295
+ updateGitHubIssue,
296
+ // Export
297
+ exportData,
298
+ importData,
299
+ // Normalize
300
+ applyNormalize,
301
+ // Templates
302
+ createFromTemplate,
303
+ // Modals
304
+ showModal,
305
+ hideModal,
306
+ // Global search
307
+ openSearchModal,
308
+ globalSearchDebounced,
309
+ openMobileCommandSheet,
310
+ runMobileCommand,
311
+ // Badge
312
+ loadItemsBadge,
313
+ // Toast (used by search.ts)
314
+ toast,
315
+ // SSE
316
+ connectSSE,
317
+ disconnectSSE,
318
+ // Admin
319
+ adminSwitchTab,
320
+ adminDeleteUser,
321
+ adminDeleteProject,
322
+ adminDeleteGroup,
323
+ adminFilterUsers,
324
+ adminFilterProjects,
325
+ adminFilterAudit,
326
+ adminSetPage,
327
+ adminCreateGroup,
328
+ // Graph navigation
329
+ openGraphAt,
330
+ // Plan
331
+ initPlanView,
332
+ openPlanDetail,
333
+ openCreatePlanModal,
334
+ submitCreatePlan,
335
+ openAddStepModal,
336
+ submitAddStep,
337
+ planCompleteStep,
338
+ planBlockStepPrompt,
339
+ submitBlockStep,
340
+ planRemoveStep,
341
+ planApprove,
342
+ planMaterializePrompt,
343
+ submitMaterializePlan,
344
+ planEditPrompt,
345
+ submitEditPlan,
346
+ planDeletePrompt,
347
+ };
348
+ // ═══════════════════════════════════════════════════════════════
349
+ // SSE REAL-TIME SYNC
350
+ // ═══════════════════════════════════════════════════════════════
351
+ let sseSource = null;
352
+ let sseReconnectTimer = null;
353
+ let sseCurrentProjectId = null;
354
+ let sseClientId = null;
355
+ // Debounce timer for SSE-triggered view refresh (prevents thrashing on bulk operations)
356
+ let sseRefreshTimer = null;
357
+ const SSE_REFRESH_DEBOUNCE_MS = 400;
358
+ function userInitials(displayName) {
359
+ return displayName
360
+ .split(/[\s@._-]+/)
361
+ .filter(Boolean)
362
+ .slice(0, 2)
363
+ .map((w) => w[0].toUpperCase())
364
+ .join('');
365
+ }
366
+ function renderPresenceBar(users) {
367
+ const bar = document.getElementById('presence-bar');
368
+ if (!bar)
369
+ return;
370
+ const myId = state.user?.id;
371
+ // Show other users; if only me, hide the bar
372
+ const others = users.filter((u) => u.userId !== myId);
373
+ if (others.length === 0) {
374
+ bar.innerHTML = '';
375
+ bar.style.display = 'none';
376
+ return;
377
+ }
378
+ bar.style.display = 'flex';
379
+ const MAX_VISIBLE = 5;
380
+ const visible = others.slice(0, MAX_VISIBLE);
381
+ const extra = others.length - MAX_VISIBLE;
382
+ const chips = visible.map((u) => {
383
+ const initials = userInitials(u.displayName);
384
+ const hue = Math.abs(u.userId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)) % 360;
385
+ const color = `hsl(${hue},60%,55%)`;
386
+ const viewLabel = u.currentView.replace(/-/g, ' ');
387
+ return `<div class="presence-chip" title="${escHtml(u.displayName)} · ${escHtml(viewLabel)}" style="background:${color}22;border-color:${color}66;color:${color}">${escHtml(initials)}</div>`;
388
+ }).join('');
389
+ const extraChip = extra > 0
390
+ ? `<div class="presence-chip presence-chip-extra" title="${extra} more user${extra > 1 ? 's' : ''} viewing">+${extra}</div>`
391
+ : '';
392
+ bar.innerHTML = `<span class="presence-label">Viewing:</span>${chips}${extraChip}`;
393
+ }
394
+ function setSseStatus(status) {
395
+ const el = document.getElementById('sse-indicator');
396
+ if (!el)
397
+ return;
398
+ el.classList.remove('connected', 'reconnecting');
399
+ if (status === 'connected') {
400
+ el.classList.add('connected');
401
+ el.title = 'Real-time sync connected';
402
+ }
403
+ else if (status === 'reconnecting') {
404
+ el.classList.add('reconnecting');
405
+ el.title = 'Real-time sync reconnecting…';
406
+ }
407
+ else {
408
+ el.title = 'Real-time sync disconnected';
409
+ // Clear presence bar on disconnect
410
+ const bar = document.getElementById('presence-bar');
411
+ if (bar) {
412
+ bar.innerHTML = '';
413
+ bar.style.display = 'none';
414
+ }
415
+ }
416
+ }
417
+ function disconnectSSE() {
418
+ if (sseReconnectTimer) {
419
+ clearTimeout(sseReconnectTimer);
420
+ sseReconnectTimer = null;
421
+ }
422
+ if (sseRefreshTimer) {
423
+ clearTimeout(sseRefreshTimer);
424
+ sseRefreshTimer = null;
425
+ }
426
+ if (sseSource) {
427
+ sseSource.close();
428
+ sseSource = null;
429
+ }
430
+ sseCurrentProjectId = null;
431
+ sseClientId = null;
432
+ setSseStatus('disconnected');
433
+ }
434
+ function notifyPresenceView(view) {
435
+ if (!sseClientId || !sseCurrentProjectId)
436
+ return;
437
+ // Fire-and-forget: update current view on server
438
+ void fetch(`/api/projects/${encodeURIComponent(sseCurrentProjectId)}/pm/presence/${encodeURIComponent(sseClientId)}`, {
439
+ method: 'PATCH',
440
+ headers: { 'Content-Type': 'application/json' },
441
+ body: JSON.stringify({ view }),
442
+ }).catch(() => undefined);
443
+ }
444
+ function connectSSE(projectId, attempt = 0) {
445
+ if (sseCurrentProjectId === projectId && sseSource && sseSource.readyState !== EventSource.CLOSED)
446
+ return;
447
+ disconnectSSE();
448
+ sseCurrentProjectId = projectId;
449
+ setSseStatus(attempt > 0 ? 'reconnecting' : 'disconnected');
450
+ const u = state.user;
451
+ const displayName = encodeURIComponent(u?.display_name || u?.email || '');
452
+ const currentView = encodeURIComponent(state.currentView || 'items');
453
+ const url = `/api/projects/${encodeURIComponent(projectId)}/pm/events?dn=${displayName}&view=${currentView}`;
454
+ try {
455
+ const source = new EventSource(url);
456
+ sseSource = source;
457
+ source.addEventListener('connected', (evt) => {
458
+ setSseStatus('connected');
459
+ try {
460
+ const data = JSON.parse(evt.data);
461
+ if (data.clientId)
462
+ sseClientId = data.clientId;
463
+ }
464
+ catch { /* ignore */ }
465
+ });
466
+ // Handle presence updates
467
+ source.addEventListener('presence', (evt) => {
468
+ try {
469
+ const data = JSON.parse(evt.data);
470
+ renderPresenceBar(data.users);
471
+ }
472
+ catch { /* ignore */ }
473
+ });
474
+ // Handle item updates — refresh the current view (debounced to prevent thrashing)
475
+ const doRefreshView = () => {
476
+ const view = state.currentView;
477
+ if (view === 'items') {
478
+ fetchAndRenderItems();
479
+ }
480
+ else if (view === 'activity') {
481
+ renderActivityView();
482
+ }
483
+ else if (view === 'stats') {
484
+ renderStatsView();
485
+ }
486
+ else if (view === 'plan') {
487
+ initPlanView();
488
+ }
489
+ loadItemsBadge();
490
+ };
491
+ const refreshView = () => {
492
+ if (sseRefreshTimer)
493
+ clearTimeout(sseRefreshTimer);
494
+ sseRefreshTimer = setTimeout(doRefreshView, SSE_REFRESH_DEBOUNCE_MS);
495
+ };
496
+ // Graph-synced events (Neo4j sync complete) do a full graph reload
497
+ const refreshGraph = () => {
498
+ if (state.currentView === 'graph') {
499
+ import('./views/graph.js').then((module) => module.renderGraphView());
500
+ }
501
+ };
502
+ // Item events on graph view use lightweight data-only refresh
503
+ const refreshGraphData = (evt) => {
504
+ // Show a subtle toast when another user's action triggers a refresh
505
+ // (only if client ID is known, meaning we've fully connected)
506
+ if (sseClientId) {
507
+ try {
508
+ const payload = JSON.parse(evt.data);
509
+ const myUserId = state.user?.id;
510
+ // Only notify if the change came from a different user
511
+ if (payload.userId && payload.userId !== myUserId) {
512
+ const evtType = evt.type || '';
513
+ let msg = 'Project updated by another user';
514
+ if (evtType.includes('created'))
515
+ msg = 'New item created by another user';
516
+ else if (evtType.includes('deleted'))
517
+ msg = 'An item was deleted by another user';
518
+ else if (evtType.includes('closed'))
519
+ msg = 'An item was closed by another user';
520
+ else if (evtType.includes('imported'))
521
+ msg = 'Items were imported by another user';
522
+ else if (evtType.includes('bulk'))
523
+ msg = 'Items were bulk-updated by another user';
524
+ toast(msg, 'info');
525
+ }
526
+ }
527
+ catch { /* ignore parse errors */ }
528
+ }
529
+ if (state.currentView === 'graph') {
530
+ import('./views/graph.js').then((module) => module.refreshGraphData());
531
+ }
532
+ else {
533
+ refreshView();
534
+ }
535
+ };
536
+ const graphSyncFailed = (evt) => {
537
+ let reason = '';
538
+ let detail = '';
539
+ try {
540
+ const payload = JSON.parse(evt.data);
541
+ reason = payload.reason || '';
542
+ detail = payload.error || '';
543
+ }
544
+ catch {
545
+ detail = evt.data;
546
+ }
547
+ const message = detail
548
+ ? `Graph sync failed${reason ? ` (${reason})` : ''}: ${detail}`
549
+ : `Graph sync failed${reason ? ` (${reason})` : ''}`;
550
+ toast(message, 'error');
551
+ };
552
+ source.addEventListener('item-created', refreshGraphData);
553
+ source.addEventListener('item-updated', refreshGraphData);
554
+ source.addEventListener('dependency-added', refreshGraphData);
555
+ source.addEventListener('dependency-removed', refreshGraphData);
556
+ source.addEventListener('dependency_added', refreshGraphData);
557
+ source.addEventListener('dependency_removed', refreshGraphData);
558
+ source.addEventListener('items-imported', refreshGraphData);
559
+ source.addEventListener('items-bulk-updated', refreshGraphData);
560
+ source.addEventListener('item-closed', refreshGraphData);
561
+ source.addEventListener('item-deleted', refreshGraphData);
562
+ source.addEventListener('graph-synced', refreshGraph);
563
+ source.addEventListener('item_created', refreshGraphData);
564
+ source.addEventListener('item_updated', refreshGraphData);
565
+ source.addEventListener('item_bulk_updated', refreshGraphData);
566
+ source.addEventListener('item_closed', refreshGraphData);
567
+ source.addEventListener('item_deleted', refreshGraphData);
568
+ source.addEventListener('graph_synced', refreshGraph);
569
+ source.addEventListener('graph-sync-failed', graphSyncFailed);
570
+ source.addEventListener('graph_sync_failed', graphSyncFailed);
571
+ source.addEventListener('update', refreshView);
572
+ source.onerror = () => {
573
+ setSseStatus('reconnecting');
574
+ source.close();
575
+ sseSource = null;
576
+ const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
577
+ sseReconnectTimer = setTimeout(() => connectSSE(projectId, attempt + 1), delay);
578
+ };
579
+ }
580
+ catch {
581
+ // EventSource not supported or URL invalid
582
+ setSseStatus('disconnected');
583
+ }
584
+ }
585
+ export { connectSSE, disconnectSSE, notifyPresenceView };
586
+ // ═══════════════════════════════════════════════════════════════
587
+ // BOOT
588
+ // ═══════════════════════════════════════════════════════════════
589
+ export async function bootApp() {
590
+ const authScreen = document.getElementById('auth-screen');
591
+ const mainApp = document.getElementById('main-app');
592
+ if (authScreen)
593
+ authScreen.style.display = 'none';
594
+ if (mainApp)
595
+ mainApp.style.display = 'flex';
596
+ const u = state.user;
597
+ const initials = (u.display_name || u.email || '?').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
598
+ const avatarEl = document.getElementById('user-avatar');
599
+ if (avatarEl)
600
+ avatarEl.textContent = initials;
601
+ const nameEl = document.getElementById('user-name-display');
602
+ if (nameEl)
603
+ nameEl.textContent = u.display_name || u.email;
604
+ document.querySelectorAll('.admin-only').forEach((el) => {
605
+ el.style.display = u.is_admin ? '' : 'none';
606
+ });
607
+ buildCreateProjectModal();
608
+ buildSearchModal();
609
+ buildMobileCommandSheet();
610
+ // Wire up presence view-change notifications
611
+ setOnViewChange((view) => notifyPresenceView(view));
612
+ await loadProjects();
613
+ await handleLaunchAction();
614
+ }
615
+ async function handleLaunchAction() {
616
+ const action = new URLSearchParams(window.location.search).get('action');
617
+ if (action === 'new-project') {
618
+ showView('projects');
619
+ showModal('create-project-modal');
620
+ setTimeout(() => document.getElementById('cp-name')?.focus(), 50);
621
+ return;
622
+ }
623
+ if (action === 'new-item' || action === 'search') {
624
+ if (!state.currentProject && state.projects[0]) {
625
+ await onProjectSelect(state.projects[0].id);
626
+ }
627
+ if (!state.currentProject) {
628
+ showView('projects');
629
+ toast('Create a project first', 'info');
630
+ return;
631
+ }
632
+ showView(action === 'new-item' ? 'create' : 'search');
633
+ if (action === 'search') {
634
+ setTimeout(() => document.getElementById('search-query')?.focus(), 100);
635
+ }
636
+ return;
637
+ }
638
+ // Restore view from URL path (supports refresh/bookmark)
639
+ const { getViewForPath } = await import('./views/router.js');
640
+ const view = getViewForPath(window.location.pathname);
641
+ // If view requires a project and none is selected, try to select first one
642
+ const projectRequired = view !== 'projects' && view !== 'settings' && view !== 'admin' && view !== 'shared' && view !== 'groups' && view !== 'guide';
643
+ if (projectRequired && !state.currentProject && state.projects[0]) {
644
+ await onProjectSelect(state.projects[0].id);
645
+ }
646
+ if (view === 'admin' && !state.user?.is_admin) {
647
+ history.replaceState({ view: 'projects' }, '', '/');
648
+ toast('Admin access is required to open this view', 'error');
649
+ showView('projects');
650
+ return;
651
+ }
652
+ if (projectRequired && !state.currentProject) {
653
+ showView('projects');
654
+ toast('Select a project first', 'info');
655
+ return;
656
+ }
657
+ // Replace current history state so back/forward works properly
658
+ history.replaceState({ view }, '', window.location.pathname);
659
+ showView(view, false);
660
+ }
661
+ // ═══════════════════════════════════════════════════════════════
662
+ // GLOBAL ERROR BOUNDARY
663
+ // ═══════════════════════════════════════════════════════════════
664
+ function showGlobalError(errorMsg, error) {
665
+ const appEl = document.getElementById('app');
666
+ if (!appEl)
667
+ return;
668
+ // Hide other screens
669
+ const authScreen = document.getElementById('auth-screen');
670
+ const mainApp = document.getElementById('main-app');
671
+ if (authScreen)
672
+ authScreen.style.display = 'none';
673
+ if (mainApp)
674
+ mainApp.style.display = 'none';
675
+ // Create or update error screen
676
+ let errorScreen = document.getElementById('global-error-screen');
677
+ if (!errorScreen) {
678
+ errorScreen = document.createElement('div');
679
+ errorScreen.id = 'global-error-screen';
680
+ errorScreen.setAttribute('role', 'alert');
681
+ errorScreen.setAttribute('aria-live', 'assertive');
682
+ appEl.appendChild(errorScreen);
683
+ }
684
+ const stackTrace = error instanceof Error && error.stack
685
+ ? `<details style="margin-top:12px;text-align:left"><summary style="cursor:pointer;color:var(--text-muted);font-size:12px">Stack trace</summary><pre style="margin-top:8px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);white-space:pre-wrap;word-break:break-all">${escHtml(error.stack)}</pre></details>`
686
+ : '';
687
+ errorScreen.innerHTML = `
688
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:40px;text-align:center">
689
+ <div style="font-size:48px;margin-bottom:20px;opacity:0.5">⚠</div>
690
+ <h1 style="font-size:22px;font-weight:600;margin-bottom:8px">Something went wrong</h1>
691
+ <p style="color:var(--text-secondary);max-width:480px;line-height:1.7;margin-bottom:8px">${escHtml(errorMsg)}</p>
692
+ ${stackTrace}
693
+ <div style="display:flex;gap:12px;margin-top:24px;flex-wrap:wrap;justify-content:center">
694
+ <button class="btn btn-primary" onclick="location.reload()" aria-label="Reload the page">Reload Page</button>
695
+ <button class="btn btn-secondary" onclick="document.getElementById('global-error-screen').remove();document.getElementById('auth-screen').style.display='flex'" aria-label="Go to login screen">Go to Login</button>
696
+ <button class="btn btn-ghost" onclick="navigator.clipboard.writeText(this.closest('[role=alert]').innerText);window.__app.toast('Error details copied','success')" aria-label="Copy error details">Copy Details</button>
697
+ </div>
698
+ </div>`;
699
+ }
700
+ // Global error handlers
701
+ window.addEventListener('error', (event) => {
702
+ console.error('Global error:', event.error);
703
+ // Don't show for script load failures that are likely network issues
704
+ if (event.message && !event.message.includes('Load failed') && !event.message.includes('error loading dynamically imported module')) {
705
+ showGlobalError(event.message || 'An unexpected error occurred', event.error);
706
+ }
707
+ event.preventDefault();
708
+ });
709
+ window.addEventListener('unhandledrejection', (event) => {
710
+ console.error('Unhandled promise rejection:', event.reason);
711
+ const msg = event.reason instanceof Error ? event.reason.message : String(event.reason);
712
+ // Don't show for network/import errors during normal operation
713
+ if (!msg.includes('Load failed') && !msg.includes('error loading dynamically imported module') && !msg.includes('Failed to fetch')) {
714
+ showGlobalError(msg, event.reason instanceof Error ? event.reason : undefined);
715
+ }
716
+ event.preventDefault();
717
+ });
718
+ async function init() {
719
+ try {
720
+ const data = await api('GET', '/auth/me');
721
+ state.user = data.user;
722
+ await bootApp();
723
+ }
724
+ catch (_) {
725
+ showAuth();
726
+ }
727
+ }
728
+ // ═══════════════════════════════════════════════════════════════
729
+ // KEYBOARD SHORTCUTS
730
+ // ═══════════════════════════════════════════════════════════════
731
+ let lastKeyTime = 0;
732
+ let lastKey = '';
733
+ function openShortcutsHelp() {
734
+ createModal('shortcuts-help-modal', 'Keyboard Shortcuts', `
735
+ <table style="width:100%;border-collapse:collapse;font-size:13px">
736
+ <tbody>
737
+ ${[
738
+ ['?', 'Show this shortcuts help'],
739
+ ['Ctrl+K', 'Open global search'],
740
+ ['/', 'Focus search (from any view)'],
741
+ ['Esc', 'Close modal / go back'],
742
+ ['n / c', 'Create new item'],
743
+ ['a', 'Go to Activity view'],
744
+ ['g i', 'Go to Items view'],
745
+ ['g g', 'Go to Graph view'],
746
+ ['g s', 'Go to Search view'],
747
+ ['g c', 'Go to Calendar view'],
748
+ ].map(([key, desc]) => `
749
+ <tr style="border-bottom:1px solid var(--border)">
750
+ <td style="padding:8px 12px 8px 0;white-space:nowrap"><kbd style="font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:2px 6px;color:var(--accent)">${escHtml(key)}</kbd></td>
751
+ <td style="padding:8px 0;color:var(--text-secondary)">${escHtml(desc)}</td>
752
+ </tr>`).join('')}
753
+ </tbody>
754
+ </table>`, '');
755
+ showModal('shortcuts-help-modal');
756
+ }
757
+ document.addEventListener('keydown', e => {
758
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
759
+ e.preventDefault();
760
+ if (state.user)
761
+ openSearchModal();
762
+ }
763
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')
764
+ return;
765
+ // Multi-key sequences (e.g. g i, g g)
766
+ const now = Date.now();
767
+ if (lastKey === 'g' && now - lastKeyTime < 1500) {
768
+ if (e.key === 'i') {
769
+ e.preventDefault();
770
+ if (state.currentProject)
771
+ showView('items');
772
+ lastKey = '';
773
+ return;
774
+ }
775
+ if (e.key === 'g') {
776
+ e.preventDefault();
777
+ if (state.currentProject)
778
+ showView('graph');
779
+ lastKey = '';
780
+ return;
781
+ }
782
+ if (e.key === 's') {
783
+ e.preventDefault();
784
+ if (state.currentProject)
785
+ showView('search');
786
+ lastKey = '';
787
+ return;
788
+ }
789
+ if (e.key === 'c') {
790
+ e.preventDefault();
791
+ if (state.currentProject)
792
+ showView('calendar');
793
+ lastKey = '';
794
+ return;
795
+ }
796
+ }
797
+ lastKey = e.key;
798
+ lastKeyTime = now;
799
+ if (e.key === '?') {
800
+ e.preventDefault();
801
+ if (state.user)
802
+ openShortcutsHelp();
803
+ }
804
+ if (e.key === 'n' || e.key === 'N') {
805
+ if (state.currentProject)
806
+ showView('create');
807
+ }
808
+ if (e.key === 'c' || e.key === 'C') {
809
+ if (state.currentProject)
810
+ showView('create');
811
+ }
812
+ if (e.key === 'a' || e.key === 'A') {
813
+ if (state.currentProject)
814
+ showView('activity');
815
+ }
816
+ if (e.key === '/') {
817
+ e.preventDefault();
818
+ if (state.currentProject) {
819
+ showView('search');
820
+ setTimeout(() => document.getElementById('search-query')?.focus(), 100);
821
+ }
822
+ }
823
+ if (e.key === 'Escape') {
824
+ document.querySelectorAll('.modal-backdrop').forEach(m => { m.style.display = 'none'; });
825
+ }
826
+ });
827
+ // ═══════════════════════════════════════════════════════════════
828
+ // PWA INSTALL PROMPT
829
+ // ═══════════════════════════════════════════════════════════════
830
+ window.addEventListener('beforeinstallprompt', (e) => {
831
+ e.preventDefault();
832
+ deferredPrompt = e;
833
+ const banner = document.getElementById('install-banner');
834
+ if (banner && !localStorage.getItem('pm-web-install-dismissed')) {
835
+ banner.classList.add('visible');
836
+ }
837
+ });
838
+ window.addEventListener('appinstalled', () => {
839
+ deferredPrompt = null;
840
+ const banner = document.getElementById('install-banner');
841
+ if (banner)
842
+ banner.classList.remove('visible');
843
+ toast('pm-web installed!', 'success');
844
+ });
845
+ window.installPwa = function () {
846
+ if (!deferredPrompt)
847
+ return;
848
+ deferredPrompt.prompt();
849
+ deferredPrompt.userChoice.then((result) => {
850
+ if (result.outcome === 'accepted') {
851
+ toast('Installing pm-web...', 'success');
852
+ }
853
+ deferredPrompt = null;
854
+ const banner = document.getElementById('install-banner');
855
+ if (banner)
856
+ banner.classList.remove('visible');
857
+ });
858
+ };
859
+ window.dismissInstallBanner = function () {
860
+ localStorage.setItem('pm-web-install-dismissed', '1');
861
+ const banner = document.getElementById('install-banner');
862
+ if (banner)
863
+ banner.classList.remove('visible');
864
+ };
865
+ // ═══════════════════════════════════════════════════════════════
866
+ // OFFLINE / ONLINE STATUS BANNER
867
+ // ═══════════════════════════════════════════════════════════════
868
+ function updateOfflineBanner() {
869
+ const banner = document.getElementById('offline-banner');
870
+ if (!banner)
871
+ return;
872
+ if (!navigator.onLine) {
873
+ banner.classList.add('visible');
874
+ }
875
+ else {
876
+ banner.classList.remove('visible');
877
+ }
878
+ }
879
+ window.addEventListener('offline', updateOfflineBanner);
880
+ window.addEventListener('online', () => {
881
+ updateOfflineBanner();
882
+ toast('Back online — syncing…', 'success');
883
+ });
884
+ // Set initial state
885
+ updateOfflineBanner();
886
+ // ═══════════════════════════════════════════════════════════════
887
+ // PULL TO REFRESH (mobile)
888
+ // ═══════════════════════════════════════════════════════════════
889
+ (function () {
890
+ let startY = 0, pulling = false;
891
+ const threshold = 80;
892
+ document.addEventListener('touchstart', e => {
893
+ const mc = document.getElementById('main-content');
894
+ if (!mc || mc.scrollTop > 0)
895
+ return;
896
+ startY = e.touches[0].pageY;
897
+ pulling = true;
898
+ }, { passive: true });
899
+ document.addEventListener('touchmove', _e => {
900
+ if (!pulling)
901
+ return;
902
+ }, { passive: true });
903
+ document.addEventListener('touchend', e => {
904
+ if (!pulling)
905
+ return;
906
+ const diff = (e.changedTouches[0]?.pageY || 0) - startY;
907
+ pulling = false;
908
+ if (diff > threshold && state.currentProject) {
909
+ const view = state.currentView;
910
+ if (showView)
911
+ showView(view);
912
+ toast('Refreshed', 'info');
913
+ }
914
+ }, { passive: true });
915
+ })();
916
+ // ═══════════════════════════════════════════════════════════════
917
+ // SERVICE WORKER
918
+ // ═══════════════════════════════════════════════════════════════
919
+ if ('serviceWorker' in navigator) {
920
+ window.addEventListener('load', () => {
921
+ navigator.serviceWorker.register('/sw.js?v=8', { updateViaCache: 'none' }).catch(() => { });
922
+ });
923
+ }
924
+ // Start the app
925
+ init();
926
+ //# sourceMappingURL=app.js.map