@loupekit/sdk 0.10.11 → 0.10.12
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 +2 -1
- package/dist/index.global.js +312 -28
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +312 -28
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -90,7 +90,8 @@ init({
|
|
|
90
90
|
});
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
A dockable control panel appears with a
|
|
93
|
+
A dockable control panel appears with a three-page sidebar — **Home** (stat tiles, a this-page ↔
|
|
94
|
+
whole-project scope switch, and the most recent feedback), **Comments** (with the
|
|
94
95
|
**Inspect**, **Note**, **Region**, and **Record** tools + the comment list) and
|
|
95
96
|
**Connect Claude** (MCP setup steps). Pass `repo` and `branch` to `init()` to make
|
|
96
97
|
threads branch-aware — the board can then be filtered by repository and branch.
|
package/dist/index.global.js
CHANGED
|
@@ -327,11 +327,63 @@ var Loupe = (() => {
|
|
|
327
327
|
.tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
|
|
328
328
|
.tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
|
|
329
329
|
|
|
330
|
-
/*
|
|
330
|
+
/* Three sidebar pages: only the active one shows. */
|
|
331
331
|
.view { display: none; }
|
|
332
|
+
.dock.tab-home .home-view { display: block; flex: 1; min-height: 0; overflow-y: auto; }
|
|
332
333
|
.dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
333
334
|
.dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
|
|
334
335
|
|
|
336
|
+
/* ------------------------------------------------------------- Home overview */
|
|
337
|
+
.home-view { padding: 12px 12px 18px; }
|
|
338
|
+
.hstat { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
|
339
|
+
.hstat-b {
|
|
340
|
+
display: flex; flex-direction: column; gap: 2px; text-align: left; padding: 10px;
|
|
341
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2); color: var(--ink); cursor: pointer;
|
|
342
|
+
}
|
|
343
|
+
.hstat-b:hover { border-color: var(--accent); }
|
|
344
|
+
.hstat-b.on { border-color: var(--accent); background: var(--bg-3); }
|
|
345
|
+
.hstat-n { font-size: 20px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
|
346
|
+
.hstat-l { font-size: 11px; color: var(--muted); }
|
|
347
|
+
.hscope { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
|
|
348
|
+
.hscope-b {
|
|
349
|
+
flex: 1; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px;
|
|
350
|
+
background: var(--bg-2); color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer;
|
|
351
|
+
}
|
|
352
|
+
.hscope-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
353
|
+
.hrefresh {
|
|
354
|
+
flex: none; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px;
|
|
355
|
+
background: var(--bg-2); color: var(--muted); cursor: pointer; font-size: 14px;
|
|
356
|
+
}
|
|
357
|
+
.hrefresh:hover { border-color: var(--accent); color: var(--ink); }
|
|
358
|
+
.hpin {
|
|
359
|
+
margin-top: 10px; width: 100%; padding: 9px; border: 0; border-radius: 9px;
|
|
360
|
+
background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
|
361
|
+
}
|
|
362
|
+
.hlabel { margin-top: 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
|
|
363
|
+
.hfeed { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
|
|
364
|
+
.hfeed-i {
|
|
365
|
+
display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 8px;
|
|
366
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2); color: var(--ink); cursor: pointer;
|
|
367
|
+
}
|
|
368
|
+
.hfeed-i:hover { border-color: var(--accent); }
|
|
369
|
+
.hfeed-t { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
370
|
+
.hfeed-m { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--muted); }
|
|
371
|
+
.hfeed-s { font-weight: 700; }
|
|
372
|
+
.hfeed-s-resolved { color: #34c281; }
|
|
373
|
+
.hfeed-p { font-weight: 700; }
|
|
374
|
+
.hfeed-p-critical { color: var(--pin); }
|
|
375
|
+
.hfeed-p-high { color: #e0a92c; }
|
|
376
|
+
.hempty { padding: 14px; text-align: center; color: var(--muted); font-size: 12px; }
|
|
377
|
+
.hfoot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); text-align: center; }
|
|
378
|
+
|
|
379
|
+
/* Timeline grouping (project scope) + the repo filter. */
|
|
380
|
+
.daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
|
|
381
|
+
.listhead .reposel {
|
|
382
|
+
margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
|
|
383
|
+
border-radius: 7px; background: var(--bg-2); color: var(--ink);
|
|
384
|
+
text-transform: none; letter-spacing: 0; max-width: 130px;
|
|
385
|
+
}
|
|
386
|
+
|
|
335
387
|
/* recording marker + video in the list */
|
|
336
388
|
.item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
|
|
337
389
|
.item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); background: #000; display: block; }
|
|
@@ -2382,6 +2434,20 @@ var Loupe = (() => {
|
|
|
2382
2434
|
async list(projectKey, url) {
|
|
2383
2435
|
return this.readAll(projectKey, url);
|
|
2384
2436
|
}
|
|
2437
|
+
/** Every page's comments for this project, newest first — the "All" scope. */
|
|
2438
|
+
async listAll(projectKey) {
|
|
2439
|
+
const prefix = `loupe:${projectKey}:`;
|
|
2440
|
+
const out = [];
|
|
2441
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2442
|
+
const k = localStorage.key(i);
|
|
2443
|
+
if (!k || !k.startsWith(prefix)) continue;
|
|
2444
|
+
try {
|
|
2445
|
+
out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
|
|
2446
|
+
} catch {
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2450
|
+
}
|
|
2385
2451
|
async save(comment) {
|
|
2386
2452
|
const all = this.readAll(comment.projectKey, comment.url);
|
|
2387
2453
|
all.push(comment);
|
|
@@ -2402,11 +2468,30 @@ var Loupe = (() => {
|
|
|
2402
2468
|
size: file.size
|
|
2403
2469
|
};
|
|
2404
2470
|
}
|
|
2405
|
-
|
|
2471
|
+
/**
|
|
2472
|
+
* Keys that hold a comment list. `loupe:dock` also lives under `loupe:` but
|
|
2473
|
+
* holds an OBJECT (panel state), so treating every `loupe:` key as a list made
|
|
2474
|
+
* update()/remove() throw as soon as the panel persisted anything.
|
|
2475
|
+
*/
|
|
2476
|
+
commentKeys() {
|
|
2477
|
+
const keys = [];
|
|
2406
2478
|
for (let i = 0; i < localStorage.length; i++) {
|
|
2407
2479
|
const k = localStorage.key(i);
|
|
2408
|
-
if (
|
|
2409
|
-
|
|
2480
|
+
if (k && k.startsWith("loupe:") && k !== "loupe:dock") keys.push(k);
|
|
2481
|
+
}
|
|
2482
|
+
return keys;
|
|
2483
|
+
}
|
|
2484
|
+
parseList(key) {
|
|
2485
|
+
try {
|
|
2486
|
+
const value = JSON.parse(localStorage.getItem(key) || "[]");
|
|
2487
|
+
return Array.isArray(value) ? value : [];
|
|
2488
|
+
} catch {
|
|
2489
|
+
return [];
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async update(id, patch) {
|
|
2493
|
+
for (const k of this.commentKeys()) {
|
|
2494
|
+
const list = this.parseList(k);
|
|
2410
2495
|
const idx = list.findIndex((c) => c.id === id);
|
|
2411
2496
|
if (idx >= 0) {
|
|
2412
2497
|
list[idx] = { ...list[idx], ...patch };
|
|
@@ -2416,10 +2501,8 @@ var Loupe = (() => {
|
|
|
2416
2501
|
}
|
|
2417
2502
|
}
|
|
2418
2503
|
async remove(id) {
|
|
2419
|
-
for (
|
|
2420
|
-
const
|
|
2421
|
-
if (!k || !k.startsWith("loupe:")) continue;
|
|
2422
|
-
const list = JSON.parse(localStorage.getItem(k) || "[]");
|
|
2504
|
+
for (const k of this.commentKeys()) {
|
|
2505
|
+
const list = this.parseList(k);
|
|
2423
2506
|
const next = list.filter((c) => c.id !== id);
|
|
2424
2507
|
if (next.length !== list.length) {
|
|
2425
2508
|
localStorage.setItem(k, JSON.stringify(next));
|
|
@@ -2455,6 +2538,13 @@ var Loupe = (() => {
|
|
|
2455
2538
|
if (!res.ok) throw new Error(`list failed: ${res.status}`);
|
|
2456
2539
|
return await res.json();
|
|
2457
2540
|
}
|
|
2541
|
+
/** Every comment in the project (no page filter) — the "All" scope. */
|
|
2542
|
+
async listAll(projectKey) {
|
|
2543
|
+
const q = new URLSearchParams({ projectKey });
|
|
2544
|
+
const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
|
|
2545
|
+
if (!res.ok) throw new Error(`listAll failed: ${res.status}`);
|
|
2546
|
+
return await res.json();
|
|
2547
|
+
}
|
|
2458
2548
|
/** Upload an inline data-URL asset to object storage; return its URL (or the data URL on failure). */
|
|
2459
2549
|
async uploadBlob(projectKey, data) {
|
|
2460
2550
|
try {
|
|
@@ -2513,6 +2603,13 @@ var Loupe = (() => {
|
|
|
2513
2603
|
|
|
2514
2604
|
// ../shared/dist/index.js
|
|
2515
2605
|
var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
|
|
2606
|
+
var STAGE_LABELS = {
|
|
2607
|
+
queue: "Queue",
|
|
2608
|
+
todo: "To Do",
|
|
2609
|
+
in_progress: "In Progress",
|
|
2610
|
+
in_review: "In Review",
|
|
2611
|
+
resolved: "Resolved"
|
|
2612
|
+
};
|
|
2516
2613
|
var LEGACY_STATUS = {
|
|
2517
2614
|
open: "queue",
|
|
2518
2615
|
in_progress: "in_progress",
|
|
@@ -2543,9 +2640,15 @@ var Loupe = (() => {
|
|
|
2543
2640
|
};
|
|
2544
2641
|
var DEFAULT_PRIORITY = "medium";
|
|
2545
2642
|
var DEFAULT_CHANGE_TYPE = "other";
|
|
2643
|
+
function normalizePriority(value) {
|
|
2644
|
+
if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
|
|
2645
|
+
return value;
|
|
2646
|
+
}
|
|
2647
|
+
return DEFAULT_PRIORITY;
|
|
2648
|
+
}
|
|
2546
2649
|
|
|
2547
2650
|
// src/app.ts
|
|
2548
|
-
var SDK_VERSION = true ? "0.10.
|
|
2651
|
+
var SDK_VERSION = true ? "0.10.12" : "dev";
|
|
2549
2652
|
var DOCK_MODES = ["left", "right", "bottom", "float"];
|
|
2550
2653
|
var RECORD_MAX_MS = 2e4;
|
|
2551
2654
|
var MAX_FILES = 10;
|
|
@@ -2615,7 +2718,15 @@ var Loupe = (() => {
|
|
|
2615
2718
|
this.dockMode = "right";
|
|
2616
2719
|
this.open = true;
|
|
2617
2720
|
this.theme = "dark";
|
|
2618
|
-
this.tab = "
|
|
2721
|
+
this.tab = "home";
|
|
2722
|
+
/** "page" = this URL only; "all" = the whole project (the timeline scope). */
|
|
2723
|
+
this.scope = "page";
|
|
2724
|
+
/** Every-page comments, fetched the first time the "All" scope is opened. */
|
|
2725
|
+
this.allComments = [];
|
|
2726
|
+
/** A clicked stat tile, which narrows the list. */
|
|
2727
|
+
this.statFilter = "";
|
|
2728
|
+
/** Repo filter, offered once the scope is "all". */
|
|
2729
|
+
this.repoFilter = "";
|
|
2619
2730
|
/** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
|
|
2620
2731
|
this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
|
|
2621
2732
|
this.floatDrag = null;
|
|
@@ -2844,6 +2955,8 @@ var Loupe = (() => {
|
|
|
2844
2955
|
this.comments = await this.store.list(this.cfg.projectKey, this.url);
|
|
2845
2956
|
this.renderPins();
|
|
2846
2957
|
this.renderList();
|
|
2958
|
+
this.renderHome();
|
|
2959
|
+
if (this.scope === "all") void this.loadAllComments();
|
|
2847
2960
|
this.observe();
|
|
2848
2961
|
this.watchNavigation();
|
|
2849
2962
|
if (this.cfg.autoOpen) this.setMode("inspect");
|
|
@@ -2941,13 +3054,13 @@ var Loupe = (() => {
|
|
|
2941
3054
|
ctl.append(this.themeBtn, closeBtn);
|
|
2942
3055
|
head.append(brand, ctl);
|
|
2943
3056
|
const tabs = el("div", "tabs");
|
|
2944
|
-
const
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
tabs.append(
|
|
3057
|
+
const tabBtn = (key, label) => {
|
|
3058
|
+
const b = el("button", "tab", label);
|
|
3059
|
+
b.dataset.tab = key;
|
|
3060
|
+
b.onclick = () => this.setTab(key);
|
|
3061
|
+
return b;
|
|
3062
|
+
};
|
|
3063
|
+
tabs.append(tabBtn("home", "Home"), tabBtn("comments", "Comments"), tabBtn("connect", "Connect Claude"));
|
|
2951
3064
|
const tools = el("div", "tools");
|
|
2952
3065
|
const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
|
|
2953
3066
|
inspectBtn.title = "Inspect an element and comment on it";
|
|
@@ -2984,15 +3097,119 @@ var Loupe = (() => {
|
|
|
2984
3097
|
this.renderList();
|
|
2985
3098
|
};
|
|
2986
3099
|
listHead.appendChild(search);
|
|
3100
|
+
this.repoSel = el("select", "reposel");
|
|
3101
|
+
this.repoSel.title = "Filter by repository";
|
|
3102
|
+
this.repoSel.setAttribute("aria-label", "Filter by repository");
|
|
3103
|
+
this.repoSel.onchange = () => {
|
|
3104
|
+
this.repoFilter = this.repoSel.value;
|
|
3105
|
+
this.renderList();
|
|
3106
|
+
};
|
|
3107
|
+
this.repoSel.style.display = "none";
|
|
3108
|
+
listHead.appendChild(this.repoSel);
|
|
2987
3109
|
this.listEl = el("div", "list");
|
|
3110
|
+
const homeView = el("div", "view home-view");
|
|
3111
|
+
this.homeEl = homeView;
|
|
2988
3112
|
const commentsView = el("div", "view comments-view");
|
|
2989
3113
|
commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
|
|
2990
3114
|
const connectView = this.buildConnectPanel();
|
|
2991
3115
|
const resize = el("div", "resize");
|
|
2992
3116
|
resize.addEventListener("pointerdown", this.onResizeDown);
|
|
2993
|
-
dock.append(head, tabs, commentsView, connectView, resize);
|
|
3117
|
+
dock.append(head, tabs, homeView, commentsView, connectView, resize);
|
|
3118
|
+
this.buildHomePanel();
|
|
2994
3119
|
return dock;
|
|
2995
3120
|
}
|
|
3121
|
+
/**
|
|
3122
|
+
* The Home overview: four stat tiles over the current scope, a scope switch
|
|
3123
|
+
* (this page ↔ the whole project), a one-click way into capture, and the
|
|
3124
|
+
* most recent feedback. Every tile is a button that narrows the list.
|
|
3125
|
+
*/
|
|
3126
|
+
buildHomePanel() {
|
|
3127
|
+
const title = this.cfg.label ?? "Loupe";
|
|
3128
|
+
this.homeEl.innerHTML = `<div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page</button><button class="hscope-b" data-scope="all">All</button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 ${escapeHtml(this.cfg.repo ?? "no repo linked")}</div>`;
|
|
3129
|
+
this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
|
|
3130
|
+
b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
|
|
3131
|
+
});
|
|
3132
|
+
this.homeEl.querySelector('[data-role="home-pin"]').onclick = () => {
|
|
3133
|
+
this.setTab("comments");
|
|
3134
|
+
this.setMode("inspect");
|
|
3135
|
+
};
|
|
3136
|
+
this.homeEl.querySelector(".hrefresh").onclick = () => {
|
|
3137
|
+
void this.reloadComments();
|
|
3138
|
+
if (this.scope === "all") void this.loadAllComments();
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
/** The comments the panel is currently looking at (page scope or project scope). */
|
|
3142
|
+
get visibleComments() {
|
|
3143
|
+
return this.scope === "all" ? this.allComments : this.comments;
|
|
3144
|
+
}
|
|
3145
|
+
/** Switch the panel between "this page" and the whole project. */
|
|
3146
|
+
setScope(scope) {
|
|
3147
|
+
this.scope = scope;
|
|
3148
|
+
this.saveState();
|
|
3149
|
+
if (scope === "all" && !this.allComments.length) void this.loadAllComments();
|
|
3150
|
+
this.renderHome();
|
|
3151
|
+
this.renderList();
|
|
3152
|
+
}
|
|
3153
|
+
async loadAllComments() {
|
|
3154
|
+
try {
|
|
3155
|
+
this.allComments = await this.store.listAll(this.cfg.projectKey);
|
|
3156
|
+
this.renderHome();
|
|
3157
|
+
this.renderList();
|
|
3158
|
+
} catch {
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* The four buckets a triager actually asks about. "Stale" is open work older
|
|
3163
|
+
* than a week — the thing that quietly rots on a board.
|
|
3164
|
+
*/
|
|
3165
|
+
homeStats() {
|
|
3166
|
+
const list = this.visibleComments;
|
|
3167
|
+
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
|
|
3168
|
+
return [
|
|
3169
|
+
{ key: "open", label: "Open", n: list.filter((c) => normalizeStatus(c.status) !== "resolved").length },
|
|
3170
|
+
{ key: "needs_you", label: "Needs you", n: list.filter((c) => normalizeStatus(c.status) === "in_review").length },
|
|
3171
|
+
{ key: "resolved", label: "Resolved", n: list.filter((c) => normalizeStatus(c.status) === "resolved").length },
|
|
3172
|
+
{
|
|
3173
|
+
key: "stale",
|
|
3174
|
+
label: "Stale",
|
|
3175
|
+
n: list.filter((c) => normalizeStatus(c.status) !== "resolved" && Date.parse(c.createdAt) < weekAgo).length
|
|
3176
|
+
}
|
|
3177
|
+
];
|
|
3178
|
+
}
|
|
3179
|
+
renderHome() {
|
|
3180
|
+
if (!this.homeEl) return;
|
|
3181
|
+
const stats = this.homeStats();
|
|
3182
|
+
const statsEl = this.homeEl.querySelector("#loupe-hstats");
|
|
3183
|
+
if (statsEl) {
|
|
3184
|
+
statsEl.innerHTML = stats.map((s) => `<button class="hstat-b${this.statFilter === s.key ? " on" : ""}" data-stat="${s.key}"><span class="hstat-n">${s.n}</span><span class="hstat-l">${s.label}</span></button>`).join("");
|
|
3185
|
+
statsEl.querySelectorAll(".hstat-b").forEach((b) => {
|
|
3186
|
+
b.onclick = () => {
|
|
3187
|
+
const key = b.dataset.stat;
|
|
3188
|
+
this.statFilter = this.statFilter === key ? "" : key;
|
|
3189
|
+
this.setTab("comments");
|
|
3190
|
+
};
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
this.homeEl.querySelectorAll(".hscope-b").forEach((b) => b.classList.toggle("on", b.dataset.scope === this.scope));
|
|
3194
|
+
const feed = this.homeEl.querySelector("#loupe-hfeed");
|
|
3195
|
+
if (!feed) return;
|
|
3196
|
+
const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
|
|
3197
|
+
feed.innerHTML = recent.length ? recent.map((c) => {
|
|
3198
|
+
const stage = STAGE_LABELS[normalizeStatus(c.status)];
|
|
3199
|
+
const prio = normalizePriority(c.priority);
|
|
3200
|
+
return `<button class="hfeed-i" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 ${fmtAgo(c.createdAt)} \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span></button>`;
|
|
3201
|
+
}).join("") : `<div class="hempty">Nothing here yet.</div>`;
|
|
3202
|
+
feed.querySelectorAll(".hfeed-i").forEach((b) => {
|
|
3203
|
+
b.onclick = () => {
|
|
3204
|
+
const id = b.dataset.id;
|
|
3205
|
+
this.statFilter = "";
|
|
3206
|
+
this.setTab("comments");
|
|
3207
|
+
this.expanded.add(id);
|
|
3208
|
+
this.renderList();
|
|
3209
|
+
this.flash(id);
|
|
3210
|
+
};
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
2996
3213
|
/** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
|
|
2997
3214
|
buildIntegrations() {
|
|
2998
3215
|
const wrap = el("div", "integrations");
|
|
@@ -3585,9 +3802,9 @@ var Loupe = (() => {
|
|
|
3585
3802
|
this.updateCount();
|
|
3586
3803
|
this.position();
|
|
3587
3804
|
}
|
|
3588
|
-
updateCount() {
|
|
3805
|
+
updateCount(shown = this.comments.length) {
|
|
3806
|
+
this.countEl.textContent = String(shown);
|
|
3589
3807
|
const n = this.comments.length;
|
|
3590
|
-
this.countEl.textContent = String(n);
|
|
3591
3808
|
this.fabBadge.textContent = n ? String(n) : "";
|
|
3592
3809
|
}
|
|
3593
3810
|
/**
|
|
@@ -3646,14 +3863,18 @@ var Loupe = (() => {
|
|
|
3646
3863
|
this.saveState();
|
|
3647
3864
|
this.applyDockLayout();
|
|
3648
3865
|
}
|
|
3649
|
-
/** Switch the sidebar page (Comments ↔ Connect Claude). */
|
|
3866
|
+
/** Switch the sidebar page (Home ↔ Comments ↔ Connect Claude). */
|
|
3650
3867
|
setTab(tab) {
|
|
3651
3868
|
if (this.tab === tab) return;
|
|
3652
3869
|
if (tab !== "comments") this.setMode("off");
|
|
3653
3870
|
this.tab = tab;
|
|
3654
3871
|
this.saveState();
|
|
3655
|
-
|
|
3872
|
+
if (tab === "home") {
|
|
3873
|
+
this.renderHome();
|
|
3874
|
+
if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
|
|
3875
|
+
}
|
|
3656
3876
|
if (tab === "comments") this.renderList();
|
|
3877
|
+
this.applyDockLayout();
|
|
3657
3878
|
}
|
|
3658
3879
|
toggleTheme() {
|
|
3659
3880
|
this.theme = this.theme === "dark" ? "light" : "dark";
|
|
@@ -3672,7 +3893,10 @@ var Loupe = (() => {
|
|
|
3672
3893
|
if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
|
|
3673
3894
|
if (typeof p?.open === "boolean") this.open = p.open;
|
|
3674
3895
|
if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
|
|
3675
|
-
if (p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
|
|
3896
|
+
if (p?.tab === "home" || p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
|
|
3897
|
+
if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
|
|
3898
|
+
if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
|
|
3899
|
+
if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
|
|
3676
3900
|
if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
|
|
3677
3901
|
if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
|
|
3678
3902
|
} catch {
|
|
@@ -3686,7 +3910,10 @@ var Loupe = (() => {
|
|
|
3686
3910
|
theme: this.theme,
|
|
3687
3911
|
tab: this.tab,
|
|
3688
3912
|
float: this.floatRect,
|
|
3689
|
-
markersHidden: this.markersHidden
|
|
3913
|
+
markersHidden: this.markersHidden,
|
|
3914
|
+
scope: this.scope,
|
|
3915
|
+
statFilter: this.statFilter,
|
|
3916
|
+
repoFilter: this.repoFilter
|
|
3690
3917
|
}));
|
|
3691
3918
|
} catch {
|
|
3692
3919
|
}
|
|
@@ -3699,8 +3926,10 @@ var Loupe = (() => {
|
|
|
3699
3926
|
const d = this.dock;
|
|
3700
3927
|
d.classList.toggle("open", this.open);
|
|
3701
3928
|
for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
|
|
3929
|
+
d.classList.toggle("tab-home", this.tab === "home");
|
|
3702
3930
|
d.classList.toggle("tab-comments", this.tab === "comments");
|
|
3703
3931
|
d.classList.toggle("tab-connect", this.tab === "connect");
|
|
3932
|
+
this.renderHome();
|
|
3704
3933
|
d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
|
|
3705
3934
|
if (this.dockMode === "float") {
|
|
3706
3935
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
@@ -3746,16 +3975,46 @@ var Loupe = (() => {
|
|
|
3746
3975
|
renderList() {
|
|
3747
3976
|
this.listEl.innerHTML = "";
|
|
3748
3977
|
const q = this.search.trim().toLowerCase();
|
|
3749
|
-
const
|
|
3978
|
+
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
|
|
3979
|
+
let items = this.visibleComments.filter((c) => {
|
|
3980
|
+
const stage = normalizeStatus(c.status);
|
|
3981
|
+
if (this.statFilter === "open" && stage === "resolved") return false;
|
|
3982
|
+
if (this.statFilter === "needs_you" && stage !== "in_review") return false;
|
|
3983
|
+
if (this.statFilter === "resolved" && stage !== "resolved") return false;
|
|
3984
|
+
if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
|
|
3985
|
+
if (this.repoFilter && c.repo !== this.repoFilter) return false;
|
|
3986
|
+
return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
|
|
3987
|
+
});
|
|
3988
|
+
if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
3989
|
+
this.renderRepoFilter();
|
|
3750
3990
|
if (!items.length) {
|
|
3751
3991
|
this.listEl.appendChild(el(
|
|
3752
3992
|
"div",
|
|
3753
3993
|
"empty",
|
|
3754
|
-
q ? "No comments match your search." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
|
|
3994
|
+
this.statFilter ? "Nothing in this bucket." : q ? "No comments match your search." : this.scope === "all" ? "No feedback in this project yet." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
|
|
3755
3995
|
));
|
|
3756
3996
|
}
|
|
3757
|
-
|
|
3758
|
-
|
|
3997
|
+
let lastDay = "";
|
|
3998
|
+
items.forEach((c, i) => {
|
|
3999
|
+
if (this.scope === "all") {
|
|
4000
|
+
const day = dayLabel(c.createdAt);
|
|
4001
|
+
if (day !== lastDay) {
|
|
4002
|
+
this.listEl.appendChild(el("div", "daylabel", day));
|
|
4003
|
+
lastDay = day;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
this.listEl.appendChild(this.itemView(c, i));
|
|
4007
|
+
});
|
|
4008
|
+
this.updateCount(items.length);
|
|
4009
|
+
}
|
|
4010
|
+
/** Repo filter — only offered in the project scope, where it means something. */
|
|
4011
|
+
renderRepoFilter() {
|
|
4012
|
+
if (!this.repoSel) return;
|
|
4013
|
+
const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
|
|
4014
|
+
this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
|
|
4015
|
+
const current = this.repoFilter;
|
|
4016
|
+
this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
|
|
4017
|
+
this.repoSel.value = current;
|
|
3759
4018
|
}
|
|
3760
4019
|
itemView(c, i) {
|
|
3761
4020
|
const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
|
|
@@ -3898,6 +4157,28 @@ var Loupe = (() => {
|
|
|
3898
4157
|
function clamp(n) {
|
|
3899
4158
|
return Math.max(0, Math.min(1, n));
|
|
3900
4159
|
}
|
|
4160
|
+
function fmtAgo(iso) {
|
|
4161
|
+
const then = Date.parse(iso);
|
|
4162
|
+
if (Number.isNaN(then)) return "";
|
|
4163
|
+
const s = Math.max(0, Math.round((Date.now() - then) / 1e3));
|
|
4164
|
+
if (s < 60) return "just now";
|
|
4165
|
+
const m = Math.round(s / 60);
|
|
4166
|
+
if (m < 60) return `${m}m ago`;
|
|
4167
|
+
const h = Math.round(m / 60);
|
|
4168
|
+
if (h < 24) return `${h}h ago`;
|
|
4169
|
+
return `${Math.round(h / 24)}d ago`;
|
|
4170
|
+
}
|
|
4171
|
+
function dayLabel(iso) {
|
|
4172
|
+
const d = new Date(iso);
|
|
4173
|
+
if (Number.isNaN(d.getTime())) return "Earlier";
|
|
4174
|
+
const today = /* @__PURE__ */ new Date();
|
|
4175
|
+
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
4176
|
+
const days = Math.round((startOf(today) - startOf(d)) / 864e5);
|
|
4177
|
+
if (days <= 0) return "Today";
|
|
4178
|
+
if (days === 1) return "Yesterday";
|
|
4179
|
+
if (days < 7) return `${days} days ago`;
|
|
4180
|
+
return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
|
|
4181
|
+
}
|
|
3901
4182
|
function clampPx(n, min, max) {
|
|
3902
4183
|
return Math.max(min, Math.min(max, n));
|
|
3903
4184
|
}
|
|
@@ -3911,6 +4192,9 @@ var Loupe = (() => {
|
|
|
3911
4192
|
function escapeHtml(s) {
|
|
3912
4193
|
return s.replace(/[&<>"']/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m]);
|
|
3913
4194
|
}
|
|
4195
|
+
function escapeAttr(s) {
|
|
4196
|
+
return escapeHtml(s);
|
|
4197
|
+
}
|
|
3914
4198
|
var svg = (inner) => `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">${inner}</svg>`;
|
|
3915
4199
|
var DOCK_FRAME = `<rect x="1.5" y="2.5" width="13" height="11" rx="1.6" stroke="currentColor" stroke-width="1.3"/>`;
|
|
3916
4200
|
var I_DOCK_LEFT = svg(`${DOCK_FRAME}<rect x="2.2" y="3.2" width="4" height="9.6" rx="1" fill="currentColor"/>`);
|