trekoon 0.2.7 → 0.2.9
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/README.md +60 -0
- package/docs/commands.md +100 -0
- package/docs/quickstart.md +74 -1
- package/package.json +2 -1
- package/src/board/assets/app.js +589 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +64 -0
- package/src/board/assets/components/EpicsOverview.js +80 -0
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +80 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +308 -0
- package/src/board/assets/components/assetMap.js +80 -0
- package/src/board/assets/components/helpers.js +244 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +39 -0
- package/src/board/assets/main.js +11 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/runtime/delegation.js +309 -0
- package/src/board/assets/state/actions.js +454 -0
- package/src/board/assets/state/api.js +281 -0
- package/src/board/assets/state/store.js +472 -0
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +1811 -0
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +308 -0
- package/src/board/server.ts +185 -0
- package/src/board/snapshot.ts +277 -0
- package/src/board/types.ts +43 -0
- package/src/commands/board.ts +158 -0
- package/src/commands/help.ts +21 -0
- package/src/commands/init.ts +29 -0
- package/src/domain/mutation-service.ts +40 -0
- package/src/domain/tracker-domain.ts +11 -3
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
|
|
2
|
+
|
|
3
|
+
export { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER };
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Status labels & styles
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export const STATUS_LABELS = {
|
|
10
|
+
todo: "Todo",
|
|
11
|
+
blocked: "Blocked",
|
|
12
|
+
in_progress: "In progress",
|
|
13
|
+
done: "Done",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const STATUS_BADGE_STYLES = {
|
|
17
|
+
todo: "border-white/10 bg-white/[0.05] text-[var(--board-text-muted)]",
|
|
18
|
+
blocked: "border-amber-500/20 bg-amber-500/10 text-amber-300",
|
|
19
|
+
in_progress: "border-sky-400/20 bg-sky-400/10 text-sky-300",
|
|
20
|
+
done: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
|
|
21
|
+
default: "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text-muted)]",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Class-name helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function cx(...classNames) {
|
|
29
|
+
return classNames.filter(Boolean).join(" ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function panelClasses(extra = "") {
|
|
33
|
+
return cx(
|
|
34
|
+
"rounded-[28px] border border-[var(--board-border)] bg-[var(--board-surface)] shadow-panel",
|
|
35
|
+
extra,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function secondaryPanelClasses(extra = "") {
|
|
40
|
+
return cx(
|
|
41
|
+
"rounded-[24px] border border-[var(--board-border)] bg-[var(--board-surface-2)]",
|
|
42
|
+
extra,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sectionLabelClasses() {
|
|
47
|
+
return "text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function neutralChipClasses() {
|
|
51
|
+
return "inline-flex items-center gap-1 rounded-full border border-[var(--board-border)] bg-white/[0.04] px-2.5 py-1 text-[11px] font-medium text-[var(--board-text-muted)]";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buttonClasses(options = {}) {
|
|
55
|
+
const kind = options.kind ?? "secondary";
|
|
56
|
+
const iconOnly = options.iconOnly ?? false;
|
|
57
|
+
|
|
58
|
+
return cx(
|
|
59
|
+
"inline-flex items-center justify-center gap-2 rounded-2xl border text-sm font-medium transition duration-200",
|
|
60
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--board-border-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--board-bg)]",
|
|
61
|
+
iconOnly ? "h-10 w-10 px-0" : "min-h-10 px-4 py-2.5",
|
|
62
|
+
kind === "primary"
|
|
63
|
+
? "border-[var(--board-accent)] bg-[var(--board-accent)] text-white hover:bg-[var(--board-accent-strong)] hover:border-[var(--board-accent-strong)]"
|
|
64
|
+
: "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text)] hover:bg-white/[0.08] hover:border-[var(--board-border-strong)]",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function fieldClasses() {
|
|
69
|
+
return cx(
|
|
70
|
+
"w-full rounded-2xl border border-[var(--board-border)] bg-[var(--board-surface-2)] px-3.5 py-3 text-sm text-[var(--board-text)] shadow-sm transition",
|
|
71
|
+
"placeholder:text-[var(--board-text-soft)] focus:border-[var(--board-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--board-accent-soft)]",
|
|
72
|
+
"disabled:cursor-not-allowed disabled:opacity-60",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function statusBadgeClasses(status) {
|
|
77
|
+
return cx(
|
|
78
|
+
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
|
|
79
|
+
STATUS_BADGE_STYLES[normalizeStatus(status)] ?? STATUS_BADGE_STYLES.default,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Render helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export function renderIcon(name, className = "") {
|
|
88
|
+
return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readStatusLabel(rawStatus) {
|
|
92
|
+
if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
|
|
93
|
+
return "Unknown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const normalized = normalizeStatus(rawStatus);
|
|
97
|
+
if (STATUS_LABELS[normalized]) {
|
|
98
|
+
return STATUS_LABELS[normalized];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return rawStatus.replaceAll("_", " ").replaceAll("-", " ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus)) {
|
|
105
|
+
return `<span class="${statusBadgeClasses(rawStatus)}">${escapeHtml(label)}</span>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderStatusSelect(name, selectedStatus, disabled = false) {
|
|
109
|
+
return `
|
|
110
|
+
<select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
|
|
111
|
+
${STATUS_ORDER.map((status) => `
|
|
112
|
+
<option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
|
|
113
|
+
`).join("")}
|
|
114
|
+
</select>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderEmptyState(title, description, shortcut) {
|
|
119
|
+
return `
|
|
120
|
+
<div class="rounded-[24px] border border-dashed border-[var(--board-border-strong)] bg-[var(--board-accent-soft)]/40 px-5 py-6 text-center">
|
|
121
|
+
<strong class="block text-base font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
|
|
122
|
+
<p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(description)}</p>
|
|
123
|
+
${shortcut
|
|
124
|
+
? `<p class="mt-3 text-xs text-[var(--board-text-soft)]">Try <span class="inline-flex items-center rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 font-medium text-[var(--board-text-muted)]">${escapeHtml(shortcut)}</span></p>`
|
|
125
|
+
: ""}
|
|
126
|
+
</div>
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Description rendering helpers
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export function renderDescriptionPreview(description, className = "mt-1 text-sm leading-6 text-[var(--board-text-muted)]") {
|
|
135
|
+
if (!description || description.trim().length === 0) return "";
|
|
136
|
+
return `<p class="${escapeHtml(className)}">${escapeHtml(description)}</p>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderDescriptionBody(description, className = "text-sm leading-7 text-[var(--board-text-muted)]") {
|
|
140
|
+
if (!description || description.trim().length === 0) {
|
|
141
|
+
return `<p class="${escapeHtml(className)}">No description provided.</p>`;
|
|
142
|
+
}
|
|
143
|
+
return `<div class="${escapeHtml(className)}" style="white-space:pre-wrap;word-break:break-word">${escapeHtml(description)}</div>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function shouldCollapseDescription(description) {
|
|
147
|
+
if (!description) return false;
|
|
148
|
+
const trimmed = description.trim();
|
|
149
|
+
return trimmed.length > 260 || trimmed.split("\n").length > 5;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function renderDescriptionSection(title, description, options = {}) {
|
|
153
|
+
const {
|
|
154
|
+
open = false,
|
|
155
|
+
compact = false,
|
|
156
|
+
emptyText = "Add context so collaborators know what done looks like.",
|
|
157
|
+
} = options;
|
|
158
|
+
|
|
159
|
+
if (!description || description.trim().length === 0) {
|
|
160
|
+
return `
|
|
161
|
+
<section class="${secondaryPanelClasses("board-detail-card p-4")}">
|
|
162
|
+
<div class="board-section__header flex items-center justify-between gap-3">
|
|
163
|
+
<strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
|
|
164
|
+
<span class="${neutralChipClasses()}">Empty</span>
|
|
165
|
+
</div>
|
|
166
|
+
<p class="mt-3 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(emptyText)}</p>
|
|
167
|
+
</section>
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!shouldCollapseDescription(description)) {
|
|
172
|
+
return `
|
|
173
|
+
<section class="${secondaryPanelClasses("board-detail-card p-4")}">
|
|
174
|
+
<div class="board-section__header flex items-center justify-between gap-3">
|
|
175
|
+
<strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
|
|
176
|
+
<span class="${neutralChipClasses()}">${escapeHtml(`${description.trim().length} chars`)}</span>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="mt-3 ${compact ? "board-detail-copy board-detail-copy--compact" : "board-detail-copy"}">
|
|
179
|
+
${renderDescriptionBody(description)}
|
|
180
|
+
</div>
|
|
181
|
+
</section>
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return `
|
|
186
|
+
<details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${open ? "open" : ""}>
|
|
187
|
+
<summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
|
|
188
|
+
<span>${escapeHtml(title)}</span>
|
|
189
|
+
<span class="${neutralChipClasses()}">Long</span>
|
|
190
|
+
</summary>
|
|
191
|
+
<div class="mt-3 board-detail-copy ${compact ? "board-detail-copy--compact" : ""}">
|
|
192
|
+
${renderDescriptionBody(description)}
|
|
193
|
+
</div>
|
|
194
|
+
</details>
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Misc shared helpers
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function readNodeLabel(kind, title) {
|
|
203
|
+
if (kind === "task") return `Task: ${title}`;
|
|
204
|
+
if (kind === "subtask") return `Subtask: ${title}`;
|
|
205
|
+
return title;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function renderEpicCountSummary(epic) {
|
|
209
|
+
const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
|
|
210
|
+
const counts = epic.counts || { todo: 0, blocked: 0, in_progress: 0, done: 0 };
|
|
211
|
+
return `
|
|
212
|
+
<span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
|
|
213
|
+
<span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
|
|
214
|
+
<span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function renderTaskMeta(task, includeStatus = false) {
|
|
219
|
+
return `
|
|
220
|
+
${includeStatus ? renderStatusBadge(task.status) : ""}
|
|
221
|
+
<span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
|
|
222
|
+
${task.blockedBy.length > 0 ? `<span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>` : ""}
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function hasLongTaskTitle(title) {
|
|
227
|
+
if (!title) return false;
|
|
228
|
+
const trimmed = title.trim();
|
|
229
|
+
return trimmed.length > 72 || trimmed.split("\n").length > 2;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isCompactViewport() {
|
|
233
|
+
return typeof window !== "undefined" && window.matchMedia?.("(max-width: 900px)")?.matches;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function shouldUseTaskModal(boardState, store) {
|
|
237
|
+
return Boolean(boardState?.selectedTask);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function lookupNode(snapshot, id) {
|
|
241
|
+
return snapshot.tasks.find((task) => task.id === id)
|
|
242
|
+
?? snapshot.subtasks.find((subtask) => subtask.id === id)
|
|
243
|
+
?? null;
|
|
244
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<meta name="theme-color" content="#0b0d12" data-board-theme-color="active" />
|
|
8
|
+
<meta name="theme-color" content="#0b0d12" media="(prefers-color-scheme: dark)" />
|
|
9
|
+
<meta name="theme-color" content="#f4f6fb" media="(prefers-color-scheme: light)" />
|
|
10
|
+
<meta
|
|
11
|
+
name="description"
|
|
12
|
+
content="Trekoon board — local-first workspace for browsing epics, tasks, and flow states."
|
|
13
|
+
/>
|
|
14
|
+
<title>Trekoon Board</title>
|
|
15
|
+
<link rel="stylesheet" href="./styles/fonts.css" />
|
|
16
|
+
<link rel="stylesheet" href="./styles/board.css" />
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<a href="#board-runtime-root" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-[var(--board-accent)] focus:px-4 focus:py-2 focus:text-white">
|
|
20
|
+
Skip to content
|
|
21
|
+
</a>
|
|
22
|
+
|
|
23
|
+
<main id="app">
|
|
24
|
+
<div class="board-shell-v2">
|
|
25
|
+
<section class="board-shell-v2__frame">
|
|
26
|
+
<div class="board-shell-v2__runtime-shell">
|
|
27
|
+
<div
|
|
28
|
+
id="board-runtime-root"
|
|
29
|
+
class="board-shell-v2__runtime"
|
|
30
|
+
data-board-runtime-root
|
|
31
|
+
></div>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
</div>
|
|
35
|
+
</main>
|
|
36
|
+
|
|
37
|
+
<script type="module" src="./main.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
window.__TREKOON_BOARD_BOOTSTRAP__ = "main";
|
|
2
|
+
|
|
3
|
+
const runtimeRoot = document.querySelector("[data-board-runtime-root]");
|
|
4
|
+
|
|
5
|
+
if (!(runtimeRoot instanceof HTMLElement)) {
|
|
6
|
+
throw new Error("Board shell could not find the runtime mount root.");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { bootLegacyBoard } = await import("./app.js");
|
|
10
|
+
|
|
11
|
+
await bootLegacyBoard({ mountElement: runtimeRoot });
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event delegation system for the board runtime.
|
|
3
|
+
*
|
|
4
|
+
* Attaches a single listener per event type on the root element.
|
|
5
|
+
* Uses event.target.closest() to match data-attributes, so dynamically
|
|
6
|
+
* rendered content is handled automatically without rebinding.
|
|
7
|
+
*
|
|
8
|
+
* @param {HTMLElement} rootElement Mount root for delegated listeners.
|
|
9
|
+
* @param {object} actions Callback map the delegation dispatches into.
|
|
10
|
+
* @returns {() => void} Teardown function that removes every listener.
|
|
11
|
+
*/
|
|
12
|
+
export function createDelegation(rootElement, actions) {
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Click delegation
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function handleClick(event) {
|
|
17
|
+
const { target } = event;
|
|
18
|
+
|
|
19
|
+
// -- Destructive / mutation buttons (most specific first) -----------------
|
|
20
|
+
|
|
21
|
+
const deleteSubtaskEl = target.closest("[data-delete-subtask]");
|
|
22
|
+
if (deleteSubtaskEl) {
|
|
23
|
+
if (actions.isMutating()) return;
|
|
24
|
+
const subtaskId = deleteSubtaskEl.dataset.deleteSubtask;
|
|
25
|
+
if (subtaskId) actions.deleteSubtask(subtaskId, deleteSubtaskEl);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const removeDependencyEl = target.closest("[data-remove-dependency-source]");
|
|
30
|
+
if (removeDependencyEl) {
|
|
31
|
+
if (actions.isMutating()) return;
|
|
32
|
+
const sourceId = removeDependencyEl.dataset.removeDependencySource;
|
|
33
|
+
const dependsOnId = removeDependencyEl.dataset.removeDependencyTarget;
|
|
34
|
+
actions.removeDependency(sourceId, dependsOnId, removeDependencyEl);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// -- Disclosure openers ---------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const openSubtaskEl = target.closest("[data-open-subtask]");
|
|
41
|
+
if (openSubtaskEl) {
|
|
42
|
+
actions.openSubtask(openSubtaskEl.dataset.openSubtask || null, openSubtaskEl);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// -- Backdrop-style close handlers ----------------------------------------
|
|
47
|
+
// Only close when the click lands directly on the backdrop element itself,
|
|
48
|
+
// not on any child content rendered inside the overlay.
|
|
49
|
+
|
|
50
|
+
const closeSubtaskEl = target.closest("[data-close-subtask]");
|
|
51
|
+
if (closeSubtaskEl) {
|
|
52
|
+
if (
|
|
53
|
+
closeSubtaskEl.classList.contains("board-modal-backdrop") &&
|
|
54
|
+
target !== closeSubtaskEl
|
|
55
|
+
) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
actions.closeSubtask();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const closeTaskEl = target.closest("[data-close-task]");
|
|
63
|
+
if (closeTaskEl) {
|
|
64
|
+
if (
|
|
65
|
+
closeTaskEl.classList.contains("board-task-modal-backdrop") &&
|
|
66
|
+
target !== closeTaskEl
|
|
67
|
+
) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
actions.closeTask();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const closeConfirmEl = target.closest("[data-close-confirm]");
|
|
75
|
+
if (closeConfirmEl) {
|
|
76
|
+
if (
|
|
77
|
+
closeConfirmEl.classList.contains("board-confirm-backdrop") &&
|
|
78
|
+
target !== closeConfirmEl
|
|
79
|
+
) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
actions.cancelDelete();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- Navigation -----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const navEl = target.closest("[data-nav]");
|
|
89
|
+
if (navEl) {
|
|
90
|
+
if (navEl.dataset.nav === "epics") actions.showEpics();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const navBoardEl = target.closest("[data-nav-board]");
|
|
95
|
+
if (navBoardEl) {
|
|
96
|
+
actions.showBoard();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const navDetailEl = target.closest("[data-nav-detail]");
|
|
101
|
+
if (navDetailEl) {
|
|
102
|
+
actions.scrollToDetail();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- Notes panel toggle ---------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const toggleNotesEl = target.closest("[data-toggle-notes]");
|
|
109
|
+
if (toggleNotesEl) {
|
|
110
|
+
actions.toggleNotesPanel();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -- View switching -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const viewEl = target.closest("[data-view]");
|
|
117
|
+
if (viewEl) {
|
|
118
|
+
actions.setView(viewEl.dataset.view);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -- Generic actions (toggle-theme, confirm-delete, cancel-delete) --------
|
|
123
|
+
|
|
124
|
+
const actionEl = target.closest("[data-action]");
|
|
125
|
+
if (actionEl) {
|
|
126
|
+
const action = actionEl.dataset.action;
|
|
127
|
+
if (action === "toggle-theme") actions.toggleTheme();
|
|
128
|
+
if (action === "confirm-delete") actions.confirmDelete();
|
|
129
|
+
if (action === "cancel-delete") actions.cancelDelete();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -- Epic selection -------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const openEpicEl = target.closest("[data-open-epic]");
|
|
136
|
+
if (openEpicEl) {
|
|
137
|
+
actions.openEpic(openEpicEl.dataset.openEpic || null);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -- Task selection (broadest — checked last) -----------------------------
|
|
142
|
+
|
|
143
|
+
const taskEl = target.closest("[data-task-id]");
|
|
144
|
+
if (taskEl) {
|
|
145
|
+
actions.selectTask(taskEl.dataset.taskId, taskEl);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Input delegation (#board-search-input)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
function handleInput(event) {
|
|
154
|
+
if (event.target.id === "board-search-input") {
|
|
155
|
+
actions.updateSearch(event.target.value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Change delegation (#board-epic-select)
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
function handleChange(event) {
|
|
163
|
+
if (event.target.id === "board-epic-select") {
|
|
164
|
+
actions.selectEpic(event.target.value || null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const epicStatusForm = event.target.closest("[data-epic-status-form]");
|
|
169
|
+
if (epicStatusForm) {
|
|
170
|
+
if (actions.isMutating()) return;
|
|
171
|
+
actions.changeEpicStatus(epicStatusForm.dataset.epicStatusForm, event.target.value);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const bulkStatusForm = event.target.closest("[data-bulk-status-form]");
|
|
176
|
+
if (bulkStatusForm) {
|
|
177
|
+
if (actions.isMutating()) return;
|
|
178
|
+
const newStatus = event.target.value;
|
|
179
|
+
if (newStatus) {
|
|
180
|
+
actions.bulkSetStatus(bulkStatusForm.dataset.bulkStatusForm, newStatus);
|
|
181
|
+
event.target.value = "";
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Submit delegation (data-task-form, data-subtask-form, etc.)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
function handleSubmit(event) {
|
|
191
|
+
const form = event.target;
|
|
192
|
+
|
|
193
|
+
const taskForm = form.closest("[data-task-form]");
|
|
194
|
+
if (taskForm) {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
if (actions.isMutating()) return;
|
|
197
|
+
actions.submitTaskForm(taskForm.dataset.taskForm, new FormData(taskForm));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const subtaskForm = form.closest("[data-subtask-form]");
|
|
202
|
+
if (subtaskForm) {
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
if (actions.isMutating()) return;
|
|
205
|
+
actions.submitSubtaskForm(
|
|
206
|
+
subtaskForm.dataset.subtaskForm,
|
|
207
|
+
new FormData(subtaskForm),
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const createSubtaskForm = form.closest("[data-create-subtask-form]");
|
|
213
|
+
if (createSubtaskForm) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
if (actions.isMutating()) return;
|
|
216
|
+
actions.submitCreateSubtask(
|
|
217
|
+
createSubtaskForm.dataset.createSubtaskForm,
|
|
218
|
+
new FormData(createSubtaskForm),
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const dependencyForm = form.closest("[data-dependency-form]");
|
|
224
|
+
if (dependencyForm) {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
if (actions.isMutating()) return;
|
|
227
|
+
actions.addDependency(
|
|
228
|
+
dependencyForm.dataset.dependencyForm,
|
|
229
|
+
new FormData(dependencyForm),
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Keyboard shortcuts (window-level)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
function handleKeydown(event) {
|
|
239
|
+
if (event.defaultPrevented) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
actions.handleKeydown(event);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Drag-and-drop delegation
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
function handleDragstart(event) {
|
|
249
|
+
const draggable = event.target.closest("[data-draggable-task]");
|
|
250
|
+
if (!draggable) return;
|
|
251
|
+
|
|
252
|
+
if (actions.isMutating()) {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const taskId = draggable.dataset.taskId;
|
|
258
|
+
if (!taskId) return;
|
|
259
|
+
|
|
260
|
+
event.dataTransfer?.setData("text/task-id", taskId);
|
|
261
|
+
event.dataTransfer?.setData("text/plain", taskId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function handleDragover(event) {
|
|
265
|
+
if (event.target.closest("[data-drop-status]")) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleDrop(event) {
|
|
271
|
+
const column = event.target.closest("[data-drop-status]");
|
|
272
|
+
if (!column) return;
|
|
273
|
+
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
if (actions.isMutating()) return;
|
|
276
|
+
|
|
277
|
+
const taskId =
|
|
278
|
+
event.dataTransfer?.getData("text/task-id") ||
|
|
279
|
+
event.dataTransfer?.getData("text/plain");
|
|
280
|
+
const nextStatus = column.dataset.dropStatus;
|
|
281
|
+
actions.dropTaskStatus(taskId, nextStatus);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Attach
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
rootElement.addEventListener("click", handleClick);
|
|
288
|
+
rootElement.addEventListener("input", handleInput);
|
|
289
|
+
rootElement.addEventListener("change", handleChange);
|
|
290
|
+
rootElement.addEventListener("submit", handleSubmit);
|
|
291
|
+
rootElement.addEventListener("dragstart", handleDragstart);
|
|
292
|
+
rootElement.addEventListener("dragover", handleDragover);
|
|
293
|
+
rootElement.addEventListener("drop", handleDrop);
|
|
294
|
+
window.addEventListener("keydown", handleKeydown);
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Teardown
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
return function teardown() {
|
|
300
|
+
rootElement.removeEventListener("click", handleClick);
|
|
301
|
+
rootElement.removeEventListener("input", handleInput);
|
|
302
|
+
rootElement.removeEventListener("change", handleChange);
|
|
303
|
+
rootElement.removeEventListener("submit", handleSubmit);
|
|
304
|
+
rootElement.removeEventListener("dragstart", handleDragstart);
|
|
305
|
+
rootElement.removeEventListener("dragover", handleDragover);
|
|
306
|
+
rootElement.removeEventListener("drop", handleDrop);
|
|
307
|
+
window.removeEventListener("keydown", handleKeydown);
|
|
308
|
+
};
|
|
309
|
+
}
|