backlog-mcp 0.26.1 → 0.27.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/dist/server/viewer-routes.mjs +1 -2
- package/dist/server/viewer-routes.mjs.map +1 -1
- package/dist/viewer/index.html +1 -1
- package/dist/viewer/main.css +92 -21
- package/dist/viewer/main.js +145 -114
- package/dist/viewer/ring-IUCBBGZ6.svg +3 -0
- package/package.json +1 -1
- package/viewer/components/breadcrumb.ts +52 -0
- package/viewer/components/task-item.ts +16 -34
- package/viewer/components/task-list.ts +75 -62
- package/viewer/icons/index.ts +2 -0
- package/viewer/index.html +1 -1
- package/viewer/main.ts +9 -0
- package/viewer/styles.css +87 -22
- package/dist/viewer/chevron-CBYYYF2L.svg +0 -3
- package/dist/viewer/pin-CTBSQJY3.svg +0 -3
|
@@ -20,8 +20,7 @@ function registerViewerRoutes(app) {
|
|
|
20
20
|
"in_progress",
|
|
21
21
|
"blocked"
|
|
22
22
|
] },
|
|
23
|
-
|
|
24
|
-
cancelled: { status: ["cancelled"] },
|
|
23
|
+
completed: { status: ["done", "cancelled"] },
|
|
25
24
|
all: {}
|
|
26
25
|
};
|
|
27
26
|
const filterConfig = statusMap[filter || "active"] || statusMap.active;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit } = request.query as { filter?: string; limit?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n
|
|
1
|
+
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit } = request.query as { filter?: string; limit?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = storage.list({ ...filterConfig, limit: limit ? parseInt(limit) : 100 });\n \n return tasks;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n}\n"],"mappings":"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"}
|
package/dist/viewer/index.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<!-- Left Pane: Task List -->
|
|
21
21
|
<div class="left-pane" id="left-pane">
|
|
22
22
|
<div class="pane-header">
|
|
23
|
-
<div class="pane-title">
|
|
23
|
+
<div class="pane-title" id="home-button" style="cursor: pointer;" title="Go to All Tasks">
|
|
24
24
|
<img src="./logo.svg" class="logo" alt="">
|
|
25
25
|
Backlog
|
|
26
26
|
</div>
|
package/dist/viewer/main.css
CHANGED
|
@@ -134,7 +134,7 @@ body {
|
|
|
134
134
|
}
|
|
135
135
|
task-filter-bar {
|
|
136
136
|
display: block;
|
|
137
|
-
margin-bottom:
|
|
137
|
+
margin-bottom: 0;
|
|
138
138
|
padding: 12px;
|
|
139
139
|
background: #252526;
|
|
140
140
|
border-radius: 8px;
|
|
@@ -175,6 +175,41 @@ task-filter-bar {
|
|
|
175
175
|
border-color: #007acc;
|
|
176
176
|
color: white;
|
|
177
177
|
}
|
|
178
|
+
.breadcrumb {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 8px;
|
|
182
|
+
padding: 12px 0;
|
|
183
|
+
margin-bottom: 8px;
|
|
184
|
+
border-bottom: 1px solid #3e3e42;
|
|
185
|
+
font-size: 13px;
|
|
186
|
+
}
|
|
187
|
+
.breadcrumb-segment {
|
|
188
|
+
background: none;
|
|
189
|
+
border: none;
|
|
190
|
+
color: #888;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
padding: 4px 8px;
|
|
193
|
+
border-radius: 4px;
|
|
194
|
+
transition: all 0.2s;
|
|
195
|
+
font-size: 13px;
|
|
196
|
+
max-width: 200px;
|
|
197
|
+
overflow: hidden;
|
|
198
|
+
text-overflow: ellipsis;
|
|
199
|
+
white-space: nowrap;
|
|
200
|
+
}
|
|
201
|
+
.breadcrumb-segment:hover {
|
|
202
|
+
background: #2d2d30;
|
|
203
|
+
color: #d4d4d4;
|
|
204
|
+
}
|
|
205
|
+
.breadcrumb-segment:last-child {
|
|
206
|
+
color: #d4d4d4;
|
|
207
|
+
font-weight: 500;
|
|
208
|
+
}
|
|
209
|
+
.breadcrumb-separator {
|
|
210
|
+
color: #555;
|
|
211
|
+
user-select: none;
|
|
212
|
+
}
|
|
178
213
|
.task-list {
|
|
179
214
|
display: flex;
|
|
180
215
|
flex-direction: column;
|
|
@@ -209,10 +244,6 @@ task-filter-bar {
|
|
|
209
244
|
width: 1px;
|
|
210
245
|
background: #3e3e42;
|
|
211
246
|
}
|
|
212
|
-
.task-item-wrapper.pinned .task-item {
|
|
213
|
-
border-color: #f0b429;
|
|
214
|
-
background: #3d3522;
|
|
215
|
-
}
|
|
216
247
|
.task-item {
|
|
217
248
|
flex: 1;
|
|
218
249
|
display: flex;
|
|
@@ -251,6 +282,17 @@ task-filter-bar {
|
|
|
251
282
|
padding: 2px 6px;
|
|
252
283
|
border-radius: 10px;
|
|
253
284
|
}
|
|
285
|
+
.enter-icon {
|
|
286
|
+
font-size: 14px;
|
|
287
|
+
color: #888;
|
|
288
|
+
margin-left: auto;
|
|
289
|
+
opacity: 0.6;
|
|
290
|
+
transition: opacity 0.2s;
|
|
291
|
+
}
|
|
292
|
+
.task-item.type-epic:hover .enter-icon {
|
|
293
|
+
opacity: 1;
|
|
294
|
+
color: #007acc;
|
|
295
|
+
}
|
|
254
296
|
.task-item:hover {
|
|
255
297
|
background: #2d2d30;
|
|
256
298
|
border-color: #007acc;
|
|
@@ -259,26 +301,50 @@ task-filter-bar {
|
|
|
259
301
|
background: #094771;
|
|
260
302
|
border-color: #007acc;
|
|
261
303
|
}
|
|
262
|
-
.task-item
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
border-radius: 6px;
|
|
266
|
-
padding: 0 8px;
|
|
267
|
-
cursor: pointer;
|
|
268
|
-
color: #888;
|
|
304
|
+
.task-item.current-epic {
|
|
305
|
+
}
|
|
306
|
+
.epic-separator {
|
|
269
307
|
display: flex;
|
|
270
308
|
align-items: center;
|
|
271
|
-
|
|
309
|
+
margin: 12px 0;
|
|
310
|
+
gap: 12px;
|
|
272
311
|
}
|
|
273
|
-
.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
.epic-separator::before,
|
|
313
|
+
.epic-separator::after {
|
|
314
|
+
content: "";
|
|
315
|
+
flex: 1;
|
|
316
|
+
height: 1px;
|
|
317
|
+
background:
|
|
318
|
+
linear-gradient(
|
|
319
|
+
90deg,
|
|
320
|
+
transparent,
|
|
321
|
+
rgba(9, 105, 218, 0.3),
|
|
322
|
+
transparent);
|
|
277
323
|
}
|
|
278
|
-
.
|
|
279
|
-
background:
|
|
280
|
-
|
|
281
|
-
|
|
324
|
+
.epic-separator::before {
|
|
325
|
+
background:
|
|
326
|
+
linear-gradient(
|
|
327
|
+
90deg,
|
|
328
|
+
transparent,
|
|
329
|
+
rgba(9, 105, 218, 0.3));
|
|
330
|
+
}
|
|
331
|
+
.epic-separator::after {
|
|
332
|
+
background:
|
|
333
|
+
linear-gradient(
|
|
334
|
+
90deg,
|
|
335
|
+
rgba(9, 105, 218, 0.3),
|
|
336
|
+
transparent);
|
|
337
|
+
}
|
|
338
|
+
.separator-icon {
|
|
339
|
+
width: 10px !important;
|
|
340
|
+
height: 10px !important;
|
|
341
|
+
flex-shrink: 0;
|
|
342
|
+
background:
|
|
343
|
+
linear-gradient(
|
|
344
|
+
135deg,
|
|
345
|
+
#00d4ff,
|
|
346
|
+
#7b2dff,
|
|
347
|
+
#ff2d7b) !important;
|
|
282
348
|
}
|
|
283
349
|
.task-id {
|
|
284
350
|
font-size: 11px;
|
|
@@ -462,6 +528,11 @@ task-filter-bar {
|
|
|
462
528
|
padding: 60px 20px;
|
|
463
529
|
color: #888;
|
|
464
530
|
}
|
|
531
|
+
.empty-state-inline {
|
|
532
|
+
text-align: center;
|
|
533
|
+
padding: 40px 20px;
|
|
534
|
+
color: #888;
|
|
535
|
+
}
|
|
465
536
|
.empty-state-icon {
|
|
466
537
|
font-size: 48px;
|
|
467
538
|
margin-bottom: 16px;
|
package/dist/viewer/main.js
CHANGED
|
@@ -2527,31 +2527,85 @@ async function fetchTask(taskId) {
|
|
|
2527
2527
|
return response.json();
|
|
2528
2528
|
}
|
|
2529
2529
|
|
|
2530
|
+
// viewer/components/breadcrumb.ts
|
|
2531
|
+
var Breadcrumb = class extends HTMLElement {
|
|
2532
|
+
currentEpicId = null;
|
|
2533
|
+
tasks = [];
|
|
2534
|
+
setData(currentEpicId, tasks) {
|
|
2535
|
+
this.currentEpicId = currentEpicId;
|
|
2536
|
+
this.tasks = tasks;
|
|
2537
|
+
this.render();
|
|
2538
|
+
}
|
|
2539
|
+
buildPath() {
|
|
2540
|
+
if (!this.currentEpicId) return [];
|
|
2541
|
+
const path = [];
|
|
2542
|
+
let currentId = this.currentEpicId;
|
|
2543
|
+
while (currentId) {
|
|
2544
|
+
const epic = this.tasks.find((t) => t.id === currentId);
|
|
2545
|
+
if (!epic) break;
|
|
2546
|
+
path.unshift(epic);
|
|
2547
|
+
currentId = epic.epic_id || null;
|
|
2548
|
+
}
|
|
2549
|
+
return path;
|
|
2550
|
+
}
|
|
2551
|
+
render() {
|
|
2552
|
+
const path = this.buildPath();
|
|
2553
|
+
this.innerHTML = `
|
|
2554
|
+
<div class="breadcrumb">
|
|
2555
|
+
<button class="breadcrumb-segment" data-epic-id="" title="All Tasks">All Tasks</button>
|
|
2556
|
+
${path.map((epic) => `
|
|
2557
|
+
<span class="breadcrumb-separator">\u203A</span>
|
|
2558
|
+
<button class="breadcrumb-segment" data-epic-id="${epic.id}" title="${epic.title}">${epic.title}</button>
|
|
2559
|
+
`).join("")}
|
|
2560
|
+
</div>
|
|
2561
|
+
`;
|
|
2562
|
+
this.querySelectorAll(".breadcrumb-segment").forEach((btn) => {
|
|
2563
|
+
btn.addEventListener("click", () => {
|
|
2564
|
+
const epicId = btn.dataset.epicId || null;
|
|
2565
|
+
document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId } }));
|
|
2566
|
+
});
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
};
|
|
2570
|
+
customElements.define("epic-breadcrumb", Breadcrumb);
|
|
2571
|
+
|
|
2572
|
+
// viewer/icons/copy.svg
|
|
2573
|
+
var copy_default = "./copy-3LO2VTYZ.svg";
|
|
2574
|
+
|
|
2575
|
+
// viewer/icons/epic.svg
|
|
2576
|
+
var epic_default = "./epic-OFTO5ZU3.svg";
|
|
2577
|
+
|
|
2578
|
+
// viewer/icons/task.svg
|
|
2579
|
+
var task_default = "./task-QBLM24U4.svg";
|
|
2580
|
+
|
|
2581
|
+
// viewer/icons/settings.svg
|
|
2582
|
+
var settings_default = "./settings-3ELIWNF4.svg";
|
|
2583
|
+
|
|
2584
|
+
// viewer/icons/ring.svg
|
|
2585
|
+
var ring_default = "./ring-IUCBBGZ6.svg";
|
|
2586
|
+
|
|
2587
|
+
// viewer/icons/index.ts
|
|
2588
|
+
var copyIcon = copy_default;
|
|
2589
|
+
var epicIcon = epic_default;
|
|
2590
|
+
var taskIcon = task_default;
|
|
2591
|
+
var settingsIcon = settings_default;
|
|
2592
|
+
var ringIcon = ring_default;
|
|
2593
|
+
|
|
2530
2594
|
// viewer/components/task-list.ts
|
|
2531
2595
|
function escapeAttr(text2) {
|
|
2532
2596
|
if (!text2) return "";
|
|
2533
2597
|
return text2.replace(/"/g, """).replace(/'/g, "'");
|
|
2534
2598
|
}
|
|
2535
|
-
function getCollapsedEpics() {
|
|
2536
|
-
try {
|
|
2537
|
-
return new Set(JSON.parse(localStorage.getItem("collapsed-epics") || "[]"));
|
|
2538
|
-
} catch {
|
|
2539
|
-
return /* @__PURE__ */ new Set();
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
function setCollapsedEpics(ids) {
|
|
2543
|
-
localStorage.setItem("collapsed-epics", JSON.stringify([...ids]));
|
|
2544
|
-
}
|
|
2545
2599
|
var TaskList = class extends HTMLElement {
|
|
2546
2600
|
currentFilter = "active";
|
|
2547
2601
|
currentType = "all";
|
|
2548
|
-
|
|
2602
|
+
currentEpicId = null;
|
|
2549
2603
|
selectedTaskId = null;
|
|
2550
|
-
|
|
2604
|
+
allTasks = [];
|
|
2551
2605
|
connectedCallback() {
|
|
2552
2606
|
const params = new URLSearchParams(window.location.search);
|
|
2553
2607
|
this.selectedTaskId = params.get("task");
|
|
2554
|
-
this.
|
|
2608
|
+
this.currentEpicId = params.get("epic");
|
|
2555
2609
|
this.loadTasks();
|
|
2556
2610
|
setInterval(() => this.loadTasks(), 5e3);
|
|
2557
2611
|
document.addEventListener("filter-change", ((e) => {
|
|
@@ -2562,87 +2616,102 @@ var TaskList = class extends HTMLElement {
|
|
|
2562
2616
|
document.addEventListener("task-selected", ((e) => {
|
|
2563
2617
|
this.setSelected(e.detail.taskId);
|
|
2564
2618
|
}));
|
|
2565
|
-
document.addEventListener("epic-
|
|
2566
|
-
this.
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
const { epicId } = e.detail;
|
|
2571
|
-
if (this.collapsedEpics.has(epicId)) {
|
|
2572
|
-
this.collapsedEpics.delete(epicId);
|
|
2573
|
-
} else {
|
|
2574
|
-
this.collapsedEpics.add(epicId);
|
|
2619
|
+
document.addEventListener("epic-navigate", ((e) => {
|
|
2620
|
+
this.currentEpicId = e.detail.epicId;
|
|
2621
|
+
if (e.detail.epicId) {
|
|
2622
|
+
this.selectedTaskId = e.detail.epicId;
|
|
2623
|
+
document.dispatchEvent(new CustomEvent("task-selected", { detail: { taskId: e.detail.epicId } }));
|
|
2575
2624
|
}
|
|
2576
|
-
setCollapsedEpics(this.collapsedEpics);
|
|
2577
2625
|
this.loadTasks();
|
|
2578
2626
|
}));
|
|
2627
|
+
document.addEventListener("keydown", (e) => {
|
|
2628
|
+
if (e.key === "Escape" && this.currentEpicId) {
|
|
2629
|
+
const currentEpic = this.allTasks.find((t) => t.id === this.currentEpicId);
|
|
2630
|
+
const parentEpicId = currentEpic?.epic_id || null;
|
|
2631
|
+
document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId: parentEpicId } }));
|
|
2632
|
+
}
|
|
2633
|
+
});
|
|
2579
2634
|
}
|
|
2580
2635
|
setState(filter, type, epicId, taskId) {
|
|
2581
2636
|
this.currentFilter = filter;
|
|
2582
2637
|
this.currentType = type;
|
|
2583
|
-
this.
|
|
2638
|
+
this.currentEpicId = epicId;
|
|
2584
2639
|
this.selectedTaskId = taskId;
|
|
2585
2640
|
this.loadTasks();
|
|
2586
2641
|
}
|
|
2587
2642
|
async loadTasks() {
|
|
2588
2643
|
try {
|
|
2589
2644
|
let tasks = await fetchTasks(this.currentFilter);
|
|
2645
|
+
this.allTasks = tasks;
|
|
2590
2646
|
if (this.currentType !== "all") {
|
|
2591
2647
|
tasks = tasks.filter((t) => (t.type ?? "task") === this.currentType);
|
|
2592
2648
|
}
|
|
2593
|
-
if (this.
|
|
2594
|
-
const
|
|
2595
|
-
const children = tasks.filter((t) => t.epic_id === this.
|
|
2596
|
-
tasks =
|
|
2649
|
+
if (this.currentEpicId) {
|
|
2650
|
+
const currentEpic = tasks.find((t) => t.id === this.currentEpicId);
|
|
2651
|
+
const children = tasks.filter((t) => t.epic_id === this.currentEpicId);
|
|
2652
|
+
tasks = currentEpic ? [currentEpic, ...children] : children;
|
|
2653
|
+
} else {
|
|
2654
|
+
const rootEpics = tasks.filter((t) => (t.type ?? "task") === "epic" && !t.epic_id);
|
|
2655
|
+
const orphanTasks = tasks.filter((t) => (t.type ?? "task") === "task" && !t.epic_id);
|
|
2656
|
+
tasks = [...rootEpics, ...orphanTasks];
|
|
2597
2657
|
}
|
|
2598
2658
|
this.render(tasks);
|
|
2659
|
+
const breadcrumb = this.querySelector("epic-breadcrumb");
|
|
2660
|
+
if (breadcrumb) {
|
|
2661
|
+
breadcrumb.setData(this.currentEpicId, this.allTasks);
|
|
2662
|
+
}
|
|
2599
2663
|
} catch (error) {
|
|
2600
2664
|
this.innerHTML = `<div class="error">Failed to load tasks: ${error.message}</div>`;
|
|
2601
2665
|
}
|
|
2602
2666
|
}
|
|
2603
2667
|
render(tasks) {
|
|
2604
|
-
|
|
2668
|
+
const isEmpty = tasks.length === 0;
|
|
2669
|
+
const isInsideEpic = !!this.currentEpicId;
|
|
2670
|
+
const currentEpic = isInsideEpic ? tasks.find((t) => t.id === this.currentEpicId) : null;
|
|
2671
|
+
const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
|
|
2672
|
+
if (isEmpty) {
|
|
2605
2673
|
this.innerHTML = `
|
|
2674
|
+
<epic-breadcrumb></epic-breadcrumb>
|
|
2606
2675
|
<div class="empty-state">
|
|
2607
2676
|
<div class="empty-state-icon">\u2014</div>
|
|
2608
2677
|
<div>No tasks found</div>
|
|
2609
2678
|
</div>
|
|
2610
2679
|
`;
|
|
2680
|
+
const breadcrumb2 = this.querySelector("epic-breadcrumb");
|
|
2681
|
+
if (breadcrumb2) {
|
|
2682
|
+
breadcrumb2.setData(this.currentEpicId, this.allTasks);
|
|
2683
|
+
}
|
|
2611
2684
|
return;
|
|
2612
2685
|
}
|
|
2613
2686
|
const epics = tasks.filter((t) => (t.type ?? "task") === "epic");
|
|
2614
|
-
const
|
|
2615
|
-
const
|
|
2616
|
-
const orphanTasks = tasks.filter((t) => (t.type ?? "task") === "task" && !childTasks.includes(t));
|
|
2617
|
-
const grouped = [];
|
|
2618
|
-
for (const epic of rootEpics) {
|
|
2619
|
-
const children = childTasks.filter((t) => t.epic_id === epic.id);
|
|
2620
|
-
const isCollapsed = this.collapsedEpics.has(epic.id);
|
|
2621
|
-
grouped.push({ ...epic, childCount: children.length });
|
|
2622
|
-
if (!isCollapsed) {
|
|
2623
|
-
for (const child of children) {
|
|
2624
|
-
grouped.push({ ...child, isChild: true });
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
grouped.push(...orphanTasks);
|
|
2687
|
+
const regularTasks = tasks.filter((t) => (t.type ?? "task") === "task");
|
|
2688
|
+
const grouped = [...epics, ...regularTasks];
|
|
2629
2689
|
this.innerHTML = `
|
|
2690
|
+
<epic-breadcrumb></epic-breadcrumb>
|
|
2630
2691
|
<div class="task-list">
|
|
2631
|
-
${grouped.map((task) =>
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2692
|
+
${grouped.map((task, index) => {
|
|
2693
|
+
const childCount = (task.type ?? "task") === "epic" ? this.allTasks.filter((t) => t.epic_id === task.id).length : 0;
|
|
2694
|
+
const isCurrentEpic = this.currentEpicId === task.id;
|
|
2695
|
+
return `
|
|
2696
|
+
<task-item
|
|
2697
|
+
data-id="${task.id}"
|
|
2698
|
+
data-title="${escapeAttr(task.title)}"
|
|
2699
|
+
data-status="${task.status}"
|
|
2700
|
+
data-type="${task.type ?? "task"}"
|
|
2701
|
+
data-child-count="${childCount}"
|
|
2702
|
+
${this.selectedTaskId === task.id ? "selected" : ""}
|
|
2703
|
+
${isCurrentEpic ? 'data-current-epic="true"' : ""}
|
|
2704
|
+
></task-item>
|
|
2705
|
+
${isCurrentEpic ? `<div class="epic-separator"><svg-icon class="separator-icon" src="${ringIcon}"></svg-icon></div>` : ""}
|
|
2706
|
+
`;
|
|
2707
|
+
}).join("")}
|
|
2708
|
+
${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">\u2014</div><div>No tasks in this epic</div></div>' : ""}
|
|
2644
2709
|
</div>
|
|
2645
2710
|
`;
|
|
2711
|
+
const breadcrumb = this.querySelector("epic-breadcrumb");
|
|
2712
|
+
if (breadcrumb) {
|
|
2713
|
+
breadcrumb.setData(this.currentEpicId, this.allTasks);
|
|
2714
|
+
}
|
|
2646
2715
|
}
|
|
2647
2716
|
setSelected(taskId) {
|
|
2648
2717
|
this.selectedTaskId = taskId;
|
|
@@ -2650,32 +2719,6 @@ var TaskList = class extends HTMLElement {
|
|
|
2650
2719
|
};
|
|
2651
2720
|
customElements.define("task-list", TaskList);
|
|
2652
2721
|
|
|
2653
|
-
// viewer/icons/copy.svg
|
|
2654
|
-
var copy_default = "./copy-3LO2VTYZ.svg";
|
|
2655
|
-
|
|
2656
|
-
// viewer/icons/pin.svg
|
|
2657
|
-
var pin_default = "./pin-CTBSQJY3.svg";
|
|
2658
|
-
|
|
2659
|
-
// viewer/icons/epic.svg
|
|
2660
|
-
var epic_default = "./epic-OFTO5ZU3.svg";
|
|
2661
|
-
|
|
2662
|
-
// viewer/icons/task.svg
|
|
2663
|
-
var task_default = "./task-QBLM24U4.svg";
|
|
2664
|
-
|
|
2665
|
-
// viewer/icons/chevron.svg
|
|
2666
|
-
var chevron_default = "./chevron-CBYYYF2L.svg";
|
|
2667
|
-
|
|
2668
|
-
// viewer/icons/settings.svg
|
|
2669
|
-
var settings_default = "./settings-3ELIWNF4.svg";
|
|
2670
|
-
|
|
2671
|
-
// viewer/icons/index.ts
|
|
2672
|
-
var copyIcon = copy_default;
|
|
2673
|
-
var pinIcon = pin_default;
|
|
2674
|
-
var epicIcon = epic_default;
|
|
2675
|
-
var taskIcon = task_default;
|
|
2676
|
-
var chevronIcon = chevron_default;
|
|
2677
|
-
var settingsIcon = settings_default;
|
|
2678
|
-
|
|
2679
2722
|
// viewer/components/task-item.ts
|
|
2680
2723
|
var TaskItem = class extends HTMLElement {
|
|
2681
2724
|
connectedCallback() {
|
|
@@ -2687,29 +2730,31 @@ var TaskItem = class extends HTMLElement {
|
|
|
2687
2730
|
const title = this.dataset.title || "";
|
|
2688
2731
|
const status = this.dataset.status || "open";
|
|
2689
2732
|
const type = this.dataset.type || "task";
|
|
2690
|
-
const
|
|
2691
|
-
const isPinned = this.hasAttribute("pinned");
|
|
2733
|
+
const isCurrentEpic = this.dataset.currentEpic === "true";
|
|
2692
2734
|
const isSelected = this.hasAttribute("selected");
|
|
2693
|
-
const
|
|
2694
|
-
|
|
2695
|
-
this.className = `task-item-wrapper ${isPinned ? "pinned" : ""} ${isChild ? "child" : ""}`;
|
|
2735
|
+
const childCount = this.dataset.childCount || "0";
|
|
2736
|
+
this.className = "task-item-wrapper";
|
|
2696
2737
|
this.innerHTML = `
|
|
2697
|
-
<div class="task-item ${isSelected ? "selected" : ""} type-${type}">
|
|
2698
|
-
${type === "epic" ? `<button class="collapse-btn ${isCollapsed ? "collapsed" : ""}" title="${isCollapsed ? "Expand" : "Collapse"}"><svg-icon src="${chevronIcon}"></svg-icon></button>` : ""}
|
|
2738
|
+
<div class="task-item ${isSelected ? "selected" : ""} ${isCurrentEpic ? "current-epic" : ""} type-${type}">
|
|
2699
2739
|
<task-badge task-id="${id}" type="${type}"></task-badge>
|
|
2700
2740
|
<span class="task-title">${title}</span>
|
|
2701
|
-
${
|
|
2741
|
+
${type === "epic" ? `<span class="child-count">${childCount}</span>` : ""}
|
|
2742
|
+
${type === "epic" && !isCurrentEpic ? '<span class="enter-icon">\u2192</span>' : ""}
|
|
2702
2743
|
<span class="status-badge status-${status}">${status.replace("_", " ")}</span>
|
|
2703
2744
|
</div>
|
|
2704
|
-
${type === "epic" ? `<button class="pin-btn ${isPinned ? "pinned" : ""}" title="${isPinned ? "Unpin" : "Pin to filter"}"><svg-icon src="${pinIcon}"></svg-icon></button>` : ""}
|
|
2705
2745
|
`;
|
|
2706
2746
|
}
|
|
2707
2747
|
attachListeners() {
|
|
2708
2748
|
const taskItem = this.querySelector(".task-item");
|
|
2749
|
+
const type = this.dataset.type || "task";
|
|
2750
|
+
const isCurrentEpic = this.dataset.currentEpic === "true";
|
|
2709
2751
|
taskItem?.addEventListener("click", (e) => {
|
|
2710
|
-
if (e.target.closest(".collapse-btn")) return;
|
|
2711
2752
|
const taskId = this.dataset.id;
|
|
2712
2753
|
if (!taskId) return;
|
|
2754
|
+
if (type === "epic" && !isCurrentEpic) {
|
|
2755
|
+
document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId: taskId } }));
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2713
2758
|
document.querySelectorAll("task-item .task-item").forEach((item) => {
|
|
2714
2759
|
item.classList.toggle("selected", item.closest("task-item")?.dataset.id === taskId);
|
|
2715
2760
|
});
|
|
@@ -2723,26 +2768,6 @@ var TaskItem = class extends HTMLElement {
|
|
|
2723
2768
|
taskList.setSelected(taskId);
|
|
2724
2769
|
}
|
|
2725
2770
|
});
|
|
2726
|
-
const collapseBtn = this.querySelector(".collapse-btn");
|
|
2727
|
-
if (collapseBtn) {
|
|
2728
|
-
collapseBtn.addEventListener("click", (e) => {
|
|
2729
|
-
e.stopPropagation();
|
|
2730
|
-
const epicId = this.dataset.id;
|
|
2731
|
-
if (epicId) {
|
|
2732
|
-
document.dispatchEvent(new CustomEvent("epic-toggle", { detail: { epicId } }));
|
|
2733
|
-
}
|
|
2734
|
-
});
|
|
2735
|
-
}
|
|
2736
|
-
const pinBtn = this.querySelector(".pin-btn");
|
|
2737
|
-
if (pinBtn) {
|
|
2738
|
-
pinBtn.addEventListener("click", (e) => {
|
|
2739
|
-
e.stopPropagation();
|
|
2740
|
-
const epicId = this.dataset.id;
|
|
2741
|
-
if (!epicId) return;
|
|
2742
|
-
const isPinned = this.hasAttribute("pinned");
|
|
2743
|
-
document.dispatchEvent(new CustomEvent("epic-pin", { detail: { epicId: isPinned ? null : epicId } }));
|
|
2744
|
-
});
|
|
2745
|
-
}
|
|
2746
2771
|
}
|
|
2747
2772
|
};
|
|
2748
2773
|
customElements.define("task-item", TaskItem);
|
|
@@ -3534,6 +3559,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
3534
3559
|
}
|
|
3535
3560
|
const modal = document.querySelector("system-info-modal");
|
|
3536
3561
|
systemInfoBtn?.addEventListener("click", () => modal?.open());
|
|
3562
|
+
document.getElementById("home-button")?.addEventListener("click", () => {
|
|
3563
|
+
urlState.set({ epic: null, task: null });
|
|
3564
|
+
});
|
|
3537
3565
|
const savedResource = localStorage.getItem("openResource");
|
|
3538
3566
|
if (savedResource) {
|
|
3539
3567
|
if (savedResource.startsWith("mcp://")) {
|
|
@@ -3549,6 +3577,9 @@ document.addEventListener("filter-change", ((e) => {
|
|
|
3549
3577
|
document.addEventListener("task-selected", ((e) => {
|
|
3550
3578
|
urlState.set({ task: e.detail.taskId });
|
|
3551
3579
|
}));
|
|
3580
|
+
document.addEventListener("epic-navigate", ((e) => {
|
|
3581
|
+
urlState.set({ epic: e.detail.epicId });
|
|
3582
|
+
}));
|
|
3552
3583
|
document.addEventListener("epic-pin", ((e) => {
|
|
3553
3584
|
urlState.set({ epic: e.detail.epicId });
|
|
3554
3585
|
}));
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Task } from '../utils/api.js';
|
|
2
|
+
|
|
3
|
+
export class Breadcrumb extends HTMLElement {
|
|
4
|
+
private currentEpicId: string | null = null;
|
|
5
|
+
private tasks: Task[] = [];
|
|
6
|
+
|
|
7
|
+
setData(currentEpicId: string | null, tasks: Task[]) {
|
|
8
|
+
this.currentEpicId = currentEpicId;
|
|
9
|
+
this.tasks = tasks;
|
|
10
|
+
this.render();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private buildPath(): Task[] {
|
|
14
|
+
if (!this.currentEpicId) return [];
|
|
15
|
+
|
|
16
|
+
const path: Task[] = [];
|
|
17
|
+
let currentId: string | null = this.currentEpicId;
|
|
18
|
+
|
|
19
|
+
while (currentId) {
|
|
20
|
+
const epic = this.tasks.find(t => t.id === currentId);
|
|
21
|
+
if (!epic) break;
|
|
22
|
+
path.unshift(epic);
|
|
23
|
+
currentId = epic.epic_id || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private render() {
|
|
30
|
+
const path = this.buildPath();
|
|
31
|
+
|
|
32
|
+
// Always render breadcrumb, even at root
|
|
33
|
+
this.innerHTML = `
|
|
34
|
+
<div class="breadcrumb">
|
|
35
|
+
<button class="breadcrumb-segment" data-epic-id="" title="All Tasks">All Tasks</button>
|
|
36
|
+
${path.map(epic => `
|
|
37
|
+
<span class="breadcrumb-separator">›</span>
|
|
38
|
+
<button class="breadcrumb-segment" data-epic-id="${epic.id}" title="${epic.title}">${epic.title}</button>
|
|
39
|
+
`).join('')}
|
|
40
|
+
</div>
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
this.querySelectorAll('.breadcrumb-segment').forEach(btn => {
|
|
44
|
+
btn.addEventListener('click', () => {
|
|
45
|
+
const epicId = (btn as HTMLElement).dataset.epicId || null;
|
|
46
|
+
document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId } }));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
customElements.define('epic-breadcrumb', Breadcrumb);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { pinIcon, chevronIcon } from '../icons/index.js';
|
|
2
|
-
|
|
3
1
|
export class TaskItem extends HTMLElement {
|
|
4
2
|
connectedCallback() {
|
|
5
3
|
this.render();
|
|
@@ -11,32 +9,38 @@ export class TaskItem extends HTMLElement {
|
|
|
11
9
|
const title = this.dataset.title || '';
|
|
12
10
|
const status = this.dataset.status || 'open';
|
|
13
11
|
const type = this.dataset.type || 'task';
|
|
14
|
-
const
|
|
15
|
-
const isPinned = this.hasAttribute('pinned');
|
|
12
|
+
const isCurrentEpic = this.dataset.currentEpic === 'true';
|
|
16
13
|
const isSelected = this.hasAttribute('selected');
|
|
17
|
-
const
|
|
18
|
-
const childCount = this.dataset.childCount;
|
|
14
|
+
const childCount = this.dataset.childCount || '0';
|
|
19
15
|
|
|
20
|
-
this.className =
|
|
16
|
+
this.className = 'task-item-wrapper';
|
|
21
17
|
this.innerHTML = `
|
|
22
|
-
<div class="task-item ${isSelected ? 'selected' : ''} type-${type}">
|
|
23
|
-
${type === 'epic' ? `<button class="collapse-btn ${isCollapsed ? 'collapsed' : ''}" title="${isCollapsed ? 'Expand' : 'Collapse'}"><svg-icon src="${chevronIcon}"></svg-icon></button>` : ''}
|
|
18
|
+
<div class="task-item ${isSelected ? 'selected' : ''} ${isCurrentEpic ? 'current-epic' : ''} type-${type}">
|
|
24
19
|
<task-badge task-id="${id}" type="${type}"></task-badge>
|
|
25
20
|
<span class="task-title">${title}</span>
|
|
26
|
-
${
|
|
21
|
+
${type === 'epic' ? `<span class="child-count">${childCount}</span>` : ''}
|
|
22
|
+
${type === 'epic' && !isCurrentEpic ? '<span class="enter-icon">→</span>' : ''}
|
|
27
23
|
<span class="status-badge status-${status}">${status.replace('_', ' ')}</span>
|
|
28
24
|
</div>
|
|
29
|
-
${type === 'epic' ? `<button class="pin-btn ${isPinned ? 'pinned' : ''}" title="${isPinned ? 'Unpin' : 'Pin to filter'}"><svg-icon src="${pinIcon}"></svg-icon></button>` : ''}
|
|
30
25
|
`;
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
attachListeners() {
|
|
34
29
|
const taskItem = this.querySelector('.task-item');
|
|
30
|
+
const type = this.dataset.type || 'task';
|
|
31
|
+
const isCurrentEpic = this.dataset.currentEpic === 'true';
|
|
32
|
+
|
|
35
33
|
taskItem?.addEventListener('click', (e) => {
|
|
36
|
-
if ((e.target as HTMLElement).closest('.collapse-btn')) return;
|
|
37
34
|
const taskId = this.dataset.id;
|
|
38
35
|
if (!taskId) return;
|
|
39
36
|
|
|
37
|
+
// If epic and not current epic, navigate into it
|
|
38
|
+
if (type === 'epic' && !isCurrentEpic) {
|
|
39
|
+
document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId: taskId } }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Otherwise, select and show detail
|
|
40
44
|
document.querySelectorAll('task-item .task-item').forEach(item => {
|
|
41
45
|
item.classList.toggle('selected', (item.closest('task-item') as HTMLElement)?.dataset.id === taskId);
|
|
42
46
|
});
|
|
@@ -53,28 +57,6 @@ export class TaskItem extends HTMLElement {
|
|
|
53
57
|
(taskList as any).setSelected(taskId);
|
|
54
58
|
}
|
|
55
59
|
});
|
|
56
|
-
|
|
57
|
-
const collapseBtn = this.querySelector('.collapse-btn');
|
|
58
|
-
if (collapseBtn) {
|
|
59
|
-
collapseBtn.addEventListener('click', (e) => {
|
|
60
|
-
e.stopPropagation();
|
|
61
|
-
const epicId = this.dataset.id;
|
|
62
|
-
if (epicId) {
|
|
63
|
-
document.dispatchEvent(new CustomEvent('epic-toggle', { detail: { epicId } }));
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const pinBtn = this.querySelector('.pin-btn');
|
|
69
|
-
if (pinBtn) {
|
|
70
|
-
pinBtn.addEventListener('click', (e) => {
|
|
71
|
-
e.stopPropagation();
|
|
72
|
-
const epicId = this.dataset.id;
|
|
73
|
-
if (!epicId) return;
|
|
74
|
-
const isPinned = this.hasAttribute('pinned');
|
|
75
|
-
document.dispatchEvent(new CustomEvent('epic-pin', { detail: { epicId: isPinned ? null : epicId } }));
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
60
|
}
|
|
79
61
|
}
|
|
80
62
|
|
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
import { fetchTasks, type Task } from '../utils/api.js';
|
|
2
|
+
import './breadcrumb.js';
|
|
3
|
+
import { ringIcon } from '../icons/index.js';
|
|
2
4
|
|
|
3
5
|
function escapeAttr(text: string | undefined): string {
|
|
4
6
|
if (!text) return '';
|
|
5
7
|
return text.replace(/"/g, '"').replace(/'/g, ''');
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
function getCollapsedEpics(): Set<string> {
|
|
9
|
-
try {
|
|
10
|
-
return new Set(JSON.parse(localStorage.getItem('collapsed-epics') || '[]'));
|
|
11
|
-
} catch { return new Set(); }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function setCollapsedEpics(ids: Set<string>) {
|
|
15
|
-
localStorage.setItem('collapsed-epics', JSON.stringify([...ids]));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
export class TaskList extends HTMLElement {
|
|
19
11
|
private currentFilter: string = 'active';
|
|
20
12
|
private currentType: string = 'all';
|
|
21
|
-
private
|
|
13
|
+
private currentEpicId: string | null = null;
|
|
22
14
|
private selectedTaskId: string | null = null;
|
|
23
|
-
private
|
|
15
|
+
private allTasks: Task[] = [];
|
|
24
16
|
|
|
25
17
|
connectedCallback() {
|
|
26
18
|
const params = new URLSearchParams(window.location.search);
|
|
27
19
|
this.selectedTaskId = params.get('task');
|
|
28
|
-
this.
|
|
20
|
+
this.currentEpicId = params.get('epic');
|
|
29
21
|
|
|
30
22
|
this.loadTasks();
|
|
31
23
|
setInterval(() => this.loadTasks(), 5000);
|
|
@@ -40,27 +32,29 @@ export class TaskList extends HTMLElement {
|
|
|
40
32
|
this.setSelected(e.detail.taskId);
|
|
41
33
|
}) as EventListener);
|
|
42
34
|
|
|
43
|
-
document.addEventListener('epic-
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const { epicId } = e.detail;
|
|
50
|
-
if (this.collapsedEpics.has(epicId)) {
|
|
51
|
-
this.collapsedEpics.delete(epicId);
|
|
52
|
-
} else {
|
|
53
|
-
this.collapsedEpics.add(epicId);
|
|
35
|
+
document.addEventListener('epic-navigate', ((e: CustomEvent) => {
|
|
36
|
+
this.currentEpicId = e.detail.epicId;
|
|
37
|
+
// Auto-select epic when navigating into it
|
|
38
|
+
if (e.detail.epicId) {
|
|
39
|
+
this.selectedTaskId = e.detail.epicId;
|
|
40
|
+
document.dispatchEvent(new CustomEvent('task-selected', { detail: { taskId: e.detail.epicId } }));
|
|
54
41
|
}
|
|
55
|
-
setCollapsedEpics(this.collapsedEpics);
|
|
56
42
|
this.loadTasks();
|
|
57
43
|
}) as EventListener);
|
|
44
|
+
|
|
45
|
+
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
46
|
+
if (e.key === 'Escape' && this.currentEpicId) {
|
|
47
|
+
const currentEpic = this.allTasks.find(t => t.id === this.currentEpicId);
|
|
48
|
+
const parentEpicId = currentEpic?.epic_id || null;
|
|
49
|
+
document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId: parentEpicId } }));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
58
52
|
}
|
|
59
53
|
|
|
60
54
|
setState(filter: string, type: string, epicId: string | null, taskId: string | null) {
|
|
61
55
|
this.currentFilter = filter;
|
|
62
56
|
this.currentType = type;
|
|
63
|
-
this.
|
|
57
|
+
this.currentEpicId = epicId;
|
|
64
58
|
this.selectedTaskId = taskId;
|
|
65
59
|
this.loadTasks();
|
|
66
60
|
}
|
|
@@ -68,72 +62,91 @@ export class TaskList extends HTMLElement {
|
|
|
68
62
|
async loadTasks() {
|
|
69
63
|
try {
|
|
70
64
|
let tasks = await fetchTasks(this.currentFilter as any);
|
|
65
|
+
this.allTasks = tasks;
|
|
71
66
|
|
|
72
67
|
// Type filter
|
|
73
68
|
if (this.currentType !== 'all') {
|
|
74
69
|
tasks = tasks.filter(t => (t.type ?? 'task') === this.currentType);
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
// Epic
|
|
78
|
-
if (this.
|
|
79
|
-
const
|
|
80
|
-
const children = tasks.filter(t => t.epic_id === this.
|
|
81
|
-
tasks =
|
|
72
|
+
// Epic navigation filter
|
|
73
|
+
if (this.currentEpicId) {
|
|
74
|
+
const currentEpic = tasks.find(t => t.id === this.currentEpicId);
|
|
75
|
+
const children = tasks.filter(t => t.epic_id === this.currentEpicId);
|
|
76
|
+
tasks = currentEpic ? [currentEpic, ...children] : children;
|
|
77
|
+
} else {
|
|
78
|
+
// Home page: only root epics and orphan tasks
|
|
79
|
+
const rootEpics = tasks.filter(t => (t.type ?? 'task') === 'epic' && !t.epic_id);
|
|
80
|
+
const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !t.epic_id);
|
|
81
|
+
tasks = [...rootEpics, ...orphanTasks];
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
this.render(tasks);
|
|
85
|
+
|
|
86
|
+
const breadcrumb = this.querySelector('epic-breadcrumb');
|
|
87
|
+
if (breadcrumb) {
|
|
88
|
+
(breadcrumb as any).setData(this.currentEpicId, this.allTasks);
|
|
89
|
+
}
|
|
85
90
|
} catch (error) {
|
|
86
91
|
this.innerHTML = `<div class="error">Failed to load tasks: ${(error as Error).message}</div>`;
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
render(tasks: Task[]) {
|
|
91
|
-
|
|
96
|
+
const isEmpty = tasks.length === 0;
|
|
97
|
+
const isInsideEpic = !!this.currentEpicId;
|
|
98
|
+
const currentEpic = isInsideEpic ? tasks.find(t => t.id === this.currentEpicId) : null;
|
|
99
|
+
const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
|
|
100
|
+
|
|
101
|
+
if (isEmpty) {
|
|
92
102
|
this.innerHTML = `
|
|
103
|
+
<epic-breadcrumb></epic-breadcrumb>
|
|
93
104
|
<div class="empty-state">
|
|
94
105
|
<div class="empty-state-icon">—</div>
|
|
95
106
|
<div>No tasks found</div>
|
|
96
107
|
</div>
|
|
97
108
|
`;
|
|
109
|
+
const breadcrumb = this.querySelector('epic-breadcrumb');
|
|
110
|
+
if (breadcrumb) {
|
|
111
|
+
(breadcrumb as any).setData(this.currentEpicId, this.allTasks);
|
|
112
|
+
}
|
|
98
113
|
return;
|
|
99
114
|
}
|
|
100
115
|
|
|
101
|
-
// Group: epics first
|
|
116
|
+
// Group: epics first, then tasks
|
|
102
117
|
const epics = tasks.filter(t => (t.type ?? 'task') === 'epic');
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !childTasks.includes(t));
|
|
106
|
-
|
|
107
|
-
const grouped: Array<Task & { isChild?: boolean; childCount?: number }> = [];
|
|
108
|
-
for (const epic of rootEpics) {
|
|
109
|
-
const children = childTasks.filter(t => t.epic_id === epic.id);
|
|
110
|
-
const isCollapsed = this.collapsedEpics.has(epic.id);
|
|
111
|
-
grouped.push({ ...epic, childCount: children.length });
|
|
112
|
-
if (!isCollapsed) {
|
|
113
|
-
for (const child of children) {
|
|
114
|
-
grouped.push({ ...child, isChild: true });
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
grouped.push(...orphanTasks);
|
|
118
|
+
const regularTasks = tasks.filter(t => (t.type ?? 'task') === 'task');
|
|
119
|
+
const grouped = [...epics, ...regularTasks];
|
|
119
120
|
|
|
120
121
|
this.innerHTML = `
|
|
122
|
+
<epic-breadcrumb></epic-breadcrumb>
|
|
121
123
|
<div class="task-list">
|
|
122
|
-
${grouped.map(task =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
124
|
+
${grouped.map((task, index) => {
|
|
125
|
+
const childCount = (task.type ?? 'task') === 'epic'
|
|
126
|
+
? this.allTasks.filter(t => t.epic_id === task.id).length
|
|
127
|
+
: 0;
|
|
128
|
+
const isCurrentEpic = this.currentEpicId === task.id;
|
|
129
|
+
return `
|
|
130
|
+
<task-item
|
|
131
|
+
data-id="${task.id}"
|
|
132
|
+
data-title="${escapeAttr(task.title)}"
|
|
133
|
+
data-status="${task.status}"
|
|
134
|
+
data-type="${task.type ?? 'task'}"
|
|
135
|
+
data-child-count="${childCount}"
|
|
136
|
+
${this.selectedTaskId === task.id ? 'selected' : ''}
|
|
137
|
+
${isCurrentEpic ? 'data-current-epic="true"' : ''}
|
|
138
|
+
></task-item>
|
|
139
|
+
${isCurrentEpic ? `<div class="epic-separator"><svg-icon class="separator-icon" src="${ringIcon}"></svg-icon></div>` : ''}
|
|
140
|
+
`;
|
|
141
|
+
}).join('')}
|
|
142
|
+
${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">—</div><div>No tasks in this epic</div></div>' : ''}
|
|
135
143
|
</div>
|
|
136
144
|
`;
|
|
145
|
+
|
|
146
|
+
const breadcrumb = this.querySelector('epic-breadcrumb');
|
|
147
|
+
if (breadcrumb) {
|
|
148
|
+
(breadcrumb as any).setData(this.currentEpicId, this.allTasks);
|
|
149
|
+
}
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
setSelected(taskId: string) {
|
package/viewer/icons/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import epicIconSvg from './epic.svg';
|
|
|
5
5
|
import taskIconSvg from './task.svg';
|
|
6
6
|
import chevronIconSvg from './chevron.svg';
|
|
7
7
|
import settingsIconSvg from './settings.svg';
|
|
8
|
+
import ringIconSvg from './ring.svg';
|
|
8
9
|
|
|
9
10
|
export const copyIcon = copyIconSvg;
|
|
10
11
|
export const pinIcon = pinIconSvg;
|
|
@@ -12,3 +13,4 @@ export const epicIcon = epicIconSvg;
|
|
|
12
13
|
export const taskIcon = taskIconSvg;
|
|
13
14
|
export const chevronIcon = chevronIconSvg;
|
|
14
15
|
export const settingsIcon = settingsIconSvg;
|
|
16
|
+
export const ringIcon = ringIconSvg;
|
package/viewer/index.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<!-- Left Pane: Task List -->
|
|
21
21
|
<div class="left-pane" id="left-pane">
|
|
22
22
|
<div class="pane-header">
|
|
23
|
-
<div class="pane-title">
|
|
23
|
+
<div class="pane-title" id="home-button" style="cursor: pointer;" title="Go to All Tasks">
|
|
24
24
|
<img src="./logo.svg" class="logo" alt="">
|
|
25
25
|
Backlog
|
|
26
26
|
</div>
|
package/viewer/main.ts
CHANGED
|
@@ -47,6 +47,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
47
47
|
const modal = document.querySelector('system-info-modal') as any;
|
|
48
48
|
systemInfoBtn?.addEventListener('click', () => modal?.open());
|
|
49
49
|
|
|
50
|
+
// Wire up home button
|
|
51
|
+
document.getElementById('home-button')?.addEventListener('click', () => {
|
|
52
|
+
urlState.set({ epic: null, task: null });
|
|
53
|
+
});
|
|
54
|
+
|
|
50
55
|
// Restore resource from localStorage
|
|
51
56
|
const savedResource = localStorage.getItem('openResource');
|
|
52
57
|
if (savedResource) {
|
|
@@ -67,6 +72,10 @@ document.addEventListener('task-selected', ((e: CustomEvent) => {
|
|
|
67
72
|
urlState.set({ task: e.detail.taskId });
|
|
68
73
|
}) as EventListener);
|
|
69
74
|
|
|
75
|
+
document.addEventListener('epic-navigate', ((e: CustomEvent) => {
|
|
76
|
+
urlState.set({ epic: e.detail.epicId });
|
|
77
|
+
}) as EventListener);
|
|
78
|
+
|
|
70
79
|
document.addEventListener('epic-pin', ((e: CustomEvent) => {
|
|
71
80
|
urlState.set({ epic: e.detail.epicId });
|
|
72
81
|
}) as EventListener);
|
package/viewer/styles.css
CHANGED
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
|
|
152
152
|
task-filter-bar {
|
|
153
153
|
display: block;
|
|
154
|
-
margin-bottom:
|
|
154
|
+
margin-bottom: 0;
|
|
155
155
|
padding: 12px;
|
|
156
156
|
background: #252526;
|
|
157
157
|
border-radius: 8px;
|
|
@@ -200,6 +200,47 @@
|
|
|
200
200
|
color: white;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/* Breadcrumb */
|
|
204
|
+
.breadcrumb {
|
|
205
|
+
display: flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
gap: 8px;
|
|
208
|
+
padding: 12px 0;
|
|
209
|
+
margin-bottom: 8px;
|
|
210
|
+
border-bottom: 1px solid #3e3e42;
|
|
211
|
+
font-size: 13px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.breadcrumb-segment {
|
|
215
|
+
background: none;
|
|
216
|
+
border: none;
|
|
217
|
+
color: #888;
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
padding: 4px 8px;
|
|
220
|
+
border-radius: 4px;
|
|
221
|
+
transition: all 0.2s;
|
|
222
|
+
font-size: 13px;
|
|
223
|
+
max-width: 200px;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
text-overflow: ellipsis;
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.breadcrumb-segment:hover {
|
|
230
|
+
background: #2d2d30;
|
|
231
|
+
color: #d4d4d4;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.breadcrumb-segment:last-child {
|
|
235
|
+
color: #d4d4d4;
|
|
236
|
+
font-weight: 500;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.breadcrumb-separator {
|
|
240
|
+
color: #555;
|
|
241
|
+
user-select: none;
|
|
242
|
+
}
|
|
243
|
+
|
|
203
244
|
/* Task List */
|
|
204
245
|
.task-list {
|
|
205
246
|
display: flex;
|
|
@@ -240,11 +281,6 @@
|
|
|
240
281
|
background: #3e3e42;
|
|
241
282
|
}
|
|
242
283
|
|
|
243
|
-
.task-item-wrapper.pinned .task-item {
|
|
244
|
-
border-color: #f0b429;
|
|
245
|
-
background: #3d3522;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
284
|
.task-item {
|
|
249
285
|
flex: 1;
|
|
250
286
|
display: flex;
|
|
@@ -288,6 +324,19 @@
|
|
|
288
324
|
border-radius: 10px;
|
|
289
325
|
}
|
|
290
326
|
|
|
327
|
+
.enter-icon {
|
|
328
|
+
font-size: 14px;
|
|
329
|
+
color: #888;
|
|
330
|
+
margin-left: auto;
|
|
331
|
+
opacity: 0.6;
|
|
332
|
+
transition: opacity 0.2s;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.task-item.type-epic:hover .enter-icon {
|
|
336
|
+
opacity: 1;
|
|
337
|
+
color: #007acc;
|
|
338
|
+
}
|
|
339
|
+
|
|
291
340
|
.task-item:hover {
|
|
292
341
|
background: #2d2d30;
|
|
293
342
|
border-color: #007acc;
|
|
@@ -298,28 +347,38 @@
|
|
|
298
347
|
border-color: #007acc;
|
|
299
348
|
}
|
|
300
349
|
|
|
301
|
-
.task-item
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
cursor: pointer;
|
|
307
|
-
color: #888;
|
|
350
|
+
.task-item.current-epic {
|
|
351
|
+
/* Separator below provides visual distinction */
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.epic-separator {
|
|
308
355
|
display: flex;
|
|
309
356
|
align-items: center;
|
|
310
|
-
|
|
357
|
+
margin: 12px 0;
|
|
358
|
+
gap: 12px;
|
|
311
359
|
}
|
|
312
360
|
|
|
313
|
-
.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
361
|
+
.epic-separator::before,
|
|
362
|
+
.epic-separator::after {
|
|
363
|
+
content: '';
|
|
364
|
+
flex: 1;
|
|
365
|
+
height: 1px;
|
|
366
|
+
background: linear-gradient(90deg, transparent, rgba(9, 105, 218, 0.3), transparent);
|
|
317
367
|
}
|
|
318
368
|
|
|
319
|
-
.
|
|
320
|
-
background:
|
|
321
|
-
|
|
322
|
-
|
|
369
|
+
.epic-separator::before {
|
|
370
|
+
background: linear-gradient(90deg, transparent, rgba(9, 105, 218, 0.3));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.epic-separator::after {
|
|
374
|
+
background: linear-gradient(90deg, rgba(9, 105, 218, 0.3), transparent);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.separator-icon {
|
|
378
|
+
width: 10px !important;
|
|
379
|
+
height: 10px !important;
|
|
380
|
+
flex-shrink: 0;
|
|
381
|
+
background: linear-gradient(135deg, #00d4ff, #7b2dff, #ff2d7b) !important;
|
|
323
382
|
}
|
|
324
383
|
|
|
325
384
|
.task-id {
|
|
@@ -491,6 +550,12 @@
|
|
|
491
550
|
color: #888;
|
|
492
551
|
}
|
|
493
552
|
|
|
553
|
+
.empty-state-inline {
|
|
554
|
+
text-align: center;
|
|
555
|
+
padding: 40px 20px;
|
|
556
|
+
color: #888;
|
|
557
|
+
}
|
|
558
|
+
|
|
494
559
|
.empty-state-icon {
|
|
495
560
|
font-size: 48px;
|
|
496
561
|
margin-bottom: 16px;
|
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
-
<path d="M4.456.734a1.75 1.75 0 012.826.504l.613 1.327a3.08 3.08 0 002.084 1.707l2.454.584c1.332.317 1.8 1.972.832 2.94L11.06 10l3.72 3.72a.75.75 0 11-1.06 1.06L10 11.06l-2.204 2.205c-.968.968-2.623.5-2.94-.832l-.584-2.454a3.08 3.08 0 00-1.707-2.084l-1.327-.613a1.75 1.75 0 01-.504-2.826L4.456.734z"/>
|
|
3
|
-
</svg>
|