claude-plan-viewer 1.1.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/README.md +105 -0
- package/frontend.ts +843 -0
- package/index.html +14 -0
- package/index.ts +266 -0
- package/package.json +53 -0
- package/prism.bundle.js +35 -0
- package/styles.css +996 -0
package/frontend.ts
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
// HMR support
|
|
2
|
+
if (import.meta.hot) {
|
|
3
|
+
import.meta.hot.accept();
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Prism.js is loaded from CDN
|
|
7
|
+
declare const Prism: {
|
|
8
|
+
highlight: (code: string, grammar: unknown, language: string) => string;
|
|
9
|
+
languages: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
interface Plan {
|
|
13
|
+
filename: string;
|
|
14
|
+
filepath: string;
|
|
15
|
+
title: string;
|
|
16
|
+
content: string;
|
|
17
|
+
size: number;
|
|
18
|
+
modified: string;
|
|
19
|
+
created: string;
|
|
20
|
+
lineCount: number;
|
|
21
|
+
wordCount: number;
|
|
22
|
+
project: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let plans: Plan[] = [];
|
|
26
|
+
let filteredPlans: Plan[] = [];
|
|
27
|
+
let selectedPlan: Plan | null = null;
|
|
28
|
+
let sortKey = "modified";
|
|
29
|
+
let sortDir: "asc" | "desc" = "desc";
|
|
30
|
+
let searchQuery = "";
|
|
31
|
+
let showHelpModal = false;
|
|
32
|
+
let selectedProjects: Set<string> = new Set();
|
|
33
|
+
const scrollPositions = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
const app = document.getElementById("app")!;
|
|
36
|
+
|
|
37
|
+
function selectPlan(plan: Plan | null): void {
|
|
38
|
+
// Save scroll position of current plan
|
|
39
|
+
if (selectedPlan) {
|
|
40
|
+
const detailContent = document.querySelector(".detail-content");
|
|
41
|
+
if (detailContent) {
|
|
42
|
+
scrollPositions.set(selectedPlan.filename, detailContent.scrollTop);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
selectedPlan = plan;
|
|
47
|
+
|
|
48
|
+
// Update URL
|
|
49
|
+
if (plan) {
|
|
50
|
+
const url = new URL(window.location.href);
|
|
51
|
+
url.searchParams.set("plan", plan.filename);
|
|
52
|
+
history.pushState(null, "", url.toString());
|
|
53
|
+
} else {
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
url.searchParams.delete("plan");
|
|
56
|
+
history.pushState(null, "", url.toString());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render();
|
|
60
|
+
|
|
61
|
+
// Restore scroll position
|
|
62
|
+
if (plan) {
|
|
63
|
+
const savedScroll = scrollPositions.get(plan.filename);
|
|
64
|
+
if (savedScroll !== undefined) {
|
|
65
|
+
requestAnimationFrame(() => {
|
|
66
|
+
const detailContent = document.querySelector(".detail-content");
|
|
67
|
+
if (detailContent) {
|
|
68
|
+
detailContent.scrollTop = savedScroll;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function openInEditor(): Promise<void> {
|
|
76
|
+
if (!selectedPlan) return;
|
|
77
|
+
try {
|
|
78
|
+
await fetch("/api/open", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ filepath: selectedPlan.filepath }),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error("Failed to open in editor:", err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function highlightText(text: string, query: string): string {
|
|
89
|
+
if (!query) return escapeHtml(text);
|
|
90
|
+
const escaped = escapeHtml(text);
|
|
91
|
+
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
|
92
|
+
return escaped.replace(regex, '<mark>$1</mark>');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function escapeRegex(str: string): string {
|
|
96
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function escapeHtml(str: string): string {
|
|
100
|
+
return str
|
|
101
|
+
.replace(/&/g, "&")
|
|
102
|
+
.replace(/</g, "<")
|
|
103
|
+
.replace(/>/g, ">")
|
|
104
|
+
.replace(/"/g, """);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function debounce<Args extends unknown[]>(fn: (...args: Args) => void, ms: number): (...args: Args) => void {
|
|
108
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
return (...args: Args) => {
|
|
110
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
111
|
+
timeoutId = setTimeout(() => fn(...args), ms);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateSearchQueryParam(query: string): void {
|
|
116
|
+
const url = new URL(window.location.href);
|
|
117
|
+
if (query) {
|
|
118
|
+
url.searchParams.set("q", query);
|
|
119
|
+
} else {
|
|
120
|
+
url.searchParams.delete("q");
|
|
121
|
+
}
|
|
122
|
+
history.replaceState(null, "", url.toString());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const debouncedUpdateSearchQueryParam = debounce(updateSearchQueryParam, 500);
|
|
126
|
+
|
|
127
|
+
function formatDate(iso: string): string {
|
|
128
|
+
const d = new Date(iso);
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const diff = now.getTime() - d.getTime();
|
|
131
|
+
|
|
132
|
+
if (diff < 60000) return "just now";
|
|
133
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
|
|
134
|
+
if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
|
|
135
|
+
if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
|
|
136
|
+
|
|
137
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatFullDate(iso: string): string {
|
|
141
|
+
return new Date(iso).toLocaleDateString("en-US", {
|
|
142
|
+
year: "numeric",
|
|
143
|
+
month: "short",
|
|
144
|
+
day: "numeric",
|
|
145
|
+
hour: "2-digit",
|
|
146
|
+
minute: "2-digit",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatSize(bytes: number): string {
|
|
151
|
+
if (bytes < 1024) return bytes + " B";
|
|
152
|
+
return (bytes / 1024).toFixed(1) + " KB";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sortPlans(): void {
|
|
156
|
+
filteredPlans.sort((a, b) => {
|
|
157
|
+
let cmp = 0;
|
|
158
|
+
switch (sortKey) {
|
|
159
|
+
case "title":
|
|
160
|
+
cmp = a.title.localeCompare(b.title);
|
|
161
|
+
break;
|
|
162
|
+
case "project":
|
|
163
|
+
cmp = (a.project || "zzz").localeCompare(b.project || "zzz");
|
|
164
|
+
break;
|
|
165
|
+
case "modified":
|
|
166
|
+
cmp = new Date(a.modified).getTime() - new Date(b.modified).getTime();
|
|
167
|
+
break;
|
|
168
|
+
case "size":
|
|
169
|
+
cmp = a.size - b.size;
|
|
170
|
+
break;
|
|
171
|
+
case "lines":
|
|
172
|
+
cmp = a.lineCount - b.lineCount;
|
|
173
|
+
break;
|
|
174
|
+
case "created":
|
|
175
|
+
cmp = new Date(a.created).getTime() - new Date(b.created).getTime();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
return sortDir === "asc" ? cmp : -cmp;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderMarkdown(content: string): string {
|
|
183
|
+
let html = escapeHtml(content);
|
|
184
|
+
|
|
185
|
+
// Code blocks (with language hint and Prism highlighting)
|
|
186
|
+
html = html.replace(
|
|
187
|
+
/```(\w*)\n([\s\S]*?)```/g,
|
|
188
|
+
(_, lang, code) => {
|
|
189
|
+
const language = lang || 'plaintext';
|
|
190
|
+
const grammar = Prism.languages[language] || Prism.languages.plaintext;
|
|
191
|
+
try {
|
|
192
|
+
const highlighted = grammar
|
|
193
|
+
? Prism.highlight(code, grammar, language)
|
|
194
|
+
: code;
|
|
195
|
+
return `<pre class="language-${language}"><code class="language-${language}">${highlighted}</code></pre>`;
|
|
196
|
+
} catch {
|
|
197
|
+
return `<pre class="language-${language}"><code class="language-${language}">${code}</code></pre>`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Inline code
|
|
203
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
204
|
+
|
|
205
|
+
// Headers
|
|
206
|
+
html = html.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
|
|
207
|
+
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
|
208
|
+
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
|
209
|
+
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
|
210
|
+
|
|
211
|
+
// Bold and italic
|
|
212
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
213
|
+
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
214
|
+
|
|
215
|
+
// Blockquotes
|
|
216
|
+
html = html.replace(/^> (.+)$/gm, "<blockquote><p>$1</p></blockquote>");
|
|
217
|
+
|
|
218
|
+
// Horizontal rules
|
|
219
|
+
html = html.replace(/^---$/gm, "<hr>");
|
|
220
|
+
|
|
221
|
+
// Links
|
|
222
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
223
|
+
|
|
224
|
+
// Tables
|
|
225
|
+
html = html.replace(
|
|
226
|
+
/\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g,
|
|
227
|
+
(_, header, body) => {
|
|
228
|
+
const headers = header
|
|
229
|
+
.split("|")
|
|
230
|
+
.filter((c: string) => c.trim())
|
|
231
|
+
.map((c: string) => `<th>${c.trim()}</th>`)
|
|
232
|
+
.join("");
|
|
233
|
+
const rows = body
|
|
234
|
+
.trim()
|
|
235
|
+
.split("\n")
|
|
236
|
+
.map((row: string) => {
|
|
237
|
+
const cells = row
|
|
238
|
+
.split("|")
|
|
239
|
+
.filter((c: string) => c.trim())
|
|
240
|
+
.map((c: string) => `<td>${c.trim()}</td>`)
|
|
241
|
+
.join("");
|
|
242
|
+
return `<tr>${cells}</tr>`;
|
|
243
|
+
})
|
|
244
|
+
.join("");
|
|
245
|
+
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Checkbox lists
|
|
250
|
+
html = html.replace(/^- \[x\] (.+)$/gm, '<li><input type="checkbox" checked disabled> $1</li>');
|
|
251
|
+
html = html.replace(/^- \[ \] (.+)$/gm, '<li><input type="checkbox" disabled> $1</li>');
|
|
252
|
+
|
|
253
|
+
// Regular lists
|
|
254
|
+
html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
|
|
255
|
+
html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
|
|
256
|
+
|
|
257
|
+
// Numbered lists
|
|
258
|
+
html = html.replace(/^\d+\. (.+)$/gm, "<li>$1</li>");
|
|
259
|
+
|
|
260
|
+
// Paragraphs - wrap remaining text blocks
|
|
261
|
+
const blocks = html.split(/\n\n+/);
|
|
262
|
+
html = blocks
|
|
263
|
+
.map((block) => {
|
|
264
|
+
const trimmed = block.trim();
|
|
265
|
+
if (!trimmed) return "";
|
|
266
|
+
if (
|
|
267
|
+
trimmed.startsWith("<h") ||
|
|
268
|
+
trimmed.startsWith("<ul") ||
|
|
269
|
+
trimmed.startsWith("<ol") ||
|
|
270
|
+
trimmed.startsWith("<pre") ||
|
|
271
|
+
trimmed.startsWith("<hr") ||
|
|
272
|
+
trimmed.startsWith("<blockquote") ||
|
|
273
|
+
trimmed.startsWith("<table")
|
|
274
|
+
) {
|
|
275
|
+
return trimmed;
|
|
276
|
+
}
|
|
277
|
+
return "<p>" + trimmed.replace(/\n/g, "<br>") + "</p>";
|
|
278
|
+
})
|
|
279
|
+
.join("\n");
|
|
280
|
+
|
|
281
|
+
return html;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyFilters(): void {
|
|
285
|
+
const query = searchQuery.toLowerCase();
|
|
286
|
+
filteredPlans = plans.filter((p) => {
|
|
287
|
+
// Apply project filter (empty set = show all)
|
|
288
|
+
if (selectedProjects.size > 0 && (!p.project || !selectedProjects.has(p.project))) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
// Apply search filter
|
|
292
|
+
if (query) {
|
|
293
|
+
return (
|
|
294
|
+
p.title.toLowerCase().includes(query) ||
|
|
295
|
+
p.content.toLowerCase().includes(query) ||
|
|
296
|
+
p.filename.toLowerCase().includes(query) ||
|
|
297
|
+
(p.project && p.project.toLowerCase().includes(query))
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
});
|
|
302
|
+
sortPlans();
|
|
303
|
+
render();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getProjectTriggerText(projects: string[]): string {
|
|
307
|
+
if (selectedProjects.size === 0) {
|
|
308
|
+
return "All projects";
|
|
309
|
+
}
|
|
310
|
+
const selected = projects.filter(p => selectedProjects.has(p));
|
|
311
|
+
const first = selected[0];
|
|
312
|
+
if (!first) return "All projects";
|
|
313
|
+
if (selected.length === 1) {
|
|
314
|
+
return first;
|
|
315
|
+
}
|
|
316
|
+
return `${first} <span class="badge">+${selected.length - 1}</span>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function updateTableAndStats(): void {
|
|
320
|
+
const totalSize = filteredPlans.reduce((sum, p) => sum + p.size, 0);
|
|
321
|
+
|
|
322
|
+
// Update stats
|
|
323
|
+
const statsEl = document.querySelector(".stats");
|
|
324
|
+
if (statsEl) {
|
|
325
|
+
statsEl.innerHTML = `
|
|
326
|
+
<span>${filteredPlans.length}/${plans.length}</span>
|
|
327
|
+
<span class="divider">|</span>
|
|
328
|
+
<span>${formatSize(totalSize)}</span>
|
|
329
|
+
`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Update trigger text and style
|
|
333
|
+
const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
|
|
334
|
+
const trigger = document.getElementById("project-trigger");
|
|
335
|
+
if (trigger) {
|
|
336
|
+
trigger.classList.toggle("has-selection", selectedProjects.size > 0);
|
|
337
|
+
const chevronHtml = `<svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>`;
|
|
338
|
+
trigger.innerHTML = getProjectTriggerText(projects) + chevronHtml;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Update table
|
|
342
|
+
const tbody = document.getElementById("plans-table");
|
|
343
|
+
if (tbody) {
|
|
344
|
+
tbody.innerHTML = filteredPlans.map((plan) => `
|
|
345
|
+
<tr data-filename="${plan.filename}" class="${selectedPlan?.filename === plan.filename ? "selected" : ""}">
|
|
346
|
+
<td class="title-cell"><button class="title-btn" data-filename="${plan.filename}">${highlightText(plan.title, searchQuery)}</button></td>
|
|
347
|
+
<td class="project-cell">${plan.project ? highlightText(plan.project, searchQuery) : "—"}</td>
|
|
348
|
+
<td class="num-cell">${formatSize(plan.size)}</td>
|
|
349
|
+
<td class="num-cell">${plan.lineCount}</td>
|
|
350
|
+
<td class="meta-cell">${formatDate(plan.modified)}</td>
|
|
351
|
+
<td class="meta-cell">${formatDate(plan.created)}</td>
|
|
352
|
+
</tr>
|
|
353
|
+
`).join("");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function render(): void {
|
|
358
|
+
const totalSize = filteredPlans.reduce((sum, p) => sum + p.size, 0);
|
|
359
|
+
|
|
360
|
+
// Get unique projects for filter chips
|
|
361
|
+
const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
|
|
362
|
+
projects.sort();
|
|
363
|
+
|
|
364
|
+
app.innerHTML = `
|
|
365
|
+
<div class="container">
|
|
366
|
+
<div class="list-panel">
|
|
367
|
+
<div class="header">
|
|
368
|
+
<div class="header-row">
|
|
369
|
+
<h1>
|
|
370
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
371
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
372
|
+
</svg>
|
|
373
|
+
Plans
|
|
374
|
+
</h1>
|
|
375
|
+
<div class="header-spacer"></div>
|
|
376
|
+
<select class="sort-select" id="sort">
|
|
377
|
+
<option value="modified-desc" ${sortKey === "modified" && sortDir === "desc" ? "selected" : ""}>Modified (newest)</option>
|
|
378
|
+
<option value="modified-asc" ${sortKey === "modified" && sortDir === "asc" ? "selected" : ""}>Modified (oldest)</option>
|
|
379
|
+
<option value="project-asc" ${sortKey === "project" && sortDir === "asc" ? "selected" : ""}>Project (A-Z)</option>
|
|
380
|
+
<option value="project-desc" ${sortKey === "project" && sortDir === "desc" ? "selected" : ""}>Project (Z-A)</option>
|
|
381
|
+
<option value="title-asc" ${sortKey === "title" && sortDir === "asc" ? "selected" : ""}>Title (A-Z)</option>
|
|
382
|
+
<option value="title-desc" ${sortKey === "title" && sortDir === "desc" ? "selected" : ""}>Title (Z-A)</option>
|
|
383
|
+
<option value="size-desc" ${sortKey === "size" && sortDir === "desc" ? "selected" : ""}>Size (largest)</option>
|
|
384
|
+
<option value="size-asc" ${sortKey === "size" && sortDir === "asc" ? "selected" : ""}>Size (smallest)</option>
|
|
385
|
+
<option value="lines-desc" ${sortKey === "lines" && sortDir === "desc" ? "selected" : ""}>Lines (most)</option>
|
|
386
|
+
<option value="lines-asc" ${sortKey === "lines" && sortDir === "asc" ? "selected" : ""}>Lines (least)</option>
|
|
387
|
+
<option value="created-desc" ${sortKey === "created" && sortDir === "desc" ? "selected" : ""}>Created (newest)</option>
|
|
388
|
+
<option value="created-asc" ${sortKey === "created" && sortDir === "asc" ? "selected" : ""}>Created (oldest)</option>
|
|
389
|
+
</select>
|
|
390
|
+
<div class="search-wrapper">
|
|
391
|
+
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
392
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
393
|
+
</svg>
|
|
394
|
+
<input type="search" class="search-input" id="search" placeholder="Search..." value="${escapeHtml(searchQuery)}" autofocus>
|
|
395
|
+
<span class="search-kbd">⌘K</span>
|
|
396
|
+
</div>
|
|
397
|
+
${projects.length > 0 ? `
|
|
398
|
+
<div class="project-dropdown">
|
|
399
|
+
<button class="dropdown-trigger ${selectedProjects.size > 0 ? 'has-selection' : ''}" id="project-trigger" popovertarget="project-menu">
|
|
400
|
+
${getProjectTriggerText(projects)}
|
|
401
|
+
<svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
402
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
403
|
+
</svg>
|
|
404
|
+
</button>
|
|
405
|
+
<div id="project-menu" popover class="dropdown-menu">
|
|
406
|
+
${projects.map(p => `
|
|
407
|
+
<label class="dropdown-item">
|
|
408
|
+
<input type="checkbox" value="${escapeHtml(p)}" ${selectedProjects.has(p) ? 'checked' : ''}>
|
|
409
|
+
<span>${escapeHtml(p)}</span>
|
|
410
|
+
</label>
|
|
411
|
+
`).join('')}
|
|
412
|
+
${selectedProjects.size > 0 ? `
|
|
413
|
+
<button class="dropdown-clear" id="clear-projects">Clear all</button>
|
|
414
|
+
` : ''}
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
` : ''}
|
|
418
|
+
<button class="action-btn" id="refresh-btn" title="Refresh plans">
|
|
419
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
420
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
421
|
+
</svg>
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="table-container">
|
|
426
|
+
<table>
|
|
427
|
+
<thead>
|
|
428
|
+
<tr>
|
|
429
|
+
<th data-sort="title" class="${sortKey === "title" ? "sorted " + sortDir : ""}">
|
|
430
|
+
Title <span class="sort-icon">▲</span>
|
|
431
|
+
</th>
|
|
432
|
+
<th data-sort="project" class="${sortKey === "project" ? "sorted " + sortDir : ""}">
|
|
433
|
+
Project <span class="sort-icon">▲</span>
|
|
434
|
+
</th>
|
|
435
|
+
<th data-sort="size" class="num-col ${sortKey === "size" ? "sorted " + sortDir : ""}">
|
|
436
|
+
Size <span class="sort-icon">▲</span>
|
|
437
|
+
</th>
|
|
438
|
+
<th data-sort="lines" class="num-col ${sortKey === "lines" ? "sorted " + sortDir : ""}">
|
|
439
|
+
Lines <span class="sort-icon">▲</span>
|
|
440
|
+
</th>
|
|
441
|
+
<th data-sort="modified" class="${sortKey === "modified" ? "sorted " + sortDir : ""}">
|
|
442
|
+
Modified <span class="sort-icon">▲</span>
|
|
443
|
+
</th>
|
|
444
|
+
<th data-sort="created" class="${sortKey === "created" ? "sorted " + sortDir : ""}">
|
|
445
|
+
Created <span class="sort-icon">▲</span>
|
|
446
|
+
</th>
|
|
447
|
+
</tr>
|
|
448
|
+
</thead>
|
|
449
|
+
<tbody id="plans-table">
|
|
450
|
+
${filteredPlans
|
|
451
|
+
.map(
|
|
452
|
+
(plan) => `
|
|
453
|
+
<tr data-filename="${plan.filename}" class="${selectedPlan?.filename === plan.filename ? "selected" : ""}">
|
|
454
|
+
<td class="title-cell"><button class="title-btn" data-filename="${plan.filename}">${highlightText(plan.title, searchQuery)}</button></td>
|
|
455
|
+
<td class="project-cell">${plan.project ? highlightText(plan.project, searchQuery) : "—"}</td>
|
|
456
|
+
<td class="num-cell">${formatSize(plan.size)}</td>
|
|
457
|
+
<td class="num-cell">${plan.lineCount}</td>
|
|
458
|
+
<td class="meta-cell">${formatDate(plan.modified)}</td>
|
|
459
|
+
<td class="meta-cell">${formatDate(plan.created)}</td>
|
|
460
|
+
</tr>
|
|
461
|
+
`
|
|
462
|
+
)
|
|
463
|
+
.join("")}
|
|
464
|
+
</tbody>
|
|
465
|
+
</table>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
<div class="detail-panel">
|
|
469
|
+
${
|
|
470
|
+
selectedPlan
|
|
471
|
+
? `
|
|
472
|
+
<div class="detail-header">
|
|
473
|
+
<div class="detail-header-top">
|
|
474
|
+
<div class="detail-title">${escapeHtml(selectedPlan.title)}</div>
|
|
475
|
+
<div class="detail-actions">
|
|
476
|
+
<button class="action-btn" id="copy-btn" title="Copy markdown">
|
|
477
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
478
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
479
|
+
</svg>
|
|
480
|
+
</button>
|
|
481
|
+
<button class="action-btn" id="copy-path-btn" title="Copy file path">
|
|
482
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
483
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
484
|
+
</svg>
|
|
485
|
+
</button>
|
|
486
|
+
<button class="action-btn" id="open-editor-btn" title="Open in editor">
|
|
487
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
488
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
489
|
+
</svg>
|
|
490
|
+
</button>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
<div class="detail-meta">
|
|
494
|
+
${selectedPlan.project ? `<span class="project-tag">${selectedPlan.project}</span>` : ""}
|
|
495
|
+
<span>${selectedPlan.filename}</span>
|
|
496
|
+
<span>${formatFullDate(selectedPlan.modified)}</span>
|
|
497
|
+
<span>${formatSize(selectedPlan.size)}</span>
|
|
498
|
+
<span>${selectedPlan.lineCount} lines</span>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
<div class="detail-content">
|
|
502
|
+
<div class="markdown">${renderMarkdown(selectedPlan.content)}</div>
|
|
503
|
+
</div>
|
|
504
|
+
`
|
|
505
|
+
: `
|
|
506
|
+
<div class="empty-state">
|
|
507
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
508
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
509
|
+
</svg>
|
|
510
|
+
<p>Select a plan to view details</p>
|
|
511
|
+
<p class="hint">Use ↑↓ arrows to navigate</p>
|
|
512
|
+
</div>
|
|
513
|
+
`
|
|
514
|
+
}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
${showHelpModal ? `
|
|
518
|
+
<div class="modal-backdrop" id="help-modal">
|
|
519
|
+
<div class="modal">
|
|
520
|
+
<div class="modal-header">
|
|
521
|
+
<h2>Keyboard Shortcuts</h2>
|
|
522
|
+
<button class="modal-close" id="close-help">×</button>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="modal-body">
|
|
525
|
+
<div class="shortcut-row"><kbd>↑</kbd> <kbd>↓</kbd> <span>Navigate plans</span></div>
|
|
526
|
+
<div class="shortcut-row"><kbd>Enter</kbd> <span>Open in editor</span></div>
|
|
527
|
+
<div class="shortcut-row"><kbd>⌘</kbd> <kbd>K</kbd> <span>Focus search</span></div>
|
|
528
|
+
<div class="shortcut-row"><kbd>Esc</kbd> <span>Blur search / Close modal</span></div>
|
|
529
|
+
<div class="shortcut-row"><kbd>?</kbd> <span>Toggle this help</span></div>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
` : ''}
|
|
534
|
+
`;
|
|
535
|
+
|
|
536
|
+
attachEventListeners();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function attachEventListeners(): void {
|
|
540
|
+
const searchInput = document.getElementById("search") as HTMLInputElement;
|
|
541
|
+
const sortSelect = document.getElementById("sort") as HTMLSelectElement;
|
|
542
|
+
const tbody = document.getElementById("plans-table")!;
|
|
543
|
+
const ths = document.querySelectorAll("th[data-sort]");
|
|
544
|
+
|
|
545
|
+
searchInput?.addEventListener("input", (e) => {
|
|
546
|
+
searchQuery = (e.target as HTMLInputElement).value;
|
|
547
|
+
applyFilters();
|
|
548
|
+
debouncedUpdateSearchQueryParam(searchQuery);
|
|
549
|
+
// Restore focus and cursor position after render
|
|
550
|
+
const newInput = document.getElementById("search") as HTMLInputElement;
|
|
551
|
+
newInput?.focus();
|
|
552
|
+
newInput?.setSelectionRange(searchQuery.length, searchQuery.length);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Project dropdown handlers
|
|
556
|
+
const projectMenu = document.getElementById("project-menu") as HTMLElement | null;
|
|
557
|
+
projectMenu?.addEventListener("change", (e) => {
|
|
558
|
+
const checkbox = e.target as HTMLInputElement;
|
|
559
|
+
if (checkbox.type === "checkbox") {
|
|
560
|
+
if (checkbox.checked) {
|
|
561
|
+
selectedProjects.add(checkbox.value);
|
|
562
|
+
} else {
|
|
563
|
+
selectedProjects.delete(checkbox.value);
|
|
564
|
+
}
|
|
565
|
+
// Update filter without full re-render to keep popover open
|
|
566
|
+
const query = searchQuery.toLowerCase();
|
|
567
|
+
filteredPlans = plans.filter((p) => {
|
|
568
|
+
if (selectedProjects.size > 0 && (!p.project || !selectedProjects.has(p.project))) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (query) {
|
|
572
|
+
return (
|
|
573
|
+
p.title.toLowerCase().includes(query) ||
|
|
574
|
+
p.content.toLowerCase().includes(query) ||
|
|
575
|
+
p.filename.toLowerCase().includes(query) ||
|
|
576
|
+
(p.project && p.project.toLowerCase().includes(query))
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
});
|
|
581
|
+
sortPlans();
|
|
582
|
+
// Update just the table and stats without closing popover
|
|
583
|
+
updateTableAndStats();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
document.getElementById("clear-projects")?.addEventListener("click", () => {
|
|
588
|
+
selectedProjects.clear();
|
|
589
|
+
projectMenu?.hidePopover();
|
|
590
|
+
applyFilters();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
sortSelect?.addEventListener("change", (e) => {
|
|
594
|
+
const parts = (e.target as HTMLSelectElement).value.split("-");
|
|
595
|
+
const key = parts[0] ?? "modified";
|
|
596
|
+
const dir = parts[1] ?? "desc";
|
|
597
|
+
sortKey = key;
|
|
598
|
+
sortDir = dir as "asc" | "desc";
|
|
599
|
+
sortPlans();
|
|
600
|
+
render();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
ths.forEach((th) => {
|
|
604
|
+
th.addEventListener("click", () => {
|
|
605
|
+
const key = (th as HTMLElement).dataset.sort!;
|
|
606
|
+
if (sortKey === key) {
|
|
607
|
+
sortDir = sortDir === "asc" ? "desc" : "asc";
|
|
608
|
+
} else {
|
|
609
|
+
sortKey = key;
|
|
610
|
+
sortDir = key === "title" ? "asc" : "desc";
|
|
611
|
+
}
|
|
612
|
+
sortPlans();
|
|
613
|
+
render();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
tbody?.addEventListener("click", (e) => {
|
|
618
|
+
const row = (e.target as HTMLElement).closest("tr");
|
|
619
|
+
if (row) {
|
|
620
|
+
const filename = (row as HTMLElement).dataset.filename;
|
|
621
|
+
const plan = filteredPlans.find((p) => p.filename === filename);
|
|
622
|
+
if (plan) {
|
|
623
|
+
selectPlan(plan);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Help modal close handlers
|
|
629
|
+
document.getElementById("close-help")?.addEventListener("click", () => {
|
|
630
|
+
showHelpModal = false;
|
|
631
|
+
render();
|
|
632
|
+
});
|
|
633
|
+
document.getElementById("help-modal")?.addEventListener("click", (e) => {
|
|
634
|
+
if (e.target === e.currentTarget) {
|
|
635
|
+
showHelpModal = false;
|
|
636
|
+
render();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Copy markdown button handler
|
|
641
|
+
document.getElementById("copy-btn")?.addEventListener("click", async () => {
|
|
642
|
+
if (!selectedPlan) return;
|
|
643
|
+
try {
|
|
644
|
+
await navigator.clipboard.writeText(selectedPlan.content);
|
|
645
|
+
showCopiedFeedback("copy-btn");
|
|
646
|
+
} catch (err) {
|
|
647
|
+
console.error("Failed to copy:", err);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Copy path button handler
|
|
652
|
+
document.getElementById("copy-path-btn")?.addEventListener("click", async () => {
|
|
653
|
+
if (!selectedPlan) return;
|
|
654
|
+
try {
|
|
655
|
+
await navigator.clipboard.writeText(selectedPlan.filepath);
|
|
656
|
+
showCopiedFeedback("copy-path-btn");
|
|
657
|
+
} catch (err) {
|
|
658
|
+
console.error("Failed to copy path:", err);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Open in editor button handler
|
|
663
|
+
document.getElementById("open-editor-btn")?.addEventListener("click", async () => {
|
|
664
|
+
if (!selectedPlan) return;
|
|
665
|
+
try {
|
|
666
|
+
await fetch("/api/open", {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: { "Content-Type": "application/json" },
|
|
669
|
+
body: JSON.stringify({ filepath: selectedPlan.filepath }),
|
|
670
|
+
});
|
|
671
|
+
} catch (err) {
|
|
672
|
+
console.error("Failed to open in editor:", err);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Title button click handler for keyboard accessibility
|
|
677
|
+
document.querySelectorAll(".title-btn").forEach((btn) => {
|
|
678
|
+
btn.addEventListener("click", (e) => {
|
|
679
|
+
e.stopPropagation();
|
|
680
|
+
const filename = (btn as HTMLButtonElement).dataset.filename;
|
|
681
|
+
const plan = filteredPlans.find((p) => p.filename === filename);
|
|
682
|
+
if (plan) {
|
|
683
|
+
selectPlan(plan);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Refresh button handler
|
|
689
|
+
document.getElementById("refresh-btn")?.addEventListener("click", async () => {
|
|
690
|
+
const btn = document.getElementById("refresh-btn");
|
|
691
|
+
if (btn) btn.classList.add("loading");
|
|
692
|
+
try {
|
|
693
|
+
const resp = await fetch("/api/plans");
|
|
694
|
+
plans = await resp.json();
|
|
695
|
+
filteredPlans = [...plans];
|
|
696
|
+
applyFilters();
|
|
697
|
+
} catch (err) {
|
|
698
|
+
console.error("Failed to refresh:", err);
|
|
699
|
+
} finally {
|
|
700
|
+
if (btn) btn.classList.remove("loading");
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function showCopiedFeedback(btnId: string): void {
|
|
706
|
+
const btn = document.getElementById(btnId);
|
|
707
|
+
if (btn) {
|
|
708
|
+
btn.classList.add("copied");
|
|
709
|
+
const originalHtml = btn.innerHTML;
|
|
710
|
+
btn.innerHTML = `
|
|
711
|
+
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
712
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
713
|
+
</svg>
|
|
714
|
+
`;
|
|
715
|
+
setTimeout(() => {
|
|
716
|
+
btn.classList.remove("copied");
|
|
717
|
+
btn.innerHTML = originalHtml;
|
|
718
|
+
}, 1500);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Global keyboard navigation
|
|
723
|
+
document.addEventListener("keydown", (e) => {
|
|
724
|
+
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
725
|
+
e.preventDefault();
|
|
726
|
+
const search = document.getElementById("search") as HTMLInputElement;
|
|
727
|
+
search?.focus();
|
|
728
|
+
search?.select();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
const idx = filteredPlans.findIndex(
|
|
734
|
+
(p) => p.filename === selectedPlan?.filename
|
|
735
|
+
);
|
|
736
|
+
let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
|
|
737
|
+
if (newIdx < 0) newIdx = 0;
|
|
738
|
+
if (newIdx >= filteredPlans.length) newIdx = filteredPlans.length - 1;
|
|
739
|
+
const plan = filteredPlans[newIdx];
|
|
740
|
+
if (plan) {
|
|
741
|
+
selectPlan(plan);
|
|
742
|
+
document
|
|
743
|
+
.querySelector(`tr[data-filename="${plan.filename}"]`)
|
|
744
|
+
?.scrollIntoView({ block: "nearest" });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Enter to open in editor
|
|
749
|
+
if (e.key === "Enter" && selectedPlan) {
|
|
750
|
+
const activeEl = document.activeElement;
|
|
751
|
+
if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA" && activeEl?.tagName !== "BUTTON") {
|
|
752
|
+
e.preventDefault();
|
|
753
|
+
openInEditor();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (e.key === "Escape") {
|
|
758
|
+
if (showHelpModal) {
|
|
759
|
+
showHelpModal = false;
|
|
760
|
+
render();
|
|
761
|
+
} else {
|
|
762
|
+
const searchInput = document.getElementById("search") as HTMLInputElement;
|
|
763
|
+
if (document.activeElement === searchInput) {
|
|
764
|
+
if (searchQuery) {
|
|
765
|
+
// Clear search first
|
|
766
|
+
searchQuery = "";
|
|
767
|
+
searchInput.value = "";
|
|
768
|
+
updateSearchQueryParam("");
|
|
769
|
+
applyFilters();
|
|
770
|
+
} else {
|
|
771
|
+
// Already empty, blur
|
|
772
|
+
searchInput.blur();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Toggle help modal with ?
|
|
779
|
+
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
|
|
780
|
+
const activeEl = document.activeElement;
|
|
781
|
+
if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
showHelpModal = !showHelpModal;
|
|
784
|
+
render();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Handle browser back/forward
|
|
790
|
+
window.addEventListener("popstate", () => {
|
|
791
|
+
const url = new URL(window.location.href);
|
|
792
|
+
const planFilename = url.searchParams.get("plan");
|
|
793
|
+
if (planFilename) {
|
|
794
|
+
const plan = plans.find((p) => p.filename === planFilename);
|
|
795
|
+
if (plan && plan !== selectedPlan) {
|
|
796
|
+
selectedPlan = plan;
|
|
797
|
+
render();
|
|
798
|
+
}
|
|
799
|
+
} else if (selectedPlan) {
|
|
800
|
+
selectedPlan = null;
|
|
801
|
+
render();
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// Initial load
|
|
806
|
+
async function init(): Promise<void> {
|
|
807
|
+
app.innerHTML = '<div class="loading">Loading plans...</div>';
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch("/api/plans");
|
|
811
|
+
plans = await res.json();
|
|
812
|
+
filteredPlans = [...plans];
|
|
813
|
+
|
|
814
|
+
// Check URL for initial state
|
|
815
|
+
const url = new URL(window.location.href);
|
|
816
|
+
|
|
817
|
+
// Load search query from URL
|
|
818
|
+
const queryParam = url.searchParams.get("q");
|
|
819
|
+
if (queryParam) {
|
|
820
|
+
searchQuery = queryParam;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Load plan selection from URL
|
|
824
|
+
const planFilename = url.searchParams.get("plan");
|
|
825
|
+
if (planFilename) {
|
|
826
|
+
const plan = plans.find((p) => p.filename === planFilename);
|
|
827
|
+
if (plan) {
|
|
828
|
+
selectedPlan = plan;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Apply filters if search query was loaded
|
|
833
|
+
if (searchQuery) {
|
|
834
|
+
applyFilters();
|
|
835
|
+
} else {
|
|
836
|
+
render();
|
|
837
|
+
}
|
|
838
|
+
} catch (err) {
|
|
839
|
+
app.innerHTML = `<div class="empty-state"><p>Failed to load plans</p><p class="hint">${err}</p></div>`;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
init();
|