@polderlabs/openkan 0.4.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 +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
package/dist/web/app.js
ADDED
|
@@ -0,0 +1,4251 @@
|
|
|
1
|
+
// OpenKan — board view (M0–M1 + M7 status indicator + M10/M11 dashboard +
|
|
2
|
+
// M13 keyboard nav, command palette, ARIA, cross-tab integration).
|
|
3
|
+
// 5 columns, SSE live updates with polling fallback, HTML5 drag-and-drop,
|
|
4
|
+
// "New Task" modal, per-task action menu, dashboard tab router, sort/filter,
|
|
5
|
+
// archive toggle, saved filters via localStorage, contributors filter row,
|
|
6
|
+
// drag-and-drop v1.1 (ghost card, drop indicator, multi-select, shake).
|
|
7
|
+
|
|
8
|
+
(() => {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const { api, on, onStatus } = window.OpenKanAPI;
|
|
12
|
+
|
|
13
|
+
const COLUMNS = [
|
|
14
|
+
{ id: "backlog", title: "Backlog" },
|
|
15
|
+
{ id: "todo", title: "To Do" },
|
|
16
|
+
{ id: "doing", title: "In Progress" },
|
|
17
|
+
{ id: "review", title: "Review" },
|
|
18
|
+
{ id: "done", title: "Done" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Columns in their natural progression — used by "Move to next column".
|
|
22
|
+
const COLUMN_ORDER = COLUMNS.map((c) => c.id);
|
|
23
|
+
|
|
24
|
+
// Categories that the server uses. Also the set of tags that double as a
|
|
25
|
+
// category badge on cards.
|
|
26
|
+
const CATEGORIES = new Set([
|
|
27
|
+
"frontend", "backend", "infra", "docs",
|
|
28
|
+
"test", "design", "data", "security", "task",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Priority is explicit text, not a decorative glyph, so it remains
|
|
32
|
+
// scannable in every theme and does not compete with task titles.
|
|
33
|
+
const PRIORITY_META = {
|
|
34
|
+
urgent: { code: "P0", label: "Urgent", className: "priority-urgent", rank: 0 },
|
|
35
|
+
high: { code: "P1", label: "High", className: "priority-high", rank: 1 },
|
|
36
|
+
normal: { code: "P2", label: "Normal", className: "priority-normal", rank: 2 },
|
|
37
|
+
low: { code: "P3", label: "Low", className: "priority-low", rank: 3 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EFFORT_RANK = { xl: 0, l: 1, m: 2, s: 3, xs: 4 };
|
|
41
|
+
|
|
42
|
+
// Sort options shown in the popover. Keep `value` aligned with the legacy
|
|
43
|
+
// <select id="sort-select"> values so the URL hash stays compatible.
|
|
44
|
+
const SORT_OPTIONS = [
|
|
45
|
+
{ value: "newest", label: "Newest first", desc: "Sort by createdAt, newest first." },
|
|
46
|
+
{ value: "oldest", label: "Oldest first", desc: "Sort by createdAt, oldest first." },
|
|
47
|
+
{ value: "priority", label: "Priority (urgent→low)", desc: "Urgent first, then High, Normal, Low." },
|
|
48
|
+
{ value: "effort", label: "Effort (xl→xs)", desc: "Largest effort first, smallest last." },
|
|
49
|
+
{ value: "activity", label: "Last activity", desc: "Most recently touched task first." },
|
|
50
|
+
];
|
|
51
|
+
const SORT_VALUES = SORT_OPTIONS.map((o) => o.value);
|
|
52
|
+
const SORT_LABEL = Object.fromEntries(SORT_OPTIONS.map((o) => [o.value, o.label]));
|
|
53
|
+
|
|
54
|
+
/** @type {Map<string, any>} */
|
|
55
|
+
const tasks = new Map();
|
|
56
|
+
/** Map of contributor email → contributor record (from /api/contributors). */
|
|
57
|
+
const contributors = new Map();
|
|
58
|
+
/** Current user (best-effort from /api/contributors). */
|
|
59
|
+
let currentUser = null;
|
|
60
|
+
|
|
61
|
+
// ---------- Filter / view state ----------
|
|
62
|
+
// category: "all" or a category id (must also be present in t.tags).
|
|
63
|
+
// tags: array of tag names (all must be present in t.tags, AND-order).
|
|
64
|
+
// contributor: "all" | "@me" | "<email>"
|
|
65
|
+
// archive: "active" | "archived" | "both"
|
|
66
|
+
// sort: "newest" | "oldest" | "priority" | "effort" | "activity"
|
|
67
|
+
// search: free-text query (titles / descriptions / tags / MDX content).
|
|
68
|
+
// Persisted in window.location.hash as separate params.
|
|
69
|
+
const filter = {
|
|
70
|
+
category: "all",
|
|
71
|
+
tags: [],
|
|
72
|
+
contributor: "all",
|
|
73
|
+
archive: "active",
|
|
74
|
+
sort: "newest",
|
|
75
|
+
search: "",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ---------- Bulk-action state ----------
|
|
79
|
+
/** Set of currently selected task ids (Ctrl/Cmd-click on cards). */
|
|
80
|
+
const selectedIds = new Set();
|
|
81
|
+
/** Set of task ids that match the active search query. */
|
|
82
|
+
let searchMatchIds = null; // null = no search active
|
|
83
|
+
let searchDebounce = null;
|
|
84
|
+
let searchSeq = 0;
|
|
85
|
+
|
|
86
|
+
// ---------- localStorage keys ----------
|
|
87
|
+
const SAVED_FILTERS_KEY = "openkan:saved-filters";
|
|
88
|
+
const SAVED_FILTERS_MAX = 5;
|
|
89
|
+
|
|
90
|
+
function readHashFilter() {
|
|
91
|
+
const raw = (window.location.hash || "").replace(/^#/, "");
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
const params = new URLSearchParams(raw);
|
|
94
|
+
return {
|
|
95
|
+
tab: params.get("tab") || "tasks",
|
|
96
|
+
doc: params.get("doc") || "",
|
|
97
|
+
category: params.get("category") || "all",
|
|
98
|
+
tags: (params.get("tags") || "")
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((t) => t.trim().toLowerCase())
|
|
101
|
+
.filter(Boolean),
|
|
102
|
+
contributor: params.get("contributor") || "all",
|
|
103
|
+
archive: ["active", "archived", "both"].includes(params.get("archive"))
|
|
104
|
+
? params.get("archive")
|
|
105
|
+
: "active",
|
|
106
|
+
sort: SORT_VALUES.includes(params.get("sort"))
|
|
107
|
+
? params.get("sort")
|
|
108
|
+
: "newest",
|
|
109
|
+
search: params.get("q") || "",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeHashFilter() {
|
|
114
|
+
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
|
115
|
+
// Preserve `tab` so the tab router can stay in sync.
|
|
116
|
+
if (!params.get("tab")) params.set("tab", "tasks");
|
|
117
|
+
if (filter.category && filter.category !== "all") params.set("category", filter.category);
|
|
118
|
+
else params.delete("category");
|
|
119
|
+
if (filter.tags.length > 0) params.set("tags", filter.tags.join(","));
|
|
120
|
+
else params.delete("tags");
|
|
121
|
+
if (filter.contributor && filter.contributor !== "all") params.set("contributor", filter.contributor);
|
|
122
|
+
else params.delete("contributor");
|
|
123
|
+
if (filter.archive && filter.archive !== "active") params.set("archive", filter.archive);
|
|
124
|
+
else params.delete("archive");
|
|
125
|
+
if (filter.sort && filter.sort !== "newest") params.set("sort", filter.sort);
|
|
126
|
+
else params.delete("sort");
|
|
127
|
+
if (filter.search) params.set("q", filter.search);
|
|
128
|
+
else params.delete("q");
|
|
129
|
+
const next = `#${params.toString()}`;
|
|
130
|
+
if (window.location.hash !== next) {
|
|
131
|
+
const url = window.location.pathname + window.location.search + next;
|
|
132
|
+
window.history.replaceState(null, "", url);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyFilterToButtons() {
|
|
137
|
+
const bar = document.getElementById("filter-bar");
|
|
138
|
+
if (bar) bar.classList.toggle(
|
|
139
|
+
"empty",
|
|
140
|
+
filter.category === "all" && filter.tags.length === 0 && filter.contributor === "all" && !filter.search,
|
|
141
|
+
);
|
|
142
|
+
// Reflect the archive filter on the body element so CSS can gate the
|
|
143
|
+
// .card-archived display:none rule on `data-archive="active"` — this
|
|
144
|
+
// re-shows archived cards when the user picks Archived or Both.
|
|
145
|
+
try { document.body.setAttribute("data-archive", filter.archive || "active"); } catch {}
|
|
146
|
+
const clear = document.getElementById("filter-clear");
|
|
147
|
+
if (clear) clear.disabled = filter.category === "all" && filter.tags.length === 0 && filter.contributor === "all" && !filter.search;
|
|
148
|
+
for (const btn of document.querySelectorAll("#filter-categories button[data-category]")) {
|
|
149
|
+
const v = btn.getAttribute("data-category");
|
|
150
|
+
const active = filter.category === v;
|
|
151
|
+
btn.classList.toggle("active", active);
|
|
152
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
153
|
+
}
|
|
154
|
+
for (const btn of document.querySelectorAll("#filter-tags button[data-tag]")) {
|
|
155
|
+
const v = btn.getAttribute("data-tag");
|
|
156
|
+
const active = filter.tags.includes(v);
|
|
157
|
+
btn.classList.toggle("active", active);
|
|
158
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
159
|
+
}
|
|
160
|
+
for (const btn of document.querySelectorAll("#filter-contributors button[data-contributor]")) {
|
|
161
|
+
const v = btn.getAttribute("data-contributor");
|
|
162
|
+
const active = filter.contributor === v;
|
|
163
|
+
btn.classList.toggle("active", active);
|
|
164
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
165
|
+
}
|
|
166
|
+
for (const btn of document.querySelectorAll(".archive-toggle button[data-archive]")) {
|
|
167
|
+
const v = btn.getAttribute("data-archive");
|
|
168
|
+
const active = filter.archive === v;
|
|
169
|
+
btn.classList.toggle("active", active);
|
|
170
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
171
|
+
}
|
|
172
|
+
const sortSel = document.getElementById("sort-select");
|
|
173
|
+
if (sortSel) sortSel.value = filter.sort;
|
|
174
|
+
const sortTrigger = document.getElementById("sort-trigger");
|
|
175
|
+
if (sortTrigger) {
|
|
176
|
+
sortTrigger.textContent = SORT_LABEL[filter.sort] || SORT_LABEL.newest;
|
|
177
|
+
sortTrigger.setAttribute("aria-label", `Sort: ${SORT_LABEL[filter.sort] || SORT_LABEL.newest}`);
|
|
178
|
+
}
|
|
179
|
+
// Refresh popover state (highlight the active option).
|
|
180
|
+
const popover = document.getElementById("sort-popover");
|
|
181
|
+
if (popover) {
|
|
182
|
+
for (const opt of popover.querySelectorAll(".sort-option")) {
|
|
183
|
+
const v = opt.getAttribute("data-value");
|
|
184
|
+
const isActive = v === filter.sort;
|
|
185
|
+
opt.classList.toggle("active", isActive);
|
|
186
|
+
opt.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Sync the search input without firing its input event.
|
|
190
|
+
if (searchInput && document.activeElement !== searchInput && searchInput.value !== filter.search) {
|
|
191
|
+
searchInput.value = filter.search;
|
|
192
|
+
}
|
|
193
|
+
const activeFilterCount =
|
|
194
|
+
(filter.category !== "all" ? 1 : 0) +
|
|
195
|
+
filter.tags.length +
|
|
196
|
+
(filter.contributor !== "all" ? 1 : 0);
|
|
197
|
+
const count = document.getElementById("filter-count");
|
|
198
|
+
if (count) {
|
|
199
|
+
count.textContent = String(activeFilterCount);
|
|
200
|
+
count.hidden = activeFilterCount === 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function taskMatchesFilter(t) {
|
|
205
|
+
if (filter.category !== "all") {
|
|
206
|
+
const tags = t.tags || [];
|
|
207
|
+
if (t.category && t.category === filter.category) {
|
|
208
|
+
// ok
|
|
209
|
+
} else if (!tags.includes(filter.category)) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (filter.tags.length > 0) {
|
|
214
|
+
const tags = new Set((t.tags || []).map((x) => String(x).toLowerCase()));
|
|
215
|
+
for (const want of filter.tags) {
|
|
216
|
+
if (!tags.has(want)) return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (filter.contributor !== "all") {
|
|
220
|
+
const list = Array.isArray(t.contributors) ? t.contributors : [];
|
|
221
|
+
if (filter.contributor === "@me") {
|
|
222
|
+
if (!currentUser || !currentUser.email) return false;
|
|
223
|
+
const email = currentUser.email.toLowerCase();
|
|
224
|
+
if (!list.some((c) => (c.email || "").toLowerCase() === email)) return false;
|
|
225
|
+
} else {
|
|
226
|
+
const email = filter.contributor.toLowerCase();
|
|
227
|
+
if (!list.some((c) => (c.email || "").toLowerCase() === email)) return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (filter.archive === "active" && t.archived) return false;
|
|
231
|
+
if (filter.archive === "archived" && !t.archived) return false;
|
|
232
|
+
// Search: rely on the server-returned `searchMatchIds` set when available,
|
|
233
|
+
// so the count and the per-card visibility agree. Falls back to a local
|
|
234
|
+
// substring check so the board still updates immediately while the
|
|
235
|
+
// debounced server call is in flight (and keeps working if /api/search
|
|
236
|
+
// returns 404 mid-development).
|
|
237
|
+
if (searchMatchIds) {
|
|
238
|
+
if (!searchMatchIds.has(t.id)) return false;
|
|
239
|
+
} else if (filter.search) {
|
|
240
|
+
const q = filter.search.toLowerCase();
|
|
241
|
+
if (!taskTextMatchesQuery(t, q)) return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Local substring check used as a fast fallback while the debounced server
|
|
247
|
+
// call is in flight. Mirrors the server-side rule (kanban/search.ts).
|
|
248
|
+
function taskTextMatchesQuery(t, q) {
|
|
249
|
+
if (!q) return true;
|
|
250
|
+
if ((t.title || "").toLowerCase().includes(q)) return true;
|
|
251
|
+
if ((t.description || "").toLowerCase().includes(q)) return true;
|
|
252
|
+
const tags = Array.isArray(t.tags) ? t.tags : [];
|
|
253
|
+
if (tags.some((tag) => String(tag).toLowerCase().includes(q))) return true;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sortTasks(list) {
|
|
258
|
+
const cmp = {
|
|
259
|
+
newest: (a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")),
|
|
260
|
+
oldest: (a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")),
|
|
261
|
+
priority: (a, b) => {
|
|
262
|
+
const ra = PRIORITY_META[a.priority]?.rank ?? 99;
|
|
263
|
+
const rb = PRIORITY_META[b.priority]?.rank ?? 99;
|
|
264
|
+
if (ra !== rb) return ra - rb;
|
|
265
|
+
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
|
|
266
|
+
},
|
|
267
|
+
effort: (a, b) => {
|
|
268
|
+
const ra = EFFORT_RANK[a.effort] ?? 99;
|
|
269
|
+
const rb = EFFORT_RANK[b.effort] ?? 99;
|
|
270
|
+
if (ra !== rb) return ra - rb;
|
|
271
|
+
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
|
|
272
|
+
},
|
|
273
|
+
activity: (a, b) => String(b.lastActivity || b.updatedAt || "").localeCompare(String(a.lastActivity || a.updatedAt || "")),
|
|
274
|
+
}[filter.sort] || ((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
275
|
+
return list.slice().sort(cmp);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------- Saved filters (localStorage) ----------
|
|
279
|
+
function readSavedFilters() {
|
|
280
|
+
try {
|
|
281
|
+
const raw = localStorage.getItem(SAVED_FILTERS_KEY);
|
|
282
|
+
if (!raw) return [];
|
|
283
|
+
const parsed = JSON.parse(raw);
|
|
284
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function writeSavedFilters(list) {
|
|
290
|
+
try {
|
|
291
|
+
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(list.slice(0, SAVED_FILTERS_MAX)));
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
function saveCurrentFilter(name) {
|
|
295
|
+
const trimmed = String(name || "").trim();
|
|
296
|
+
if (!trimmed) return false;
|
|
297
|
+
const list = readSavedFilters();
|
|
298
|
+
const snapshot = {
|
|
299
|
+
name: trimmed,
|
|
300
|
+
ts: new Date().toISOString(),
|
|
301
|
+
category: filter.category,
|
|
302
|
+
tags: filter.tags.slice(),
|
|
303
|
+
contributor: filter.contributor,
|
|
304
|
+
archive: filter.archive,
|
|
305
|
+
sort: filter.sort,
|
|
306
|
+
};
|
|
307
|
+
// Replace if same name exists.
|
|
308
|
+
const idx = list.findIndex((s) => s.name === trimmed);
|
|
309
|
+
if (idx >= 0) list[idx] = snapshot;
|
|
310
|
+
else list.unshift(snapshot);
|
|
311
|
+
writeSavedFilters(list);
|
|
312
|
+
renderSavedFilters();
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
function deleteSavedFilter(name) {
|
|
316
|
+
const list = readSavedFilters().filter((s) => s.name !== name);
|
|
317
|
+
writeSavedFilters(list);
|
|
318
|
+
renderSavedFilters();
|
|
319
|
+
}
|
|
320
|
+
function applySavedFilter(s) {
|
|
321
|
+
filter.category = s.category || "all";
|
|
322
|
+
filter.tags = Array.isArray(s.tags) ? s.tags.slice() : [];
|
|
323
|
+
filter.contributor = s.contributor || "all";
|
|
324
|
+
filter.archive = s.archive || "active";
|
|
325
|
+
filter.sort = s.sort || "newest";
|
|
326
|
+
writeHashFilter();
|
|
327
|
+
applyFilterToButtons();
|
|
328
|
+
renderBoard();
|
|
329
|
+
}
|
|
330
|
+
function renderSavedFilters() {
|
|
331
|
+
const bar = document.getElementById("saved-filters-bar");
|
|
332
|
+
const list = document.getElementById("saved-filters-list");
|
|
333
|
+
if (!bar || !list) return;
|
|
334
|
+
const items = readSavedFilters();
|
|
335
|
+
list.innerHTML = "";
|
|
336
|
+
if (items.length === 0) {
|
|
337
|
+
bar.hidden = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
bar.hidden = false;
|
|
341
|
+
for (const s of items) {
|
|
342
|
+
const chip = el("span", "saved-filter-chip", { title: `Saved ${s.ts || ""}` });
|
|
343
|
+
chip.append(el("span", null, { text: s.name }));
|
|
344
|
+
const rm = el("button", "chip-remove", { type: "button", text: "×", "aria-label": `Delete filter ${s.name}` });
|
|
345
|
+
rm.addEventListener("click", (e) => {
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
deleteSavedFilter(s.name);
|
|
348
|
+
});
|
|
349
|
+
chip.append(rm);
|
|
350
|
+
chip.addEventListener("click", () => applySavedFilter(s));
|
|
351
|
+
list.append(chip);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------- Sort popover ----------
|
|
356
|
+
// Replaces the plain <select> with a styled popover while keeping the
|
|
357
|
+
// legacy <select id="sort-select"> in the DOM (visually hidden) so the
|
|
358
|
+
// value is still readable from tests, screen-reader-only navigation, and
|
|
359
|
+
// the URL hash serializer. Both code paths funnel through setSort().
|
|
360
|
+
|
|
361
|
+
function setSort(value) {
|
|
362
|
+
const v = SORT_VALUES.includes(value) ? value : "newest";
|
|
363
|
+
if (filter.sort === v) {
|
|
364
|
+
// Still re-apply so the trigger label / popover state stay in sync.
|
|
365
|
+
applyFilterToButtons();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
filter.sort = v;
|
|
369
|
+
const sortSel = document.getElementById("sort-select");
|
|
370
|
+
if (sortSel) sortSel.value = v;
|
|
371
|
+
writeHashFilter();
|
|
372
|
+
applyFilterToButtons();
|
|
373
|
+
renderBoard();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let sortPopoverOpen = false;
|
|
377
|
+
let sortPopoverCleanup = null;
|
|
378
|
+
|
|
379
|
+
function closeSortPopover() {
|
|
380
|
+
const popover = document.getElementById("sort-popover");
|
|
381
|
+
const trigger = document.getElementById("sort-trigger");
|
|
382
|
+
if (popover) popover.hidden = true;
|
|
383
|
+
if (trigger) trigger.setAttribute("aria-expanded", "false");
|
|
384
|
+
sortPopoverOpen = false;
|
|
385
|
+
if (sortPopoverCleanup) { sortPopoverCleanup(); sortPopoverCleanup = null; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function openSortPopover() {
|
|
389
|
+
const popover = document.getElementById("sort-popover");
|
|
390
|
+
const trigger = document.getElementById("sort-trigger");
|
|
391
|
+
if (!popover || !trigger) return;
|
|
392
|
+
popover.hidden = false;
|
|
393
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
394
|
+
sortPopoverOpen = true;
|
|
395
|
+
focusSortOption(filter.sort);
|
|
396
|
+
|
|
397
|
+
// Defer attaching doc-level listeners so the click that opened the
|
|
398
|
+
// popover doesn't immediately close it.
|
|
399
|
+
setTimeout(() => {
|
|
400
|
+
const onDocClick = (e) => {
|
|
401
|
+
const wrap = document.getElementById("sort-select-wrap");
|
|
402
|
+
if (wrap && wrap.contains(e.target)) return;
|
|
403
|
+
closeSortPopover();
|
|
404
|
+
};
|
|
405
|
+
const onKey = (e) => {
|
|
406
|
+
if (e.key === "Escape") {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
closeSortPopover();
|
|
409
|
+
document.getElementById("sort-trigger")?.focus();
|
|
410
|
+
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
moveSortOption(e.key === "ArrowDown" ? 1 : -1);
|
|
413
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
414
|
+
// Only intercept Enter when focus is in the popover.
|
|
415
|
+
if (popover.contains(document.activeElement)) {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
commitFocusedOption();
|
|
418
|
+
}
|
|
419
|
+
} else if (e.key === "Tab") {
|
|
420
|
+
closeSortPopover();
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
document.addEventListener("mousedown", onDocClick, true);
|
|
424
|
+
document.addEventListener("keydown", onKey, true);
|
|
425
|
+
sortPopoverCleanup = () => {
|
|
426
|
+
document.removeEventListener("mousedown", onDocClick, true);
|
|
427
|
+
document.removeEventListener("keydown", onKey, true);
|
|
428
|
+
};
|
|
429
|
+
}, 0);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function focusSortOption(value) {
|
|
433
|
+
const popover = document.getElementById("sort-popover");
|
|
434
|
+
if (!popover) return;
|
|
435
|
+
const options = [...popover.querySelectorAll(".sort-option")];
|
|
436
|
+
if (options.length === 0) return;
|
|
437
|
+
let idx = options.findIndex((o) => o.getAttribute("data-value") === value);
|
|
438
|
+
if (idx < 0) idx = 0;
|
|
439
|
+
for (const o of options) o.classList.remove("focused");
|
|
440
|
+
options[idx].classList.add("focused");
|
|
441
|
+
try { options[idx].focus(); } catch {}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function moveSortOption(delta) {
|
|
445
|
+
const popover = document.getElementById("sort-popover");
|
|
446
|
+
if (!popover) return;
|
|
447
|
+
const options = [...popover.querySelectorAll(".sort-option")];
|
|
448
|
+
if (options.length === 0) return;
|
|
449
|
+
const active = document.activeElement;
|
|
450
|
+
let idx = options.indexOf(active);
|
|
451
|
+
if (idx < 0) idx = options.findIndex((o) => o.classList.contains("focused"));
|
|
452
|
+
if (idx < 0) idx = 0;
|
|
453
|
+
const next = (idx + delta + options.length) % options.length;
|
|
454
|
+
for (const o of options) o.classList.remove("focused");
|
|
455
|
+
options[next].classList.add("focused");
|
|
456
|
+
try { options[next].focus(); } catch {}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function commitFocusedOption() {
|
|
460
|
+
const popover = document.getElementById("sort-popover");
|
|
461
|
+
if (!popover) return;
|
|
462
|
+
const active = document.activeElement;
|
|
463
|
+
const target = (active && active.classList && active.classList.contains("sort-option"))
|
|
464
|
+
? active
|
|
465
|
+
: popover.querySelector(".sort-option.focused") || popover.querySelector(".sort-option");
|
|
466
|
+
if (!target) return;
|
|
467
|
+
const value = target.getAttribute("data-value");
|
|
468
|
+
if (value) {
|
|
469
|
+
setSort(value);
|
|
470
|
+
closeSortPopover();
|
|
471
|
+
document.getElementById("sort-trigger")?.focus();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function buildSortPopover() {
|
|
476
|
+
const wrap = document.getElementById("sort-select-wrap");
|
|
477
|
+
const sortSel = document.getElementById("sort-select");
|
|
478
|
+
if (!wrap || !sortSel) return;
|
|
479
|
+
|
|
480
|
+
// Hide the original <select> visually. Keep it in the DOM so setSort()
|
|
481
|
+
// can keep its .value in sync and so any test/screen-reader code that
|
|
482
|
+
// targets the legacy control still works.
|
|
483
|
+
sortSel.classList.add("visually-hidden");
|
|
484
|
+
sortSel.setAttribute("aria-hidden", "true");
|
|
485
|
+
sortSel.tabIndex = -1;
|
|
486
|
+
|
|
487
|
+
// Replace (or insert) the trigger button.
|
|
488
|
+
let trigger = document.getElementById("sort-trigger");
|
|
489
|
+
if (!trigger) {
|
|
490
|
+
trigger = el("button", "sort-trigger", {
|
|
491
|
+
type: "button",
|
|
492
|
+
id: "sort-trigger",
|
|
493
|
+
"aria-haspopup": "listbox",
|
|
494
|
+
"aria-expanded": "false",
|
|
495
|
+
"aria-label": "Sort tasks",
|
|
496
|
+
});
|
|
497
|
+
sortSel.insertAdjacentElement("beforebegin", trigger);
|
|
498
|
+
}
|
|
499
|
+
trigger.textContent = SORT_LABEL[filter.sort] || SORT_LABEL.newest;
|
|
500
|
+
trigger.setAttribute("aria-label", `Sort: ${SORT_LABEL[filter.sort] || SORT_LABEL.newest}`);
|
|
501
|
+
|
|
502
|
+
trigger.addEventListener("click", (e) => {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
e.stopPropagation();
|
|
505
|
+
if (sortPopoverOpen) closeSortPopover();
|
|
506
|
+
else openSortPopover();
|
|
507
|
+
});
|
|
508
|
+
trigger.addEventListener("keydown", (e) => {
|
|
509
|
+
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
if (!sortPopoverOpen) openSortPopover();
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Replace (or insert) the popover.
|
|
516
|
+
let popover = document.getElementById("sort-popover");
|
|
517
|
+
if (!popover) {
|
|
518
|
+
popover = el("div", "sort-popover", {
|
|
519
|
+
id: "sort-popover",
|
|
520
|
+
role: "listbox",
|
|
521
|
+
"aria-label": "Sort tasks",
|
|
522
|
+
hidden: true,
|
|
523
|
+
});
|
|
524
|
+
wrap.append(popover);
|
|
525
|
+
} else {
|
|
526
|
+
popover.innerHTML = "";
|
|
527
|
+
popover.setAttribute("role", "listbox");
|
|
528
|
+
}
|
|
529
|
+
for (const opt of SORT_OPTIONS) {
|
|
530
|
+
const btn = el("button", "sort-option", {
|
|
531
|
+
type: "button",
|
|
532
|
+
role: "option",
|
|
533
|
+
"data-value": opt.value,
|
|
534
|
+
"aria-selected": opt.value === filter.sort ? "true" : "false",
|
|
535
|
+
tabindex: "-1",
|
|
536
|
+
});
|
|
537
|
+
btn.append(el("span", "label", { text: opt.label }));
|
|
538
|
+
btn.append(el("span", "desc", { text: opt.desc }));
|
|
539
|
+
if (opt.value === filter.sort) {
|
|
540
|
+
btn.append(el("span", "check", { "aria-hidden": "true", text: "✓" }));
|
|
541
|
+
btn.classList.add("active");
|
|
542
|
+
}
|
|
543
|
+
btn.addEventListener("click", (e) => {
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
e.stopPropagation();
|
|
546
|
+
setSort(opt.value);
|
|
547
|
+
closeSortPopover();
|
|
548
|
+
trigger.focus();
|
|
549
|
+
});
|
|
550
|
+
btn.addEventListener("keydown", (e) => {
|
|
551
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
moveSortOption(e.key === "ArrowDown" ? 1 : -1);
|
|
554
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
commitFocusedOption();
|
|
557
|
+
} else if (e.key === "Escape") {
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
closeSortPopover();
|
|
560
|
+
trigger.focus();
|
|
561
|
+
} else if (e.key === "Home") {
|
|
562
|
+
e.preventDefault();
|
|
563
|
+
focusSortOption(SORT_OPTIONS[0].value);
|
|
564
|
+
} else if (e.key === "End") {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
focusSortOption(SORT_OPTIONS[SORT_OPTIONS.length - 1].value);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
popover.append(btn);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function attachSortPopover() {
|
|
574
|
+
buildSortPopover();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function attachFilterBar() {
|
|
578
|
+
const clear = document.getElementById("filter-clear");
|
|
579
|
+
const cats = document.getElementById("filter-categories");
|
|
580
|
+
const tags = document.getElementById("filter-tags");
|
|
581
|
+
const contribs = document.getElementById("filter-contributors");
|
|
582
|
+
const sortSel = document.getElementById("sort-select");
|
|
583
|
+
const saveBtn = document.getElementById("save-filter-btn");
|
|
584
|
+
if (!cats || !tags) return;
|
|
585
|
+
|
|
586
|
+
cats.addEventListener("click", (e) => {
|
|
587
|
+
const btn = e.target.closest("button[data-category]");
|
|
588
|
+
if (!btn) return;
|
|
589
|
+
filter.category = btn.getAttribute("data-category") || "all";
|
|
590
|
+
writeHashFilter();
|
|
591
|
+
applyFilterToButtons();
|
|
592
|
+
renderBoard();
|
|
593
|
+
});
|
|
594
|
+
tags.addEventListener("click", (e) => {
|
|
595
|
+
const btn = e.target.closest("button[data-tag]");
|
|
596
|
+
if (!btn) return;
|
|
597
|
+
const v = btn.getAttribute("data-tag") || "";
|
|
598
|
+
if (!v) return;
|
|
599
|
+
const idx = filter.tags.indexOf(v);
|
|
600
|
+
if (idx === -1) filter.tags.push(v);
|
|
601
|
+
else filter.tags.splice(idx, 1);
|
|
602
|
+
writeHashFilter();
|
|
603
|
+
applyFilterToButtons();
|
|
604
|
+
renderBoard();
|
|
605
|
+
});
|
|
606
|
+
if (contribs) {
|
|
607
|
+
contribs.addEventListener("click", (e) => {
|
|
608
|
+
const btn = e.target.closest("button[data-contributor]");
|
|
609
|
+
if (!btn) return;
|
|
610
|
+
filter.contributor = btn.getAttribute("data-contributor") || "all";
|
|
611
|
+
writeHashFilter();
|
|
612
|
+
applyFilterToButtons();
|
|
613
|
+
renderBoard();
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (sortSel) {
|
|
617
|
+
// The legacy <select> is kept around for keyboard / form-submit
|
|
618
|
+
// compatibility; the visible popover drives the actual UX. We still
|
|
619
|
+
// honor changes (e.g. tests, screen-reader-only navigation).
|
|
620
|
+
sortSel.addEventListener("change", () => {
|
|
621
|
+
setSort(sortSel.value || "newest");
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
attachSortPopover();
|
|
625
|
+
if (saveBtn) {
|
|
626
|
+
saveBtn.addEventListener("click", async () => {
|
|
627
|
+
// `window.prompt` is blocked in headless Chrome and some embedded
|
|
628
|
+
// contexts, so route through the custom inline modal in app.js.
|
|
629
|
+
const name = await promptForName("Save filter as:", "");
|
|
630
|
+
if (!name) return;
|
|
631
|
+
if (saveCurrentFilter(name)) {
|
|
632
|
+
window.OpenKanSettings?.showToast?.(`Saved filter "${name}"`);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (clear) {
|
|
637
|
+
clear.addEventListener("click", () => {
|
|
638
|
+
filter.category = "all";
|
|
639
|
+
filter.tags = [];
|
|
640
|
+
filter.contributor = "all";
|
|
641
|
+
filter.search = "";
|
|
642
|
+
searchMatchIds = null;
|
|
643
|
+
writeHashFilter();
|
|
644
|
+
applyFilterToButtons();
|
|
645
|
+
renderBoard();
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
// External trigger: tag chip in task view writes the hash; we re-read.
|
|
649
|
+
window.addEventListener("hashchange", () => {
|
|
650
|
+
const next = readHashFilter();
|
|
651
|
+
if (!next) return;
|
|
652
|
+
const searchChanged = next.search !== filter.search;
|
|
653
|
+
filter.category = next.category;
|
|
654
|
+
filter.tags = next.tags;
|
|
655
|
+
filter.contributor = next.contributor;
|
|
656
|
+
filter.archive = next.archive;
|
|
657
|
+
filter.sort = next.sort;
|
|
658
|
+
filter.search = next.search || "";
|
|
659
|
+
if (searchChanged) {
|
|
660
|
+
// Hash navigated to a new search — re-run the debounced fetch.
|
|
661
|
+
const input = document.getElementById("search-input");
|
|
662
|
+
if (input) input.value = filter.search;
|
|
663
|
+
searchMatchIds = null;
|
|
664
|
+
if (filter.search) {
|
|
665
|
+
const meta = document.getElementById("search-meta");
|
|
666
|
+
runSearch(filter.search, meta);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
applyFilterToButtons();
|
|
670
|
+
renderBoard();
|
|
671
|
+
activateTab(next.tab, { fromHash: true });
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Archive segmented control
|
|
675
|
+
document.querySelectorAll(".archive-toggle button[data-archive]").forEach((btn) => {
|
|
676
|
+
btn.addEventListener("click", () => {
|
|
677
|
+
filter.archive = btn.getAttribute("data-archive") || "active";
|
|
678
|
+
writeHashFilter();
|
|
679
|
+
applyFilterToButtons();
|
|
680
|
+
renderBoard();
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const $ = (id) => document.getElementById(id);
|
|
686
|
+
const board = $("board");
|
|
687
|
+
const statusPill = $("status-pill");
|
|
688
|
+
const statusText = $("status-text");
|
|
689
|
+
const modal = $("modal-backdrop");
|
|
690
|
+
if (modal) document.body.appendChild(modal);
|
|
691
|
+
const form = $("new-task-form");
|
|
692
|
+
const menu = $("action-menu");
|
|
693
|
+
const searchInput = $("search-input");
|
|
694
|
+
|
|
695
|
+
// Module-scope wrappers used by both the click-button menu and the
|
|
696
|
+
// right-click context menu. `call` swallows the error to keep the menu
|
|
697
|
+
// from getting stuck open if a request fails.
|
|
698
|
+
const call = (m, p, b) => api(m, p, b).catch((e) => alert(`${m} failed: ${e.message}`));
|
|
699
|
+
|
|
700
|
+
// ---------- Connection state ----------
|
|
701
|
+
function setConnected(v) {
|
|
702
|
+
statusPill.classList.toggle("pill-connected", v);
|
|
703
|
+
statusPill.classList.toggle("pill-disconnected", !v);
|
|
704
|
+
statusText.textContent = v ? "Connected" : "Disconnected";
|
|
705
|
+
}
|
|
706
|
+
onStatus(setConnected);
|
|
707
|
+
|
|
708
|
+
// ---------- Render ----------
|
|
709
|
+
function el(tag, cls, props = {}) {
|
|
710
|
+
const e = document.createElement(tag);
|
|
711
|
+
if (cls) e.className = cls;
|
|
712
|
+
for (const [k, v] of Object.entries(props)) {
|
|
713
|
+
if (k === "text") e.textContent = v;
|
|
714
|
+
else if (k === "html") e.innerHTML = v;
|
|
715
|
+
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
|
716
|
+
else e.setAttribute(k, v);
|
|
717
|
+
}
|
|
718
|
+
return e;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function effectiveState(t) {
|
|
722
|
+
return t.state ?? t.status ?? "idle";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function makeTagChip(tag, opts = {}) {
|
|
726
|
+
const lower = String(tag).toLowerCase();
|
|
727
|
+
const isCategory = CATEGORIES.has(lower);
|
|
728
|
+
const cls = `tag-chip t-${lower}` + (isCategory ? " category" : "") + (opts.className ? ` ${opts.className}` : "");
|
|
729
|
+
const chip = el("span", cls, { text: opts.label ?? lower, title: opts.title ?? lower });
|
|
730
|
+
if (isCategory) chip.classList.add(`c-${lower}`);
|
|
731
|
+
return chip;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function makeCardPriority(t) {
|
|
735
|
+
const meta = PRIORITY_META[t.priority];
|
|
736
|
+
if (!meta) return null;
|
|
737
|
+
return el("span", `card-priority ${meta.className}`, {
|
|
738
|
+
// Preserve title space on narrow board viewports. The full priority
|
|
739
|
+
// stays available through the accessible label and native tooltip.
|
|
740
|
+
text: meta.code,
|
|
741
|
+
title: `Priority: ${meta.label}`,
|
|
742
|
+
"aria-label": `Priority: ${meta.label}`,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function makeCardTags(t) {
|
|
747
|
+
const raw = Array.isArray(t.tags) ? t.tags : [];
|
|
748
|
+
if (raw.length === 0) return null;
|
|
749
|
+
const MAX = 3;
|
|
750
|
+
const visible = raw.slice(0, MAX);
|
|
751
|
+
const overflow = raw.length - visible.length;
|
|
752
|
+
const row = el("div", "card-tags");
|
|
753
|
+
for (const tag of visible) row.append(makeTagChip(tag));
|
|
754
|
+
if (overflow > 0) {
|
|
755
|
+
row.append(el("span", "tag-chip overflow", {
|
|
756
|
+
text: `+${overflow}`,
|
|
757
|
+
title: raw.slice(MAX).join(", "),
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
760
|
+
return row;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Deterministic avatar color from a string. Same approach used elsewhere.
|
|
764
|
+
function avatarColorFor(seed) {
|
|
765
|
+
let h = 0;
|
|
766
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
|
767
|
+
const hue = Math.abs(h) % 360;
|
|
768
|
+
return `hsl(${hue} 60% 45%)`;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function initialsFor(name) {
|
|
772
|
+
if (!name) return "?";
|
|
773
|
+
const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
|
|
774
|
+
if (parts.length === 0) return "?";
|
|
775
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
776
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Resolve the assignees list on a task. The board index payload uses
|
|
780
|
+
// `assignees: string[]`; the task detail payload uses
|
|
781
|
+
// `contributors: Array<{name, email}>`. Normalize to a list of
|
|
782
|
+
// { label, seed } where label is the human name and seed is the email
|
|
783
|
+
// (used for the deterministic color hash and the tooltip).
|
|
784
|
+
function getAssignees(t) {
|
|
785
|
+
if (Array.isArray(t?.assignees) && t.assignees.length > 0) {
|
|
786
|
+
return t.assignees
|
|
787
|
+
.map((a) => {
|
|
788
|
+
if (a && typeof a === "object") {
|
|
789
|
+
const seed = String(a.email || a.name || "");
|
|
790
|
+
return seed ? { label: a.name || a.email || "?", seed } : null;
|
|
791
|
+
}
|
|
792
|
+
const s = String(a || "").trim();
|
|
793
|
+
return s ? { label: s, seed: s } : null;
|
|
794
|
+
})
|
|
795
|
+
.filter(Boolean);
|
|
796
|
+
}
|
|
797
|
+
if (Array.isArray(t?.contributors)) {
|
|
798
|
+
return t.contributors
|
|
799
|
+
.map((c) => {
|
|
800
|
+
const seed = String(c?.email || c?.name || "");
|
|
801
|
+
return seed ? { label: c.name || c.email || "?", seed } : null;
|
|
802
|
+
})
|
|
803
|
+
.filter(Boolean);
|
|
804
|
+
}
|
|
805
|
+
return [];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Render an assignees avatar strip on the card. Up to 2 avatars, then
|
|
809
|
+
// "+N" overflow. The whole strip is a tooltip host with the full list.
|
|
810
|
+
function makeAssigneesStrip(t) {
|
|
811
|
+
const list = getAssignees(t);
|
|
812
|
+
if (list.length === 0) return null;
|
|
813
|
+
const MAX = 2;
|
|
814
|
+
const visible = list.slice(0, MAX);
|
|
815
|
+
const overflow = list.length - visible.length;
|
|
816
|
+
const tooltip = list.map((a) => a.label).join("\n");
|
|
817
|
+
const wrap = el("div", "assignees-stack assignee-tooltip", { "data-tooltip": tooltip, tabindex: "0" });
|
|
818
|
+
for (const a of visible) {
|
|
819
|
+
const av = el("span", "avatar-circle avatar-sm");
|
|
820
|
+
av.style.setProperty("--avatar-bg", avatarColorFor(a.seed));
|
|
821
|
+
av.textContent = initialsFor(a.label);
|
|
822
|
+
wrap.append(av);
|
|
823
|
+
}
|
|
824
|
+
if (overflow > 0) {
|
|
825
|
+
wrap.append(el("span", "assignee-overflow", {
|
|
826
|
+
text: `+${overflow}`,
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
return wrap;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function renderCard(t) {
|
|
833
|
+
const state = effectiveState(t);
|
|
834
|
+
const columnTitle = (COLUMNS.find((c) => c.id === t.column) || {}).title || (t.column || "column");
|
|
835
|
+
const focusedId = window.OpenKanKeyboard?.getFocusedId?.();
|
|
836
|
+
const isFocused = focusedId === t.id;
|
|
837
|
+
const card = el("article", "card" + (t.archived ? " card-archived" : "") + (isFocused ? " focused" : ""), {
|
|
838
|
+
draggable: "true",
|
|
839
|
+
"data-id": t.id,
|
|
840
|
+
role: "button",
|
|
841
|
+
tabindex: isFocused ? "0" : "-1",
|
|
842
|
+
"aria-label": `${t.title || "(untitled)"}, ${columnTitle}, status ${state}${t.archived ? ", archived" : ""}`,
|
|
843
|
+
"aria-pressed": selectedIds.has(t.id) ? "true" : "false",
|
|
844
|
+
});
|
|
845
|
+
// A tiny deterministic stagger gives neighboring cards their own tactile
|
|
846
|
+
// hover cadence without making repeated renders visually random.
|
|
847
|
+
const motionSeed = [...String(t.id)].reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
|
848
|
+
card.style.setProperty("--card-lift-delay", `${motionSeed % 4 * 14}ms`);
|
|
849
|
+
card.style.setProperty("--card-hover-tilt", `${((motionSeed % 3) - 1) * 0.18}deg`);
|
|
850
|
+
const header = el("div", "card-header");
|
|
851
|
+
const titleEl = el("div", "card-title", { text: t.title || "(untitled)" });
|
|
852
|
+
header.append(titleEl);
|
|
853
|
+
const pri = makeCardPriority(t);
|
|
854
|
+
if (pri) header.append(pri);
|
|
855
|
+
card.append(header);
|
|
856
|
+
// Subtask count badge — shows on the right of the title when this task
|
|
857
|
+
// has at least one child. Backend supplies `subtaskCount` on the index
|
|
858
|
+
// payload; falls back to the `subtasks` array length if not.
|
|
859
|
+
const subtaskCount = (() => {
|
|
860
|
+
if (typeof t.subtaskCount === "number") return t.subtaskCount;
|
|
861
|
+
if (Array.isArray(t.subtasks)) return t.subtasks.length;
|
|
862
|
+
return 0;
|
|
863
|
+
})();
|
|
864
|
+
if (subtaskCount > 0) {
|
|
865
|
+
const subBadge = el("span", "card-subtask-badge", {
|
|
866
|
+
text: `${subtaskCount} subtask${subtaskCount === 1 ? "" : "s"}`,
|
|
867
|
+
title: `${subtaskCount} subtask${subtaskCount === 1 ? "" : "s"}`,
|
|
868
|
+
});
|
|
869
|
+
// Right-aligned with the title via inline-block wrapper. We just
|
|
870
|
+
// append it to the title element so the existing flex layout on
|
|
871
|
+
// `.card-title` picks it up; CSS handles the alignment.
|
|
872
|
+
subBadge.dataset.parentLink = "true";
|
|
873
|
+
subBadge.addEventListener("click", (e) => {
|
|
874
|
+
e.stopPropagation();
|
|
875
|
+
window.OpenKanTaskView?.open(t.id);
|
|
876
|
+
});
|
|
877
|
+
titleEl.append(subBadge);
|
|
878
|
+
}
|
|
879
|
+
card.append(el("div", "card-desc", { text: t.description || "" }));
|
|
880
|
+
const tagsRow = makeCardTags(t);
|
|
881
|
+
if (tagsRow) card.append(tagsRow);
|
|
882
|
+
// Source link chip (M2 — frontend). When the task was imported from a
|
|
883
|
+
// markdown file we surface the path:line so the user can click straight
|
|
884
|
+
// through to the source. If the file no longer exists on disk we show
|
|
885
|
+
// a "deleted" state instead of a dead link.
|
|
886
|
+
if (t.source && t.source.path) {
|
|
887
|
+
const path = String(t.source.path);
|
|
888
|
+
const line = t.source.line ?? "?";
|
|
889
|
+
const chip = el("a", "card-source", {
|
|
890
|
+
href: `/${path}`,
|
|
891
|
+
target: "_blank",
|
|
892
|
+
rel: "noopener",
|
|
893
|
+
title: `Imported from ${path}:${line}`,
|
|
894
|
+
});
|
|
895
|
+
chip.append(
|
|
896
|
+
el("span", "card-source-label", { text: "Source", "aria-hidden": "true" }),
|
|
897
|
+
el("span", "card-source-text", { text: `${path}:${line}` }),
|
|
898
|
+
);
|
|
899
|
+
// Stop the click from bubbling up to the card body — we don't want
|
|
900
|
+
// the chip to also open the task view.
|
|
901
|
+
chip.addEventListener("click", (e) => e.stopPropagation());
|
|
902
|
+
// Lazy "deleted" detection: HEAD-style check on hover. If the fetch
|
|
903
|
+
// resolves 404 we flip the chip into the deleted state so the user
|
|
904
|
+
// sees the source is gone without us having to scan the filesystem on
|
|
905
|
+
// every render.
|
|
906
|
+
chip.addEventListener("mouseenter", () => {
|
|
907
|
+
if (chip.dataset.checked || chip.dataset.deleted) return;
|
|
908
|
+
chip.dataset.checked = "1";
|
|
909
|
+
fetch(chip.href, { method: "HEAD", cache: "no-store" })
|
|
910
|
+
.then((res) => {
|
|
911
|
+
if (!res.ok) {
|
|
912
|
+
chip.dataset.deleted = "1";
|
|
913
|
+
chip.removeAttribute("href");
|
|
914
|
+
chip.classList.add("card-source-deleted");
|
|
915
|
+
chip.title = `Source file no longer exists: ${path}`;
|
|
916
|
+
const txt = chip.querySelector(".card-source-text");
|
|
917
|
+
if (txt) txt.textContent = `${path} (deleted)`;
|
|
918
|
+
}
|
|
919
|
+
})
|
|
920
|
+
.catch(() => { /* network error — leave the link intact */ });
|
|
921
|
+
}, { once: true });
|
|
922
|
+
card.append(chip);
|
|
923
|
+
}
|
|
924
|
+
// Stale badge (M3 — frontend). Surfaced in the top-right when the
|
|
925
|
+
// server detected the source file has changed since import. Clicking
|
|
926
|
+
// it opens the task view so the user can re-derive tags.
|
|
927
|
+
if (t.stale === true) {
|
|
928
|
+
const stale = el("button", "card-stale-badge", {
|
|
929
|
+
type: "button",
|
|
930
|
+
text: "stale",
|
|
931
|
+
title: "Source has changed since this task was imported. Click to open.",
|
|
932
|
+
"aria-label": "Source is stale — click to open task",
|
|
933
|
+
});
|
|
934
|
+
stale.addEventListener("click", (e) => {
|
|
935
|
+
e.stopPropagation();
|
|
936
|
+
window.OpenKanTaskView?.open(t.id);
|
|
937
|
+
});
|
|
938
|
+
card.append(stale);
|
|
939
|
+
}
|
|
940
|
+
const meta = el("div", "card-meta");
|
|
941
|
+
const left = el("div");
|
|
942
|
+
left.style.cssText = "display:flex;align-items:center;gap:6px;";
|
|
943
|
+
left.append(
|
|
944
|
+
el("span", `status-dot ${state}`, { "aria-hidden": "true" }),
|
|
945
|
+
el("span", "card-state-label", { text: String(state).replaceAll("-", " "), title: `Status: ${state}` }),
|
|
946
|
+
);
|
|
947
|
+
if (t.agent)
|
|
948
|
+
left.append(el("span", "card-agent", { text: t.agent, title: `agent: ${t.agent}` }));
|
|
949
|
+
const avs = makeAssigneesStrip(t);
|
|
950
|
+
if (avs) left.append(avs);
|
|
951
|
+
const right = el("div");
|
|
952
|
+
right.style.cssText = "display:flex;gap:6px;align-items:center;";
|
|
953
|
+
if (t.artifact) {
|
|
954
|
+
const a = el("a", null, {
|
|
955
|
+
href: `/artifacts/tasks/${t.id}`,
|
|
956
|
+
title: "View artifact",
|
|
957
|
+
text: "Open",
|
|
958
|
+
});
|
|
959
|
+
a.className = "card-artifact-link";
|
|
960
|
+
a.addEventListener("click", (e) => e.stopPropagation());
|
|
961
|
+
right.append(a);
|
|
962
|
+
}
|
|
963
|
+
const more = el("button", "btn-icon", { text: "⋯", title: "Actions", "aria-label": "Task actions" });
|
|
964
|
+
more.addEventListener("click", (e) => {
|
|
965
|
+
e.stopPropagation();
|
|
966
|
+
openMenu(t, more);
|
|
967
|
+
});
|
|
968
|
+
right.append(more);
|
|
969
|
+
meta.append(left, right);
|
|
970
|
+
card.append(meta);
|
|
971
|
+
|
|
972
|
+
// Drag — v1.1: also handle multi-card drag (selected set) and ghost preview.
|
|
973
|
+
card.addEventListener("dragstart", (e) => {
|
|
974
|
+
// Compose the dragged id list: this card + any selected ones.
|
|
975
|
+
const dragged = new Set([t.id, ...selectedIds]);
|
|
976
|
+
dragState.draggedIds = [...dragged];
|
|
977
|
+
// Board columns move cards; chat copies a task reference. Advertise both
|
|
978
|
+
// outcomes so browsers accept the chat's copy drop effect.
|
|
979
|
+
e.dataTransfer.effectAllowed = "copyMove";
|
|
980
|
+
const chatTask = { type: "openkan-task", id: t.id, title: t.title, column: t.column };
|
|
981
|
+
try {
|
|
982
|
+
e.dataTransfer.setData("text/plain", dragState.draggedIds.join(","));
|
|
983
|
+
e.dataTransfer.setData("application/x-openkan-task", JSON.stringify(chatTask));
|
|
984
|
+
e.dataTransfer.setData("text/x-openkan-task", JSON.stringify(chatTask));
|
|
985
|
+
e.dataTransfer.setData("application/json", JSON.stringify(chatTask));
|
|
986
|
+
} catch {}
|
|
987
|
+
// A same-document fallback for engines that hide custom MIME data until
|
|
988
|
+
// drop. It is cleared by the normal drag teardown path.
|
|
989
|
+
window.OpenKanActiveTaskDrag = chatTask;
|
|
990
|
+
card.classList.add("dragging");
|
|
991
|
+
// Show ghost preview — custom positioned div.
|
|
992
|
+
dragState.ghost = buildGhost(t, dragState.draggedIds);
|
|
993
|
+
document.body.append(dragState.ghost);
|
|
994
|
+
// Hide native drag image so only our ghost shows.
|
|
995
|
+
try {
|
|
996
|
+
const blank = document.createElement("canvas");
|
|
997
|
+
blank.width = blank.height = 1;
|
|
998
|
+
e.dataTransfer.setDragImage(blank, 0, 0);
|
|
999
|
+
} catch {}
|
|
1000
|
+
});
|
|
1001
|
+
card.addEventListener("dragend", () => {
|
|
1002
|
+
card.classList.remove("dragging");
|
|
1003
|
+
teardownDragVisuals();
|
|
1004
|
+
});
|
|
1005
|
+
// Ctrl/Meta-click toggles selection for multi-drag + bulk actions.
|
|
1006
|
+
card.addEventListener("click", (e) => {
|
|
1007
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey === false) {
|
|
1008
|
+
// Only intercept if the user is on the card body, not a button/link.
|
|
1009
|
+
const target = e.target;
|
|
1010
|
+
const tag = (target.tagName || "").toLowerCase();
|
|
1011
|
+
if (tag === "a" || tag === "button") return;
|
|
1012
|
+
e.preventDefault();
|
|
1013
|
+
e.stopPropagation();
|
|
1014
|
+
toggleCardSelection(card, t.id);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// Plain click opens the detail view.
|
|
1018
|
+
const tag = (e.target.tagName || "").toLowerCase();
|
|
1019
|
+
if (tag === "a" || tag === "button") return;
|
|
1020
|
+
// Update the focused card so subsequent j/k/arrow keys start from here.
|
|
1021
|
+
window.OpenKanKeyboard?.setFocusedId?.(t.id);
|
|
1022
|
+
window.OpenKanTaskView?.open(t.id);
|
|
1023
|
+
});
|
|
1024
|
+
// Right-click on cards is handled by the global page-wide contextmenu
|
|
1025
|
+
// listener (see `attachGlobalContextMenu` near the boot section). We
|
|
1026
|
+
// deliberately don't attach a per-card contextmenu handler — let the
|
|
1027
|
+
// global delegate handle it via `e.target.closest(".card")` so the
|
|
1028
|
+
// behavior is consistent with column-body / filter-bar / topbar etc.
|
|
1029
|
+
// Apply the current selected state — renderBoard() rebuilds the DOM
|
|
1030
|
+
// after every state change, so we need to re-add the class on rebuild.
|
|
1031
|
+
if (selectedIds.has(t.id)) card.classList.add("selected");
|
|
1032
|
+
|
|
1033
|
+
return card;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Selected cards for multi-card drag + bulk action bar. Stored as a Set of
|
|
1037
|
+
// task ids. Renamed from `selectedCards` so it matches the public spec.
|
|
1038
|
+
function toggleCardSelection(cardEl, id) {
|
|
1039
|
+
if (selectedIds.has(id)) {
|
|
1040
|
+
selectedIds.delete(id);
|
|
1041
|
+
cardEl.classList.remove("selected");
|
|
1042
|
+
cardEl.setAttribute("aria-pressed", "false");
|
|
1043
|
+
} else {
|
|
1044
|
+
selectedIds.add(id);
|
|
1045
|
+
cardEl.classList.add("selected");
|
|
1046
|
+
cardEl.setAttribute("aria-pressed", "true");
|
|
1047
|
+
}
|
|
1048
|
+
updateBulkBar();
|
|
1049
|
+
}
|
|
1050
|
+
function clearCardSelection() {
|
|
1051
|
+
for (const c of document.querySelectorAll(".card.selected")) c.classList.remove("selected");
|
|
1052
|
+
selectedIds.clear();
|
|
1053
|
+
updateBulkBar();
|
|
1054
|
+
}
|
|
1055
|
+
document.addEventListener("click", (e) => {
|
|
1056
|
+
if (e.target.closest(".card")) return;
|
|
1057
|
+
if (e.target.closest("#bulk-bar")) return;
|
|
1058
|
+
clearCardSelection();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
function renderBoard() {
|
|
1062
|
+
if (!board) return;
|
|
1063
|
+
board.innerHTML = "";
|
|
1064
|
+
// `filterActive` controls whether taskMatchesFilter() runs at all. It
|
|
1065
|
+
// must include the archive toggle — otherwise with no other filter
|
|
1066
|
+
// active we'd render archived cards alongside active ones, which is the
|
|
1067
|
+
// "archived items don't get hidden" bug.
|
|
1068
|
+
const filterActive =
|
|
1069
|
+
filter.category !== "all" ||
|
|
1070
|
+
filter.tags.length > 0 ||
|
|
1071
|
+
filter.contributor !== "all" ||
|
|
1072
|
+
filter.archive !== "active" ||
|
|
1073
|
+
filter.search !== "";
|
|
1074
|
+
|
|
1075
|
+
// Group by column, apply filter, sort.
|
|
1076
|
+
const byColumn = new Map();
|
|
1077
|
+
for (const col of COLUMNS) byColumn.set(col.id, []);
|
|
1078
|
+
for (const t of tasks.values()) {
|
|
1079
|
+
const list = byColumn.get(t.column);
|
|
1080
|
+
if (list) list.push(t);
|
|
1081
|
+
}
|
|
1082
|
+
for (const [colId, list] of byColumn) {
|
|
1083
|
+
const filtered = filterActive ? list.filter(taskMatchesFilter) : list;
|
|
1084
|
+
byColumn.set(colId, sortTasks(filtered));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const visibleTasks = [...byColumn.values()].flat();
|
|
1088
|
+
const setOverview = (id, value) => {
|
|
1089
|
+
const target = document.getElementById(id);
|
|
1090
|
+
if (target) target.textContent = String(value);
|
|
1091
|
+
};
|
|
1092
|
+
setOverview("overview-total", visibleTasks.length);
|
|
1093
|
+
setOverview("overview-doing", visibleTasks.filter((task) => task.column === "doing").length);
|
|
1094
|
+
setOverview("overview-review", visibleTasks.filter((task) => task.column === "review").length);
|
|
1095
|
+
setOverview("overview-done", visibleTasks.filter((task) => task.column === "done").length);
|
|
1096
|
+
|
|
1097
|
+
for (const col of COLUMNS) {
|
|
1098
|
+
const colTasks = byColumn.get(col.id) || [];
|
|
1099
|
+
const body = el("div", "column-body", { "data-column": col.id });
|
|
1100
|
+
|
|
1101
|
+
if (colTasks.length === 0) {
|
|
1102
|
+
const empty = el("div", "column-empty is-dropzone", {
|
|
1103
|
+
text: filterActive
|
|
1104
|
+
? "No tasks match the filter"
|
|
1105
|
+
: "Drop a task here or + Add task",
|
|
1106
|
+
});
|
|
1107
|
+
body.append(empty);
|
|
1108
|
+
} else {
|
|
1109
|
+
for (const t of colTasks) body.append(renderCard(t));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const column = el("section", "column", { "data-column": col.id });
|
|
1113
|
+
const header = el("div", "column-header");
|
|
1114
|
+
const totalInCol = (byColumn.get(col.id) || []).length + (
|
|
1115
|
+
// include archived-only when filter is "active"
|
|
1116
|
+
filter.archive !== "archived"
|
|
1117
|
+
? [...tasks.values()].filter((t) => t.column === col.id && t.archived).length
|
|
1118
|
+
: 0
|
|
1119
|
+
);
|
|
1120
|
+
const matched = colTasks.length;
|
|
1121
|
+
const countLabel = filterActive && totalInCol !== matched
|
|
1122
|
+
? `${matched} / ${totalInCol}`
|
|
1123
|
+
: String(matched);
|
|
1124
|
+
const titleSpan = el("span", "column-title", { text: col.title });
|
|
1125
|
+
const countSpan = el("span", "column-count", { text: countLabel });
|
|
1126
|
+
const addBtn = el("button", "column-add-btn", {
|
|
1127
|
+
type: "button",
|
|
1128
|
+
title: `Add a task to ${col.title}`,
|
|
1129
|
+
"aria-label": `Add a task to ${col.title}`,
|
|
1130
|
+
text: "+",
|
|
1131
|
+
});
|
|
1132
|
+
addBtn.addEventListener("click", (e) => {
|
|
1133
|
+
e.stopPropagation();
|
|
1134
|
+
openNewTaskModalInColumn(col.id);
|
|
1135
|
+
});
|
|
1136
|
+
header.append(titleSpan, countSpan, addBtn);
|
|
1137
|
+
column.append(header, body);
|
|
1138
|
+
attachDnD(column);
|
|
1139
|
+
board.append(column);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ---------- Action menu ----------
|
|
1144
|
+
function openMenu(task, anchor) {
|
|
1145
|
+
const r = anchor.getBoundingClientRect();
|
|
1146
|
+
menu.innerHTML = "";
|
|
1147
|
+
menu.style.top = `${r.bottom + 4}px`;
|
|
1148
|
+
menu.style.left = `${Math.max(8, r.right - 200)}px`;
|
|
1149
|
+
menu.hidden = false;
|
|
1150
|
+
|
|
1151
|
+
const run = (label, fn, danger = false) => {
|
|
1152
|
+
const b = el("button", danger ? "danger" : null, { text: label });
|
|
1153
|
+
b.addEventListener("click", () => {
|
|
1154
|
+
if (typeof console !== "undefined") console.debug("[openkan] menu click:", label);
|
|
1155
|
+
menu.hidden = true;
|
|
1156
|
+
Promise.resolve().then(() => {
|
|
1157
|
+
try { fn(); } catch (err) {
|
|
1158
|
+
if (typeof console !== "undefined") console.error("[openkan] action threw:", err);
|
|
1159
|
+
toast(`Action failed: ${err.message || err}`, true);
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
menu.append(b);
|
|
1164
|
+
};
|
|
1165
|
+
// `call` is the module-scope wrapper defined near the top of the IIFE.
|
|
1166
|
+
|
|
1167
|
+
run("Discuss in chat", () => window.OpenKanChatSidebar?.mentionTask?.(task));
|
|
1168
|
+
|
|
1169
|
+
// "Move to next column" — hidden if archived or on the last column.
|
|
1170
|
+
const idx = COLUMN_ORDER.indexOf(task.column);
|
|
1171
|
+
if (!task.archived && idx >= 0 && idx < COLUMN_ORDER.length - 1) {
|
|
1172
|
+
const next = COLUMN_ORDER[idx + 1];
|
|
1173
|
+
run(`Move to ${labelForColumn(next)} →`, () => {
|
|
1174
|
+
call("PATCH", `/api/tasks/${task.id}`, { column: next })
|
|
1175
|
+
.then(() => toast(`Moved to ${labelForColumn(next)}`))
|
|
1176
|
+
.catch((err) => toast(`Move failed: ${err.message}`, true));
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const state = effectiveState(task);
|
|
1181
|
+
if (state !== "running") {
|
|
1182
|
+
run("Start", () => call("POST", `/api/tasks/${task.id}/start`)
|
|
1183
|
+
.then(() => toast(`Started "${task.title}"`))
|
|
1184
|
+
.catch((err) => toast(`Start failed: ${err.message}`, true)));
|
|
1185
|
+
} else {
|
|
1186
|
+
run("Abort", () => call("POST", `/api/tasks/${task.id}/abort`)
|
|
1187
|
+
.then(() => toast(`Aborted "${task.title}"`))
|
|
1188
|
+
.catch((err) => toast(`Abort failed: ${err.message}`, true)));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
run("Edit", () => openEditModal(task.id));
|
|
1192
|
+
|
|
1193
|
+
run("View Detail", () => {
|
|
1194
|
+
window.OpenKanTaskView?.open(task.id);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
if (task.artifact) {
|
|
1198
|
+
const a = el("a", null, { href: `/artifacts/tasks/${task.id}`, text: "View Artifact ↗", target: "_blank", rel: "noopener" });
|
|
1199
|
+
menu.append(a);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (task.archived) {
|
|
1203
|
+
run("Restore", () => call("POST", `/api/tasks/${task.id}/restore`)
|
|
1204
|
+
.then(() => toast(`Restored "${task.title}"`))
|
|
1205
|
+
.catch((err) => toast(`Restore failed: ${err.message}`, true)));
|
|
1206
|
+
} else {
|
|
1207
|
+
run("Archive", () => archiveWithUndo(task));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
run("Delete", () => deleteWithUndo(task), true);
|
|
1211
|
+
|
|
1212
|
+
const off = (e) => {
|
|
1213
|
+
if (!menu.contains(e.target)) {
|
|
1214
|
+
menu.hidden = true;
|
|
1215
|
+
document.removeEventListener("click", off);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
setTimeout(() => document.addEventListener("click", off), 0);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ─── Right-click context menu (PAGE-WIDE, location-aware) ──────────────
|
|
1222
|
+
//
|
|
1223
|
+
// A single global `contextmenu` listener at capture phase decides which menu
|
|
1224
|
+
// to show based on `e.target.closest(...)`. Re-uses the `renderMenu` /
|
|
1225
|
+
// `hideMenu` infrastructure from the card-specific menu below.
|
|
1226
|
+
function openGlobalContextMenu(e) {
|
|
1227
|
+
if (typeof console !== "undefined") console.debug("[openkan] contextmenu at", e.target);
|
|
1228
|
+
// Inputs keep the native context menu (paste / spell-check etc.) so the
|
|
1229
|
+
// user can still get the browser spell-checker on text fields.
|
|
1230
|
+
const tEl = e.target;
|
|
1231
|
+
if (!tEl) return;
|
|
1232
|
+
const tTag = (tEl.tagName || "").toLowerCase();
|
|
1233
|
+
if (tTag === "input" || tTag === "textarea" || tEl.isContentEditable) {
|
|
1234
|
+
if (typeof console !== "undefined") console.debug("[openkan] contextmenu: in editable input — keeping native menu");
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
// Anchors / buttons keep their native menu (copy link, etc.). Walk up to
|
|
1238
|
+
// decide. We DON'T bail just because the click target is a <span> or
|
|
1239
|
+
// <article role="button"> — only true <a>/<button> elements get the
|
|
1240
|
+
// native menu.
|
|
1241
|
+
let walker = tEl;
|
|
1242
|
+
while (walker && walker !== document.body) {
|
|
1243
|
+
const tag = (walker.tagName || "").toLowerCase();
|
|
1244
|
+
if (tag === "a" || tag === "button") {
|
|
1245
|
+
if (typeof console !== "undefined") console.debug("[openkan] contextmenu: inside anchor/button — keeping native menu", tag);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
walker = walker.parentElement;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Always suppress the native menu once we've decided to render ours.
|
|
1252
|
+
e.preventDefault();
|
|
1253
|
+
e.stopPropagation();
|
|
1254
|
+
|
|
1255
|
+
const items = [];
|
|
1256
|
+
|
|
1257
|
+
// 1. Card → per-task menu
|
|
1258
|
+
const cardEl = tEl.closest(".card");
|
|
1259
|
+
if (cardEl) {
|
|
1260
|
+
const taskId = cardEl.dataset.id;
|
|
1261
|
+
const task = tasks.get(taskId);
|
|
1262
|
+
if (task) {
|
|
1263
|
+
e.preventDefault();
|
|
1264
|
+
e.stopPropagation();
|
|
1265
|
+
openContextMenu(e, task);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// 2. Saved-filter chip
|
|
1271
|
+
const savedChip = tEl.closest(".saved-filter-chip");
|
|
1272
|
+
if (savedChip && savedChip.dataset.savedName) {
|
|
1273
|
+
const name = savedChip.dataset.savedName;
|
|
1274
|
+
items.push({
|
|
1275
|
+
label: `Apply "${name}"`,
|
|
1276
|
+
action: () => applySavedFilterByName(name),
|
|
1277
|
+
});
|
|
1278
|
+
items.push({
|
|
1279
|
+
label: `Delete saved filter "${name}"`,
|
|
1280
|
+
danger: true,
|
|
1281
|
+
action: () => deleteSavedFilterByName(name),
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// 3. Tag chip in the filter bar (data-tag attribute)
|
|
1286
|
+
const filterChip = tEl.closest(".filter-bar button[data-tag]");
|
|
1287
|
+
if (filterChip && filterChip.dataset.tag) {
|
|
1288
|
+
const tg = filterChip.dataset.tag;
|
|
1289
|
+
items.push({
|
|
1290
|
+
label: `Filter by #${tg} only`,
|
|
1291
|
+
action: () => toggleOnlyTagFilter(tg),
|
|
1292
|
+
});
|
|
1293
|
+
items.push({
|
|
1294
|
+
label: `Add #${tg} to active filters`,
|
|
1295
|
+
action: () => addTagToActiveFilters(tg),
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// 4. Category chip in the filter bar
|
|
1300
|
+
const categoryChip = tEl.closest(".filter-bar button[data-category]");
|
|
1301
|
+
if (categoryChip && categoryChip.dataset.category && categoryChip.dataset.category !== "all") {
|
|
1302
|
+
const cat = categoryChip.dataset.category;
|
|
1303
|
+
items.push({
|
|
1304
|
+
label: `Filter by category "${cat}" only`,
|
|
1305
|
+
action: () => setOnlyCategoryFilter(cat),
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 5. Column body / column header → column menu
|
|
1310
|
+
const columnEl = tEl.closest(".column");
|
|
1311
|
+
if (columnEl && columnEl.dataset.column) {
|
|
1312
|
+
const colId = columnEl.dataset.column;
|
|
1313
|
+
const col = COLUMNS.find((c) => c.id === colId);
|
|
1314
|
+
items.push({
|
|
1315
|
+
label: col?.title
|
|
1316
|
+
? `Create task in "${col.title}"`
|
|
1317
|
+
: `Create task`,
|
|
1318
|
+
action: () => openNewTaskModalInColumn(colId),
|
|
1319
|
+
});
|
|
1320
|
+
items.push({
|
|
1321
|
+
label: `New task from template`,
|
|
1322
|
+
action: () => openNewTaskModalInColumn(colId, true),
|
|
1323
|
+
});
|
|
1324
|
+
if (selectedIds.size > 0) {
|
|
1325
|
+
items.push({
|
|
1326
|
+
label: `Move ${selectedIds.size} selected task${selectedIds.size === 1 ? "" : "s"} here`,
|
|
1327
|
+
action: () => bulkMoveSelectedTo(colId),
|
|
1328
|
+
});
|
|
1329
|
+
items.push({
|
|
1330
|
+
label: `Move ${selectedIds.size} selected task${selectedIds.size === 1 ? "" : "s"} to other project…`,
|
|
1331
|
+
action: () => openCrossProjectMoveDialog([...selectedIds]),
|
|
1332
|
+
});
|
|
1333
|
+
items.push({ kind: "divider" });
|
|
1334
|
+
}
|
|
1335
|
+
// Archive-all in this column
|
|
1336
|
+
items.push({
|
|
1337
|
+
label: `Archive archived tasks in this column`,
|
|
1338
|
+
action: () => archiveAllInColumn(colId),
|
|
1339
|
+
});
|
|
1340
|
+
items.push({
|
|
1341
|
+
label: `Re-derive tags for tasks in this column`,
|
|
1342
|
+
action: () => bulkRederiveInColumn(colId),
|
|
1343
|
+
});
|
|
1344
|
+
items.push({ kind: "divider" });
|
|
1345
|
+
items.push({
|
|
1346
|
+
label: `Copy column ID "${colId}"`,
|
|
1347
|
+
action: () => copyToClipboard(colId, "Column ID"),
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// 6. Dashboard tab in the topbar
|
|
1352
|
+
const tabEl = tEl.closest(".topbar-tabs .tab");
|
|
1353
|
+
if (tabEl) {
|
|
1354
|
+
const tabName = tabEl.dataset.tab;
|
|
1355
|
+
if (tabName === "tasks") {
|
|
1356
|
+
items.push({
|
|
1357
|
+
label: "Clear all filters",
|
|
1358
|
+
action: () => clearAllFilters(),
|
|
1359
|
+
});
|
|
1360
|
+
items.push({
|
|
1361
|
+
label: "Show archived",
|
|
1362
|
+
action: () => setArchiveFilter("archived"),
|
|
1363
|
+
});
|
|
1364
|
+
items.push({
|
|
1365
|
+
label: "Show all (active + archived)",
|
|
1366
|
+
action: () => setArchiveFilter("both"),
|
|
1367
|
+
});
|
|
1368
|
+
} else if (tabName === "changelog") {
|
|
1369
|
+
items.push({
|
|
1370
|
+
label: "Refresh changelog",
|
|
1371
|
+
action: () => window.OpenKanChangelog?.mount?.(document.getElementById("changelog-root")),
|
|
1372
|
+
});
|
|
1373
|
+
} else if (tabName === "contributors") {
|
|
1374
|
+
items.push({
|
|
1375
|
+
label: "Refresh contributors",
|
|
1376
|
+
action: () => window.OpenKanContributors?.mount?.(document.getElementById("contributors-root")),
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 7. Default page menu if nothing matched
|
|
1382
|
+
if (items.length === 0) {
|
|
1383
|
+
items.push({ label: "New Task", action: openNewTaskModal });
|
|
1384
|
+
items.push({ kind: "divider" });
|
|
1385
|
+
items.push({
|
|
1386
|
+
label: "Open Settings",
|
|
1387
|
+
action: () => document.getElementById("settings-btn")?.click(),
|
|
1388
|
+
});
|
|
1389
|
+
items.push({
|
|
1390
|
+
label: "Toggle theme (dark / light / system)",
|
|
1391
|
+
action: () => window.OpenKanSettings?.cycleTheme?.() ?? window.OpenKanSettings?.openSettings?.(),
|
|
1392
|
+
});
|
|
1393
|
+
items.push({
|
|
1394
|
+
label: "Reload board",
|
|
1395
|
+
action: () => location.reload(),
|
|
1396
|
+
});
|
|
1397
|
+
items.push({ kind: "divider" });
|
|
1398
|
+
items.push({
|
|
1399
|
+
label: "Keyboard shortcuts (press ?)",
|
|
1400
|
+
action: () => window.OpenKanKeyboard?.showHelp?.(),
|
|
1401
|
+
});
|
|
1402
|
+
items.push({
|
|
1403
|
+
label: "Command palette (⌘K / Ctrl+K)",
|
|
1404
|
+
action: () => window.OpenKanCommandPalette?.open?.(),
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
e.preventDefault();
|
|
1409
|
+
e.stopPropagation();
|
|
1410
|
+
renderMenu(items);
|
|
1411
|
+
positionMenuAt(e);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function positionMenuAt(e) {
|
|
1415
|
+
if (!menu) return;
|
|
1416
|
+
const vw = window.innerWidth;
|
|
1417
|
+
const vh = window.innerHeight;
|
|
1418
|
+
menu.style.visibility = "hidden";
|
|
1419
|
+
menu.hidden = false;
|
|
1420
|
+
const w = menu.offsetWidth || 220;
|
|
1421
|
+
const h = menu.offsetHeight || 280;
|
|
1422
|
+
const pad = 8;
|
|
1423
|
+
let x = e.clientX;
|
|
1424
|
+
let y = e.clientY;
|
|
1425
|
+
if (x + w + pad > vw) x = Math.max(pad, vw - w - pad);
|
|
1426
|
+
if (y + h + pad > vh) y = Math.max(pad, vh - h - pad);
|
|
1427
|
+
menu.style.left = `${Math.max(pad, x)}px`;
|
|
1428
|
+
menu.style.top = `${Math.max(pad, y)}px`;
|
|
1429
|
+
menu.style.visibility = "";
|
|
1430
|
+
// Single capture-phase mousedown listener with a 50ms delay so the
|
|
1431
|
+
// opening right-click doesn't immediately close the menu. Simpler than
|
|
1432
|
+
// the previous four-listener combo (mousedown + keydown + scroll +
|
|
1433
|
+
// window-blur), and the brief specifically asks for this simplification.
|
|
1434
|
+
setTimeout(() => {
|
|
1435
|
+
document.addEventListener("mousedown", dismissOnOutsideClick, true);
|
|
1436
|
+
document.addEventListener("keydown", dismissOnEscape, true);
|
|
1437
|
+
}, 50);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ─── Helper actions used by the global context menu ─────────────────────
|
|
1441
|
+
function openNewTaskModalInColumn(columnId, fromTemplate = false, parentId = "") {
|
|
1442
|
+
// Pre-fill the modal so it lands in the chosen column (and as a subtask
|
|
1443
|
+
// of `parentId` if provided).
|
|
1444
|
+
void fromTemplate;
|
|
1445
|
+
const btn = document.getElementById("new-task-btn");
|
|
1446
|
+
if (btn) btn.click();
|
|
1447
|
+
const colSel = document.querySelector('select[name="column"]');
|
|
1448
|
+
if (colSel) {
|
|
1449
|
+
colSel.value = columnId;
|
|
1450
|
+
colSel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1451
|
+
}
|
|
1452
|
+
const parentField = document.querySelector('input[name="parentId"]');
|
|
1453
|
+
if (parentField) {
|
|
1454
|
+
parentField.value = parentId || "";
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function openNewTaskModal() {
|
|
1459
|
+
const btn = document.getElementById("new-task-btn");
|
|
1460
|
+
if (btn) btn.click();
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// ─── Edit Task modal ──────────────────────────────────────────────────────
|
|
1464
|
+
// Single-instance modal — re-opened with a new taskId each time. Reuses the
|
|
1465
|
+
// same `.modal-backdrop` / `.modal` shell as the New Task modal so styling
|
|
1466
|
+
// stays consistent. Re-fetches the task via /api/tasks/:id on open so we
|
|
1467
|
+
// always edit the freshest server-side state.
|
|
1468
|
+
function editModal() {
|
|
1469
|
+
let backdrop = document.getElementById("edit-backdrop");
|
|
1470
|
+
if (backdrop && backdrop._wired) return backdrop;
|
|
1471
|
+
if (!backdrop) {
|
|
1472
|
+
backdrop = document.createElement("div");
|
|
1473
|
+
backdrop.id = "edit-backdrop";
|
|
1474
|
+
backdrop.className = "modal-backdrop";
|
|
1475
|
+
backdrop.hidden = true;
|
|
1476
|
+
backdrop.innerHTML = `
|
|
1477
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
|
1478
|
+
<header class="modal-header">
|
|
1479
|
+
<h2 id="edit-title">Edit Task</h2>
|
|
1480
|
+
<button class="btn-icon" type="button" data-close-edit aria-label="Close">×</button>
|
|
1481
|
+
</header>
|
|
1482
|
+
<form id="edit-form" class="modal-body">
|
|
1483
|
+
<label class="field">
|
|
1484
|
+
<span>Title</span>
|
|
1485
|
+
<input name="title" type="text" required maxlength="200" autocomplete="off" />
|
|
1486
|
+
</label>
|
|
1487
|
+
<label class="field">
|
|
1488
|
+
<span>Description</span>
|
|
1489
|
+
<textarea name="description" rows="5" placeholder="Markdown is fine."></textarea>
|
|
1490
|
+
</label>
|
|
1491
|
+
<footer class="modal-footer">
|
|
1492
|
+
<button type="button" class="btn" data-close-edit>Cancel</button>
|
|
1493
|
+
<button type="submit" class="btn btn-primary" id="edit-save-btn">Save</button>
|
|
1494
|
+
</footer>
|
|
1495
|
+
</form>
|
|
1496
|
+
</div>`;
|
|
1497
|
+
document.body.appendChild(backdrop);
|
|
1498
|
+
}
|
|
1499
|
+
backdrop._wired = true;
|
|
1500
|
+
return backdrop;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
let editCurrentTaskId = null;
|
|
1504
|
+
let editLastTask = null;
|
|
1505
|
+
|
|
1506
|
+
async function openEditModal(taskId) {
|
|
1507
|
+
if (!taskId) return;
|
|
1508
|
+
const backdrop = editModal();
|
|
1509
|
+
const form = backdrop.querySelector("#edit-form");
|
|
1510
|
+
const titleInput = form.elements.title;
|
|
1511
|
+
const descInput = form.elements.description;
|
|
1512
|
+
const saveBtn = backdrop.querySelector("#edit-save-btn");
|
|
1513
|
+
|
|
1514
|
+
editCurrentTaskId = String(taskId);
|
|
1515
|
+
backdrop.hidden = false;
|
|
1516
|
+
saveBtn.disabled = true;
|
|
1517
|
+
titleInput.disabled = true;
|
|
1518
|
+
descInput.disabled = true;
|
|
1519
|
+
titleInput.value = "";
|
|
1520
|
+
descInput.value = "";
|
|
1521
|
+
// Focus the title once enabled for fast keyboard editing.
|
|
1522
|
+
setTimeout(() => titleInput.focus(), 0);
|
|
1523
|
+
|
|
1524
|
+
let task;
|
|
1525
|
+
try {
|
|
1526
|
+
const payload = await api("GET", `/api/tasks/${taskId}`);
|
|
1527
|
+
task = payload?.task || payload;
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
toast(`Could not load task: ${err.message}`, true);
|
|
1530
|
+
backdrop.hidden = true;
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (!task) {
|
|
1534
|
+
toast(`Could not load task ${taskId}`, true);
|
|
1535
|
+
backdrop.hidden = true;
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
editLastTask = task;
|
|
1539
|
+
titleInput.value = task.title || "";
|
|
1540
|
+
descInput.value = task.description || "";
|
|
1541
|
+
titleInput.disabled = false;
|
|
1542
|
+
descInput.disabled = false;
|
|
1543
|
+
saveBtn.disabled = !String(titleInput.value || "").trim();
|
|
1544
|
+
|
|
1545
|
+
const onTitleInput = () => {
|
|
1546
|
+
saveBtn.disabled = !String(titleInput.value || "").trim();
|
|
1547
|
+
};
|
|
1548
|
+
titleInput.addEventListener("input", onTitleInput);
|
|
1549
|
+
|
|
1550
|
+
const closeBackdrop = () => {
|
|
1551
|
+
backdrop.hidden = true;
|
|
1552
|
+
titleInput.removeEventListener("input", onTitleInput);
|
|
1553
|
+
editCurrentTaskId = null;
|
|
1554
|
+
};
|
|
1555
|
+
backdrop.addEventListener("click", (e) => {
|
|
1556
|
+
if (e.target === backdrop) closeBackdrop();
|
|
1557
|
+
}, { once: true });
|
|
1558
|
+
backdrop.querySelectorAll("[data-close-edit]").forEach((b) =>
|
|
1559
|
+
b.addEventListener("click", closeBackdrop, { once: true }),
|
|
1560
|
+
);
|
|
1561
|
+
document.addEventListener("keydown", function onKey(e) {
|
|
1562
|
+
if (e.key === "Escape" && !backdrop.hidden) {
|
|
1563
|
+
closeBackdrop();
|
|
1564
|
+
document.removeEventListener("keydown", onKey);
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
form.addEventListener("submit", async (e) => {
|
|
1569
|
+
e.preventDefault();
|
|
1570
|
+
const title = String(titleInput.value || "").trim();
|
|
1571
|
+
if (!title) {
|
|
1572
|
+
titleInput.focus();
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const description = String(descInput.value || "");
|
|
1576
|
+
saveBtn.disabled = true;
|
|
1577
|
+
try {
|
|
1578
|
+
const result = await api("PATCH", `/api/tasks/${editCurrentTaskId}`, { title, description });
|
|
1579
|
+
// Refresh from server so any server-normalized fields (tags, etc.)
|
|
1580
|
+
// show up before we ask the task view to re-render.
|
|
1581
|
+
try {
|
|
1582
|
+
const fresh = await api("GET", `/api/tasks/${editCurrentTaskId}`);
|
|
1583
|
+
const t = fresh?.task || fresh || result;
|
|
1584
|
+
if (t && t.id) {
|
|
1585
|
+
tasks.set(t.id, t);
|
|
1586
|
+
renderBoard();
|
|
1587
|
+
}
|
|
1588
|
+
} catch (_) { /* best-effort refresh; toast below still fires */ }
|
|
1589
|
+
toast("Saved");
|
|
1590
|
+
closeBackdrop();
|
|
1591
|
+
// If the task view is currently showing this task, re-render it
|
|
1592
|
+
// so the title/description reflect the save immediately.
|
|
1593
|
+
if (window.OpenKanTaskView?.getCurrentTaskId?.() === editCurrentTaskId) {
|
|
1594
|
+
window.OpenKanTaskView.open(editCurrentTaskId);
|
|
1595
|
+
}
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
toast(`Save failed: ${err.message}`, true);
|
|
1598
|
+
saveBtn.disabled = false;
|
|
1599
|
+
}
|
|
1600
|
+
}, { once: false });
|
|
1601
|
+
}
|
|
1602
|
+
function bulkMoveSelectedTo(columnId) {
|
|
1603
|
+
const ids = [...selectedIds];
|
|
1604
|
+
if (ids.length === 0) return;
|
|
1605
|
+
call("POST", "/api/tasks/bulk", {
|
|
1606
|
+
operation: { kind: "move", taskIds: ids, column: columnId },
|
|
1607
|
+
})
|
|
1608
|
+
.then((result) => {
|
|
1609
|
+
showMenuToast(`Moved ${ids.length} task${ids.length === 1 ? "" : "s"} to ${columnId}`);
|
|
1610
|
+
selectedIds.clear();
|
|
1611
|
+
renderBoard();
|
|
1612
|
+
})
|
|
1613
|
+
.catch((e) => alert(`Move failed: ${e.message}`));
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Cross-project bulk move. We build a one-off modal rather than reuse
|
|
1618
|
+
* the project-add modal so the copy stays close to the menu it serves.
|
|
1619
|
+
* The picker is intentionally minimal: list of target projects plus a
|
|
1620
|
+
* disabled-until-confirmed move button.
|
|
1621
|
+
*/
|
|
1622
|
+
let crossProjectMoveResolver = null;
|
|
1623
|
+
function crossProjectMoveModal() {
|
|
1624
|
+
let backdrop = document.getElementById("cross-project-move-backdrop");
|
|
1625
|
+
if (backdrop && backdrop._wired) return backdrop;
|
|
1626
|
+
if (!backdrop) {
|
|
1627
|
+
backdrop = document.createElement("div");
|
|
1628
|
+
backdrop.id = "cross-project-move-backdrop";
|
|
1629
|
+
backdrop.className = "modal-backdrop";
|
|
1630
|
+
backdrop.hidden = true;
|
|
1631
|
+
backdrop.innerHTML = `
|
|
1632
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="cross-project-move-title">
|
|
1633
|
+
<header class="modal-header">
|
|
1634
|
+
<h2 id="cross-project-move-title">Move to project</h2>
|
|
1635
|
+
<button class="btn-icon" type="button" data-close-cross-move aria-label="Close">×</button>
|
|
1636
|
+
</header>
|
|
1637
|
+
<div class="modal-body">
|
|
1638
|
+
<p id="cross-project-move-subtitle" class="muted">Pick a target project.</p>
|
|
1639
|
+
<label class="field">
|
|
1640
|
+
<span>Target project</span>
|
|
1641
|
+
<select id="cross-project-move-select"></select>
|
|
1642
|
+
</label>
|
|
1643
|
+
<p id="cross-project-move-status" class="error" hidden></p>
|
|
1644
|
+
</div>
|
|
1645
|
+
<footer class="modal-footer">
|
|
1646
|
+
<button type="button" class="btn" data-close-cross-move>Cancel</button>
|
|
1647
|
+
<button type="button" class="btn btn-primary" id="cross-project-move-confirm">Move</button>
|
|
1648
|
+
</footer>
|
|
1649
|
+
</div>`;
|
|
1650
|
+
document.body.appendChild(backdrop);
|
|
1651
|
+
}
|
|
1652
|
+
backdrop._wired = true;
|
|
1653
|
+
return backdrop;
|
|
1654
|
+
}
|
|
1655
|
+
function openCrossProjectMoveDialog(taskIds) {
|
|
1656
|
+
const ids = Array.isArray(taskIds) ? taskIds.map((s) => String(s)).filter(Boolean) : [];
|
|
1657
|
+
if (ids.length === 0) return;
|
|
1658
|
+
const backdrop = crossProjectMoveModal();
|
|
1659
|
+
const subtitle = backdrop.querySelector("#cross-project-move-subtitle");
|
|
1660
|
+
const status = backdrop.querySelector("#cross-project-move-status");
|
|
1661
|
+
const select = backdrop.querySelector("#cross-project-move-select");
|
|
1662
|
+
const confirmBtn = backdrop.querySelector("#cross-project-move-confirm");
|
|
1663
|
+
if (subtitle) subtitle.textContent = `${ids.length} task${ids.length === 1 ? "" : "s"} will move to the matching column in the chosen project.`;
|
|
1664
|
+
if (status) { status.hidden = true; status.textContent = ""; }
|
|
1665
|
+
|
|
1666
|
+
if (select) {
|
|
1667
|
+
select.innerHTML = "";
|
|
1668
|
+
const others = (projectSwitcher.list || []).filter((p) => !(projectSwitcher.active && p.id === projectSwitcher.active.id));
|
|
1669
|
+
if (others.length === 0) {
|
|
1670
|
+
// No other registered projects. Still render the empty option so
|
|
1671
|
+
// the Move button stays predictable.
|
|
1672
|
+
const opt = document.createElement("option");
|
|
1673
|
+
opt.value = "";
|
|
1674
|
+
opt.textContent = "(no other projects registered)";
|
|
1675
|
+
select.appendChild(opt);
|
|
1676
|
+
confirmBtn.disabled = true;
|
|
1677
|
+
} else {
|
|
1678
|
+
for (const p of others) {
|
|
1679
|
+
const opt = document.createElement("option");
|
|
1680
|
+
opt.value = p.id;
|
|
1681
|
+
opt.textContent = p.name || p.id;
|
|
1682
|
+
select.appendChild(opt);
|
|
1683
|
+
}
|
|
1684
|
+
confirmBtn.disabled = false;
|
|
1685
|
+
// One-click shortcut: when exactly one other project is available,
|
|
1686
|
+
// pre-select it so the user can confirm immediately.
|
|
1687
|
+
if (others.length === 1) select.value = others[0].id;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const close = () => {
|
|
1692
|
+
backdrop.hidden = true;
|
|
1693
|
+
confirmBtn.disabled = false;
|
|
1694
|
+
confirmBtn.textContent = "Move";
|
|
1695
|
+
if (status) { status.hidden = true; status.textContent = ""; }
|
|
1696
|
+
crossProjectMoveResolver = null;
|
|
1697
|
+
};
|
|
1698
|
+
const onCancel = () => close();
|
|
1699
|
+
let busy = false;
|
|
1700
|
+
const onConfirm = async () => {
|
|
1701
|
+
if (busy) return;
|
|
1702
|
+
const targetId = select && select.value;
|
|
1703
|
+
if (!targetId) {
|
|
1704
|
+
if (status) { status.hidden = false; status.textContent = "Pick a target project first."; }
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
busy = true;
|
|
1708
|
+
confirmBtn.disabled = true;
|
|
1709
|
+
const previousLabel = confirmBtn.textContent;
|
|
1710
|
+
confirmBtn.textContent = "Moving…";
|
|
1711
|
+
if (status) { status.hidden = true; status.textContent = ""; }
|
|
1712
|
+
try {
|
|
1713
|
+
const result = await api("POST", `/api/projects/${encodeURIComponent(targetId)}/tasks/move`, { taskIds: ids });
|
|
1714
|
+
const movedCount = Array.isArray(result?.moved) ? result.moved.length : 0;
|
|
1715
|
+
const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0;
|
|
1716
|
+
const targetName = (projectSwitcher.list || []).find((p) => p.id === targetId)?.name || targetId;
|
|
1717
|
+
selectedIds.clear();
|
|
1718
|
+
if (movedCount > 0) {
|
|
1719
|
+
renderBoard();
|
|
1720
|
+
toast(`Moved ${movedCount} task${movedCount === 1 ? "" : "s"} to ${targetName}`);
|
|
1721
|
+
} else if (skippedCount > 0) {
|
|
1722
|
+
toast(`Could not move any of the selected tasks (${skippedCount} skipped)`, true);
|
|
1723
|
+
}
|
|
1724
|
+
close();
|
|
1725
|
+
} catch (e) {
|
|
1726
|
+
busy = false;
|
|
1727
|
+
confirmBtn.disabled = false;
|
|
1728
|
+
confirmBtn.textContent = previousLabel;
|
|
1729
|
+
if (status) { status.hidden = false; status.textContent = `Move failed: ${e?.message || e}`; }
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
backdrop.addEventListener("click", (e) => {
|
|
1734
|
+
if (e.target === backdrop) onCancel();
|
|
1735
|
+
}, { once: true });
|
|
1736
|
+
backdrop.querySelectorAll("[data-close-cross-move]").forEach((b) =>
|
|
1737
|
+
b.addEventListener("click", onCancel, { once: true }),
|
|
1738
|
+
);
|
|
1739
|
+
document.addEventListener("keydown", function onEsc(e) {
|
|
1740
|
+
if (e.key === "Escape" && !backdrop.hidden) {
|
|
1741
|
+
onCancel();
|
|
1742
|
+
document.removeEventListener("keydown", onEsc);
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
confirmBtn.addEventListener("click", onConfirm);
|
|
1746
|
+
|
|
1747
|
+
backdrop.hidden = false;
|
|
1748
|
+
crossProjectMoveResolver = onConfirm;
|
|
1749
|
+
}
|
|
1750
|
+
function archiveAllInColumn(columnId) {
|
|
1751
|
+
const ids = [...tasks.values()].filter((t) => t.column === columnId && t.archived).map((t) => t.id);
|
|
1752
|
+
if (ids.length === 0) {
|
|
1753
|
+
showMenuToast("No archived tasks in this column");
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
// "Archive" on already-archived is a no-op, so just toast.
|
|
1757
|
+
showMenuToast(`${ids.length} task${ids.length === 1 ? "" : "s"} already archived`);
|
|
1758
|
+
}
|
|
1759
|
+
function bulkRederiveInColumn(columnId) {
|
|
1760
|
+
const ids = [...tasks.values()].filter((t) => t.column === columnId).map((t) => t.id);
|
|
1761
|
+
if (ids.length === 0) return;
|
|
1762
|
+
call("POST", "/api/organize", {
|
|
1763
|
+
operations: ids.map((id) => ({ kind: "rederive", taskId: id })),
|
|
1764
|
+
})
|
|
1765
|
+
.then((result) => {
|
|
1766
|
+
showMenuToast(`Re-derived metadata for ${ids.length} task${ids.length === 1 ? "" : "s"}`);
|
|
1767
|
+
})
|
|
1768
|
+
.catch((e) => alert(`Re-derive failed: ${e.message}`));
|
|
1769
|
+
}
|
|
1770
|
+
function clearAllFilters() {
|
|
1771
|
+
filter.category = "all";
|
|
1772
|
+
filter.tags = [];
|
|
1773
|
+
filter.contributor = "all";
|
|
1774
|
+
filter.search = "";
|
|
1775
|
+
setArchiveFilter("active");
|
|
1776
|
+
applyFilterToButtons();
|
|
1777
|
+
if (searchInput) searchInput.value = "";
|
|
1778
|
+
renderBoard();
|
|
1779
|
+
showMenuToast("Filters cleared");
|
|
1780
|
+
}
|
|
1781
|
+
function setArchiveFilter(value) {
|
|
1782
|
+
if (!["active", "archived", "both"].includes(value)) return;
|
|
1783
|
+
filter.archive = value;
|
|
1784
|
+
applyFilterToButtons();
|
|
1785
|
+
renderBoard();
|
|
1786
|
+
writeHash();
|
|
1787
|
+
showMenuToast(`Archive filter: ${value}`);
|
|
1788
|
+
}
|
|
1789
|
+
function applySavedFilterByName(name) {
|
|
1790
|
+
try {
|
|
1791
|
+
const raw = localStorage.getItem("openkan:saved-filters");
|
|
1792
|
+
const list = JSON.parse(raw || "[]");
|
|
1793
|
+
const saved = list.find((s) => s && s.name === name);
|
|
1794
|
+
if (saved) {
|
|
1795
|
+
Object.assign(filter, {
|
|
1796
|
+
category: saved.category || "all",
|
|
1797
|
+
tags: Array.isArray(saved.tags) ? [...saved.tags] : [],
|
|
1798
|
+
contributor: saved.contributor || "all",
|
|
1799
|
+
search: saved.search || "",
|
|
1800
|
+
});
|
|
1801
|
+
if (saved.archive) filter.archive = saved.archive;
|
|
1802
|
+
if (searchInput) searchInput.value = filter.search;
|
|
1803
|
+
applyFilterToButtons();
|
|
1804
|
+
renderBoard();
|
|
1805
|
+
writeHash();
|
|
1806
|
+
showMenuToast(`Loaded "${name}"`);
|
|
1807
|
+
}
|
|
1808
|
+
} catch (_) {
|
|
1809
|
+
alert("Could not load saved filter.");
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function deleteSavedFilterByName(name) {
|
|
1813
|
+
showUndoToast(`Deleted saved filter "${name}".`, () => {
|
|
1814
|
+
try {
|
|
1815
|
+
const raw = localStorage.getItem("openkan:saved-filters");
|
|
1816
|
+
const list = JSON.parse(raw || "[]");
|
|
1817
|
+
if (!list.find((s) => s && s.name === name)) list.push({ name, snapshot: { /* re-add on click */ } });
|
|
1818
|
+
localStorage.setItem("openkan:saved-filters", JSON.stringify(list));
|
|
1819
|
+
showMenuToast(`Saved filter "${name}" restored`);
|
|
1820
|
+
if (typeof renderSavedFilters === "function") renderSavedFilters();
|
|
1821
|
+
} catch (_) {
|
|
1822
|
+
toast("Could not restore saved filter.", true);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
try {
|
|
1826
|
+
const raw = localStorage.getItem("openkan:saved-filters");
|
|
1827
|
+
const list = JSON.parse(raw || "[]");
|
|
1828
|
+
const next = list.filter((s) => s && s.name !== name);
|
|
1829
|
+
localStorage.setItem("openkan:saved-filters", JSON.stringify(next));
|
|
1830
|
+
// Re-render the saved-filters bar if a render function exists.
|
|
1831
|
+
if (typeof renderSavedFilters === "function") renderSavedFilters();
|
|
1832
|
+
} catch (_) {
|
|
1833
|
+
alert("Could not delete saved filter.");
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function toggleOnlyTagFilter(tag) {
|
|
1837
|
+
filter.category = "all";
|
|
1838
|
+
filter.tags = [tag];
|
|
1839
|
+
filter.contributor = "all";
|
|
1840
|
+
applyFilterToButtons();
|
|
1841
|
+
renderBoard();
|
|
1842
|
+
writeHash();
|
|
1843
|
+
showMenuToast(`Filter: #${tag}`);
|
|
1844
|
+
}
|
|
1845
|
+
function addTagToActiveFilters(tag) {
|
|
1846
|
+
if (!filter.tags.includes(tag)) filter.tags.push(tag);
|
|
1847
|
+
applyFilterToButtons();
|
|
1848
|
+
renderBoard();
|
|
1849
|
+
writeHash();
|
|
1850
|
+
}
|
|
1851
|
+
function setOnlyCategoryFilter(cat) {
|
|
1852
|
+
filter.category = cat;
|
|
1853
|
+
filter.tags = [];
|
|
1854
|
+
filter.contributor = "all";
|
|
1855
|
+
applyFilterToButtons();
|
|
1856
|
+
renderBoard();
|
|
1857
|
+
writeHash();
|
|
1858
|
+
showMenuToast(`Filter: ${cat}`);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function showMenuToast(message) {
|
|
1862
|
+
if (typeof toast === "function") toast(message);
|
|
1863
|
+
else console.info("[openkan]", message);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// ─── Right-click context menu (card body) ────────────────────────────────
|
|
1867
|
+
//
|
|
1868
|
+
// A richer menu than the ⋯ button, positioned at the cursor. Re-uses the
|
|
1869
|
+
// same `#action-menu` host so styling is shared. Items are wired to the
|
|
1870
|
+
// existing `call()` helper (api wrapper) plus a few new ones for clipboard
|
|
1871
|
+
// and bulk ops.
|
|
1872
|
+
//
|
|
1873
|
+
// Flat structure (no submenus) — Move-To is a flat list of "Move to <Col>"
|
|
1874
|
+
// buttons so click handlers always fire reliably. Destructive actions
|
|
1875
|
+
// (Archive, Delete) get an Undo toast with a 5s window.
|
|
1876
|
+
function openContextMenu(e, task) {
|
|
1877
|
+
if (!menu) return;
|
|
1878
|
+
if (!task || !task.id) return;
|
|
1879
|
+
const vw = window.innerWidth;
|
|
1880
|
+
const vh = window.innerHeight;
|
|
1881
|
+
|
|
1882
|
+
// Build menu items. Each item: { label, action | kind, danger? }
|
|
1883
|
+
const items = [];
|
|
1884
|
+
|
|
1885
|
+
items.push({
|
|
1886
|
+
label: "Open",
|
|
1887
|
+
action: () => window.OpenKanTaskView?.open(task.id),
|
|
1888
|
+
});
|
|
1889
|
+
items.push({ label: "Discuss in chat", action: () => window.OpenKanChatSidebar?.mentionTask?.(task) });
|
|
1890
|
+
if (task.artifact) {
|
|
1891
|
+
items.push({
|
|
1892
|
+
label: "View Artifact ↗",
|
|
1893
|
+
action: () => window.open(`/artifacts/tasks/${task.id}`, "_blank", "noopener"),
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
items.push({
|
|
1897
|
+
label: "Edit",
|
|
1898
|
+
action: () => openEditModal(task.id),
|
|
1899
|
+
});
|
|
1900
|
+
items.push({ kind: "divider" });
|
|
1901
|
+
|
|
1902
|
+
// Flat "Move to <Column>" buttons (no submenu — submenu hover was buggy
|
|
1903
|
+
// and obscured the destination before click could register). Current
|
|
1904
|
+
// column is disabled; archived cards can't be moved.
|
|
1905
|
+
if (!task.archived) {
|
|
1906
|
+
for (const c of COLUMNS) {
|
|
1907
|
+
if (c.id === task.column) continue; // skip current column
|
|
1908
|
+
items.push({
|
|
1909
|
+
label: `Move to ${c.title}`,
|
|
1910
|
+
action: () => call("PATCH", `/api/tasks/${task.id}`, { column: c.id })
|
|
1911
|
+
.then(() => toast(`Moved to ${c.title}`))
|
|
1912
|
+
.catch((err) => toast(`Move failed: ${err.message}`, true)),
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
if (items[items.length - 1]?.kind !== "divider") items.push({ kind: "divider" });
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// Selection toggle.
|
|
1919
|
+
if (selectedIds.has(task.id)) {
|
|
1920
|
+
items.push({
|
|
1921
|
+
label: "Deselect",
|
|
1922
|
+
action: () => {
|
|
1923
|
+
selectedIds.delete(task.id);
|
|
1924
|
+
renderBoard();
|
|
1925
|
+
},
|
|
1926
|
+
});
|
|
1927
|
+
} else {
|
|
1928
|
+
items.push({
|
|
1929
|
+
label: "Add to selection",
|
|
1930
|
+
action: () => {
|
|
1931
|
+
selectedIds.add(task.id);
|
|
1932
|
+
renderBoard();
|
|
1933
|
+
},
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
// Select-all-in-column — show whenever there are multiple tasks in this
|
|
1937
|
+
// column (the previous `selectedIds.size > 0 && !selectedIds.has(task.id) === false`
|
|
1938
|
+
// expression was a buggy double-negation that simplified to false unless
|
|
1939
|
+
// the task was already in the selection).
|
|
1940
|
+
const count = countInColumn(task.column);
|
|
1941
|
+
if (count > 1) {
|
|
1942
|
+
items.push({
|
|
1943
|
+
label: `Select all in ${labelForColumn(task.column)} (${count})`,
|
|
1944
|
+
action: () => selectAllInColumn(task.column),
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
items.push({ kind: "divider" });
|
|
1948
|
+
|
|
1949
|
+
if (!task.archived && effectiveState(task) !== "running") {
|
|
1950
|
+
items.push({
|
|
1951
|
+
label: "Start",
|
|
1952
|
+
action: () => call("POST", `/api/tasks/${task.id}/start`),
|
|
1953
|
+
});
|
|
1954
|
+
} else if (effectiveState(task) === "running") {
|
|
1955
|
+
items.push({
|
|
1956
|
+
label: "Abort",
|
|
1957
|
+
danger: true,
|
|
1958
|
+
action: () => call("POST", `/api/tasks/${task.id}/abort`)
|
|
1959
|
+
.then(() => toast(`Aborted "${task.title}"`))
|
|
1960
|
+
.catch((err) => toast(`Abort failed: ${err.message}`, true)),
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (task.archived) {
|
|
1965
|
+
items.push({
|
|
1966
|
+
label: "Restore",
|
|
1967
|
+
action: () => call("POST", `/api/tasks/${task.id}/restore`)
|
|
1968
|
+
.then(() => toast(`Restored "${task.title}"`))
|
|
1969
|
+
.catch((err) => toast(`Restore failed: ${err.message}`, true)),
|
|
1970
|
+
});
|
|
1971
|
+
} else {
|
|
1972
|
+
items.push({
|
|
1973
|
+
label: "Archive",
|
|
1974
|
+
action: () => archiveWithUndo(task),
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
items.push({ kind: "divider" });
|
|
1979
|
+
|
|
1980
|
+
items.push({
|
|
1981
|
+
label: `Copy task ID (${task.id})`,
|
|
1982
|
+
action: () => copyToClipboard(task.id, "Task ID"),
|
|
1983
|
+
});
|
|
1984
|
+
items.push({
|
|
1985
|
+
label: "Copy markdown link",
|
|
1986
|
+
action: () =>
|
|
1987
|
+
copyToClipboard(`[${task.title || task.id}](.ok/tasks/${task.id}/task.mdx)`, "Markdown link"),
|
|
1988
|
+
});
|
|
1989
|
+
items.push({
|
|
1990
|
+
label: "Copy as kanban URL",
|
|
1991
|
+
action: () => {
|
|
1992
|
+
const url = new URL(window.location.href);
|
|
1993
|
+
url.hash = `#tab=tasks&taskId=${task.id}`;
|
|
1994
|
+
copyToClipboard(url.toString(), "URL");
|
|
1995
|
+
},
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
items.push({ kind: "divider" });
|
|
1999
|
+
|
|
2000
|
+
items.push({
|
|
2001
|
+
label: "Delete",
|
|
2002
|
+
danger: true,
|
|
2003
|
+
action: () => deleteWithUndo(task),
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
// Render into the existing #action-menu host.
|
|
2007
|
+
renderMenu(items);
|
|
2008
|
+
|
|
2009
|
+
// Position at cursor with viewport clamping. Position with visibility
|
|
2010
|
+
// hidden so the user doesn't see a flicker at (0,0) before we've
|
|
2011
|
+
// measured. Then make visible.
|
|
2012
|
+
menu.style.visibility = "hidden";
|
|
2013
|
+
menu.hidden = false;
|
|
2014
|
+
// Force a layout so we can read offsetWidth.
|
|
2015
|
+
const w = menu.offsetWidth || 220;
|
|
2016
|
+
const h = menu.offsetHeight || 280;
|
|
2017
|
+
const padding = 8;
|
|
2018
|
+
let x = e.clientX;
|
|
2019
|
+
let y = e.clientY;
|
|
2020
|
+
if (x + w + padding > vw) x = Math.max(padding, vw - w - padding);
|
|
2021
|
+
if (y + h + padding > vh) y = Math.max(padding, vh - h - padding);
|
|
2022
|
+
menu.style.left = `${Math.max(padding, x)}px`;
|
|
2023
|
+
menu.style.top = `${Math.max(padding, y)}px`;
|
|
2024
|
+
menu.style.visibility = "";
|
|
2025
|
+
menu.dataset.contextFor = task.id;
|
|
2026
|
+
|
|
2027
|
+
// Dismiss: outside mousedown + Escape. The mousedown listener fires at
|
|
2028
|
+
// capture phase and uses a 50ms delay so the click that fired our
|
|
2029
|
+
// action's handler doesn't immediately re-dismiss the menu before the
|
|
2030
|
+
// action runs. Scroll/blur dismissal dropped (the per-card menu pops up
|
|
2031
|
+
// inside the board area, so scroll events are noisy without value).
|
|
2032
|
+
setTimeout(() => {
|
|
2033
|
+
document.addEventListener("mousedown", dismissOnOutsideClick, true);
|
|
2034
|
+
document.addEventListener("keydown", dismissOnEscape, true);
|
|
2035
|
+
}, 50);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function renderMenu(items) {
|
|
2039
|
+
if (!menu) return;
|
|
2040
|
+
menu.innerHTML = "";
|
|
2041
|
+
menu.classList.remove("with-submenu");
|
|
2042
|
+
for (const it of items) {
|
|
2043
|
+
if (it.kind === "divider") {
|
|
2044
|
+
const d = document.createElement("div");
|
|
2045
|
+
d.className = "menu-divider";
|
|
2046
|
+
d.setAttribute("role", "separator");
|
|
2047
|
+
menu.append(d);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
// Submenus were removed (M-bug-fix): they were buggy under hover and
|
|
2051
|
+
// obscured the destination before the click could register. Callers
|
|
2052
|
+
// that used to push `{ kind: "submenu", submenu: [...] }` should now
|
|
2053
|
+
// push one item per destination. We silently flatten here as a
|
|
2054
|
+
// back-compat safety net.
|
|
2055
|
+
if (it.kind === "submenu" && Array.isArray(it.submenu)) {
|
|
2056
|
+
for (const sub of it.submenu) menu.append(renderMenuItem(sub));
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
menu.append(renderMenuItem(it));
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function renderMenuItem(it) {
|
|
2064
|
+
const btn = document.createElement("button");
|
|
2065
|
+
btn.setAttribute("role", "menuitem");
|
|
2066
|
+
btn.tabIndex = 0;
|
|
2067
|
+
btn.textContent = it.label;
|
|
2068
|
+
if (it.danger) btn.classList.add("danger");
|
|
2069
|
+
if (it.disabled) {
|
|
2070
|
+
btn.disabled = true;
|
|
2071
|
+
btn.classList.add("disabled");
|
|
2072
|
+
return btn;
|
|
2073
|
+
}
|
|
2074
|
+
btn.addEventListener("click", (ev) => {
|
|
2075
|
+
// First-line visibility — confirms the click handler actually fires.
|
|
2076
|
+
// Useful for diagnosing why an action "did nothing".
|
|
2077
|
+
if (typeof console !== "undefined") console.debug("[openkan] menu click:", it.label, "action:", typeof it.action);
|
|
2078
|
+
ev.stopPropagation();
|
|
2079
|
+
hideMenu();
|
|
2080
|
+
// Defer the action one microtask so the menu's dismissal listeners
|
|
2081
|
+
// run first; helps with the rare race where the same click closes
|
|
2082
|
+
// the menu but the action also depends on the menu being hidden.
|
|
2083
|
+
Promise.resolve().then(() => {
|
|
2084
|
+
try {
|
|
2085
|
+
if (typeof it.action === "function") it.action();
|
|
2086
|
+
else if (typeof console !== "undefined") console.warn("[openkan] menu item has no action:", it.label);
|
|
2087
|
+
} catch (err) {
|
|
2088
|
+
if (typeof console !== "undefined") console.error("[openkan] action threw:", err);
|
|
2089
|
+
toast(`Action failed: ${err.message || err}`, true);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
});
|
|
2093
|
+
btn.addEventListener("keydown", (e) => {
|
|
2094
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
2095
|
+
e.preventDefault();
|
|
2096
|
+
hideMenu();
|
|
2097
|
+
if (typeof it.action === "function") it.action();
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
return btn;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function hideMenu() {
|
|
2104
|
+
if (!menu) return;
|
|
2105
|
+
menu.hidden = true;
|
|
2106
|
+
menu.removeAttribute("data-context-for");
|
|
2107
|
+
document.removeEventListener("mousedown", dismissOnOutsideClick, true);
|
|
2108
|
+
document.removeEventListener("keydown", dismissOnEscape, true);
|
|
2109
|
+
// Scroll/blur listeners may not be attached (we dropped them in the
|
|
2110
|
+
// simplification); removeEventListener on absent listeners is a no-op.
|
|
2111
|
+
window.removeEventListener("scroll", dismissOnScroll, true);
|
|
2112
|
+
window.removeEventListener("blur", dismissOnBlur, true);
|
|
2113
|
+
}
|
|
2114
|
+
function dismissOnOutsideClick(e) {
|
|
2115
|
+
if (!menu || menu.hidden) return;
|
|
2116
|
+
if (menu.contains(e.target)) return;
|
|
2117
|
+
hideMenu();
|
|
2118
|
+
}
|
|
2119
|
+
function dismissOnEscape(e) {
|
|
2120
|
+
if (e.key !== "Escape") return;
|
|
2121
|
+
if (menu && !menu.hidden) {
|
|
2122
|
+
e.preventDefault();
|
|
2123
|
+
e.stopPropagation();
|
|
2124
|
+
hideMenu();
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
function dismissOnScroll() { hideMenu(); }
|
|
2128
|
+
function dismissOnBlur() { hideMenu(); }
|
|
2129
|
+
|
|
2130
|
+
// Helpers used by context menu
|
|
2131
|
+
function countInColumn(column) {
|
|
2132
|
+
let n = 0;
|
|
2133
|
+
for (const t of tasks.values()) if (t.column === column) n++;
|
|
2134
|
+
return n;
|
|
2135
|
+
}
|
|
2136
|
+
function selectAllInColumn(column) {
|
|
2137
|
+
let added = 0;
|
|
2138
|
+
for (const t of tasks.values()) {
|
|
2139
|
+
if (t.column === column && !selectedIds.has(t.id)) {
|
|
2140
|
+
selectedIds.add(t.id);
|
|
2141
|
+
added++;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (added > 0) renderBoard();
|
|
2145
|
+
return added;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Optimistically archive and offer an Undo toast. The optimistic update
|
|
2149
|
+
// matches what `data-card-archived` styles expect; the toast handler can
|
|
2150
|
+
// PATCH {archived: false} on click to reverse within the 5s window.
|
|
2151
|
+
function archiveWithUndo(task) {
|
|
2152
|
+
if (!task) return;
|
|
2153
|
+
// Snapshot for rollback if Undo isn't clicked and the server returns a
|
|
2154
|
+
// failure (rare).
|
|
2155
|
+
const snapshot = { archived: !!task.archived };
|
|
2156
|
+
task.archived = true;
|
|
2157
|
+
renderBoard();
|
|
2158
|
+
// Fire-and-forget; show a toast with an Undo button regardless.
|
|
2159
|
+
api("POST", `/api/tasks/${task.id}/archive`).then(
|
|
2160
|
+
() => {},
|
|
2161
|
+
(err) => {
|
|
2162
|
+
task.archived = snapshot.archived;
|
|
2163
|
+
renderBoard();
|
|
2164
|
+
toast(`Archive failed: ${err.message}`, true);
|
|
2165
|
+
},
|
|
2166
|
+
);
|
|
2167
|
+
showUndoToast(`Archived "${task.title}".`, () => {
|
|
2168
|
+
task.archived = false;
|
|
2169
|
+
renderBoard();
|
|
2170
|
+
api("POST", `/api/tasks/${task.id}/restore`)
|
|
2171
|
+
.then(() => toast(`Restored "${task.title}"`))
|
|
2172
|
+
.catch((err) => {
|
|
2173
|
+
task.archived = true;
|
|
2174
|
+
renderBoard();
|
|
2175
|
+
toast(`Restore failed: ${err.message}`, true);
|
|
2176
|
+
});
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function deleteWithUndo(task) {
|
|
2181
|
+
if (!task) return;
|
|
2182
|
+
const snapshot = { ...task };
|
|
2183
|
+
tasks.delete(task.id);
|
|
2184
|
+
selectedIds.delete(task.id);
|
|
2185
|
+
renderBoard();
|
|
2186
|
+
// Fire-and-forget DELETE so the server actually forgets the task.
|
|
2187
|
+
// Without this the next board snapshot re-adds it and the UI looks
|
|
2188
|
+
// like the menu action no-op'd. Mirror archiveWithUndo's error path:
|
|
2189
|
+
// rollback the optimistic local remove and surface a toast.
|
|
2190
|
+
api("DELETE", `/api/tasks/${task.id}`).then(
|
|
2191
|
+
() => {},
|
|
2192
|
+
(err) => {
|
|
2193
|
+
tasks.set(snapshot.id, snapshot);
|
|
2194
|
+
renderBoard();
|
|
2195
|
+
toast(`Delete failed: ${err.message}`, true);
|
|
2196
|
+
},
|
|
2197
|
+
);
|
|
2198
|
+
showUndoToast(`Deleted "${task.title}".`, () => {
|
|
2199
|
+
// Recreate: there's no /api/tasks/recreate endpoint, so we use the
|
|
2200
|
+
// POST /api/tasks route with the original fields. Strip server-side
|
|
2201
|
+
// bookkeeping (order, createdAt, updatedAt, etc.).
|
|
2202
|
+
const { id, createdAt, updatedAt, lastActivity, ...rest } = snapshot;
|
|
2203
|
+
tasks.set(snapshot.id, snapshot);
|
|
2204
|
+
renderBoard();
|
|
2205
|
+
api("POST", "/api/tasks", rest)
|
|
2206
|
+
.then((created) => {
|
|
2207
|
+
if (created && created.id) {
|
|
2208
|
+
tasks.set(created.id, created);
|
|
2209
|
+
tasks.delete(snapshot.id);
|
|
2210
|
+
renderBoard();
|
|
2211
|
+
toast(`Restored "${snapshot.title}"`);
|
|
2212
|
+
}
|
|
2213
|
+
})
|
|
2214
|
+
.catch((err) => {
|
|
2215
|
+
tasks.delete(snapshot.id);
|
|
2216
|
+
renderBoard();
|
|
2217
|
+
toast(`Restore failed: ${err.message}`, true);
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// Render a toast with an inline "Undo" button. The Undo callback fires if
|
|
2223
|
+
// clicked within `ms` (default 5000). After the window expires, the toast
|
|
2224
|
+
// dismisses normally. Implemented as a regular toast with a button child
|
|
2225
|
+
// so it inherits the existing toast styling.
|
|
2226
|
+
function showUndoToast(message, onUndo, ms = 5000) {
|
|
2227
|
+
const host = document.getElementById("toast-container");
|
|
2228
|
+
if (!host) {
|
|
2229
|
+
console.info("[openkan]", message);
|
|
2230
|
+
if (typeof onUndo === "function") { /* best-effort: fire immediately */ }
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const t = document.createElement("div");
|
|
2234
|
+
t.className = "toast toast-undo";
|
|
2235
|
+
const text = document.createElement("span");
|
|
2236
|
+
text.className = "toast-message";
|
|
2237
|
+
text.textContent = `${message} `;
|
|
2238
|
+
t.append(text);
|
|
2239
|
+
const btn = document.createElement("button");
|
|
2240
|
+
btn.type = "button";
|
|
2241
|
+
btn.className = "toast-undo-btn";
|
|
2242
|
+
btn.textContent = "Undo →";
|
|
2243
|
+
let fired = false;
|
|
2244
|
+
const trigger = () => {
|
|
2245
|
+
if (fired) return;
|
|
2246
|
+
fired = true;
|
|
2247
|
+
try { onUndo && onUndo(); } catch (_) {}
|
|
2248
|
+
t.remove();
|
|
2249
|
+
};
|
|
2250
|
+
btn.addEventListener("click", trigger);
|
|
2251
|
+
t.append(btn);
|
|
2252
|
+
host.append(t);
|
|
2253
|
+
setTimeout(() => {
|
|
2254
|
+
if (!fired) t.classList.add("toast-leaving");
|
|
2255
|
+
setTimeout(() => t.remove(), 220);
|
|
2256
|
+
}, ms);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// ─── Clipboard helper (fallback for non-secure contexts) ─────────────────
|
|
2260
|
+
function copyToClipboard(text, label = "Copied") {
|
|
2261
|
+
if (navigator.clipboard?.writeText) {
|
|
2262
|
+
navigator.clipboard.writeText(text).then(
|
|
2263
|
+
() => toast(`${label} copied to clipboard`),
|
|
2264
|
+
() => fallbackCopy(text, label),
|
|
2265
|
+
);
|
|
2266
|
+
} else {
|
|
2267
|
+
fallbackCopy(text, label);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
function fallbackCopy(text, label) {
|
|
2271
|
+
const ta = document.createElement("textarea");
|
|
2272
|
+
ta.value = text;
|
|
2273
|
+
ta.style.cssPosition = "fixed";
|
|
2274
|
+
ta.style.left = "-9999px";
|
|
2275
|
+
document.body.append(ta);
|
|
2276
|
+
ta.select();
|
|
2277
|
+
try {
|
|
2278
|
+
document.execCommand("copy");
|
|
2279
|
+
toast(`${label} copied (fallback)`);
|
|
2280
|
+
} catch (_) {
|
|
2281
|
+
toast(`Could not copy. Value: ${text.slice(0, 80)}`, true);
|
|
2282
|
+
} finally {
|
|
2283
|
+
document.body.removeChild(ta);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
function toast(message, isError) {
|
|
2287
|
+
let host = document.getElementById("toast-container");
|
|
2288
|
+
if (!host) {
|
|
2289
|
+
host = document.createElement("div");
|
|
2290
|
+
host.id = "toast-container";
|
|
2291
|
+
host.className = "toast-container";
|
|
2292
|
+
document.body.append(host);
|
|
2293
|
+
}
|
|
2294
|
+
const el = document.createElement("div");
|
|
2295
|
+
el.className = "toast" + (isError ? " toast-error" : "");
|
|
2296
|
+
el.textContent = message;
|
|
2297
|
+
host.append(el);
|
|
2298
|
+
setTimeout(() => { el.classList.add("toast-leaving"); }, 1700);
|
|
2299
|
+
setTimeout(() => { el.remove(); }, 2100);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function labelForColumn(id) {
|
|
2303
|
+
return COLUMNS.find((c) => c.id === id)?.title || id;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// ---------- Drag-and-drop v1.1 ----------
|
|
2307
|
+
// State that lives only during an active drag.
|
|
2308
|
+
const dragState = {
|
|
2309
|
+
ghost: null,
|
|
2310
|
+
draggedIds: [],
|
|
2311
|
+
activeColumn: null,
|
|
2312
|
+
indicator: null,
|
|
2313
|
+
indicatorBody: null,
|
|
2314
|
+
indicatorIndex: null,
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
function buildGhost(card, ids) {
|
|
2318
|
+
const rect = card.getBoundingClientRect();
|
|
2319
|
+
const ghost = el("div", "ghost-card");
|
|
2320
|
+
ghost.style.left = `${rect.left}px`;
|
|
2321
|
+
ghost.style.top = `${rect.top}px`;
|
|
2322
|
+
ghost.style.setProperty("--ghost-width", `${rect.width}px`);
|
|
2323
|
+
ghost.append(el("div", "ghost-card-title", { text: card.querySelector(".card-title")?.textContent || "(untitled)" }));
|
|
2324
|
+
if (ids.length > 1) {
|
|
2325
|
+
ghost.append(el("div", "ghost-card-count", { text: `${ids.length} cards` }));
|
|
2326
|
+
}
|
|
2327
|
+
return ghost;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function positionGhost(clientX, clientY) {
|
|
2331
|
+
if (!dragState.ghost) return;
|
|
2332
|
+
const w = dragState.ghost.offsetWidth || 260;
|
|
2333
|
+
dragState.ghost.style.transform = `translate(${clientX - 20}px, ${clientY - 20}px) rotate(2deg)`;
|
|
2334
|
+
// We use position: fixed; setting top/left directly keeps it simple.
|
|
2335
|
+
dragState.ghost.style.left = `${clientX - 20}px`;
|
|
2336
|
+
dragState.ghost.style.top = `${clientY - 20}px`;
|
|
2337
|
+
void w;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function teardownDragVisuals() {
|
|
2341
|
+
if (dragState.ghost) {
|
|
2342
|
+
dragState.ghost.remove();
|
|
2343
|
+
dragState.ghost = null;
|
|
2344
|
+
}
|
|
2345
|
+
dragState.draggedIds = [];
|
|
2346
|
+
dragState.activeColumn = null;
|
|
2347
|
+
clearDropIndicator();
|
|
2348
|
+
delete window.OpenKanActiveTaskDrag;
|
|
2349
|
+
document.querySelectorAll(".column.drag-over").forEach((c) => c.classList.remove("drag-over"));
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function clearDropIndicator() {
|
|
2353
|
+
dragState.indicator?.remove();
|
|
2354
|
+
dragState.indicator = null;
|
|
2355
|
+
dragState.indicatorBody = null;
|
|
2356
|
+
dragState.indicatorIndex = null;
|
|
2357
|
+
// Defensive cleanup for an indicator orphaned by an external board render.
|
|
2358
|
+
document.querySelectorAll(".drop-indicator").forEach((indicator) => indicator.remove());
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function attachDnD(column) {
|
|
2362
|
+
const body = column.querySelector(".column-body");
|
|
2363
|
+
column.addEventListener("dragover", (e) => {
|
|
2364
|
+
e.preventDefault();
|
|
2365
|
+
e.dataTransfer.dropEffect = "move";
|
|
2366
|
+
if (dragState.activeColumn !== column) {
|
|
2367
|
+
document.querySelectorAll(".column.drag-over").forEach((item) => item.classList.remove("drag-over"));
|
|
2368
|
+
clearDropIndicator();
|
|
2369
|
+
dragState.activeColumn = column;
|
|
2370
|
+
}
|
|
2371
|
+
column.classList.add("drag-over");
|
|
2372
|
+
// Position the ghost to follow the cursor — dragover fires even when
|
|
2373
|
+
// dataTransfer is dragging from the same window.
|
|
2374
|
+
positionGhost(e.clientX, e.clientY);
|
|
2375
|
+
// Insertion indicator
|
|
2376
|
+
const idx = dropIndex(body, e.clientY);
|
|
2377
|
+
showDropIndicator(body, idx);
|
|
2378
|
+
});
|
|
2379
|
+
column.addEventListener("dragleave", (e) => {
|
|
2380
|
+
if (!column.contains(e.relatedTarget)) {
|
|
2381
|
+
column.classList.remove("drag-over");
|
|
2382
|
+
if (dragState.activeColumn === column) {
|
|
2383
|
+
clearDropIndicator();
|
|
2384
|
+
dragState.activeColumn = null;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
column.addEventListener("drop", async (e) => {
|
|
2389
|
+
e.preventDefault();
|
|
2390
|
+
column.classList.remove("drag-over");
|
|
2391
|
+
const idList = (e.dataTransfer.getData("text/plain") || "").split(",").filter(Boolean);
|
|
2392
|
+
if (idList.length === 0) {
|
|
2393
|
+
teardownDragVisuals();
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const order = dropIndex(body, e.clientY, idList[0]);
|
|
2397
|
+
teardownDragVisuals();
|
|
2398
|
+
if (idList.length > 1) {
|
|
2399
|
+
await moveTasks(idList, body.dataset.column, order);
|
|
2400
|
+
} else {
|
|
2401
|
+
await moveTask(idList[0], body.dataset.column, order);
|
|
2402
|
+
}
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// Doc-level ghost positioning using dragover with capture phase. The plain
|
|
2407
|
+
// `drag` event only fires on the source element; capture-phase dragover
|
|
2408
|
+
// fires on every mouse move during an active drag, even when the cursor
|
|
2409
|
+
// is outside any column or even outside the board.
|
|
2410
|
+
document.addEventListener("dragover", (e) => {
|
|
2411
|
+
if (dragState.ghost && e.clientX !== 0) positionGhost(e.clientX, e.clientY);
|
|
2412
|
+
}, true);
|
|
2413
|
+
// Safety net — clean up if the user drops somewhere invalid.
|
|
2414
|
+
document.addEventListener("dragend", teardownDragVisuals);
|
|
2415
|
+
|
|
2416
|
+
function showDropIndicator(body, idx) {
|
|
2417
|
+
// Dragover fires for every pointer movement. Keep the existing node when
|
|
2418
|
+
// its column and insertion index are unchanged so the placement line does
|
|
2419
|
+
// not re-mount, restart its arrival animation, or flicker in place.
|
|
2420
|
+
if (dragState.indicatorBody === body && dragState.indicatorIndex === idx && dragState.indicator?.isConnected) return;
|
|
2421
|
+
clearDropIndicator();
|
|
2422
|
+
const indicator = el("div", "drop-indicator");
|
|
2423
|
+
const cards = [...body.querySelectorAll(".card:not(.dragging):not(.selected)")];
|
|
2424
|
+
if (idx >= cards.length) {
|
|
2425
|
+
body.append(indicator);
|
|
2426
|
+
} else {
|
|
2427
|
+
body.insertBefore(indicator, cards[idx]);
|
|
2428
|
+
}
|
|
2429
|
+
dragState.indicator = indicator;
|
|
2430
|
+
dragState.indicatorBody = body;
|
|
2431
|
+
dragState.indicatorIndex = idx;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function dropIndex(body, y, draggingId) {
|
|
2435
|
+
const cards = [...body.querySelectorAll(".card:not(.dragging):not(.selected)")];
|
|
2436
|
+
for (let i = 0; i < cards.length; i++) {
|
|
2437
|
+
const r = cards[i].getBoundingClientRect();
|
|
2438
|
+
if (y < r.top + r.height / 2) return i;
|
|
2439
|
+
}
|
|
2440
|
+
return cards.length;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
async function moveTask(id, column, order) {
|
|
2444
|
+
const t = tasks.get(id);
|
|
2445
|
+
if (!t) return;
|
|
2446
|
+
const snap = { column: t.column, order: t.order };
|
|
2447
|
+
Object.assign(t, { column, order });
|
|
2448
|
+
renderBoard();
|
|
2449
|
+
try {
|
|
2450
|
+
await api("PATCH", `/api/tasks/${id}`, { column, order });
|
|
2451
|
+
} catch (e) {
|
|
2452
|
+
Object.assign(t, snap);
|
|
2453
|
+
renderBoard();
|
|
2454
|
+
flashInvalidDrop(t.column || column);
|
|
2455
|
+
alert(`Move failed: ${e.message}`);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
async function moveTasks(ids, column, order) {
|
|
2460
|
+
// Optimistic move: place all into the destination column at consecutive
|
|
2461
|
+
// orders starting at `order`. We snap each so we can roll them back on error.
|
|
2462
|
+
const snaps = [];
|
|
2463
|
+
for (const id of ids) {
|
|
2464
|
+
const t = tasks.get(id);
|
|
2465
|
+
if (!t) continue;
|
|
2466
|
+
snaps.push({ id, snap: { column: t.column, order: t.order } });
|
|
2467
|
+
Object.assign(t, { column });
|
|
2468
|
+
}
|
|
2469
|
+
// Re-sort orders within the destination column.
|
|
2470
|
+
const colTasks = [...tasks.values()].filter((t) => t.column === column).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
2471
|
+
// Find the insertion point — it's the first non-dragged card at position `order`.
|
|
2472
|
+
const draggedSet = new Set(ids);
|
|
2473
|
+
const nonDragged = colTasks.filter((t) => !draggedSet.has(t.id));
|
|
2474
|
+
const insertAt = Math.max(0, Math.min(order, nonDragged.length));
|
|
2475
|
+
const before = nonDragged.slice(0, insertAt);
|
|
2476
|
+
const after = nonDragged.slice(insertAt);
|
|
2477
|
+
const finalOrder = [...before, ...colTasks.filter((t) => draggedSet.has(t.id)), ...after];
|
|
2478
|
+
finalOrder.forEach((t, i) => { t.order = i; });
|
|
2479
|
+
renderBoard();
|
|
2480
|
+
try {
|
|
2481
|
+
// Send PATCHes sequentially — server re-normalizes each.
|
|
2482
|
+
for (const id of ids) {
|
|
2483
|
+
const t = tasks.get(id);
|
|
2484
|
+
if (!t) continue;
|
|
2485
|
+
await api("PATCH", `/api/tasks/${id}`, { column, order: t.order });
|
|
2486
|
+
}
|
|
2487
|
+
} catch (e) {
|
|
2488
|
+
for (const { id, snap } of snaps) {
|
|
2489
|
+
const t = tasks.get(id);
|
|
2490
|
+
if (t) Object.assign(t, snap);
|
|
2491
|
+
}
|
|
2492
|
+
renderBoard();
|
|
2493
|
+
flashInvalidDrop(column);
|
|
2494
|
+
alert(`Move failed: ${e.message}`);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function flashInvalidDrop(columnId) {
|
|
2499
|
+
const col = document.querySelector(`.column[data-column="${columnId}"]`);
|
|
2500
|
+
if (!col) return;
|
|
2501
|
+
col.classList.add("drag-invalid");
|
|
2502
|
+
setTimeout(() => col.classList.remove("drag-invalid"), 250);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// ---------- Bulk action bar (selection mode) ----------
|
|
2506
|
+
// The bar is hidden by default and slides up from the bottom when ≥ 1 card
|
|
2507
|
+
// is selected via Ctrl/Cmd-click. Actions POST /api/tasks/bulk with the
|
|
2508
|
+
// appropriate `operation.kind`; on success, selection clears and a toast
|
|
2509
|
+
// confirms the result.
|
|
2510
|
+
|
|
2511
|
+
const bulkBar = document.getElementById("bulk-bar");
|
|
2512
|
+
const bulkCount = document.getElementById("bulk-bar-count");
|
|
2513
|
+
|
|
2514
|
+
function updateBulkBar() {
|
|
2515
|
+
if (!bulkBar) return;
|
|
2516
|
+
if (selectedIds.size === 0) {
|
|
2517
|
+
bulkBar.hidden = true;
|
|
2518
|
+
bulkBar.setAttribute("aria-hidden", "true");
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
bulkBar.hidden = false;
|
|
2522
|
+
bulkBar.setAttribute("aria-hidden", "false");
|
|
2523
|
+
if (bulkCount) {
|
|
2524
|
+
const n = selectedIds.size;
|
|
2525
|
+
const total = tasks.size;
|
|
2526
|
+
bulkCount.textContent = `${n} of ${total} selected`;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function closeBulkMenus() {
|
|
2531
|
+
for (const menu of document.querySelectorAll(".bulk-bar-menu")) menu.hidden = true;
|
|
2532
|
+
for (const btn of document.querySelectorAll(".bulk-bar-dropdown > .bulk-bar-btn")) {
|
|
2533
|
+
btn.setAttribute("aria-expanded", "false");
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function toggleBulkMenu(menu, btn) {
|
|
2538
|
+
const wasOpen = !menu.hidden;
|
|
2539
|
+
closeBulkMenus();
|
|
2540
|
+
if (!wasOpen) {
|
|
2541
|
+
menu.hidden = false;
|
|
2542
|
+
btn.setAttribute("aria-expanded", "true");
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Wire the dropdown triggers + menu items.
|
|
2547
|
+
function attachBulkBar() {
|
|
2548
|
+
if (!bulkBar) return;
|
|
2549
|
+
const moveBtn = document.getElementById("bulk-move-btn");
|
|
2550
|
+
const moveMenu = document.getElementById("bulk-move-menu");
|
|
2551
|
+
const prioBtn = document.getElementById("bulk-priority-btn");
|
|
2552
|
+
const prioMenu = document.getElementById("bulk-priority-menu");
|
|
2553
|
+
|
|
2554
|
+
if (moveBtn && moveMenu) {
|
|
2555
|
+
moveBtn.addEventListener("click", (e) => {
|
|
2556
|
+
e.stopPropagation();
|
|
2557
|
+
toggleBulkMenu(moveMenu, moveBtn);
|
|
2558
|
+
});
|
|
2559
|
+
for (const item of moveMenu.querySelectorAll("button[data-column]")) {
|
|
2560
|
+
item.addEventListener("click", async () => {
|
|
2561
|
+
const col = item.getAttribute("data-column");
|
|
2562
|
+
closeBulkMenus();
|
|
2563
|
+
if (!col) return;
|
|
2564
|
+
await runBulkOperation({ kind: "move", taskIds: [...selectedIds], column: col });
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
if (prioBtn && prioMenu) {
|
|
2569
|
+
prioBtn.addEventListener("click", (e) => {
|
|
2570
|
+
e.stopPropagation();
|
|
2571
|
+
toggleBulkMenu(prioMenu, prioBtn);
|
|
2572
|
+
});
|
|
2573
|
+
for (const item of prioMenu.querySelectorAll("button[data-priority]")) {
|
|
2574
|
+
item.addEventListener("click", async () => {
|
|
2575
|
+
const pri = item.getAttribute("data-priority");
|
|
2576
|
+
closeBulkMenus();
|
|
2577
|
+
if (!pri) return;
|
|
2578
|
+
await runBulkOperation({ kind: "priority", taskIds: [...selectedIds], priority: pri });
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
const archBtn = document.getElementById("bulk-archive-btn");
|
|
2583
|
+
if (archBtn) {
|
|
2584
|
+
archBtn.addEventListener("click", async () => {
|
|
2585
|
+
await runBulkOperation({ kind: "archive", taskIds: [...selectedIds] });
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
const delBtn = document.getElementById("bulk-delete-btn");
|
|
2589
|
+
if (delBtn) {
|
|
2590
|
+
delBtn.addEventListener("click", async () => {
|
|
2591
|
+
if (selectedIds.size === 0) return;
|
|
2592
|
+
// Snapshot every selected task so we can Undo within the toast
|
|
2593
|
+
// window. Use undo toast instead of a confirm dialog (which was
|
|
2594
|
+
// reliably either ignored or auto-dismissed by browser focus quirks).
|
|
2595
|
+
const ids = [...selectedIds];
|
|
2596
|
+
const snaps = new Map();
|
|
2597
|
+
for (const id of ids) {
|
|
2598
|
+
const t = tasks.get(id);
|
|
2599
|
+
if (t) snaps.set(id, { ...t });
|
|
2600
|
+
}
|
|
2601
|
+
await runBulkOperation({ kind: "delete", taskIds: ids });
|
|
2602
|
+
showUndoToast(`Deleted ${ids.length} task${ids.length === 1 ? "" : "s"}.`, () => {
|
|
2603
|
+
for (const [id, snap] of snaps) tasks.set(id, snap);
|
|
2604
|
+
renderBoard();
|
|
2605
|
+
showUndoToast(`Restored ${snaps.size} task${snaps.size === 1 ? "" : "s"}.`, () => {
|
|
2606
|
+
runBulkOperation({ kind: "delete", taskIds: ids });
|
|
2607
|
+
});
|
|
2608
|
+
});
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
const clearBtn = document.getElementById("bulk-clear-btn");
|
|
2612
|
+
if (clearBtn) {
|
|
2613
|
+
clearBtn.addEventListener("click", clearCardSelection);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// Click-outside closes any open dropdown menu.
|
|
2617
|
+
document.addEventListener("mousedown", (e) => {
|
|
2618
|
+
if (!e.target.closest(".bulk-bar-dropdown")) closeBulkMenus();
|
|
2619
|
+
}, true);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// Send a bulk operation. Falls back to per-task PATCH/DELETE if the bulk
|
|
2623
|
+
// endpoint is not implemented yet (404) — the brief assumes Thor is adding
|
|
2624
|
+
// it; this keeps the UI functional during the rollout.
|
|
2625
|
+
async function runBulkOperation(operation) {
|
|
2626
|
+
const ids = operation.taskIds || [];
|
|
2627
|
+
if (ids.length === 0) return;
|
|
2628
|
+
const snapshot = new Map();
|
|
2629
|
+
for (const id of ids) {
|
|
2630
|
+
const t = tasks.get(id);
|
|
2631
|
+
if (t) snapshot.set(id, { ...t });
|
|
2632
|
+
}
|
|
2633
|
+
// Optimistic local update so the UI reacts immediately.
|
|
2634
|
+
if (operation.kind === "move") {
|
|
2635
|
+
for (const id of ids) {
|
|
2636
|
+
const t = tasks.get(id);
|
|
2637
|
+
if (t) t.column = operation.column;
|
|
2638
|
+
}
|
|
2639
|
+
} else if (operation.kind === "priority") {
|
|
2640
|
+
for (const id of ids) {
|
|
2641
|
+
const t = tasks.get(id);
|
|
2642
|
+
if (t) t.priority = operation.priority;
|
|
2643
|
+
}
|
|
2644
|
+
} else if (operation.kind === "archive") {
|
|
2645
|
+
for (const id of ids) {
|
|
2646
|
+
const t = tasks.get(id);
|
|
2647
|
+
if (t) t.archived = true;
|
|
2648
|
+
}
|
|
2649
|
+
} else if (operation.kind === "delete") {
|
|
2650
|
+
for (const id of ids) tasks.delete(id);
|
|
2651
|
+
}
|
|
2652
|
+
renderBoard();
|
|
2653
|
+
clearCardSelection();
|
|
2654
|
+
|
|
2655
|
+
const summary = describeBulkOp(operation);
|
|
2656
|
+
let usedFallback = false;
|
|
2657
|
+
try {
|
|
2658
|
+
await api("POST", "/api/tasks/bulk", { operation });
|
|
2659
|
+
} catch (err) {
|
|
2660
|
+
// Fallback path — server endpoint not implemented yet. Walk the ids.
|
|
2661
|
+
usedFallback = true;
|
|
2662
|
+
try {
|
|
2663
|
+
if (operation.kind === "move" || operation.kind === "priority") {
|
|
2664
|
+
for (const id of ids) {
|
|
2665
|
+
const body = operation.kind === "move"
|
|
2666
|
+
? { column: operation.column }
|
|
2667
|
+
: { priority: operation.priority };
|
|
2668
|
+
await api("PATCH", `/api/tasks/${id}`, body);
|
|
2669
|
+
}
|
|
2670
|
+
} else if (operation.kind === "archive") {
|
|
2671
|
+
for (const id of ids) {
|
|
2672
|
+
await api("POST", `/api/tasks/${id}/archive`);
|
|
2673
|
+
}
|
|
2674
|
+
} else if (operation.kind === "delete") {
|
|
2675
|
+
for (const id of ids) {
|
|
2676
|
+
await api("DELETE", `/api/tasks/${id}`);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
} catch (fallbackErr) {
|
|
2680
|
+
// Roll back optimistic changes and re-render.
|
|
2681
|
+
for (const [id, snap] of snapshot) tasks.set(id, snap);
|
|
2682
|
+
for (const id of ids) if (!snapshot.has(id)) tasks.delete(id);
|
|
2683
|
+
renderBoard();
|
|
2684
|
+
showToast(`${summary} failed: ${fallbackErr.message}`, "error");
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
// Always clear selection after success.
|
|
2689
|
+
clearCardSelection();
|
|
2690
|
+
showToast(`${summary}`, usedFallback ? "success" : "success");
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function describeBulkOp(op) {
|
|
2694
|
+
const n = (op.taskIds || []).length;
|
|
2695
|
+
const noun = n === 1 ? "task" : "tasks";
|
|
2696
|
+
switch (op.kind) {
|
|
2697
|
+
case "move": {
|
|
2698
|
+
const col = (COLUMNS.find((c) => c.id === op.column) || {}).title || op.column;
|
|
2699
|
+
return `Moved ${n} ${noun} to ${col}`;
|
|
2700
|
+
}
|
|
2701
|
+
case "priority": {
|
|
2702
|
+
const label = (PRIORITY_META[op.priority] || {}).label || op.priority;
|
|
2703
|
+
return `Set priority to ${label} on ${n} ${noun}`;
|
|
2704
|
+
}
|
|
2705
|
+
case "archive": return `Archived ${n} ${noun}`;
|
|
2706
|
+
case "delete": return `Deleted ${n} ${noun}`;
|
|
2707
|
+
default: return `Bulk operation on ${n} ${noun}`;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
function showToast(message, kind = "success") {
|
|
2712
|
+
if (window.OpenKanSettings?.showToast) {
|
|
2713
|
+
window.OpenKanSettings.showToast(message, kind);
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
const container = document.getElementById("toast-container");
|
|
2717
|
+
if (!container) return;
|
|
2718
|
+
const toast = el("div", `toast toast-${kind}`, { text: message });
|
|
2719
|
+
container.append(toast);
|
|
2720
|
+
// 4s auto-dismiss (UX spec). Use the .toast-leaving class so the fade +
|
|
2721
|
+
// slide animation matches the entry animation defined in style.css.
|
|
2722
|
+
setTimeout(() => {
|
|
2723
|
+
toast.classList.add("toast-leaving");
|
|
2724
|
+
setTimeout(() => toast.remove(), 220);
|
|
2725
|
+
}, 4000);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// ---------- Search (debounced live filter) ----------
|
|
2729
|
+
function attachSearch() {
|
|
2730
|
+
const input = document.getElementById("search-input");
|
|
2731
|
+
const meta = document.getElementById("search-meta");
|
|
2732
|
+
if (!input) return;
|
|
2733
|
+
|
|
2734
|
+
// Initial value from filter state (restored from URL hash on boot).
|
|
2735
|
+
input.value = filter.search || "";
|
|
2736
|
+
|
|
2737
|
+
input.addEventListener("input", () => {
|
|
2738
|
+
const value = input.value;
|
|
2739
|
+
filter.search = value;
|
|
2740
|
+
writeHashFilter();
|
|
2741
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
2742
|
+
if (!value) {
|
|
2743
|
+
// Empty query — show everything immediately and skip the server call.
|
|
2744
|
+
searchMatchIds = null;
|
|
2745
|
+
renderBoard();
|
|
2746
|
+
updateSearchMeta(meta, null, null);
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
// Optimistic local filter so the board updates instantly while the
|
|
2750
|
+
// debounced server call is in flight.
|
|
2751
|
+
searchMatchIds = null;
|
|
2752
|
+
renderBoard();
|
|
2753
|
+
searchDebounce = setTimeout(() => runSearch(value, meta), 200);
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
// ESC clears the search input when it has focus.
|
|
2757
|
+
input.addEventListener("keydown", (e) => {
|
|
2758
|
+
if (e.key === "Escape" && input.value) {
|
|
2759
|
+
e.preventDefault();
|
|
2760
|
+
e.stopPropagation();
|
|
2761
|
+
input.value = "";
|
|
2762
|
+
filter.search = "";
|
|
2763
|
+
writeHashFilter();
|
|
2764
|
+
searchMatchIds = null;
|
|
2765
|
+
renderBoard();
|
|
2766
|
+
updateSearchMeta(meta, null, null);
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
async function runSearch(q, metaEl) {
|
|
2772
|
+
const mySeq = ++searchSeq;
|
|
2773
|
+
const params = new URLSearchParams();
|
|
2774
|
+
params.set("q", q);
|
|
2775
|
+
if (filter.category && filter.category !== "all") params.set("category", filter.category);
|
|
2776
|
+
if (filter.tags.length > 0) params.set("tags", filter.tags.join(","));
|
|
2777
|
+
if (filter.contributor && filter.contributor !== "all") params.set("contributor", filter.contributor);
|
|
2778
|
+
if (filter.priority && filter.priority !== "all") params.set("priority", filter.priority);
|
|
2779
|
+
if (filter.column && filter.column !== "all") params.set("column", filter.column);
|
|
2780
|
+
if (filter.archive === "archived" || filter.archive === "both") params.set("archived", "true");
|
|
2781
|
+
|
|
2782
|
+
updateSearchMeta(metaEl, "searching", null);
|
|
2783
|
+
|
|
2784
|
+
let payload;
|
|
2785
|
+
try {
|
|
2786
|
+
payload = await api("GET", `/api/search?${params.toString()}`);
|
|
2787
|
+
} catch (err) {
|
|
2788
|
+
// Endpoint not implemented yet — fall back to local-only filtering.
|
|
2789
|
+
if (mySeq !== searchSeq) return;
|
|
2790
|
+
const matched = [...tasks.values()].filter((t) => taskTextMatchesQuery(t, q.toLowerCase())).length;
|
|
2791
|
+
searchMatchIds = null;
|
|
2792
|
+
renderBoard();
|
|
2793
|
+
updateSearchMeta(metaEl, "fallback", matched);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (mySeq !== searchSeq) return; // a newer query has superseded this one
|
|
2797
|
+
const list = Array.isArray(payload?.results) ? payload.results : Array.isArray(payload) ? payload : [];
|
|
2798
|
+
const matchedIds = new Set(list.map((t) => t.id));
|
|
2799
|
+
searchMatchIds = matchedIds;
|
|
2800
|
+
const total = typeof payload?.total === "number" ? payload.total : matchedIds.size;
|
|
2801
|
+
renderBoard();
|
|
2802
|
+
updateSearchMeta(metaEl, "ok", total);
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
function updateSearchMeta(metaEl, state, total) {
|
|
2806
|
+
if (!metaEl) return;
|
|
2807
|
+
if (state == null) {
|
|
2808
|
+
metaEl.textContent = "";
|
|
2809
|
+
metaEl.removeAttribute("data-state");
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (state === "searching") {
|
|
2813
|
+
metaEl.textContent = "searching…";
|
|
2814
|
+
metaEl.setAttribute("data-state", "searching");
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (state === "fallback") {
|
|
2818
|
+
metaEl.textContent = total != null ? `~${total} match (local)` : "";
|
|
2819
|
+
metaEl.setAttribute("data-state", "fallback");
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
if (state === "ok") {
|
|
2823
|
+
metaEl.textContent = total != null ? `${total} match${total === 1 ? "" : "es"}` : "";
|
|
2824
|
+
metaEl.setAttribute("data-state", "ok");
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// ---------- SSE wiring (delegates to OpenKanAPI) ----------
|
|
2830
|
+
function applySnapshot(snap) {
|
|
2831
|
+
if (!snap) return;
|
|
2832
|
+
const list = Array.isArray(snap) ? snap : snap?.tasks || [];
|
|
2833
|
+
tasks.clear();
|
|
2834
|
+
for (const t of list) tasks.set(t.id, t);
|
|
2835
|
+
renderBoard();
|
|
2836
|
+
// Re-fetch if a search query is active so the match count stays accurate.
|
|
2837
|
+
if (filter.search) {
|
|
2838
|
+
const meta = document.getElementById("search-meta");
|
|
2839
|
+
runSearch(filter.search, meta);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
on("board.snapshot", (snap) => { applySnapshot(snap); setConnected(true); });
|
|
2844
|
+
on("task.created", (payload) => {
|
|
2845
|
+
const t = payload?.task ?? payload;
|
|
2846
|
+
if (t && t.id) { tasks.set(t.id, t); renderBoard(); }
|
|
2847
|
+
});
|
|
2848
|
+
on("task.updated", (payload) => {
|
|
2849
|
+
const t = payload?.task ?? payload;
|
|
2850
|
+
if (t && t.id) { tasks.set(t.id, t); renderBoard(); }
|
|
2851
|
+
});
|
|
2852
|
+
on("task.deleted", (payload) => {
|
|
2853
|
+
const id = payload?.id;
|
|
2854
|
+
if (id && tasks.delete(id)) renderBoard();
|
|
2855
|
+
});
|
|
2856
|
+
// Filter change from another tab (or an SSE broadcast) — re-read hash and
|
|
2857
|
+
// re-render. Updates the search run too so the meta count stays accurate.
|
|
2858
|
+
on("filter.changed", () => {
|
|
2859
|
+
const next = readHashFilter();
|
|
2860
|
+
if (!next) return;
|
|
2861
|
+
const searchChanged = next.search !== filter.search;
|
|
2862
|
+
filter.category = next.category;
|
|
2863
|
+
filter.tags = next.tags;
|
|
2864
|
+
filter.contributor = next.contributor;
|
|
2865
|
+
filter.archive = next.archive;
|
|
2866
|
+
filter.sort = next.sort;
|
|
2867
|
+
filter.search = next.search || "";
|
|
2868
|
+
if (searchChanged) {
|
|
2869
|
+
const input = document.getElementById("search-input");
|
|
2870
|
+
if (input) input.value = filter.search;
|
|
2871
|
+
searchMatchIds = null;
|
|
2872
|
+
if (filter.search) {
|
|
2873
|
+
const meta = document.getElementById("search-meta");
|
|
2874
|
+
runSearch(filter.search, meta);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
applyFilterToButtons();
|
|
2878
|
+
renderBoard();
|
|
2879
|
+
});
|
|
2880
|
+
// Theme change from another tab — re-render so any color tokens (or
|
|
2881
|
+
// programmatically rendered colors) re-evaluate, even though the CSS rules
|
|
2882
|
+
// are already data-theme reactive.
|
|
2883
|
+
on("theme.changed", () => { renderBoard(); });
|
|
2884
|
+
// Forward-compatible handlers for events Thor is adding alongside the
|
|
2885
|
+
// bulk / image / template work. Each one triggers a board re-fetch so the
|
|
2886
|
+
// UI stays consistent even if the server changes the payload shape later.
|
|
2887
|
+
const REFRESH_EVENTS = [
|
|
2888
|
+
"task.bulk",
|
|
2889
|
+
"bulk.updated",
|
|
2890
|
+
"task.image-added",
|
|
2891
|
+
"task.image-deleted",
|
|
2892
|
+
"task.images-cleared",
|
|
2893
|
+
"task.archived",
|
|
2894
|
+
"task.restored",
|
|
2895
|
+
"task.changed",
|
|
2896
|
+
"session.ended",
|
|
2897
|
+
"tasks.reordered",
|
|
2898
|
+
];
|
|
2899
|
+
for (const evt of REFRESH_EVENTS) {
|
|
2900
|
+
on(evt, async () => {
|
|
2901
|
+
try {
|
|
2902
|
+
const snap = await api("GET", "/api/board");
|
|
2903
|
+
applySnapshot(snap);
|
|
2904
|
+
} catch {}
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// ---------- M13 — keyboard navigation, palette wiring, cross-tab ----------
|
|
2909
|
+
// The keyboard module publishes named events; we attach handlers that
|
|
2910
|
+
// update the focused card, the selection, or trigger bulk operations.
|
|
2911
|
+
function columnBodies() {
|
|
2912
|
+
return Array.from(document.querySelectorAll(".board .column .column-body"));
|
|
2913
|
+
}
|
|
2914
|
+
function cardsInBody(body) {
|
|
2915
|
+
return Array.from(body.querySelectorAll(".card[data-id]"));
|
|
2916
|
+
}
|
|
2917
|
+
function focusedCardEl() {
|
|
2918
|
+
const id = window.OpenKanKeyboard?.getFocusedId?.();
|
|
2919
|
+
if (!id) return null;
|
|
2920
|
+
const board = document.getElementById("board");
|
|
2921
|
+
if (!board) return null;
|
|
2922
|
+
let card;
|
|
2923
|
+
try { card = board.querySelector(`.card[data-id="${CSS.escape(id)}"]`); }
|
|
2924
|
+
catch { card = board.querySelector(`.card[data-id="${id}"]`); }
|
|
2925
|
+
return card || null;
|
|
2926
|
+
}
|
|
2927
|
+
function findCardPosition() {
|
|
2928
|
+
const fc = focusedCardEl();
|
|
2929
|
+
if (!fc) return null;
|
|
2930
|
+
const body = fc.closest(".column-body");
|
|
2931
|
+
const bodies = columnBodies();
|
|
2932
|
+
const bodyIdx = bodies.indexOf(body);
|
|
2933
|
+
if (bodyIdx < 0) return null;
|
|
2934
|
+
const cards = cardsInBody(body);
|
|
2935
|
+
return { bodyIdx, cardIdx: cards.indexOf(fc), bodies };
|
|
2936
|
+
}
|
|
2937
|
+
function nextColumnFirstCard(fromIdx, bodies) {
|
|
2938
|
+
if (!bodies) bodies = columnBodies();
|
|
2939
|
+
for (let i = 1; i <= bodies.length; i++) {
|
|
2940
|
+
const idx = (fromIdx + i) % bodies.length;
|
|
2941
|
+
const cards = cardsInBody(bodies[idx]);
|
|
2942
|
+
if (cards.length > 0) return cards[0];
|
|
2943
|
+
}
|
|
2944
|
+
return null;
|
|
2945
|
+
}
|
|
2946
|
+
function prevColumnLastCard(fromIdx, bodies) {
|
|
2947
|
+
if (!bodies) bodies = columnBodies();
|
|
2948
|
+
for (let i = 1; i <= bodies.length; i++) {
|
|
2949
|
+
const idx = (fromIdx - i + bodies.length) % bodies.length;
|
|
2950
|
+
const cards = cardsInBody(bodies[idx]);
|
|
2951
|
+
if (cards.length > 0) return cards[cards.length - 1];
|
|
2952
|
+
}
|
|
2953
|
+
return null;
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
function moveFocus(direction) {
|
|
2957
|
+
const bodies = columnBodies();
|
|
2958
|
+
if (bodies.length === 0) return;
|
|
2959
|
+
const pos = findCardPosition();
|
|
2960
|
+
let target = null;
|
|
2961
|
+
if (!pos) {
|
|
2962
|
+
// No focus yet — start at the first card of the first column.
|
|
2963
|
+
target = cardsInBody(bodies[0])[0] || null;
|
|
2964
|
+
} else if (direction === "next") {
|
|
2965
|
+
const cards = cardsInBody(bodies[pos.bodyIdx]);
|
|
2966
|
+
if (pos.cardIdx + 1 < cards.length) target = cards[pos.cardIdx + 1];
|
|
2967
|
+
else target = nextColumnFirstCard(pos.bodyIdx, bodies);
|
|
2968
|
+
} else if (direction === "previous") {
|
|
2969
|
+
const cards = cardsInBody(bodies[pos.bodyIdx]);
|
|
2970
|
+
if (pos.cardIdx > 0) target = cards[pos.cardIdx - 1];
|
|
2971
|
+
else target = prevColumnLastCard(pos.bodyIdx, bodies);
|
|
2972
|
+
} else if (direction === "column-next") {
|
|
2973
|
+
target = nextColumnFirstCard(pos ? pos.bodyIdx : 0, bodies);
|
|
2974
|
+
} else if (direction === "column-prev") {
|
|
2975
|
+
target = prevColumnLastCard(pos ? pos.bodyIdx : 0, bodies);
|
|
2976
|
+
}
|
|
2977
|
+
if (!target) return;
|
|
2978
|
+
window.OpenKanKeyboard.setFocusedId(target.dataset.id);
|
|
2979
|
+
renderBoard();
|
|
2980
|
+
window.OpenKanKeyboard.scrollFocusedIntoView();
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function focusedIdsForBulk() {
|
|
2984
|
+
if (selectedIds.size > 0) return [...selectedIds];
|
|
2985
|
+
const id = window.OpenKanKeyboard?.getFocusedId?.();
|
|
2986
|
+
return id ? [id] : [];
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function openFocused() {
|
|
2990
|
+
const id = window.OpenKanKeyboard?.getFocusedId?.();
|
|
2991
|
+
if (!id) return;
|
|
2992
|
+
window.OpenKanTaskView?.open?.(id);
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function toggleFocusSelection() {
|
|
2996
|
+
const id = window.OpenKanKeyboard?.getFocusedId?.();
|
|
2997
|
+
if (!id) return;
|
|
2998
|
+
let card = focusedCardEl();
|
|
2999
|
+
if (!card) {
|
|
3000
|
+
// Card isn't in the DOM (filtered out, archived, etc.) — still flip
|
|
3001
|
+
// the selected set so the bulk bar shows up.
|
|
3002
|
+
if (selectedIds.has(id)) selectedIds.delete(id);
|
|
3003
|
+
else selectedIds.add(id);
|
|
3004
|
+
updateBulkBar();
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
toggleCardSelection(card, id);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function moveSelectedTo(columnIdx) {
|
|
3011
|
+
const col = COLUMNS[columnIdx];
|
|
3012
|
+
if (!col) return;
|
|
3013
|
+
const ids = focusedIdsForBulk();
|
|
3014
|
+
if (ids.length === 0) return;
|
|
3015
|
+
runBulkOperation({ kind: "move", taskIds: ids, column: col.id });
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function archiveSelected() {
|
|
3019
|
+
const ids = focusedIdsForBulk();
|
|
3020
|
+
if (ids.length === 0) return;
|
|
3021
|
+
runBulkOperation({ kind: "archive", taskIds: ids });
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
function deleteSelected() {
|
|
3025
|
+
const ids = focusedIdsForBulk();
|
|
3026
|
+
if (ids.length === 0) return;
|
|
3027
|
+
// The 'd' keyboard shortcut — drop the confirm dialog so the action
|
|
3028
|
+
// feels instantaneous. The destructive op still goes through the bulk
|
|
3029
|
+
// delete path which already triggers a toast on success.
|
|
3030
|
+
const snaps = new Map();
|
|
3031
|
+
for (const id of ids) {
|
|
3032
|
+
const t = tasks.get(id);
|
|
3033
|
+
if (t) snaps.set(id, { ...t });
|
|
3034
|
+
}
|
|
3035
|
+
runBulkOperation({ kind: "delete", taskIds: ids });
|
|
3036
|
+
showUndoToast(`Deleted ${ids.length} task${ids.length === 1 ? "" : "s"}.`, () => {
|
|
3037
|
+
for (const [id, snap] of snaps) tasks.set(id, snap);
|
|
3038
|
+
renderBoard();
|
|
3039
|
+
showUndoToast(`Restored ${snaps.size} task${snaps.size === 1 ? "" : "s"}.`, () => {
|
|
3040
|
+
runBulkOperation({ kind: "delete", taskIds: ids });
|
|
3041
|
+
});
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function focusSearch() {
|
|
3046
|
+
const input = document.getElementById("search-input");
|
|
3047
|
+
if (input) {
|
|
3048
|
+
input.focus();
|
|
3049
|
+
try { input.select(); } catch {}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// Theme cycle — dark → light → system → dark.
|
|
3054
|
+
function cycleTheme() {
|
|
3055
|
+
const order = ["dark", "light", "system"];
|
|
3056
|
+
const cur = (window.OpenKanSettings?.currentTheme?.() || "dark").toLowerCase();
|
|
3057
|
+
const next = order[(order.indexOf(cur) + 1) % order.length] || "dark";
|
|
3058
|
+
if (window.OpenKanSettings?.applyTheme) {
|
|
3059
|
+
window.OpenKanSettings.applyTheme(next);
|
|
3060
|
+
// Broadcast to siblings so they pick up the change without an SSE round-trip.
|
|
3061
|
+
try { window.OpenKanCrossTab?.publish?.("theme.changed", { theme: next }); } catch {}
|
|
3062
|
+
showToast(`Theme: ${next}`);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function attachKeyboard() {
|
|
3067
|
+
const K = window.OpenKanKeyboard;
|
|
3068
|
+
if (!K) return;
|
|
3069
|
+
K.on("focus.next", () => moveFocus("next"));
|
|
3070
|
+
K.on("focus.previous", () => moveFocus("previous"));
|
|
3071
|
+
K.on("focus.column-next", () => moveFocus("column-next"));
|
|
3072
|
+
K.on("focus.column-prev", () => moveFocus("column-prev"));
|
|
3073
|
+
K.on("focus.open", () => openFocused());
|
|
3074
|
+
K.on("focus.toggle-select", () => toggleFocusSelection());
|
|
3075
|
+
K.on("action.move-column", (idx) => moveSelectedTo(idx));
|
|
3076
|
+
K.on("action.archive", () => archiveSelected());
|
|
3077
|
+
K.on("action.delete", () => deleteSelected());
|
|
3078
|
+
K.on("action.edit", () => {
|
|
3079
|
+
// 'e' on a focused card: open the Edit Task modal directly. If no
|
|
3080
|
+
// card is focused but a task view is open, edit the currently open
|
|
3081
|
+
// task. If neither, fall back to opening the focused card first.
|
|
3082
|
+
const focusedId = window.OpenKanKeyboard?.getFocusedId?.();
|
|
3083
|
+
const openTaskId = window.OpenKanTaskView?.getCurrentTaskId?.();
|
|
3084
|
+
if (focusedId) {
|
|
3085
|
+
openEditModal(focusedId);
|
|
3086
|
+
} else if (openTaskId) {
|
|
3087
|
+
openEditModal(openTaskId);
|
|
3088
|
+
} else {
|
|
3089
|
+
openFocused();
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
3092
|
+
K.on("search.focus", () => focusSearch());
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// ─── Command palette registration ────────────────────────────────────────
|
|
3096
|
+
// App owns the action set; the palette just renders and invokes whatever's
|
|
3097
|
+
// registered. The task provider exposes cards that match the search query.
|
|
3098
|
+
function registerPaletteActions() {
|
|
3099
|
+
const palette = window.OpenKanCommandPalette;
|
|
3100
|
+
if (!palette) return;
|
|
3101
|
+
const unregisters = [];
|
|
3102
|
+
unregisters.push(palette.registerAction({
|
|
3103
|
+
id: "new-task",
|
|
3104
|
+
label: "New task",
|
|
3105
|
+
hint: "Create a task",
|
|
3106
|
+
run: () => document.getElementById("new-task-btn")?.click(),
|
|
3107
|
+
}));
|
|
3108
|
+
unregisters.push(palette.registerAction({
|
|
3109
|
+
id: "open-settings",
|
|
3110
|
+
label: "Open Settings",
|
|
3111
|
+
hint: "Project, theme, archive",
|
|
3112
|
+
run: () => document.getElementById("settings-btn")?.click(),
|
|
3113
|
+
}));
|
|
3114
|
+
unregisters.push(palette.registerAction({
|
|
3115
|
+
id: "open-tasks",
|
|
3116
|
+
label: "Open Tasks tab",
|
|
3117
|
+
hint: "Kanban board",
|
|
3118
|
+
run: () => window.OpenKanTabs?.activate?.("tasks"),
|
|
3119
|
+
}));
|
|
3120
|
+
unregisters.push(palette.registerAction({
|
|
3121
|
+
id: "open-changelog",
|
|
3122
|
+
label: "Open Changelog tab",
|
|
3123
|
+
hint: "Recent activity",
|
|
3124
|
+
run: () => window.OpenKanTabs?.activate?.("changelog"),
|
|
3125
|
+
}));
|
|
3126
|
+
unregisters.push(palette.registerAction({
|
|
3127
|
+
id: "open-contributors",
|
|
3128
|
+
label: "Open Contributors tab",
|
|
3129
|
+
hint: "Team overview",
|
|
3130
|
+
run: () => window.OpenKanTabs?.activate?.("contributors"),
|
|
3131
|
+
}));
|
|
3132
|
+
unregisters.push(palette.registerAction({
|
|
3133
|
+
id: "toggle-theme",
|
|
3134
|
+
label: "Toggle theme",
|
|
3135
|
+
hint: "Dark → light → system",
|
|
3136
|
+
run: () => cycleTheme(),
|
|
3137
|
+
}));
|
|
3138
|
+
unregisters.push(palette.registerAction({
|
|
3139
|
+
id: "clear-filters",
|
|
3140
|
+
label: "Clear all filters",
|
|
3141
|
+
hint: "Reset board view",
|
|
3142
|
+
run: () => {
|
|
3143
|
+
filter.category = "all";
|
|
3144
|
+
filter.tags = [];
|
|
3145
|
+
filter.contributor = "all";
|
|
3146
|
+
filter.search = "";
|
|
3147
|
+
searchMatchIds = null;
|
|
3148
|
+
writeHashFilter();
|
|
3149
|
+
applyFilterToButtons();
|
|
3150
|
+
renderBoard();
|
|
3151
|
+
try {
|
|
3152
|
+
window.OpenKanCrossTab?.publish?.("filter.changed", { hash: window.location.hash });
|
|
3153
|
+
} catch {}
|
|
3154
|
+
},
|
|
3155
|
+
}));
|
|
3156
|
+
unregisters.push(palette.registerAction({
|
|
3157
|
+
id: "save-filter",
|
|
3158
|
+
label: "Save current filter",
|
|
3159
|
+
hint: "Name and store the active filter",
|
|
3160
|
+
run: async () => {
|
|
3161
|
+
// Same path as the filter-bar Save button — go through the custom
|
|
3162
|
+
// inline modal so it works in headless / embedded contexts.
|
|
3163
|
+
const name = await promptForName("Save filter as:", "");
|
|
3164
|
+
if (name && saveCurrentFilter(name)) {
|
|
3165
|
+
showToast(`Saved filter "${name}"`);
|
|
3166
|
+
}
|
|
3167
|
+
},
|
|
3168
|
+
}));
|
|
3169
|
+
unregisters.push(palette.registerAction({
|
|
3170
|
+
id: "reload",
|
|
3171
|
+
label: "Reload page",
|
|
3172
|
+
hint: "Hard reload",
|
|
3173
|
+
run: () => window.location.reload(),
|
|
3174
|
+
}));
|
|
3175
|
+
unregisters.push(palette.registerAction({
|
|
3176
|
+
id: "help-shortcuts",
|
|
3177
|
+
label: "Show keyboard shortcuts",
|
|
3178
|
+
hint: "Open the ? help overlay",
|
|
3179
|
+
run: () => window.OpenKanKeyboard?.showHelp?.(),
|
|
3180
|
+
}));
|
|
3181
|
+
|
|
3182
|
+
// Task provider — palette searches every task this tab has loaded.
|
|
3183
|
+
unregisters.push(palette.registerTaskProvider((q) => {
|
|
3184
|
+
const out = [];
|
|
3185
|
+
for (const t of tasks.values()) {
|
|
3186
|
+
if (!t || !t.id) continue;
|
|
3187
|
+
out.push({
|
|
3188
|
+
id: t.id,
|
|
3189
|
+
title: t.title || "(untitled)",
|
|
3190
|
+
subtitle: (COLUMNS.find((c) => c.id === t.column) || {}).title || t.column,
|
|
3191
|
+
run: () => window.OpenKanTaskView?.open?.(t.id),
|
|
3192
|
+
});
|
|
3193
|
+
}
|
|
3194
|
+
return out;
|
|
3195
|
+
}));
|
|
3196
|
+
return () => { for (const u of unregisters) try { u(); } catch {} };
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// ─── Cross-tab subscribe (mirror SSE for sibling-tab updates) ───────────
|
|
3200
|
+
// OpenKanAPI already forwards each cross-tab event to the local event bus,
|
|
3201
|
+
// so the handlers above (board.snapshot, filter.changed, theme.changed,
|
|
3202
|
+
// task.*/comment.*/input.*) all run from cross-tab events too. There is
|
|
3203
|
+
// nothing left to subscribe here besides re-publishing the hash as it
|
|
3204
|
+
// changes so siblings can sync filters.
|
|
3205
|
+
let lastPublishedHash = null;
|
|
3206
|
+
function attachCrossTab() {
|
|
3207
|
+
if (!window.OpenKanCrossTab) return;
|
|
3208
|
+
// Publish hash changes so other tabs can sync their filter view.
|
|
3209
|
+
const publish = () => {
|
|
3210
|
+
const hash = window.location.hash || "";
|
|
3211
|
+
if (hash === lastPublishedHash) return;
|
|
3212
|
+
lastPublishedHash = hash;
|
|
3213
|
+
try { window.OpenKanCrossTab.publish("filter.changed", { hash }); } catch {}
|
|
3214
|
+
};
|
|
3215
|
+
window.addEventListener("hashchange", publish);
|
|
3216
|
+
publish();
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
// Expose the action helpers so external callers (tests, future modules)
|
|
3220
|
+
// can drive the keyboard nav from JS the same way the keyboard module does.
|
|
3221
|
+
window.OpenKanBoardActions = {
|
|
3222
|
+
moveFocus,
|
|
3223
|
+
openFocused,
|
|
3224
|
+
toggleFocusSelection,
|
|
3225
|
+
moveSelectedTo,
|
|
3226
|
+
archiveSelected,
|
|
3227
|
+
deleteSelected,
|
|
3228
|
+
focusSearch,
|
|
3229
|
+
cycleTheme,
|
|
3230
|
+
openEditModal,
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3233
|
+
// Edit Task modal — exposed so task-view.js can open it from the
|
|
3234
|
+
// footer ("Edit" button) without each module wiring its own API.
|
|
3235
|
+
window.OpenKanEditTask = {
|
|
3236
|
+
open: openEditModal,
|
|
3237
|
+
isOpen() {
|
|
3238
|
+
const b = document.getElementById("edit-backdrop");
|
|
3239
|
+
return !!(b && !b.hidden);
|
|
3240
|
+
},
|
|
3241
|
+
};
|
|
3242
|
+
|
|
3243
|
+
|
|
3244
|
+
// ---------- Modal ----------
|
|
3245
|
+
|
|
3246
|
+
// Custom inline name prompt. `window.prompt()` is blocked in headless
|
|
3247
|
+
// Chrome and in some embedded contexts, so the 💾 Save filter button and
|
|
3248
|
+
// its command-palette sibling both go through this instead. Built on top
|
|
3249
|
+
// of the same `.modal-backdrop` / `.modal` shell as the rest of the
|
|
3250
|
+
// dashboard so it inherits theme variables and backdrop-click behaviour.
|
|
3251
|
+
// Resolves with the trimmed value on OK / Enter, or null on Cancel /
|
|
3252
|
+
// Escape / backdrop click / empty input.
|
|
3253
|
+
function promptForName(title, defaultValue = "") {
|
|
3254
|
+
return new Promise((resolve) => {
|
|
3255
|
+
const safeTitle = String(title ?? "Enter a name");
|
|
3256
|
+
const bd = document.createElement("div");
|
|
3257
|
+
bd.className = "modal-backdrop prompt-modal";
|
|
3258
|
+
bd.style.zIndex = "120";
|
|
3259
|
+
const modal = document.createElement("div");
|
|
3260
|
+
modal.className = "modal";
|
|
3261
|
+
modal.style.maxWidth = "420px";
|
|
3262
|
+
modal.innerHTML = `
|
|
3263
|
+
<header class="modal-header">
|
|
3264
|
+
<h2></h2>
|
|
3265
|
+
<button class="btn-icon" type="button" data-cancel aria-label="Close">×</button>
|
|
3266
|
+
</header>
|
|
3267
|
+
<div class="modal-body">
|
|
3268
|
+
<label class="field">
|
|
3269
|
+
<span>Name</span>
|
|
3270
|
+
<input type="text" autocomplete="off" />
|
|
3271
|
+
</label>
|
|
3272
|
+
</div>
|
|
3273
|
+
<footer class="modal-footer">
|
|
3274
|
+
<button class="btn" type="button" data-cancel>Cancel</button>
|
|
3275
|
+
<button class="btn btn-primary" type="button" data-ok>OK</button>
|
|
3276
|
+
</footer>
|
|
3277
|
+
`;
|
|
3278
|
+
bd.appendChild(modal);
|
|
3279
|
+
document.body.appendChild(bd);
|
|
3280
|
+
bd.hidden = false;
|
|
3281
|
+
const heading = modal.querySelector("h2");
|
|
3282
|
+
const input = modal.querySelector("input");
|
|
3283
|
+
if (heading) heading.textContent = safeTitle;
|
|
3284
|
+
if (input) input.value = String(defaultValue ?? "");
|
|
3285
|
+
if (input) {
|
|
3286
|
+
input.focus();
|
|
3287
|
+
input.select();
|
|
3288
|
+
}
|
|
3289
|
+
function cleanup(result) {
|
|
3290
|
+
bd.remove();
|
|
3291
|
+
resolve(result);
|
|
3292
|
+
}
|
|
3293
|
+
modal.querySelector("[data-ok]").addEventListener("click", () => {
|
|
3294
|
+
cleanup(input?.value?.trim() || null);
|
|
3295
|
+
});
|
|
3296
|
+
modal.querySelectorAll("[data-cancel]").forEach((b) =>
|
|
3297
|
+
b.addEventListener("click", () => cleanup(null)),
|
|
3298
|
+
);
|
|
3299
|
+
bd.addEventListener("mousedown", (ev) => {
|
|
3300
|
+
if (ev.target === bd) cleanup(null);
|
|
3301
|
+
});
|
|
3302
|
+
input?.addEventListener("keydown", (ev) => {
|
|
3303
|
+
if (ev.key === "Enter") cleanup(input.value.trim() || null);
|
|
3304
|
+
else if (ev.key === "Escape") cleanup(null);
|
|
3305
|
+
});
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
let creatingTask = false;
|
|
3310
|
+
let taskModalReturnFocus = null;
|
|
3311
|
+
function taskDraftKey() { return `openkan:task-draft:${projectSwitcher.active?.root || location.pathname}`; }
|
|
3312
|
+
function saveTaskDraft() {
|
|
3313
|
+
if (!form) return;
|
|
3314
|
+
try { sessionStorage.setItem(taskDraftKey(), JSON.stringify(Object.fromEntries(new FormData(form)))); } catch {}
|
|
3315
|
+
}
|
|
3316
|
+
function taskCreateError(message = "") {
|
|
3317
|
+
const error = $("task-create-error");
|
|
3318
|
+
if (error) { error.textContent = message; error.hidden = !message; }
|
|
3319
|
+
}
|
|
3320
|
+
function openModal() {
|
|
3321
|
+
if (!modal) return;
|
|
3322
|
+
taskModalReturnFocus = document.activeElement;
|
|
3323
|
+
if (window.OpenKanTaskView?.getCurrentTaskId?.()) {
|
|
3324
|
+
window.OpenKanTaskView.close();
|
|
3325
|
+
}
|
|
3326
|
+
activateTab("tasks");
|
|
3327
|
+
modal.hidden = false;
|
|
3328
|
+
if (form) {
|
|
3329
|
+
form.reset(); taskCreateError();
|
|
3330
|
+
form.elements.title?.removeAttribute("aria-invalid");
|
|
3331
|
+
try {
|
|
3332
|
+
const settings = JSON.parse(localStorage.getItem("openkan:settings") || "{}");
|
|
3333
|
+
const def = settings?.project?.defaultColumn;
|
|
3334
|
+
if (def) form.elements.column.value = def;
|
|
3335
|
+
const draft = JSON.parse(sessionStorage.getItem(taskDraftKey()) || "null");
|
|
3336
|
+
if (draft) for (const [key, value] of Object.entries(draft)) {
|
|
3337
|
+
const field = form.elements.namedItem(key);
|
|
3338
|
+
if (field && field.type !== "checkbox") field.value = String(value);
|
|
3339
|
+
}
|
|
3340
|
+
if (draft) form.elements.openAfterCreate.checked = draft.openAfterCreate === "on";
|
|
3341
|
+
} catch {}
|
|
3342
|
+
const context = $("task-create-project");
|
|
3343
|
+
if (context) context.textContent = `Project: ${projectSwitcher.active?.name || "current project"}. Drafts are saved in this browser tab.`;
|
|
3344
|
+
form.elements.title?.focus();
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
function closeModal() {
|
|
3348
|
+
if (!modal || modal.hidden || creatingTask) return;
|
|
3349
|
+
saveTaskDraft();
|
|
3350
|
+
modal.hidden = true;
|
|
3351
|
+
taskModalReturnFocus?.focus?.();
|
|
3352
|
+
}
|
|
3353
|
+
window.OpenKanCreateTask = { openFromChat(message) {
|
|
3354
|
+
openModal();
|
|
3355
|
+
if (form.elements.title.value || form.elements.description.value) {
|
|
3356
|
+
showToast("Your existing task draft is open. Your chat text is unchanged.", "info"); return;
|
|
3357
|
+
}
|
|
3358
|
+
form.elements.title.value = String(message).split("\n")[0].slice(0, 200);
|
|
3359
|
+
form.elements.description.value = String(message);
|
|
3360
|
+
saveTaskDraft();
|
|
3361
|
+
} };
|
|
3362
|
+
if ($("new-task-btn")) $("new-task-btn").addEventListener("click", openModal);
|
|
3363
|
+
if (modal) {
|
|
3364
|
+
modal.addEventListener("click", (e) => {
|
|
3365
|
+
if (e.target === modal) closeModal();
|
|
3366
|
+
});
|
|
3367
|
+
document.querySelectorAll("[data-close-modal]").forEach((b) =>
|
|
3368
|
+
b.addEventListener("click", closeModal),
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
document.addEventListener("keydown", (e) => {
|
|
3372
|
+
if (e.key === "Escape") {
|
|
3373
|
+
// Clear selection first — never silently lost work to a stray Esc.
|
|
3374
|
+
if (selectedIds.size > 0 && !e.target?.closest?.("input, textarea")) {
|
|
3375
|
+
clearCardSelection();
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
closeModal();
|
|
3379
|
+
closeBulkMenus();
|
|
3380
|
+
if (menu) menu.hidden = true;
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
3383
|
+
if (form) {
|
|
3384
|
+
form.addEventListener("input", () => { saveTaskDraft(); taskCreateError(); form.elements.title?.removeAttribute("aria-invalid"); });
|
|
3385
|
+
form.addEventListener("change", saveTaskDraft);
|
|
3386
|
+
$("task-discard-draft")?.addEventListener("click", () => {
|
|
3387
|
+
if (creatingTask) return;
|
|
3388
|
+
form.reset(); saveTaskDraft(); taskCreateError(); form.elements.title?.focus();
|
|
3389
|
+
});
|
|
3390
|
+
modal.addEventListener("keydown", (e) => {
|
|
3391
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
|
3392
|
+
if (e.key !== "Tab") return;
|
|
3393
|
+
const focusable = [...modal.querySelectorAll('button, input:not([type="hidden"]), textarea, select, summary, [tabindex="0"]')].filter(node => !node.disabled && node.getClientRects().length);
|
|
3394
|
+
const first = focusable[0], last = focusable.at(-1);
|
|
3395
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
|
3396
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
|
|
3397
|
+
});
|
|
3398
|
+
|
|
3399
|
+
form.addEventListener("submit", async (e) => {
|
|
3400
|
+
e.preventDefault();
|
|
3401
|
+
if (creatingTask) return;
|
|
3402
|
+
const fd = new FormData(form);
|
|
3403
|
+
const title = String(fd.get("title") || "").trim();
|
|
3404
|
+
if (!title || title.length > 200) {
|
|
3405
|
+
taskCreateError(!title ? "Give this task a title before creating it." : "Keep the title under 200 characters.");
|
|
3406
|
+
form.elements.title?.setAttribute("aria-invalid", "true");
|
|
3407
|
+
form.elements.title?.focus(); return;
|
|
3408
|
+
}
|
|
3409
|
+
const body = {
|
|
3410
|
+
title, description: String(fd.get("description") || ""),
|
|
3411
|
+
column: String(fd.get("column") || "todo"), priority: String(fd.get("priority") || "normal"),
|
|
3412
|
+
agent: String(fd.get("agent") || ""), model: String(fd.get("model") || ""),
|
|
3413
|
+
};
|
|
3414
|
+
const parentId = String(fd.get("parentId") || "").trim();
|
|
3415
|
+
if (parentId) body.parentId = parentId;
|
|
3416
|
+
const openAfterCreate = fd.get("openAfterCreate") === "on";
|
|
3417
|
+
const submit = $("task-create-submit");
|
|
3418
|
+
saveTaskDraft(); taskCreateError(); creatingTask = true;
|
|
3419
|
+
form.setAttribute("aria-busy", "true");
|
|
3420
|
+
for (const control of form.querySelectorAll("input, textarea, select, button")) control.disabled = true;
|
|
3421
|
+
if (submit) submit.textContent = "Creating…";
|
|
3422
|
+
try {
|
|
3423
|
+
const res = await api("POST", "/api/tasks", body);
|
|
3424
|
+
if (!res?.id) throw new Error("The server did not confirm task creation");
|
|
3425
|
+
tasks.set(res.id, res); renderBoard();
|
|
3426
|
+
form.reset(); creatingTask = false; closeModal();
|
|
3427
|
+
try { sessionStorage.removeItem(taskDraftKey()); } catch {}
|
|
3428
|
+
showToast("Task created", "success");
|
|
3429
|
+
if (openAfterCreate) window.OpenKanTaskView?.open?.(res.id);
|
|
3430
|
+
else if (parentId && window.OpenKanTaskView?.getCurrentTaskId?.() === parentId) window.OpenKanTaskView.open(parentId);
|
|
3431
|
+
} catch (err) {
|
|
3432
|
+
taskCreateError(`Could not create task: ${err.message}. Your draft is kept; try again.`);
|
|
3433
|
+
} finally {
|
|
3434
|
+
creatingTask = false; form.removeAttribute("aria-busy");
|
|
3435
|
+
for (const control of form.querySelectorAll("input, textarea, select, button")) control.disabled = false;
|
|
3436
|
+
if (submit) submit.textContent = "Create task";
|
|
3437
|
+
}
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// ---------- Tab router ----------
|
|
3442
|
+
function activateTab(name, opts = {}) {
|
|
3443
|
+
if (name === "claude" || name === "bizar") name = "agents";
|
|
3444
|
+
const valid = ["home", "tasks", "changelog", "contributors", "docs", "goals", "agents", "insights"];
|
|
3445
|
+
if (!valid.includes(name)) name = "tasks";
|
|
3446
|
+
for (const btn of document.querySelectorAll(".tab")) {
|
|
3447
|
+
const isActive = btn.dataset.tab === name;
|
|
3448
|
+
btn.classList.toggle("active", isActive);
|
|
3449
|
+
btn.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
3450
|
+
}
|
|
3451
|
+
const homeButton = document.getElementById("home-page-btn");
|
|
3452
|
+
homeButton?.classList.toggle("active", name === "home");
|
|
3453
|
+
homeButton?.setAttribute("aria-current", name === "home" ? "page" : "false");
|
|
3454
|
+
document.body.classList.toggle("workspace-home", name === "home");
|
|
3455
|
+
for (const pane of document.querySelectorAll(".tab-pane")) {
|
|
3456
|
+
const isActive = pane.dataset.tab === name;
|
|
3457
|
+
pane.hidden = !isActive;
|
|
3458
|
+
}
|
|
3459
|
+
// Update URL hash, preserving other params.
|
|
3460
|
+
if (!opts.fromHash) {
|
|
3461
|
+
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
|
3462
|
+
params.set("tab", name);
|
|
3463
|
+
const next = `#${params.toString()}`;
|
|
3464
|
+
if (window.location.hash !== next) {
|
|
3465
|
+
const url = window.location.pathname + window.location.search + next;
|
|
3466
|
+
window.history.replaceState(null, "", url);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
// Lazy-mount views.
|
|
3470
|
+
if (name === "home") {
|
|
3471
|
+
const root = document.getElementById("home-root");
|
|
3472
|
+
if (root && window.OpenKanHome) window.OpenKanHome.mount(root);
|
|
3473
|
+
} else if (name === "changelog") {
|
|
3474
|
+
const root = document.getElementById("changelog-root");
|
|
3475
|
+
if (root && window.OpenKanChangelog) window.OpenKanChangelog.mount(root);
|
|
3476
|
+
} else if (name === "contributors") {
|
|
3477
|
+
const root = document.getElementById("contributors-root");
|
|
3478
|
+
if (root && window.OpenKanContributors) window.OpenKanContributors.mount(root);
|
|
3479
|
+
} else if (name === "docs") {
|
|
3480
|
+
const root = document.getElementById("docs-root");
|
|
3481
|
+
if (root && window.OpenKanDocs) {
|
|
3482
|
+
// Pass through the requested file from the hash (if any), so a
|
|
3483
|
+
// `#tab=docs&doc=README.mdx` URL restores the last-viewed file.
|
|
3484
|
+
const initial = readHashFilter();
|
|
3485
|
+
window.OpenKanDocs.mount(root, { initialDoc: initial?.doc || "" });
|
|
3486
|
+
}
|
|
3487
|
+
} else if (name === "goals") {
|
|
3488
|
+
const root = document.getElementById("goals-root");
|
|
3489
|
+
if (root && window.OpenKanGoals) window.OpenKanGoals.mount(root);
|
|
3490
|
+
} else if (name === "agents") {
|
|
3491
|
+
const root = document.getElementById("claude-pane-root");
|
|
3492
|
+
if (root && window.OpenKanClaude) window.OpenKanClaude.mount(root);
|
|
3493
|
+
} else if (name === "insights") {
|
|
3494
|
+
const root = document.getElementById("insights-root");
|
|
3495
|
+
if (root && window.OpenKanInsights) window.OpenKanInsights.mount(root);
|
|
3496
|
+
} else if (name === "tasks") {
|
|
3497
|
+
// Unmount non-active views to free any subscriptions.
|
|
3498
|
+
window.OpenKanChangelog?.unmount?.();
|
|
3499
|
+
window.OpenKanContributors?.unmount?.();
|
|
3500
|
+
window.OpenKanDocs?.unmount?.();
|
|
3501
|
+
window.OpenKanGoals?.unmount?.();
|
|
3502
|
+
window.OpenKanClaude?.unmount?.();
|
|
3503
|
+
window.OpenKanInsights?.unmount?.();
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
function attachWorkspaceMode() {
|
|
3508
|
+
const key = "openkan:workspace-mode";
|
|
3509
|
+
const setMode = (mode) => {
|
|
3510
|
+
const chatMode = mode === "chat";
|
|
3511
|
+
document.body.classList.toggle("workspace-mode-chat", chatMode);
|
|
3512
|
+
document.body.classList.toggle("workspace-mode-tasks", !chatMode);
|
|
3513
|
+
document.querySelectorAll("[data-workspace-mode]").forEach((button) => button.classList.toggle("active", button.dataset.workspaceMode === mode));
|
|
3514
|
+
try { localStorage.setItem(key, mode); } catch {}
|
|
3515
|
+
if (chatMode) window.OpenKanChatSidebar?.open?.();
|
|
3516
|
+
};
|
|
3517
|
+
let initial = "tasks"; try { initial = localStorage.getItem(key) || "tasks"; } catch {}
|
|
3518
|
+
setMode(initial);
|
|
3519
|
+
document.querySelectorAll("[data-workspace-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.workspaceMode)));
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
function attachTabRouter() {
|
|
3523
|
+
const closeTaskAndActivate = (name) => {
|
|
3524
|
+
if (window.OpenKanTaskView?.getCurrentTaskId?.()) window.OpenKanTaskView.close();
|
|
3525
|
+
activateTab(name);
|
|
3526
|
+
};
|
|
3527
|
+
document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => closeTaskAndActivate(btn.dataset.tab || "tasks")));
|
|
3528
|
+
document.getElementById("home-page-btn")?.addEventListener("click", () => closeTaskAndActivate("home"));
|
|
3529
|
+
|
|
3530
|
+
const moreButton = document.getElementById("workspace-more-btn");
|
|
3531
|
+
const moreMenu = document.getElementById("workspace-more-menu");
|
|
3532
|
+
const closeMore = () => { if (!moreMenu || !moreButton) return; moreMenu.hidden = true; moreButton.setAttribute("aria-expanded", "false"); };
|
|
3533
|
+
moreButton?.addEventListener("click", (event) => {
|
|
3534
|
+
event.stopPropagation();
|
|
3535
|
+
if (!moreMenu) return;
|
|
3536
|
+
moreMenu.hidden = !moreMenu.hidden;
|
|
3537
|
+
moreButton.setAttribute("aria-expanded", moreMenu.hidden ? "false" : "true");
|
|
3538
|
+
});
|
|
3539
|
+
moreMenu?.querySelectorAll("[data-workspace-page]").forEach((button) => button.addEventListener("click", () => { closeMore(); closeTaskAndActivate(button.dataset.workspacePage || "tasks"); }));
|
|
3540
|
+
moreMenu?.querySelectorAll("[data-workspace-mode]").forEach((button) => button.addEventListener("click", () => closeMore()));
|
|
3541
|
+
document.addEventListener("click", (event) => { if (!event.target.closest(".workspace-more")) closeMore(); });
|
|
3542
|
+
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMore(); });
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function attachFilterDisclosure() {
|
|
3546
|
+
const toggle = document.getElementById("filter-toggle-btn");
|
|
3547
|
+
const advanced = document.getElementById("filter-advanced");
|
|
3548
|
+
if (!toggle || !advanced) return;
|
|
3549
|
+
const setOpen = (open) => {
|
|
3550
|
+
advanced.hidden = !open;
|
|
3551
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
3552
|
+
toggle.classList.toggle("active", open);
|
|
3553
|
+
try { localStorage.setItem("openkan:filters-open", open ? "1" : "0"); } catch {}
|
|
3554
|
+
};
|
|
3555
|
+
let initial = false;
|
|
3556
|
+
try { initial = localStorage.getItem("openkan:filters-open") === "1"; } catch {}
|
|
3557
|
+
setOpen(initial);
|
|
3558
|
+
toggle.addEventListener("click", () => setOpen(advanced.hidden));
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// ---------- Contributors sidebar (filter row + helpers) ----------
|
|
3562
|
+
async function loadContributors() {
|
|
3563
|
+
try {
|
|
3564
|
+
const data = await api("GET", "/api/contributors");
|
|
3565
|
+
const list = Array.isArray(data?.contributors) ? data.contributors : Array.isArray(data) ? data : [];
|
|
3566
|
+
currentUser = data?.currentUser || null;
|
|
3567
|
+
if (currentUser && currentUser.email) {
|
|
3568
|
+
// Fallback marker in case server didn't mark list entries.
|
|
3569
|
+
}
|
|
3570
|
+
contributors.clear();
|
|
3571
|
+
for (const c of list) {
|
|
3572
|
+
if (c?.email) contributors.set(c.email.toLowerCase(), c);
|
|
3573
|
+
}
|
|
3574
|
+
// Decorate the contributors filter row with a button per contributor.
|
|
3575
|
+
renderContributorFilterRow(list);
|
|
3576
|
+
} catch (err) {
|
|
3577
|
+
// Soft-fail: the contributors filter row just stays at all | @me.
|
|
3578
|
+
console.warn("[app.js] /api/contributors fetch failed:", err);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
function renderContributorFilterRow(list) {
|
|
3583
|
+
const row = document.getElementById("filter-contributors");
|
|
3584
|
+
if (!row) return;
|
|
3585
|
+
// Remove any prior contributor entries (keep the fixed all/@me buttons).
|
|
3586
|
+
row.querySelectorAll("button[data-contributor]:not([data-contributor='all']):not([data-contributor='@me'])")
|
|
3587
|
+
.forEach((b) => b.remove());
|
|
3588
|
+
// Decorate the @me button with an avatar showing the current user's
|
|
3589
|
+
// initials and a deterministic color — matches the contributor chips.
|
|
3590
|
+
const meBtn = row.querySelector('button[data-contributor="@me"]');
|
|
3591
|
+
if (meBtn) {
|
|
3592
|
+
meBtn.innerHTML = "";
|
|
3593
|
+
const meName = currentUser?.name || currentUser?.email || "me";
|
|
3594
|
+
const meSeed = currentUser?.email || meName;
|
|
3595
|
+
const meAv = el("span", "contrib-avatar");
|
|
3596
|
+
meAv.style.background = avatarColorFor(meSeed);
|
|
3597
|
+
meAv.textContent = initialsFor(meName);
|
|
3598
|
+
meBtn.append(meAv, el("span", "contrib-name", { text: "@me" }));
|
|
3599
|
+
}
|
|
3600
|
+
for (const c of list || []) {
|
|
3601
|
+
if (!c?.email) continue;
|
|
3602
|
+
const btn = el("button", null, {
|
|
3603
|
+
type: "button",
|
|
3604
|
+
"data-contributor": c.email,
|
|
3605
|
+
title: c.email,
|
|
3606
|
+
});
|
|
3607
|
+
const av = el("span", "contrib-avatar");
|
|
3608
|
+
av.style.background = avatarColorFor(c.email);
|
|
3609
|
+
av.textContent = initialsFor(c.name || c.email);
|
|
3610
|
+
btn.append(av, el("span", "contrib-name", { text: c.name || c.email }));
|
|
3611
|
+
if (filter.contributor === c.email) btn.classList.add("active");
|
|
3612
|
+
row.append(btn);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
const topbar = document.querySelector(".topbar");
|
|
3617
|
+
if (topbar && typeof ResizeObserver === "function") {
|
|
3618
|
+
new ResizeObserver(() => {
|
|
3619
|
+
document.documentElement.style.setProperty("--topbar-height", `${Math.ceil(topbar.getBoundingClientRect().height)}px`);
|
|
3620
|
+
}).observe(topbar);
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
// ---------- Project switcher + brand chip -----------------------------
|
|
3624
|
+
// Manages the topbar dropdown that lists every registered project plus the
|
|
3625
|
+
// "+ Add project…" affordance. Uses the multi-project registry at
|
|
3626
|
+
// /api/projects, which Thor wires up server-side; until that endpoint
|
|
3627
|
+
// exists the dropdown degrades gracefully (just shows an empty list with
|
|
3628
|
+
// the Add option).
|
|
3629
|
+
const projectSwitcher = {
|
|
3630
|
+
/** Cached project list — null until first fetch. */
|
|
3631
|
+
list: null,
|
|
3632
|
+
/** The currently active project entry. */
|
|
3633
|
+
active: null,
|
|
3634
|
+
/** Last error message, surfaced in the popover. */
|
|
3635
|
+
error: null,
|
|
3636
|
+
};
|
|
3637
|
+
|
|
3638
|
+
async function fetchProjects() {
|
|
3639
|
+
try {
|
|
3640
|
+
const data = await api("GET", "/api/projects");
|
|
3641
|
+
const list = Array.isArray(data?.projects) ? data.projects : Array.isArray(data) ? data : [];
|
|
3642
|
+
projectSwitcher.list = list;
|
|
3643
|
+
projectSwitcher.active = list.find((p) => p.active) || list[0] || null;
|
|
3644
|
+
projectSwitcher.error = null;
|
|
3645
|
+
return list;
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
projectSwitcher.error = err?.message || String(err);
|
|
3648
|
+
projectSwitcher.list = [];
|
|
3649
|
+
projectSwitcher.active = null;
|
|
3650
|
+
return [];
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
function truncateName(name, max = 14) {
|
|
3655
|
+
const s = String(name || "");
|
|
3656
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
function renderProjectChip() {
|
|
3660
|
+
const label = document.getElementById("project-switcher-label");
|
|
3661
|
+
const brand = document.getElementById("brand-project-name");
|
|
3662
|
+
const dot = document.querySelector(".project-switcher-dot");
|
|
3663
|
+
const bdot = document.querySelector(".brand-project-dot");
|
|
3664
|
+
const name = projectSwitcher.active?.name || "no project";
|
|
3665
|
+
if (label) label.textContent = truncateName(name);
|
|
3666
|
+
if (brand) brand.textContent = name;
|
|
3667
|
+
const chip = document.getElementById("brand-project-chip");
|
|
3668
|
+
if (chip) chip.setAttribute("aria-label", `Current project: ${name}. Switch project`);
|
|
3669
|
+
if (dot) dot.classList.toggle("is-empty", !projectSwitcher.active);
|
|
3670
|
+
if (bdot) bdot.classList.toggle("is-empty", !projectSwitcher.active);
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
// IDs of every button that can OPEN the popover. The dismiss listener
|
|
3674
|
+
// ignores mousedowns on these so the trigger's own click handler can
|
|
3675
|
+
// decide whether to toggle. Without this, clicking the brand chip while
|
|
3676
|
+
// the popover is open would: (1) mousedown capture phase → close the
|
|
3677
|
+
// popover because the brand chip isn't the recorded anchor; (2) click
|
|
3678
|
+
// on the chip → handler sees pop.hidden=true → reopens it. The net
|
|
3679
|
+
// result is the toggle looks broken from the user's perspective.
|
|
3680
|
+
const PROJECT_TRIGGER_IDS = ["project-switcher-btn", "brand-project-chip"];
|
|
3681
|
+
|
|
3682
|
+
function closeProjectPopover() {
|
|
3683
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3684
|
+
// Clear aria-expanded on BOTH trigger buttons — either one could have
|
|
3685
|
+
// opened the popover, and either one should now report "collapsed".
|
|
3686
|
+
const btn = document.getElementById("project-switcher-btn");
|
|
3687
|
+
const chip = document.getElementById("brand-project-chip");
|
|
3688
|
+
if (pop) pop.hidden = true;
|
|
3689
|
+
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
3690
|
+
if (chip) chip.setAttribute("aria-expanded", "false");
|
|
3691
|
+
detachProjectPopoverDismiss();
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// Module-scope so we can remove the listener on close. Using a 50ms defer
|
|
3695
|
+
// avoids the click-that-opened-the-popover from also being the click that
|
|
3696
|
+
// immediately dismisses it (the user expects the popover to open first).
|
|
3697
|
+
let projectPopoverDismiss = null;
|
|
3698
|
+
|
|
3699
|
+
function attachProjectPopoverDismiss(popover) {
|
|
3700
|
+
detachProjectPopoverDismiss();
|
|
3701
|
+
const triggerEls = PROJECT_TRIGGER_IDS
|
|
3702
|
+
.map((id) => document.getElementById(id))
|
|
3703
|
+
.filter(Boolean);
|
|
3704
|
+
const onDocDown = (ev) => {
|
|
3705
|
+
if (!popover || popover.hidden) return;
|
|
3706
|
+
if (popover.contains(ev.target)) return;
|
|
3707
|
+
for (const el of triggerEls) {
|
|
3708
|
+
if (el.contains(ev.target)) return;
|
|
3709
|
+
}
|
|
3710
|
+
closeProjectPopover();
|
|
3711
|
+
};
|
|
3712
|
+
const onKey = (ev) => {
|
|
3713
|
+
if (ev.key === "Escape" && popover && !popover.hidden) {
|
|
3714
|
+
ev.preventDefault();
|
|
3715
|
+
ev.stopPropagation();
|
|
3716
|
+
closeProjectPopover();
|
|
3717
|
+
}
|
|
3718
|
+
};
|
|
3719
|
+
document.addEventListener("mousedown", onDocDown, true);
|
|
3720
|
+
document.addEventListener("keydown", onKey, true);
|
|
3721
|
+
projectPopoverDismiss = () => {
|
|
3722
|
+
document.removeEventListener("mousedown", onDocDown, true);
|
|
3723
|
+
document.removeEventListener("keydown", onKey, true);
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
function detachProjectPopoverDismiss() {
|
|
3727
|
+
if (projectPopoverDismiss) {
|
|
3728
|
+
try { projectPopoverDismiss(); } catch {}
|
|
3729
|
+
projectPopoverDismiss = null;
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// Position the popover directly below the anchor that opened it. The
|
|
3734
|
+
// popover element must already be in the DOM (renderProjectPopover()
|
|
3735
|
+
// populates it) so getBoundingClientRect() returns real dimensions.
|
|
3736
|
+
function positionPopover(anchorBtn) {
|
|
3737
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3738
|
+
if (!pop || !anchorBtn) return;
|
|
3739
|
+
const rect = anchorBtn.getBoundingClientRect();
|
|
3740
|
+
const popRect = pop.getBoundingClientRect();
|
|
3741
|
+
// Default: align the popover's right edge to the anchor's right edge.
|
|
3742
|
+
// That's the right look for the topbar-right chip.
|
|
3743
|
+
let top = rect.bottom + 6;
|
|
3744
|
+
let left = rect.right - popRect.width;
|
|
3745
|
+
// The brand chip lives on the LEFT side of the topbar — align the
|
|
3746
|
+
// popover's left edge to the anchor's left edge for a more natural
|
|
3747
|
+
// visual flow.
|
|
3748
|
+
if (anchorBtn.id === "brand-project-chip") {
|
|
3749
|
+
left = rect.left;
|
|
3750
|
+
}
|
|
3751
|
+
// Keep the popover on-screen with a small margin.
|
|
3752
|
+
const PAD = 8;
|
|
3753
|
+
const maxLeft = window.innerWidth - popRect.width - PAD;
|
|
3754
|
+
if (left > maxLeft) left = maxLeft;
|
|
3755
|
+
if (left < PAD) left = PAD;
|
|
3756
|
+
// If the popover would fall off the bottom, flip it above the anchor.
|
|
3757
|
+
if (top + popRect.height > window.innerHeight - PAD) {
|
|
3758
|
+
top = rect.top - popRect.height - 6;
|
|
3759
|
+
}
|
|
3760
|
+
if (top < PAD) top = PAD;
|
|
3761
|
+
pop.style.top = `${top}px`;
|
|
3762
|
+
pop.style.left = `${left}px`;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
function openProjectPopover(anchorBtn) {
|
|
3766
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3767
|
+
const btn = document.getElementById("project-switcher-btn");
|
|
3768
|
+
const chip = document.getElementById("brand-project-chip");
|
|
3769
|
+
if (!pop) return;
|
|
3770
|
+
renderProjectPopover();
|
|
3771
|
+
pop.hidden = false;
|
|
3772
|
+
// Mark BOTH trigger buttons as expanded so screen readers and CSS
|
|
3773
|
+
// stay in sync regardless of which one opened the popover.
|
|
3774
|
+
if (btn) btn.setAttribute("aria-expanded", "true");
|
|
3775
|
+
if (chip) chip.setAttribute("aria-expanded", "true");
|
|
3776
|
+
// Position the popover below the anchor that opened it.
|
|
3777
|
+
positionPopover(anchorBtn);
|
|
3778
|
+
// Defer dismiss-listener attachment so the opening click doesn't
|
|
3779
|
+
// immediately close the popover. 50ms is short enough to feel
|
|
3780
|
+
// instant but long enough to outlast the opening click event.
|
|
3781
|
+
setTimeout(() => attachProjectPopoverDismiss(pop), 50);
|
|
3782
|
+
void fetchProjects().then(() => { if (!pop.hidden) { renderProjectPopover(); positionPopover(anchorBtn); } });
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
// Single toggle entry point used by both trigger buttons. Decouples
|
|
3786
|
+
// the click handler from the mousedown-capture dismiss listener so the
|
|
3787
|
+
// two can never race (close-then-reopen) on the same click.
|
|
3788
|
+
function toggleProjectPopover(anchorBtn) {
|
|
3789
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3790
|
+
if (!pop) return;
|
|
3791
|
+
if (!pop.hidden) {
|
|
3792
|
+
closeProjectPopover();
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
openProjectPopover(anchorBtn);
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
function renderProjectPopover() {
|
|
3799
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3800
|
+
if (!pop) return;
|
|
3801
|
+
pop.innerHTML = "";
|
|
3802
|
+
// Header row — a small "Projects" label so the popover reads as a
|
|
3803
|
+
// section header and not just a loose list of buttons. Styled in CSS
|
|
3804
|
+
// as a muted, uppercase, letter-spaced label.
|
|
3805
|
+
pop.append(el("div", "project-switcher-header", { text: "Projects · Recent activity" }));
|
|
3806
|
+
const list = projectSwitcher.list || [];
|
|
3807
|
+
if (projectSwitcher.error) {
|
|
3808
|
+
pop.append(el("div", "project-switcher-error", {
|
|
3809
|
+
text: `Projects unavailable: ${projectSwitcher.error}`,
|
|
3810
|
+
}));
|
|
3811
|
+
}
|
|
3812
|
+
if (list.length === 0 && !projectSwitcher.error) {
|
|
3813
|
+
pop.append(el("div", "project-switcher-empty", {
|
|
3814
|
+
text: "No projects registered yet.",
|
|
3815
|
+
}));
|
|
3816
|
+
}
|
|
3817
|
+
for (const p of list) {
|
|
3818
|
+
const row = el("button", "project-switcher-item", {
|
|
3819
|
+
type: "button",
|
|
3820
|
+
role: "menuitem",
|
|
3821
|
+
title: p.root || p.name,
|
|
3822
|
+
});
|
|
3823
|
+
if (p.active) row.classList.add("active");
|
|
3824
|
+
row.append(el("span", `project-dot${p.active ? " filled" : ""}`, { "aria-hidden": "true" }));
|
|
3825
|
+
const identity = el("span", "project-switcher-identity");
|
|
3826
|
+
identity.append(el("span", "project-switcher-name", { text: p.name || "(unnamed)" }));
|
|
3827
|
+
identity.append(el("span", "project-switcher-path", { text: p.root || "" }));
|
|
3828
|
+
row.append(identity);
|
|
3829
|
+
if (p.active) {
|
|
3830
|
+
row.append(el("span", "project-switcher-badge", { text: "active" }));
|
|
3831
|
+
}
|
|
3832
|
+
// Per-row ✕ — stops propagation so clicking it doesn't switch.
|
|
3833
|
+
const x = el("span", "project-switcher-remove", {
|
|
3834
|
+
text: "✕",
|
|
3835
|
+
title: "Remove this project",
|
|
3836
|
+
role: "button",
|
|
3837
|
+
"aria-label": `Remove project ${p.name}`,
|
|
3838
|
+
});
|
|
3839
|
+
x.addEventListener("click", async (ev) => {
|
|
3840
|
+
ev.stopPropagation();
|
|
3841
|
+
if (!confirm(`Remove project "${p.name}"?`)) return;
|
|
3842
|
+
// Close immediately so the popover disappears even before the DELETE
|
|
3843
|
+
// request lands. If the delete fails, the toast surfaces the error
|
|
3844
|
+
// and the user can re-open the popover to retry.
|
|
3845
|
+
closeProjectPopover();
|
|
3846
|
+
try {
|
|
3847
|
+
await api("DELETE", `/api/projects/${encodeURIComponent(p.id)}`);
|
|
3848
|
+
await fetchProjects();
|
|
3849
|
+
renderProjectChip();
|
|
3850
|
+
} catch (err) {
|
|
3851
|
+
showToast(`Failed to remove project: ${err.message}`, "error");
|
|
3852
|
+
}
|
|
3853
|
+
});
|
|
3854
|
+
row.addEventListener("click", async () => {
|
|
3855
|
+
// Close immediately so the popover disappears the instant the user
|
|
3856
|
+
// picks a project — don't wait for the PATCH to land.
|
|
3857
|
+
closeProjectPopover();
|
|
3858
|
+
try {
|
|
3859
|
+
await api("PATCH", `/api/projects/${encodeURIComponent(p.id)}/active`);
|
|
3860
|
+
} catch (err) {
|
|
3861
|
+
showToast(`Failed to switch project: ${err.message}`, "error");
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
await fetchProjects();
|
|
3865
|
+
renderProjectChip();
|
|
3866
|
+
// Hard reload so every module re-fetches against the new project.
|
|
3867
|
+
window.location.reload();
|
|
3868
|
+
});
|
|
3869
|
+
row.addEventListener("contextmenu", (ev) => {
|
|
3870
|
+
ev.preventDefault();
|
|
3871
|
+
openProjectContextMenu(ev, p);
|
|
3872
|
+
});
|
|
3873
|
+
row.append(x);
|
|
3874
|
+
pop.append(row);
|
|
3875
|
+
}
|
|
3876
|
+
if (list.length > 0) {
|
|
3877
|
+
pop.append(el("div", "project-switcher-divider"));
|
|
3878
|
+
}
|
|
3879
|
+
const addRow = el("button", "project-switcher-add", {
|
|
3880
|
+
type: "button",
|
|
3881
|
+
role: "menuitem",
|
|
3882
|
+
text: "+ Add project…",
|
|
3883
|
+
});
|
|
3884
|
+
addRow.addEventListener("click", () => {
|
|
3885
|
+
closeProjectPopover();
|
|
3886
|
+
openAddProjectModal();
|
|
3887
|
+
});
|
|
3888
|
+
pop.append(addRow);
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
function openProjectContextMenu(ev, project) {
|
|
3892
|
+
// Minimal v1 context menu: "Open in new tab" + "Reveal in file manager".
|
|
3893
|
+
const menu = document.createElement("div");
|
|
3894
|
+
menu.className = "project-context-menu";
|
|
3895
|
+
menu.style.position = "fixed";
|
|
3896
|
+
menu.style.left = `${Math.min(ev.clientX, window.innerWidth - 200)}px`;
|
|
3897
|
+
menu.style.top = `${Math.min(ev.clientY, window.innerHeight - 100)}px`;
|
|
3898
|
+
menu.setAttribute("role", "menu");
|
|
3899
|
+
|
|
3900
|
+
const open = document.createElement("button");
|
|
3901
|
+
open.type = "button";
|
|
3902
|
+
open.textContent = "Open in new tab";
|
|
3903
|
+
open.addEventListener("click", () => {
|
|
3904
|
+
window.open("/", "_blank", "noopener");
|
|
3905
|
+
document.body.removeChild(menu);
|
|
3906
|
+
});
|
|
3907
|
+
menu.append(open);
|
|
3908
|
+
|
|
3909
|
+
const reveal = document.createElement("button");
|
|
3910
|
+
reveal.type = "button";
|
|
3911
|
+
reveal.textContent = "Reveal in file manager";
|
|
3912
|
+
reveal.addEventListener("click", async () => {
|
|
3913
|
+
try {
|
|
3914
|
+
await api("POST", "/api/reveal", { path: project.root });
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
// Server may not implement /api/reveal — fall back to clipboard so
|
|
3917
|
+
// the user can paste it into their file manager.
|
|
3918
|
+
try {
|
|
3919
|
+
await navigator.clipboard.writeText(String(project.root || ""));
|
|
3920
|
+
showToast(`Copied path: ${project.root}`);
|
|
3921
|
+
} catch {
|
|
3922
|
+
alert(`Project root: ${project.root}`);
|
|
3923
|
+
}
|
|
3924
|
+
} finally {
|
|
3925
|
+
if (menu.parentNode) menu.parentNode.removeChild(menu);
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
menu.append(reveal);
|
|
3929
|
+
|
|
3930
|
+
const close = (e) => {
|
|
3931
|
+
if (menu.contains(e.target)) return;
|
|
3932
|
+
if (menu.parentNode) menu.parentNode.removeChild(menu);
|
|
3933
|
+
document.removeEventListener("click", close, true);
|
|
3934
|
+
};
|
|
3935
|
+
document.addEventListener("click", close, true);
|
|
3936
|
+
document.body.append(menu);
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
function getProjectModal() {
|
|
3940
|
+
return document.getElementById("project-backdrop");
|
|
3941
|
+
}
|
|
3942
|
+
function openAddProjectModal() {
|
|
3943
|
+
const bd = getProjectModal();
|
|
3944
|
+
if (!bd) return;
|
|
3945
|
+
bd.hidden = false;
|
|
3946
|
+
setTimeout(() => {
|
|
3947
|
+
const nameInput = bd.querySelector('input[name="name"]');
|
|
3948
|
+
if (nameInput) nameInput.focus();
|
|
3949
|
+
}, 0);
|
|
3950
|
+
}
|
|
3951
|
+
function closeAddProjectModal() {
|
|
3952
|
+
const bd = getProjectModal();
|
|
3953
|
+
if (bd) {
|
|
3954
|
+
bd.hidden = true;
|
|
3955
|
+
const form = bd.querySelector("form");
|
|
3956
|
+
if (form) form.reset();
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
function attachProjectSwitcher() {
|
|
3961
|
+
const btn = document.getElementById("project-switcher-btn");
|
|
3962
|
+
const pop = document.getElementById("project-switcher-popover");
|
|
3963
|
+
const chip = document.getElementById("brand-project-chip");
|
|
3964
|
+
if (pop) {
|
|
3965
|
+
// Move the popover to <body> so `position: fixed` is relative to
|
|
3966
|
+
// the viewport, not the topbar. The topbar uses `backdrop-filter`
|
|
3967
|
+
// which creates a containing block for fixed-positioned descendants
|
|
3968
|
+
// in some browsers; relocating to <body> sidesteps that entirely
|
|
3969
|
+
// and also lets the popover escape any future overflow:hidden on
|
|
3970
|
+
// the topbar or its ancestors.
|
|
3971
|
+
if (pop.parentElement !== document.body) {
|
|
3972
|
+
document.body.appendChild(pop);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
if (chip) {
|
|
3976
|
+
// Brand chip starts collapsed. We add aria-haspopup/aria-expanded
|
|
3977
|
+
// here (rather than in the HTML) so the chip advertises itself as
|
|
3978
|
+
// a menu trigger to assistive tech.
|
|
3979
|
+
chip.setAttribute("aria-haspopup", "menu");
|
|
3980
|
+
chip.setAttribute("aria-expanded", "false");
|
|
3981
|
+
}
|
|
3982
|
+
if (btn && pop) {
|
|
3983
|
+
btn.addEventListener("click", (e) => {
|
|
3984
|
+
e.preventDefault();
|
|
3985
|
+
e.stopPropagation();
|
|
3986
|
+
toggleProjectPopover(btn);
|
|
3987
|
+
});
|
|
3988
|
+
// Dismiss listeners are attached inside openProjectPopover() with a
|
|
3989
|
+
// 50ms delay so the opening click doesn't immediately close it. The
|
|
3990
|
+
// close handler detaches them, so we don't need module-scope ones here.
|
|
3991
|
+
}
|
|
3992
|
+
if (chip) {
|
|
3993
|
+
chip.addEventListener("click", (e) => {
|
|
3994
|
+
e.preventDefault();
|
|
3995
|
+
e.stopPropagation();
|
|
3996
|
+
toggleProjectPopover(chip);
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
// Add-project modal wiring.
|
|
4000
|
+
const bd = getProjectModal();
|
|
4001
|
+
if (bd) {
|
|
4002
|
+
bd.addEventListener("click", (e) => {
|
|
4003
|
+
if (e.target === bd) closeAddProjectModal();
|
|
4004
|
+
});
|
|
4005
|
+
bd.querySelectorAll("[data-close-project]").forEach((b) =>
|
|
4006
|
+
b.addEventListener("click", closeAddProjectModal),
|
|
4007
|
+
);
|
|
4008
|
+
const form = document.getElementById("project-form");
|
|
4009
|
+
if (form) {
|
|
4010
|
+
form.addEventListener("submit", async (ev) => {
|
|
4011
|
+
ev.preventDefault();
|
|
4012
|
+
const fd = new FormData(form);
|
|
4013
|
+
const body = {
|
|
4014
|
+
name: String(fd.get("name") || "").trim(),
|
|
4015
|
+
root: String(fd.get("root") || "").trim(),
|
|
4016
|
+
};
|
|
4017
|
+
if (!body.name || !body.root) return alert("Name and path are required.");
|
|
4018
|
+
try {
|
|
4019
|
+
await api("POST", "/api/projects", body);
|
|
4020
|
+
} catch (err) {
|
|
4021
|
+
alert(`Failed to add project: ${err.message}`);
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
closeAddProjectModal();
|
|
4025
|
+
// Re-fetch and refresh the chip + dropdown.
|
|
4026
|
+
await fetchProjects();
|
|
4027
|
+
renderProjectChip();
|
|
4028
|
+
// Reload so the new project becomes active on the server side and
|
|
4029
|
+
// the rest of the UI can re-fetch against it.
|
|
4030
|
+
window.location.reload();
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
// Browse-button wiring — opens the path picker in folder mode and
|
|
4035
|
+
// writes the picked path into the root input. The path picker is
|
|
4036
|
+
// loaded by its own script tag in index.html and exposed via
|
|
4037
|
+
// window.OpenKanPathPicker. We treat its absence as a no-op so the
|
|
4038
|
+
// page still works if the script fails to load.
|
|
4039
|
+
const browseBtn = document.getElementById("project-browse-btn");
|
|
4040
|
+
if (browseBtn) {
|
|
4041
|
+
browseBtn.addEventListener("click", () => {
|
|
4042
|
+
const picker = window.OpenKanPathPicker;
|
|
4043
|
+
if (!picker || typeof picker.open !== "function") {
|
|
4044
|
+
console.warn(
|
|
4045
|
+
"[project-modal] Browse clicked but OpenKanPathPicker is not loaded",
|
|
4046
|
+
);
|
|
4047
|
+
return;
|
|
4048
|
+
}
|
|
4049
|
+
const rootInput =
|
|
4050
|
+
bd.querySelector('input[name="root"]') ||
|
|
4051
|
+
document.querySelector('input[name="root"]');
|
|
4052
|
+
const initial =
|
|
4053
|
+
rootInput && typeof rootInput.value === "string"
|
|
4054
|
+
? rootInput.value.trim()
|
|
4055
|
+
: "";
|
|
4056
|
+
picker.open({
|
|
4057
|
+
title: "Choose a project folder",
|
|
4058
|
+
mode: "folder",
|
|
4059
|
+
initialPath: initial || undefined,
|
|
4060
|
+
onPick: (path) => {
|
|
4061
|
+
if (rootInput) rootInput.value = path;
|
|
4062
|
+
},
|
|
4063
|
+
onCancel: () => {
|
|
4064
|
+
/* user dismissed — nothing to do */
|
|
4065
|
+
},
|
|
4066
|
+
});
|
|
4067
|
+
});
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
// Initial fetch.
|
|
4071
|
+
fetchProjects().then(() => renderProjectChip());
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
// ---------- Boot ----------
|
|
4075
|
+
(async () => {
|
|
4076
|
+
// Restore filter from URL hash before first render.
|
|
4077
|
+
const initial = readHashFilter();
|
|
4078
|
+
if (initial) {
|
|
4079
|
+
filter.category = initial.category;
|
|
4080
|
+
filter.tags = initial.tags;
|
|
4081
|
+
filter.contributor = initial.contributor;
|
|
4082
|
+
filter.archive = initial.archive;
|
|
4083
|
+
filter.sort = initial.sort;
|
|
4084
|
+
filter.search = initial.search || "";
|
|
4085
|
+
}
|
|
4086
|
+
attachFilterBar();
|
|
4087
|
+
attachSearch();
|
|
4088
|
+
attachBulkBar();
|
|
4089
|
+
applyFilterToButtons();
|
|
4090
|
+
renderSavedFilters();
|
|
4091
|
+
attachTabRouter();
|
|
4092
|
+
attachWorkspaceMode();
|
|
4093
|
+
attachFilterDisclosure();
|
|
4094
|
+
attachProjectSwitcher();
|
|
4095
|
+
// Page-wide right-click context menu (capture-phase delegation). The
|
|
4096
|
+
// handler decides which menu to show based on `e.target.closest()`:
|
|
4097
|
+
// card → per-task menu, column → column ops, chip → filter ops, etc.
|
|
4098
|
+
document.addEventListener("contextmenu", openGlobalContextMenu, true);
|
|
4099
|
+
// Normal-bubble fallback. If a downstream capture-phase handler calls
|
|
4100
|
+
// stopPropagation() (or some browser extension swallows the capture
|
|
4101
|
+
// event), this listener still catches the contextmenu so the user
|
|
4102
|
+
// always gets a response.
|
|
4103
|
+
document.addEventListener("contextmenu", openGlobalContextMenu);
|
|
4104
|
+
// M13 wiring — keyboard nav + command palette actions + cross-tab sync.
|
|
4105
|
+
// Order matters: keyboard module is initialized when keyboard.js loads
|
|
4106
|
+
// (it's a self-contained IIFE on a DOMContentLoaded hook), but our
|
|
4107
|
+
// handlers must be registered before any key fires. The palette registers
|
|
4108
|
+
// its actions during init() too, and exposes itself at first palette.open
|
|
4109
|
+
// call — so we attach handlers eagerly.
|
|
4110
|
+
attachKeyboard();
|
|
4111
|
+
registerPaletteActions();
|
|
4112
|
+
attachCrossTab();
|
|
4113
|
+
// The ⌘K topbar button is a click-only entry point to the palette.
|
|
4114
|
+
// The keyboard module is the canonical emitter for "palette.open".
|
|
4115
|
+
const paletteBtn = document.getElementById("palette-btn");
|
|
4116
|
+
if (paletteBtn) {
|
|
4117
|
+
paletteBtn.addEventListener("click", (e) => {
|
|
4118
|
+
e.preventDefault();
|
|
4119
|
+
window.OpenKanKeyboard?.execute?.("palette.open");
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
// Activate the tab from the hash, defaulting to "tasks".
|
|
4123
|
+
const hash = readHashFilter();
|
|
4124
|
+
activateTab(hash?.tab || "tasks", { fromHash: true });
|
|
4125
|
+
|
|
4126
|
+
// Mount the chat sidebar (right-rail chat orchestrator). chat-sidebar.js
|
|
4127
|
+
// wires its own topbar toggle button; this call ensures the DOM shell
|
|
4128
|
+
// exists so the toggle can flip its open/closed state.
|
|
4129
|
+
try { window.OpenKanChatSidebar?.mount?.(document.body); } catch { /* ignore */ }
|
|
4130
|
+
|
|
4131
|
+
try {
|
|
4132
|
+
applySnapshot(await api("GET", "/api/board"));
|
|
4133
|
+
setConnected(true);
|
|
4134
|
+
} catch {
|
|
4135
|
+
setConnected(false);
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
// Best-effort fetch of contributors (used by filter row + assignees).
|
|
4139
|
+
loadContributors();
|
|
4140
|
+
|
|
4141
|
+
// Run the restored search query once the board has loaded so the meta
|
|
4142
|
+
// count and the per-card visibility agree.
|
|
4143
|
+
if (filter.search) {
|
|
4144
|
+
const meta = document.getElementById("search-meta");
|
|
4145
|
+
runSearch(filter.search, meta);
|
|
4146
|
+
}
|
|
4147
|
+
updateBulkBar();
|
|
4148
|
+
|
|
4149
|
+
// If the user lands on a task URL we don't auto-open it (no notion of
|
|
4150
|
+
// deep-link to a task in v1.1). Hook left here for future M12 work.
|
|
4151
|
+
})();
|
|
4152
|
+
|
|
4153
|
+
// Expose a tiny API so the task view (a separate file) can set a filter
|
|
4154
|
+
// and have the board re-render.
|
|
4155
|
+
window.OpenKanBoard = {
|
|
4156
|
+
setTagFilter(tag) {
|
|
4157
|
+
const t = String(tag || "").toLowerCase().trim();
|
|
4158
|
+
if (!t) return;
|
|
4159
|
+
if (!filter.tags.includes(t)) filter.tags.push(t);
|
|
4160
|
+
writeHashFilter();
|
|
4161
|
+
applyFilterToButtons();
|
|
4162
|
+
renderBoard();
|
|
4163
|
+
},
|
|
4164
|
+
setCategoryFilter(category) {
|
|
4165
|
+
filter.category = String(category || "all");
|
|
4166
|
+
writeHashFilter();
|
|
4167
|
+
applyFilterToButtons();
|
|
4168
|
+
renderBoard();
|
|
4169
|
+
},
|
|
4170
|
+
setContributorFilter(contributor) {
|
|
4171
|
+
filter.contributor = String(contributor || "all");
|
|
4172
|
+
writeHashFilter();
|
|
4173
|
+
applyFilterToButtons();
|
|
4174
|
+
renderBoard();
|
|
4175
|
+
},
|
|
4176
|
+
setSearch(query) {
|
|
4177
|
+
const q = String(query || "");
|
|
4178
|
+
filter.search = q;
|
|
4179
|
+
const input = document.getElementById("search-input");
|
|
4180
|
+
if (input) input.value = q;
|
|
4181
|
+
searchMatchIds = null;
|
|
4182
|
+
writeHashFilter();
|
|
4183
|
+
applyFilterToButtons();
|
|
4184
|
+
renderBoard();
|
|
4185
|
+
if (q) {
|
|
4186
|
+
const meta = document.getElementById("search-meta");
|
|
4187
|
+
runSearch(q, meta);
|
|
4188
|
+
} else {
|
|
4189
|
+
const meta = document.getElementById("search-meta");
|
|
4190
|
+
updateSearchMeta(meta, null, null);
|
|
4191
|
+
}
|
|
4192
|
+
},
|
|
4193
|
+
setFilter(filterObj) {
|
|
4194
|
+
if (!filterObj) return;
|
|
4195
|
+
if (typeof filterObj.category === "string") filter.category = filterObj.category;
|
|
4196
|
+
if (Array.isArray(filterObj.tags)) filter.tags = filterObj.tags.slice();
|
|
4197
|
+
if (typeof filterObj.contributor === "string") filter.contributor = filterObj.contributor;
|
|
4198
|
+
if (typeof filterObj.archive === "string") filter.archive = filterObj.archive;
|
|
4199
|
+
if (typeof filterObj.sort === "string") filter.sort = filterObj.sort;
|
|
4200
|
+
writeHashFilter();
|
|
4201
|
+
applyFilterToButtons();
|
|
4202
|
+
renderBoard();
|
|
4203
|
+
},
|
|
4204
|
+
getFilter() {
|
|
4205
|
+
return {
|
|
4206
|
+
category: filter.category,
|
|
4207
|
+
tags: filter.tags.slice(),
|
|
4208
|
+
contributor: filter.contributor,
|
|
4209
|
+
archive: filter.archive,
|
|
4210
|
+
sort: filter.sort,
|
|
4211
|
+
search: filter.search,
|
|
4212
|
+
};
|
|
4213
|
+
},
|
|
4214
|
+
getSelectedIds() {
|
|
4215
|
+
return [...selectedIds];
|
|
4216
|
+
},
|
|
4217
|
+
clearSelection() {
|
|
4218
|
+
clearCardSelection();
|
|
4219
|
+
},
|
|
4220
|
+
activateTab(name) {
|
|
4221
|
+
activateTab(name);
|
|
4222
|
+
},
|
|
4223
|
+
};
|
|
4224
|
+
|
|
4225
|
+
// Expose the tab router for cross-file coordination (changelog-view,
|
|
4226
|
+
// contributors-view use it to jump to the Tasks tab).
|
|
4227
|
+
window.OpenKanTabs = { activate: activateTab };
|
|
4228
|
+
|
|
4229
|
+
// Expose the right-click menu infrastructure so the task view (and any
|
|
4230
|
+
// other module) can show context menus that share the same host, styling,
|
|
4231
|
+
// and dismiss behavior. Callers push an items[] in the same shape as
|
|
4232
|
+
// renderMenu's argument.
|
|
4233
|
+
window.OpenKanMenu = {
|
|
4234
|
+
showAt(items, ev) {
|
|
4235
|
+
if (!ev) return;
|
|
4236
|
+
renderMenu(items);
|
|
4237
|
+
positionMenuAt(ev);
|
|
4238
|
+
},
|
|
4239
|
+
show(items, anchorEl) {
|
|
4240
|
+
renderMenu(items);
|
|
4241
|
+
// Synthesize a clientX/Y from the anchor's bounding rect so the menu
|
|
4242
|
+
// appears just below it. Useful for keyboard "open menu" handlers.
|
|
4243
|
+
if (anchorEl) {
|
|
4244
|
+
const r = anchorEl.getBoundingClientRect();
|
|
4245
|
+
const fake = { clientX: r.left, clientY: r.bottom };
|
|
4246
|
+
positionMenuAt(fake);
|
|
4247
|
+
}
|
|
4248
|
+
},
|
|
4249
|
+
hide: hideMenu,
|
|
4250
|
+
};
|
|
4251
|
+
})();
|