Use vanilla JS for aggregator frontend (no React bundling needed)
The HTML import with React/TSX doesn't work well in Docker production builds. Replace with a self-contained HTML file with vanilla JS that works reliably across all environments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
24757f5031
commit
a3ad292b81
|
|
@ -16,10 +16,14 @@ import { parseTask } from "../markdown/parser.ts";
|
|||
import type { Task } from "../types/index.ts";
|
||||
import { sortByTaskId } from "../utils/task-sorting.ts";
|
||||
|
||||
// @ts-expect-error - Bun HTML import
|
||||
import indexHtml from "./web/index.html";
|
||||
// @ts-expect-error - Bun file import
|
||||
import favicon from "../web/favicon.png" with { type: "file" };
|
||||
import { dirname, join as pathJoin } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Get the directory of this file
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
interface ProjectConfig {
|
||||
path: string;
|
||||
|
|
@ -87,7 +91,16 @@ export class BacklogAggregator {
|
|||
port: this.config.port,
|
||||
development: process.env.NODE_ENV === "development",
|
||||
routes: {
|
||||
"/": indexHtml,
|
||||
"/": async () => {
|
||||
const htmlPath = pathJoin(__dirname, "web", "index.html");
|
||||
const htmlFile = Bun.file(htmlPath);
|
||||
return new Response(htmlFile, { headers: { "Content-Type": "text/html" } });
|
||||
},
|
||||
"/app.tsx": async () => {
|
||||
const tsxPath = pathJoin(__dirname, "web", "app.tsx");
|
||||
const tsxFile = Bun.file(tsxPath);
|
||||
return new Response(tsxFile, { headers: { "Content-Type": "application/javascript" } });
|
||||
},
|
||||
"/api/projects": {
|
||||
GET: async () => this.handleGetProjects(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,9 +5,316 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backlog Aggregator - Real-time Task View</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<script type="module" src="./app.tsx"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
padding: 1rem 2rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header h1 { font-size: 1.5rem; font-weight: 600; }
|
||||
.header-left { display: flex; align-items: center; gap: 1rem; }
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
.status-badge.offline { background: #ef4444; }
|
||||
.stats { font-size: 0.875rem; color: #94a3b8; }
|
||||
.filter-bar {
|
||||
padding: 1rem 2rem;
|
||||
background: #1e293b;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-input {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-width: 200px;
|
||||
}
|
||||
.project-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; }
|
||||
.project-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.board {
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
.column {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.column-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.column-header h2 { font-size: 1rem; font-weight: 600; }
|
||||
.task-count {
|
||||
background: #334155;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; }
|
||||
.task-card {
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.task-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.task-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; }
|
||||
.task-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; }
|
||||
.task-meta { font-size: 0.75rem; color: #64748b; }
|
||||
.priority-badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.priority-high { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
|
||||
.priority-medium { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
|
||||
.priority-low { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
|
||||
.labels { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
.label {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
background: #334155;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.assignees { margin-top: 0.5rem; font-size: 0.75rem; color: #64748b; }
|
||||
.empty-state { color: #64748b; text-align: center; padding: 2rem; font-size: 0.875rem; }
|
||||
.task-description {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #334155;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
white-space: pre-wrap;
|
||||
display: none;
|
||||
}
|
||||
.task-card.expanded .task-description { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1>Backlog Aggregator</h1>
|
||||
<span id="status-badge" class="status-badge">Connecting...</span>
|
||||
</div>
|
||||
<div class="stats" id="stats">Loading...</div>
|
||||
</header>
|
||||
|
||||
<div class="filter-bar" id="filter-bar">
|
||||
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
|
||||
<button class="project-btn active" id="all-projects">All Projects</button>
|
||||
</div>
|
||||
|
||||
<div class="board" id="board">
|
||||
<div class="column" data-status="To Do">
|
||||
<div class="column-header">
|
||||
<h2>To Do</h2>
|
||||
<span class="task-count" id="count-todo">0</span>
|
||||
</div>
|
||||
<div class="tasks" id="tasks-todo"></div>
|
||||
</div>
|
||||
<div class="column" data-status="In Progress">
|
||||
<div class="column-header">
|
||||
<h2>In Progress</h2>
|
||||
<span class="task-count" id="count-progress">0</span>
|
||||
</div>
|
||||
<div class="tasks" id="tasks-progress"></div>
|
||||
</div>
|
||||
<div class="column" data-status="Done">
|
||||
<div class="column-header">
|
||||
<h2>Done</h2>
|
||||
<span class="task-count" id="count-done">0</span>
|
||||
</div>
|
||||
<div class="tasks" id="tasks-done"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let projects = [];
|
||||
let tasks = [];
|
||||
let selectedProject = null;
|
||||
let ws = null;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${location.host}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('status-badge').textContent = 'Live';
|
||||
document.getElementById('status-badge').classList.remove('offline');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('status-badge').textContent = 'Reconnecting...';
|
||||
document.getElementById('status-badge').classList.add('offline');
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'init' || data.type === 'update') {
|
||||
projects = data.projects || [];
|
||||
tasks = data.tasks || [];
|
||||
renderProjects();
|
||||
renderTasks();
|
||||
}
|
||||
} catch (e) { console.error('Parse error:', e); }
|
||||
};
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const filterBar = document.getElementById('filter-bar');
|
||||
// Remove old project buttons except filter and all-projects
|
||||
const oldBtns = filterBar.querySelectorAll('.project-btn:not(#all-projects)');
|
||||
oldBtns.forEach(btn => btn.remove());
|
||||
|
||||
projects.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'project-btn';
|
||||
btn.innerHTML = `<span class="project-dot" style="background:${p.color}"></span>${p.name}`;
|
||||
btn.onclick = () => {
|
||||
selectedProject = p.name;
|
||||
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderTasks();
|
||||
};
|
||||
filterBar.appendChild(btn);
|
||||
});
|
||||
|
||||
document.getElementById('stats').textContent =
|
||||
`${projects.length} projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const filter = document.getElementById('filter').value.toLowerCase();
|
||||
const statusMap = {
|
||||
'to do': 'todo',
|
||||
'in progress': 'progress',
|
||||
'done': 'done'
|
||||
};
|
||||
|
||||
// Clear all columns
|
||||
['todo', 'progress', 'done'].forEach(id => {
|
||||
document.getElementById(`tasks-${id}`).innerHTML = '';
|
||||
});
|
||||
|
||||
let counts = { todo: 0, progress: 0, done: 0 };
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (selectedProject && task.projectName !== selectedProject) return;
|
||||
if (filter && !task.title.toLowerCase().includes(filter) &&
|
||||
!task.projectName.toLowerCase().includes(filter)) return;
|
||||
|
||||
const statusKey = statusMap[task.status.toLowerCase()] || 'todo';
|
||||
counts[statusKey]++;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'task-card';
|
||||
card.style.borderLeftColor = task.projectColor;
|
||||
card.onclick = () => card.classList.toggle('expanded');
|
||||
|
||||
let priorityClass = '';
|
||||
if (task.priority) {
|
||||
priorityClass = `priority-${task.priority.toLowerCase()}`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="task-header">
|
||||
<div>
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
<div class="task-meta">${task.id} | ${task.projectName}</div>
|
||||
</div>
|
||||
${task.priority ? `<span class="priority-badge ${priorityClass}">${task.priority}</span>` : ''}
|
||||
</div>
|
||||
${task.labels && task.labels.length > 0 ? `
|
||||
<div class="labels">
|
||||
${task.labels.map(l => `<span class="label">${escapeHtml(l)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${task.assignee && task.assignee.length > 0 ? `
|
||||
<div class="assignees">${task.assignee.join(', ')}</div>
|
||||
` : ''}
|
||||
${task.description ? `
|
||||
<div class="task-description">${escapeHtml(task.description.slice(0, 500))}${task.description.length > 500 ? '...' : ''}</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById(`tasks-${statusKey}`).appendChild(card);
|
||||
});
|
||||
|
||||
document.getElementById('count-todo').textContent = counts.todo;
|
||||
document.getElementById('count-progress').textContent = counts.progress;
|
||||
document.getElementById('count-done').textContent = counts.done;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
document.getElementById('filter').addEventListener('input', renderTasks);
|
||||
document.getElementById('all-projects').onclick = () => {
|
||||
selectedProject = null;
|
||||
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('all-projects').classList.add('active');
|
||||
renderTasks();
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
connectWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue