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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base component utilities for the board UI.
|
|
3
|
+
*
|
|
4
|
+
* Each component is a factory function returning { mount, update, unmount }.
|
|
5
|
+
* - mount(container) binds the component to a DOM element.
|
|
6
|
+
* - update(props) renders or patches DOM based on new props.
|
|
7
|
+
* - unmount() cleans up and removes content.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Save and restore the value + cursor of an input or textarea across a DOM write.
|
|
12
|
+
*
|
|
13
|
+
* @param {HTMLElement} container
|
|
14
|
+
* @param {string} selector CSS selector for the input element
|
|
15
|
+
* @param {() => void} writeFn Function that mutates the DOM
|
|
16
|
+
*/
|
|
17
|
+
export function preserveInput(container, selector, writeFn) {
|
|
18
|
+
const input = container.querySelector(selector);
|
|
19
|
+
const state = input
|
|
20
|
+
? {
|
|
21
|
+
value: input.value,
|
|
22
|
+
selectionStart: input.selectionStart,
|
|
23
|
+
selectionEnd: input.selectionEnd,
|
|
24
|
+
focused: document.activeElement === input,
|
|
25
|
+
}
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
writeFn();
|
|
29
|
+
|
|
30
|
+
if (state) {
|
|
31
|
+
const restored = container.querySelector(selector);
|
|
32
|
+
if (restored) {
|
|
33
|
+
restored.value = state.value;
|
|
34
|
+
if (state.focused) {
|
|
35
|
+
restored.focus({ preventScroll: true });
|
|
36
|
+
try {
|
|
37
|
+
restored.setSelectionRange(state.selectionStart, state.selectionEnd);
|
|
38
|
+
} catch {
|
|
39
|
+
// setSelectionRange not supported on all input types
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FORM_ROOT_SELECTOR = [
|
|
47
|
+
"form",
|
|
48
|
+
"[data-form-id]",
|
|
49
|
+
"[data-task-form]",
|
|
50
|
+
"[data-subtask-form]",
|
|
51
|
+
"[data-create-subtask-form]",
|
|
52
|
+
"[data-dependency-form]",
|
|
53
|
+
].join(", ");
|
|
54
|
+
|
|
55
|
+
function getFormRoot(el) {
|
|
56
|
+
if (!el) return null;
|
|
57
|
+
return el.matches(FORM_ROOT_SELECTOR) ? el : el.closest(FORM_ROOT_SELECTOR);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getNamespacedFormIdentity(form) {
|
|
61
|
+
if (!form) return "default-form";
|
|
62
|
+
if (form.hasAttribute("data-form-id")) {
|
|
63
|
+
return `form:${form.getAttribute("data-form-id")}`;
|
|
64
|
+
}
|
|
65
|
+
if (form.hasAttribute("data-task-form")) {
|
|
66
|
+
return `task:${form.getAttribute("data-task-form")}`;
|
|
67
|
+
}
|
|
68
|
+
if (form.hasAttribute("data-subtask-form")) {
|
|
69
|
+
return `subtask:${form.getAttribute("data-subtask-form")}`;
|
|
70
|
+
}
|
|
71
|
+
if (form.hasAttribute("data-create-subtask-form")) {
|
|
72
|
+
return `create-subtask:${form.getAttribute("data-create-subtask-form")}`;
|
|
73
|
+
}
|
|
74
|
+
if (form.hasAttribute("data-dependency-form")) {
|
|
75
|
+
return `dependency:${form.getAttribute("data-dependency-form")}`;
|
|
76
|
+
}
|
|
77
|
+
if (form.id) {
|
|
78
|
+
return `id:${form.id}`;
|
|
79
|
+
}
|
|
80
|
+
return "anonymous-form";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getManagedControls(root) {
|
|
84
|
+
return Array.from(root.querySelectorAll("input, textarea, select"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getControlIdentity(el, form) {
|
|
88
|
+
const controlKey = el.getAttribute("data-control-id");
|
|
89
|
+
if (controlKey) {
|
|
90
|
+
return `control:${controlKey}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (el.id) {
|
|
94
|
+
return `id:${el.id}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const name = el.getAttribute("name");
|
|
98
|
+
if (name) {
|
|
99
|
+
const tagName = el.tagName.toLowerCase();
|
|
100
|
+
const type = tagName === "input" ? (el.getAttribute("type") ?? "text") : tagName;
|
|
101
|
+
const peers = getManagedControls(form).filter((candidate) => candidate.getAttribute("name") === name);
|
|
102
|
+
const index = peers.indexOf(el);
|
|
103
|
+
return `name:${name}:${type}:${index}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const index = getManagedControls(form).indexOf(el);
|
|
107
|
+
return `index:${index}:${el.tagName.toLowerCase()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function captureSelection(el) {
|
|
111
|
+
if (typeof el.selectionStart !== "number") {
|
|
112
|
+
return { selectionStart: null, selectionEnd: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
selectionStart: el.selectionStart,
|
|
117
|
+
selectionEnd: el.selectionEnd,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function restoreSelection(el, selectionStart, selectionEnd) {
|
|
122
|
+
if (typeof selectionStart !== "number") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
el.setSelectionRange(selectionStart, selectionEnd ?? selectionStart);
|
|
128
|
+
} catch {
|
|
129
|
+
// setSelectionRange is not supported on all controls
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Capture the full state of every form input inside a container, execute a DOM
|
|
135
|
+
* write, then restore all captured values and focus.
|
|
136
|
+
*
|
|
137
|
+
* It uses a hierarchical identity (closest [data-form-id] or [data-task-form] etc.
|
|
138
|
+
* plus the input name/id) to ensure correct restoration even when multiple
|
|
139
|
+
* forms with similar field names exist in the same container.
|
|
140
|
+
*
|
|
141
|
+
* @param {HTMLElement} container
|
|
142
|
+
* @param {() => void} writeFn
|
|
143
|
+
*/
|
|
144
|
+
export function preserveFormState(container, writeFn, options = {}) {
|
|
145
|
+
const resetFormIds = new Set(options.resetFormIds ?? []);
|
|
146
|
+
const inputs = getManagedControls(container);
|
|
147
|
+
|
|
148
|
+
const activeElement = document.activeElement;
|
|
149
|
+
let focusedIdentity = null;
|
|
150
|
+
|
|
151
|
+
const savedStates = inputs.map((el) => {
|
|
152
|
+
const form = getFormRoot(el);
|
|
153
|
+
const formId = getNamespacedFormIdentity(form);
|
|
154
|
+
const controlId = form ? getControlIdentity(el, form) : null;
|
|
155
|
+
const identity = controlId ? { formId, controlId } : null;
|
|
156
|
+
|
|
157
|
+
if (activeElement === el) {
|
|
158
|
+
focusedIdentity = identity;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { selectionStart, selectionEnd } = captureSelection(el);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
identity,
|
|
165
|
+
value: el.value,
|
|
166
|
+
selectionStart,
|
|
167
|
+
selectionEnd,
|
|
168
|
+
};
|
|
169
|
+
}).filter(s => s.identity);
|
|
170
|
+
|
|
171
|
+
writeFn();
|
|
172
|
+
|
|
173
|
+
const formsByIdentity = new Map(
|
|
174
|
+
Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
|
|
175
|
+
getNamespacedFormIdentity(form),
|
|
176
|
+
form,
|
|
177
|
+
]),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
for (const state of savedStates) {
|
|
181
|
+
const { formId, controlId } = state.identity;
|
|
182
|
+
if (resetFormIds.has(formId)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const form = formsByIdentity.get(formId) ?? container;
|
|
186
|
+
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
187
|
+
if (restored && restored.value !== state.value) {
|
|
188
|
+
restored.value = state.value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (restored && activeElement === container.ownerDocument?.body) {
|
|
192
|
+
restoreSelection(restored, state.selectionStart, state.selectionEnd);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (focusedIdentity) {
|
|
197
|
+
const { formId, controlId } = focusedIdentity;
|
|
198
|
+
if (resetFormIds.has(formId)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const form = formsByIdentity.get(formId) ?? container;
|
|
202
|
+
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
203
|
+
if (restored) {
|
|
204
|
+
restored.focus({ preventScroll: true });
|
|
205
|
+
const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
|
|
206
|
+
if (focusedState) {
|
|
207
|
+
restoreSelection(restored, focusedState.selectionStart, focusedState.selectionEnd);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Capture open/closed state of all `<details>` elements and restore after a DOM write.
|
|
215
|
+
*
|
|
216
|
+
* @param {HTMLElement} container
|
|
217
|
+
* @param {() => void} writeFn
|
|
218
|
+
*/
|
|
219
|
+
export function preserveDetailsState(container, writeFn) {
|
|
220
|
+
const details = Array.from(container.querySelectorAll("details"));
|
|
221
|
+
const openStates = details.map((el, i) => ({
|
|
222
|
+
index: i,
|
|
223
|
+
open: el.open,
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
writeFn();
|
|
227
|
+
|
|
228
|
+
const newDetails = Array.from(container.querySelectorAll("details"));
|
|
229
|
+
for (const state of openStates) {
|
|
230
|
+
const target = newDetails[state.index];
|
|
231
|
+
if (target && target.open !== state.open) {
|
|
232
|
+
target.open = state.open;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Create a simple component from a render function.
|
|
239
|
+
* Replaces innerHTML on every update. Suitable for components that don't
|
|
240
|
+
* contain user-editable inputs.
|
|
241
|
+
*
|
|
242
|
+
* @param {(props: object) => string} renderFn
|
|
243
|
+
* @returns {{ mount: (el: HTMLElement) => object, update: (props: object) => void, unmount: () => void }}
|
|
244
|
+
*/
|
|
245
|
+
export function createSimpleComponent(renderFn) {
|
|
246
|
+
let container = null;
|
|
247
|
+
let lastHtml = null;
|
|
248
|
+
|
|
249
|
+
const component = {
|
|
250
|
+
mount(el) {
|
|
251
|
+
container = el;
|
|
252
|
+
lastHtml = null;
|
|
253
|
+
return component;
|
|
254
|
+
},
|
|
255
|
+
update(props) {
|
|
256
|
+
if (!container) return;
|
|
257
|
+
const html = renderFn(props);
|
|
258
|
+
if (html !== lastHtml) {
|
|
259
|
+
container.innerHTML = html;
|
|
260
|
+
lastHtml = html;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
unmount() {
|
|
264
|
+
if (container) container.innerHTML = "";
|
|
265
|
+
container = null;
|
|
266
|
+
lastHtml = null;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return component;
|
|
271
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buttonClasses,
|
|
3
|
+
escapeHtml,
|
|
4
|
+
panelClasses,
|
|
5
|
+
renderIcon,
|
|
6
|
+
sectionLabelClasses,
|
|
7
|
+
} from "./helpers.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ConfirmDialog component — modal for destructive actions.
|
|
11
|
+
* Shows confirmation with cancel/confirm buttons before executing.
|
|
12
|
+
*/
|
|
13
|
+
export function createConfirmDialog() {
|
|
14
|
+
let container = null;
|
|
15
|
+
let lastProps = null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
mount(el) {
|
|
19
|
+
container = el;
|
|
20
|
+
return this;
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* open: boolean,
|
|
26
|
+
* title?: string,
|
|
27
|
+
* message?: string,
|
|
28
|
+
* confirmLabel?: string,
|
|
29
|
+
* cancelLabel?: string,
|
|
30
|
+
* tone?: string,
|
|
31
|
+
* } | null} props
|
|
32
|
+
*/
|
|
33
|
+
update(props) {
|
|
34
|
+
if (!container) return;
|
|
35
|
+
|
|
36
|
+
if (!props || !props.open) {
|
|
37
|
+
if (lastProps?.open) {
|
|
38
|
+
container.innerHTML = "";
|
|
39
|
+
}
|
|
40
|
+
lastProps = props;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
title = "Confirm action",
|
|
46
|
+
message = "This action cannot be undone. Are you sure?",
|
|
47
|
+
confirmLabel = "Confirm",
|
|
48
|
+
cancelLabel = "Cancel",
|
|
49
|
+
tone = "destructive",
|
|
50
|
+
} = props;
|
|
51
|
+
|
|
52
|
+
container.innerHTML = `
|
|
53
|
+
<div class="board-confirm-backdrop fixed inset-0 z-50 grid place-items-center bg-slate-950/70 p-4 backdrop-blur-md" data-close-confirm role="presentation">
|
|
54
|
+
<section class="${panelClasses("w-full max-w-md p-6")}" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-desc" data-overlay-root tabindex="-1">
|
|
55
|
+
<div class="flex items-start gap-4">
|
|
56
|
+
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
|
|
57
|
+
${renderIcon("warning", "text-[20px]")}
|
|
58
|
+
</div>
|
|
59
|
+
<div class="min-w-0">
|
|
60
|
+
<p class="${sectionLabelClasses()}">Destructive action</p>
|
|
61
|
+
<h3 id="confirm-dialog-title" class="mt-2 text-lg font-semibold text-[var(--board-text)]">${escapeHtml(title)}</h3>
|
|
62
|
+
<p id="confirm-dialog-desc" class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(message)}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="mt-6 flex justify-end gap-3">
|
|
66
|
+
<button type="button" class="${buttonClasses()}" data-action="cancel-delete" data-overlay-initial-focus>${escapeHtml(cancelLabel)}</button>
|
|
67
|
+
<button type="button" class="${buttonClasses({ kind: "primary" })} ${tone === "destructive" ? "board-button--destructive" : ""}" data-action="confirm-delete">${escapeHtml(confirmLabel)}</button>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
</div>
|
|
71
|
+
`;
|
|
72
|
+
lastProps = props;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
unmount() {
|
|
76
|
+
if (container) container.innerHTML = "";
|
|
77
|
+
container = null;
|
|
78
|
+
lastProps = null;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtml,
|
|
3
|
+
formatDate,
|
|
4
|
+
neutralChipClasses,
|
|
5
|
+
renderIcon,
|
|
6
|
+
renderStatusBadge,
|
|
7
|
+
} from "./helpers.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render a single epic row for the overview table.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} props
|
|
13
|
+
* @param {object} props.epic
|
|
14
|
+
* @param {boolean} [props.selected]
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function renderEpicRow(props) {
|
|
18
|
+
const { epic, selected = false } = props;
|
|
19
|
+
|
|
20
|
+
const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
|
|
21
|
+
const counts = epic.counts || { blocked: 0, done: 0, in_progress: 0 };
|
|
22
|
+
const statusLabel = String(epic.status ?? "todo").replace(/_/g, " ");
|
|
23
|
+
const openLabel = `Open epic ${epic.title}`;
|
|
24
|
+
const tooltip = `${openLabel}. ${totalTasks} task${totalTasks === 1 ? "" : "s"}.`;
|
|
25
|
+
const descriptionMarkup = epic.description?.trim()
|
|
26
|
+
? `<p class="board-epic-row__description text-sm text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(epic.description.trim())}</p>`
|
|
27
|
+
: "";
|
|
28
|
+
|
|
29
|
+
return `
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="board-epic-row ${selected ? "board-epic-row--selected" : ""}"
|
|
33
|
+
aria-current="${selected}"
|
|
34
|
+
aria-label="${escapeHtml(`${openLabel}. ${totalTasks} tasks. Status ${statusLabel}.`)}"
|
|
35
|
+
title="${escapeHtml(tooltip)}"
|
|
36
|
+
data-open-epic="${escapeHtml(epic.id)}"
|
|
37
|
+
>
|
|
38
|
+
<span class="board-epic-row__summary">
|
|
39
|
+
<span class="board-epic-row__title-row">
|
|
40
|
+
<span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
|
|
41
|
+
<strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
|
|
42
|
+
</span>
|
|
43
|
+
${descriptionMarkup}
|
|
44
|
+
</span>
|
|
45
|
+
<span class="board-epic-row__status">${renderStatusBadge(epic.status ?? "todo")}</span>
|
|
46
|
+
<span class="board-epic-row__counts" aria-label="Epic progress counts">
|
|
47
|
+
<span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
|
|
48
|
+
<span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
|
|
49
|
+
<span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
|
|
50
|
+
${(counts.blocked ?? 0) > 0 ? `<span class="${neutralChipClasses()}">${counts.blocked} blocked</span>` : ""}
|
|
51
|
+
</span>
|
|
52
|
+
<span class="board-epic-row__updated">
|
|
53
|
+
<span class="board-epic-row__label">Updated</span>
|
|
54
|
+
<span>${escapeHtml(formatDate(epic.updatedAt))}</span>
|
|
55
|
+
</span>
|
|
56
|
+
<span class="board-epic-row__action-wrap" aria-hidden="true">
|
|
57
|
+
<span class="board-epic-row__action">
|
|
58
|
+
<span>View</span>
|
|
59
|
+
${renderIcon("chevron_right", "text-[16px]")}
|
|
60
|
+
</span>
|
|
61
|
+
</span>
|
|
62
|
+
</button>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { renderEpicRow } from "./EpicRow.js";
|
|
2
|
+
import {
|
|
3
|
+
panelClasses,
|
|
4
|
+
renderEmptyState,
|
|
5
|
+
sectionLabelClasses,
|
|
6
|
+
} from "./helpers.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render the epics overview HTML.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} props
|
|
12
|
+
* @param {object[]} props.visibleEpics
|
|
13
|
+
* @param {string|null} props.selectedEpicId
|
|
14
|
+
* @param {{ snapshot: object, isMutating: boolean }} props.store
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function render(props) {
|
|
18
|
+
const { visibleEpics, selectedEpicId, store } = props;
|
|
19
|
+
|
|
20
|
+
return `
|
|
21
|
+
<div class="board-root board-root--epics">
|
|
22
|
+
<section class="board-overview ${panelClasses("board-overview--dense p-4 sm:p-5")}" aria-label="Epics overview">
|
|
23
|
+
<header class="board-section-head board-overview__header">
|
|
24
|
+
<div>
|
|
25
|
+
<span class="${sectionLabelClasses()}">Epics overview</span>
|
|
26
|
+
<h2 class="board-overview__title">Open an initiative and drive the next move</h2>
|
|
27
|
+
<p class="board-overview__summary">Each card is the entry point, so status, task counts, and freshness stay visible at a glance.</p>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="board-legend board-overview__legend">
|
|
30
|
+
<span class="board-chip board-chip--neutral">${visibleEpics.length} visible epic${visibleEpics.length === 1 ? "" : "s"}</span>
|
|
31
|
+
<span class="board-chip board-chip--neutral">${store.snapshot.tasks.length} total tasks</span>
|
|
32
|
+
${store.isMutating ? '<span class="board-chip board-chip--neutral">Saving\u2026</span>' : ""}
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
<div class="board-table board-table--epics">
|
|
36
|
+
<div class="board-table__header board-table__header--epics hidden md:grid">
|
|
37
|
+
<span>Epic</span>
|
|
38
|
+
<span>Status</span>
|
|
39
|
+
<span>Counts</span>
|
|
40
|
+
<span>Updated</span>
|
|
41
|
+
<span>Action</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="board-table__rows board-table__rows--epics">
|
|
44
|
+
${visibleEpics.length === 0
|
|
45
|
+
? renderEmptyState("No matching epics", "Try a different search or publish more work to the board.", "/")
|
|
46
|
+
: visibleEpics.map((epic) => renderEpicRow({ epic, selected: selectedEpicId === epic.id })).join("")}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* EpicsOverview component with mount/update/unmount lifecycle.
|
|
56
|
+
*/
|
|
57
|
+
export function createEpicsOverview() {
|
|
58
|
+
let container = null;
|
|
59
|
+
let lastHtml = null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
mount(el) {
|
|
63
|
+
container = el;
|
|
64
|
+
return this;
|
|
65
|
+
},
|
|
66
|
+
update(props) {
|
|
67
|
+
if (!container) return;
|
|
68
|
+
const html = render(props);
|
|
69
|
+
if (html !== lastHtml) {
|
|
70
|
+
container.innerHTML = html;
|
|
71
|
+
lastHtml = html;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
unmount() {
|
|
75
|
+
if (container) container.innerHTML = "";
|
|
76
|
+
container = null;
|
|
77
|
+
lastHtml = null;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|