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