red-pen-hub 0.7.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.
@@ -0,0 +1,1305 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
7
+ <title>Red Pen Hub - Project Board</title>
8
+ <style>
9
+ :root {
10
+ --rp-red: #D32F2F;
11
+ --bg: #f4f5f7;
12
+ --card: #ffffff;
13
+ --ink: #1d2327;
14
+ --muted: #646970;
15
+ --line: #dcdcde;
16
+ --hover: #f6f7f7;
17
+ }
18
+ body.dark {
19
+ --bg: #16181d;
20
+ --card: #1f232b;
21
+ --ink: #e6e7ea;
22
+ --muted: #9aa0aa;
23
+ --line: #353a44;
24
+ --hover: #262b34;
25
+ }
26
+ /* Always reserve the vertical scrollbar gutter so switching between a tall tab (scrollbar) and a
27
+ short one (no scrollbar) never changes the viewport width - otherwise the centered navbar +
28
+ content wrapper (and the gutter'd brand block) shift horizontally between tabs. */
29
+ html { scrollbar-gutter: stable; overflow-y: scroll; }
30
+ * { box-sizing: border-box; }
31
+ body {
32
+ margin: 0;
33
+ font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ background: var(--bg);
35
+ color: var(--ink);
36
+ }
37
+ header {
38
+ background: var(--card);
39
+ color: var(--ink);
40
+ border-bottom: 1px solid var(--line);
41
+ }
42
+ /* Inner bar shares .wrap's max-width + horizontal padding, so the header content's
43
+ left edge lines up with the main content area's left edge at every width. */
44
+ header .hbar {
45
+ position: relative;
46
+ max-width: 1600px;
47
+ margin: 0 auto;
48
+ padding: 14px 22px;
49
+ display: flex;
50
+ align-items: center;
51
+ flex-wrap: wrap;
52
+ gap: 12px 14px;
53
+ }
54
+ header .logo { width: 42px; height: 42px; display: block; flex: none; }
55
+ header .brand { display: flex; flex-direction: column; gap: 1px; line-height: 1.12; }
56
+ header h1 { font-size: 24px; margin: 0; font-weight: 700; letter-spacing: .3px; color: var(--rp-red); }
57
+ header .tag { font-size: 13px; color: var(--muted); }
58
+ header .spacer { flex: 1; }
59
+ /* One row: [brand block] [tabs] ...spacer... [buttons]. The Open tab keeps its left edge on the
60
+ content column; the brand block is the ONE element pulled outside that column into the left
61
+ gutter (on screens wide enough to have a gutter). Below that it sits inline, left of the tabs. */
62
+ header .brandblock { display: flex; align-items: center; gap: 12px; flex: none; }
63
+ header .tabs { flex: 0 1 auto; }
64
+ @media (min-width: 1840px) {
65
+ header .brandblock { position: absolute; right: 100%; top: 50%; transform: translateY(-50%); margin-right: 20px; white-space: nowrap; }
66
+ }
67
+ header button:not(.tab):not(.rp-toggle) {
68
+ background: transparent;
69
+ color: var(--ink);
70
+ border: 1px solid var(--line);
71
+ border-radius: 6px;
72
+ padding: 6px 12px;
73
+ cursor: pointer;
74
+ font-size: 13px;
75
+ }
76
+ header button:not(.tab):not(.rp-toggle):hover { border-color: var(--rp-red); color: var(--rp-red); }
77
+ /* Help "?" button: round, links to the docs, with a hover tooltip. */
78
+ .help-btn { position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); color: var(--muted); text-decoration: none; font-size: 15px; font-weight: 700; line-height: 1; cursor: pointer; }
79
+ .help-btn:hover, .help-btn:focus-visible { border-color: var(--rp-red); color: var(--rp-red); }
80
+ .help-tip { position: absolute; top: calc(100% + 8px); right: 0; white-space: nowrap; background: #2a2e35; color: #fff; font-size: 12px; font-weight: 500; padding: 5px 9px; border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.25); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .15s, transform .15s; z-index: 60; }
81
+ .help-btn:hover .help-tip, .help-btn:focus-visible .help-tip { opacity: 1; transform: translateY(0); }
82
+ /* Refresh: leftmost on the row + a red border for a touch more prominence. */
83
+ #refreshBtn { border-color: var(--rp-red); color: var(--rp-red); font-weight: 600; }
84
+ #refreshBtn:hover { background: var(--rp-red); color: #fff; border-color: var(--rp-red); }
85
+ /* Light/dark toggle: a sliding pill. The knob (red) slides left (light) / right (dark);
86
+ the sun/moon symbol lives INSIDE the knob and cross-fades between the two on switch.
87
+ Standardize this across surfaces once the look is locked. */
88
+ .rp-toggle { position: relative; width: 52px; height: 28px; flex: none; padding: 0; border: 1px solid var(--line); border-radius: 14px; background: var(--tgl-track, #dfe1e5); cursor: pointer; transition: background .3s; }
89
+ body.dark .rp-toggle { background: #2c3038; }
90
+ .rp-toggle-knob { position: absolute; top: 2px; left: 2px; width: 22px; height: 22px; border-radius: 50%; background: var(--rp-red); color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); transition: transform .32s cubic-bezier(.4,.1,.3,1.25); }
91
+ body.dark .rp-toggle-knob { transform: translateX(24px); }
92
+ .rp-toggle-knob svg { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 13px; height: 13px; fill: #fff; transition: opacity .28s; }
93
+ .rp-toggle .ic-sun { opacity: 1; }
94
+ .rp-toggle .ic-moon { opacity: 0; }
95
+ body.dark .rp-toggle .ic-sun { opacity: 0; }
96
+ body.dark .rp-toggle .ic-moon { opacity: 1; }
97
+ .wrap { padding: 18px 22px; max-width: 1600px; margin: 0 auto; }
98
+
99
+ .srctable tr.prow { cursor: pointer; }
100
+ .srctable tr.prow:hover td { background: var(--hover); }
101
+
102
+ .sources-view { margin: 16px 0 14px; }
103
+ .sources-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
104
+ .sources-head #sourcesSummary { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); }
105
+ .sources-head .spacer { flex: 1; }
106
+ .lic-banner { display: flex; align-items: center; gap: 12px; margin: 0 0 14px; padding: 10px 14px; border: 1px solid #D32F2F; border-left-width: 4px; border-radius: 8px; background: rgba(211,47,47,.06); font-size: 13px; color: var(--ink); flex-wrap: wrap; }
107
+ .lic-banner .spacer { flex: 1; }
108
+ .lic-banner button { font: inherit; font-weight: 600; cursor: pointer; border: 1px solid #D32F2F; background: #D32F2F; color: #fff; border-radius: 6px; padding: 6px 12px; }
109
+ .lic-panel { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); max-width: 760px; }
110
+ .lic-panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin: 0 0 8px; }
111
+ .lic-panel .ok { color: #2e7d46; font-weight: 600; margin: 0; }
112
+ .lic-panel .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
113
+ .lic-panel input { flex: 1; min-width: 240px; font-family: ui-monospace,Menlo,Consolas,monospace; font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); }
114
+ .lic-panel button { font: inherit; font-weight: 600; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--ink); border-radius: 6px; padding: 6px 12px; }
115
+ .lic-panel .hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
116
+ .srctable th { cursor: default; }
117
+ .srctable td { vertical-align: middle; font-size: 13px; }
118
+ .srctable .lab { font-weight: 600; }
119
+ .srctable .sub { display: block; font-size: 11px; color: var(--muted); word-break: break-all; }
120
+ .srctable .ok { color: #2e7d32; }
121
+ .srctable .err { color: var(--rp-red); }
122
+ .srctable .repo-ok { color: #2e7d32; }
123
+ .srctable .repo-warn { color: #b8860b; }
124
+ .srctable .repo-none { color: var(--muted); }
125
+ .srctable .acts { white-space: nowrap; }
126
+ .sact { background: none; border: 1px solid var(--line); border-radius: 5px; padding: 3px 7px; margin-right: 4px; cursor: pointer; font-size: 12px; color: var(--ink); }
127
+ .sact:hover { border-color: var(--rp-red); color: var(--rp-red); }
128
+ .sact.danger:hover { background: var(--rp-red); color: #fff; border-color: var(--rp-red); }
129
+ #srcModal input { width: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: 6px; padding: 8px; margin-top: 6px; font: inherit; background: var(--card); color: var(--ink); }
130
+
131
+ .filters {
132
+ background: var(--card);
133
+ border: 1px solid var(--line);
134
+ border-radius: 8px;
135
+ padding: 12px;
136
+ display: flex;
137
+ gap: 10px;
138
+ flex-wrap: wrap;
139
+ align-items: center;
140
+ margin-bottom: 14px;
141
+ }
142
+ .filters select, .filters input {
143
+ background: var(--bg);
144
+ color: var(--ink);
145
+ border: 1px solid var(--line);
146
+ border-radius: 6px;
147
+ padding: 6px 9px;
148
+ font-size: 13px;
149
+ }
150
+ .filters input[type=search] { min-width: 220px; flex: 1; }
151
+ .filters .viewsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
152
+ .viewbtn { background: transparent; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; }
153
+ .viewbtn:hover { color: var(--ink); border-color: var(--muted); }
154
+
155
+ table { width: 100%; border-collapse: collapse; background: var(--card); }
156
+ th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
157
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); cursor: pointer; user-select: none; white-space: nowrap; }
158
+ tr:hover td { background: var(--hover); }
159
+ td.body { max-width: 560px; min-width: 210px; }
160
+ td.col-proj { max-width: 280px; min-width: 200px; }
161
+ td.col-proj .project { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
162
+ .tablescroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--card); }
163
+ .col-where { width: 60px; max-width: 180px; }
164
+ .col-created, .col-resolved { width: 100px; white-space: nowrap; color: var(--muted); font-size: 12px; }
165
+ .pill.pri-high { color: var(--rp-red); border-color: var(--rp-red); font-weight: 700; }
166
+ th.sorted { color: var(--ink); }
167
+ th .arrow { color: var(--rp-red); font-size: 10px; display: inline-block; width: 1em; text-align: center; }
168
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 11px; font-size: 11px; font-weight: 600; color: #fff; }
169
+ .pill { display: inline-block; padding: 2px 8px; border-radius: 11px; font-size: 11px; border: 1px solid var(--line); color: var(--muted); }
170
+ .status-open { color: var(--rp-red); font-weight: 600; }
171
+ .status-in_progress, .status-in-progress { color: #b26a00; font-weight: 600; }
172
+ .status-resolved { color: #1a7f37; }
173
+ /* Dark theme: the mid-tone green/orange status + source colours fail AA contrast on the dark card; lighten them. */
174
+ body.dark .status-in_progress, body.dark .status-in-progress { color: #f0a83a; }
175
+ body.dark .status-resolved { color: #4ac26b; }
176
+ body.dark .srctable .ok, body.dark .srctable .repo-ok { color: #4ac26b; }
177
+ a.jump { color: var(--rp-red); text-decoration: none; font-weight: 600; }
178
+ a.jump:hover { text-decoration: underline; }
179
+ .col-acts { width: 150px; white-space: nowrap; text-align: right; }
180
+ .rb { background: transparent; color: var(--rp-red); border: 1px solid var(--rp-red); border-radius: 6px; padding: 3px 10px; font-size: 12px; font-weight: 600; cursor: pointer; }
181
+ .rb:hover { background: var(--rp-red); color: #fff; }
182
+ .rb.reopen, .rb.start { color: var(--muted); border-color: var(--line); }
183
+ .rb.reopen:hover, .rb.start:hover { background: var(--muted); color: var(--card); border-color: var(--muted); }
184
+ .rb:disabled { opacity: .5; cursor: default; }
185
+ #rphToast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--ink); color: var(--card); padding: 10px 16px; border-radius: 8px; font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,.25); opacity: 0; pointer-events: none; transition: opacity .18s, transform .18s; z-index: 9999; }
186
+ #rphToast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
187
+ #rphToast.err { background: var(--rp-red); color: #fff; }
188
+ /* Undo popup: bottom-center confirmation with an Undo action + a 5s draining countdown ring. */
189
+ #rphUndo { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(20px); display: flex; align-items: center; gap: 14px; background: var(--card); color: var(--ink); border: 1px solid var(--line); padding: 9px 14px 9px 16px; border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.3); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 10000; }
190
+ #rphUndo.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
191
+ #rphUndo .ut-msg { font-size: 13px; white-space: nowrap; }
192
+ #rphUndo .ut-undo { background: transparent; color: inherit; border: 1px solid currentColor; border-radius: 6px; padding: 3px 12px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
193
+ #rphUndo .ut-undo:hover { background: var(--rp-red); color: #fff; border-color: var(--rp-red); }
194
+ #rphUndo .ut-ring { width: 22px; height: 22px; flex: none; transform: rotate(-90deg); }
195
+ #rphUndo .ut-ring .ut-track { fill: none; stroke: rgba(127,127,127,.35); stroke-width: 2.5; }
196
+ #rphUndo .ut-ring .ut-prog { fill: none; stroke: var(--rp-red); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 56.55; stroke-dashoffset: 0; animation: ut-drain 5s linear forwards; }
197
+ @keyframes ut-drain { to { stroke-dashoffset: 56.55; } }
198
+ @media (prefers-reduced-motion: reduce) { #rphUndo .ut-ring .ut-prog { animation: none; } .rp-spin { animation: none; } }
199
+ #rphUndo.paused .ut-ring .ut-prog { animation-play-state: paused; }
200
+ /* When the undo popup is up, lift the transient toast above it so they never stack on the same spot. */
201
+ body.undo-open #rphToast { bottom: 84px; }
202
+ .where { color: var(--muted); font-size: 12px; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
203
+ .byline { color: var(--muted); font-size: 11px; margin-top: 3px; }
204
+ .empty { padding: 40px; text-align: center; color: var(--muted); }
205
+ .project { font-weight: 600; }
206
+ .surface { font-size: 11px; color: var(--muted); }
207
+ footer { text-align: center; color: var(--muted); font-size: 12px; padding: 18px; }
208
+
209
+ .overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: flex-start; justify-content: center; z-index: 1000; overflow: auto; }
210
+ .overlay.show { display: flex; }
211
+ .modal { background: var(--card); color: var(--ink); border-radius: 10px; width: min(760px, 94vw); margin: 40px 0; box-shadow: 0 20px 60px rgba(0,0,0,.4); }
212
+ .modal h2 { margin: 0; padding: 16px 20px; border-bottom: 1px solid var(--line); font-size: 16px; display: flex; align-items: center; }
213
+ .modal h2 .x { margin-left: auto; cursor: pointer; color: var(--muted); font-size: 22px; line-height: 1; background: none; border: none; }
214
+ .modal .body { padding: 18px 20px; }
215
+ .modal h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin: 0 0 8px; }
216
+ .scanbar { display: flex; gap: 8px; margin: 4px 0 14px; }
217
+ .scanbar input { flex: 1; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font-size: 13px; }
218
+ .btn { background: var(--rp-red); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; font-size: 13px; font-weight: 600; }
219
+ .btn:disabled { opacity: .5; cursor: default; }
220
+ .btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); }
221
+ .cand { border: 1px solid var(--line); border-radius: 7px; padding: 10px 12px; margin-bottom: 8px; }
222
+ .cand.dim { opacity: .55; }
223
+ .cand .top { display: flex; align-items: center; gap: 10px; }
224
+ .cand .lab { font-weight: 600; }
225
+ .cand .sub { color: var(--muted); font-size: 12px; word-break: break-all; }
226
+ .cand .auth { display: flex; gap: 8px; margin-top: 8px; }
227
+ .cand .auth input { flex: 1; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; font-size: 12px; }
228
+ .srow { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
229
+ .srow .rm { margin-left: auto; cursor: pointer; color: var(--rp-red); background: none; border: none; font-size: 13px; }
230
+ .modal .foot { padding: 14px 20px; border-top: 1px solid var(--line); display: flex; gap: 10px; align-items: center; }
231
+ .modal .foot .note { color: var(--muted); font-size: 12px; }
232
+ .hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
233
+ @keyframes rp-spin { to { transform: rotate(360deg); } }
234
+ .rp-spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rp-spin .7s linear infinite; vertical-align: -2px; }
235
+ button:disabled { opacity: .6; cursor: default; }
236
+ .tabs { display: flex; gap: 10px; flex-wrap: wrap; }
237
+ header .tabs .tab { background: var(--bg); }
238
+ .tab { box-sizing: border-box; background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 10px 16px; min-width: 90px; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 2px; font: inherit; }
239
+ .tab .n { font-size: 22px; font-weight: 700; color: var(--ink); line-height: 1.1; }
240
+ .tab .l { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
241
+ .tab:hover { border-color: var(--rp-red); }
242
+ .tab.active { border-color: var(--rp-red); }
243
+ .tab.active .n, .tab.attn .n { color: var(--rp-red); }
244
+ </style>
245
+ <script>window.__HUB_TOKEN__ = '%%HUB_TOKEN%%';</script>
246
+ </head>
247
+ <body>
248
+ <header>
249
+ <div class="hbar">
250
+ <div class="brandblock">
251
+ <img class="logo" src="favicon.svg" alt="">
252
+ <div class="brand">
253
+ <h1>Red Pen Hub</h1>
254
+ <span class="tag">combined project board</span>
255
+ </div>
256
+ </div>
257
+ <div class="tabs" id="tabs" role="group" aria-label="Filter notes by status">
258
+ <button class="tab active" type="button" data-tab="open"><span class="n" id="tc-open">0</span><span class="l">Open</span></button>
259
+ <button class="tab" type="button" data-tab="progress"><span class="n" id="tc-progress">0</span><span class="l">In progress</span></button>
260
+ <button class="tab" type="button" data-tab="resolved"><span class="n" id="tc-resolved">0</span><span class="l">Resolved</span></button>
261
+ <button class="tab" type="button" data-tab="all"><span class="n" id="tc-all">0</span><span class="l">All</span></button>
262
+ <button class="tab" type="button" data-tab="projects"><span class="n" id="tc-projects">0</span><span class="l">Projects</span></button>
263
+ <button class="tab" type="button" data-tab="sources"><span class="n" id="tc-sources">0</span><span class="l">Sources</span></button>
264
+ </div>
265
+ <span class="spacer"></span>
266
+ <button id="refreshBtn" type="button">Refresh</button>
267
+ <button id="exportMdBtn" type="button">Export MD</button>
268
+ <button id="exportCsvBtn" type="button">Export CSV</button>
269
+ <button id="darkBtn" class="rp-toggle" type="button" role="switch" aria-checked="false" aria-label="Switch dark / light mode" title="Switch dark / light mode">
270
+ <span class="rp-toggle-knob">
271
+ <svg class="ic-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5"/><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 1.5v3M12 19.5v3M4.5 4.5l2 2M17.5 17.5l2 2M1.5 12h3M19.5 12h3M4.5 19.5l2-2M17.5 6.5l2-2"/></g></svg>
272
+ <svg class="ic-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
273
+ </span>
274
+ </button>
275
+ <!-- Docs "?" button. NOTE: this is the LOCAL docs path for dogfooding - swap it for the
276
+ PUBLIC docs URL before the Hub ships to customers (their machine has no such path). -->
277
+ <a id="helpBtn" class="help-btn" href="https://docs.redpen.tools/hub.html" target="_blank" rel="noopener" aria-label="Documentation">?<span class="help-tip">View the documentation</span></a>
278
+ </div>
279
+ </header>
280
+
281
+ <div class="wrap">
282
+ <div id="licenseBanner" class="lic-banner" style="display:none"></div>
283
+ <div class="filters">
284
+ <input type="search" id="q" placeholder="Search notes..." aria-label="Search notes">
285
+ <select id="fProject" aria-label="Filter by project"><option value="">All projects</option></select>
286
+ <select id="fSurface" aria-label="Filter by surface"><option value="">All surfaces</option></select>
287
+ <select id="fType" aria-label="Filter by type"><option value="">All types</option></select>
288
+ <select id="fPriority" aria-label="Filter by priority"><option value="">All priorities</option></select>
289
+ <span class="viewsep" aria-hidden="true"></span>
290
+ <select id="viewSel" aria-label="Apply a saved view"><option value="">Saved views...</option></select>
291
+ <button id="viewSave" class="viewbtn" type="button" title="Save the current filters as a named view">Save view</button>
292
+ <button id="viewDel" class="viewbtn" type="button" title="Delete the selected view" style="display:none">Delete</button>
293
+ </div>
294
+
295
+ <div class="tablescroll">
296
+ <table>
297
+ <thead>
298
+ <tr>
299
+ <th class="col-proj" data-sort="project">Project</th>
300
+ <th data-sort="surface">Surface</th>
301
+ <th data-sort="type">Type</th>
302
+ <th data-sort="priority">Priority</th>
303
+ <th data-sort="status">Status</th>
304
+ <th data-sort="body">Note</th>
305
+ <th class="col-where" data-sort="page">Location</th>
306
+ <th class="col-created" data-sort="createdAt">Created</th>
307
+ <th class="col-resolved" data-sort="resolvedAt">Resolved</th>
308
+ <th class="col-acts"></th>
309
+ </tr>
310
+ </thead>
311
+ <tbody id="rows"></tbody>
312
+ </table>
313
+ </div>
314
+ <div class="empty" id="empty" style="display:none">No Red Pen notes found across your sources yet.</div>
315
+
316
+ <div id="sourcesView" class="sources-view" style="display:none">
317
+ <div class="sources-head">
318
+ <span id="sourcesSummary">Sources</span>
319
+ <span class="spacer"></span>
320
+ <button class="btn" id="addBtn" type="button">+ Add Local Projects</button>
321
+ <button class="btn" id="connectBtn" type="button">+ Connect a Site</button>
322
+ </div>
323
+ <p class="hint" id="sourcesHint" style="margin:0 0 12px;max-width:760px"><strong>Add Local Projects</strong> reads notes from Red Pen projects on this computer. <strong>Connect a Site</strong> lets another site - remote or live - push its notes here.</p>
324
+ <div id="licensePanel" class="lic-panel"></div>
325
+ <table class="srctable">
326
+ <thead><tr>
327
+ <th>Project</th><th>Surface</th><th>Type</th><th>Status</th><th>Notes</th><th>Repo</th><th>Actions</th>
328
+ </tr></thead>
329
+ <tbody id="sourcesRows"></tbody>
330
+ </table>
331
+ </div>
332
+
333
+ <div id="projectsView" class="sources-view" style="display:none">
334
+ <div class="sources-head"><span id="projectsSummary">Projects</span></div>
335
+ <table class="srctable">
336
+ <thead><tr>
337
+ <th>Project</th><th>Surface</th><th>Open</th><th>In progress</th><th>Resolved</th><th>Total</th>
338
+ </tr></thead>
339
+ <tbody id="projectsRows"></tbody>
340
+ </table>
341
+ </div>
342
+
343
+ <footer id="foot"></footer>
344
+ </div>
345
+
346
+ <div class="overlay" id="mdOverlay">
347
+ <div class="modal" role="dialog" aria-modal="true">
348
+ <h2>Export Markdown <button class="x" id="mdClose" type="button">&times;</button></h2>
349
+ <div class="body">
350
+ <p class="hint">A markdown digest of the current view (this tab + filters), grouped by project as Obsidian task checkboxes. Drop it into a daily note, a standup, or a changelog.</p>
351
+ <textarea id="mdText" readonly style="width:100%;box-sizing:border-box;min-height:300px;font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;border:1px solid var(--line);border-radius:6px;padding:10px;background:var(--card);color:var(--ink);resize:vertical"></textarea>
352
+ </div>
353
+ <div class="foot">
354
+ <button class="btn" id="mdCopy" type="button">Copy</button>
355
+ <button class="btn" id="mdDownload" type="button">Download .md</button>
356
+ <button class="btn ghost" id="mdCancel" type="button">Close</button>
357
+ <span class="note" id="mdNote"></span>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <div class="overlay" id="srcModal">
363
+ <div class="modal" role="dialog" aria-modal="true">
364
+ <h2 id="srcModalTitle">Edit source <button class="x" id="srcModalClose" type="button">&times;</button></h2>
365
+ <div class="body" id="srcModalBody"></div>
366
+ <div class="foot">
367
+ <button class="btn" id="srcModalSave" type="button">Save</button>
368
+ <button class="btn ghost" id="srcModalCancel" type="button">Cancel</button>
369
+ <span class="note" id="srcModalNote"></span>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <div class="overlay" id="overlay">
375
+ <div class="modal" role="dialog" aria-modal="true">
376
+ <h2>Add Local Projects <button class="x" id="modalClose" type="button">&times;</button></h2>
377
+ <div class="body">
378
+ <h3>Scan a folder on this computer</h3>
379
+ <p class="hint">Point the Hub at a dev folder on this machine - it links Red Pen projects and reads their notes from disk (file-based projects + local WordPress). For a remote or live site, use "Connect a Site" instead.</p>
380
+ <div class="scanbar">
381
+ <input type="text" id="scanRoot" placeholder="C:\path\to\your\dev\directory">
382
+ <button class="btn" id="scanBtn" type="button">Scan</button>
383
+ </div>
384
+ <div id="scanResults"></div>
385
+
386
+ <h3 style="margin-top:18px">Current sources</h3>
387
+ <div id="currentSources"></div>
388
+ </div>
389
+ <div class="foot">
390
+ <button class="btn" id="addSelectedBtn" type="button" disabled>Add selected</button>
391
+ <button class="btn ghost" id="modalCancel" type="button">Close</button>
392
+ <span class="note" id="modalNote"></span>
393
+ </div>
394
+ </div>
395
+ </div>
396
+
397
+ <div class="overlay" id="connectOverlay">
398
+ <div class="modal" role="dialog" aria-modal="true">
399
+ <h2>Connect a Site <button class="x" id="connectClose" type="button">&times;</button></h2>
400
+ <div class="body">
401
+ <p class="hint">Link a remote or live site by having it push its notes here. Paste the two values below into that site's Red Pen "Connect to Hub" settings (WordPress: Tools - Red Pen - Display settings); once it pushes, it appears in Sources.</p>
402
+ <h3 style="margin-top:14px">Hub URL</h3>
403
+ <div class="scanbar"><input type="text" id="connUrl" readonly><button class="btn ghost" data-copy="connUrl" type="button">Copy</button></div>
404
+ <h3>Connect token</h3>
405
+ <div class="scanbar"><input type="text" id="connToken" readonly><button class="btn ghost" data-copy="connToken" type="button">Copy</button></div>
406
+ <p class="hint">Keep this token private - anyone who has it (and can reach your Hub) could push notes to it.</p>
407
+ </div>
408
+ <div class="foot">
409
+ <button class="btn ghost" id="connectCancel" type="button">Close</button>
410
+ <span class="note" id="connectNote"></span>
411
+ </div>
412
+ </div>
413
+ </div>
414
+
415
+ <script>
416
+ (function () {
417
+ function readJson(k, d) { try { var v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } }
418
+ function writeJson(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
419
+
420
+ var ALL = [];
421
+ var LAST = readJson('rphub.last', null); // persisted view state (tab/sort/filters), restored on first load
422
+ var VIEWS = readJson('rphub.views', {}); // named saved views
423
+ var SORT = { key: (LAST && LAST.sortKey) || 'priority', dir: (LAST && LAST.sortDir) || -1 }; // default: priority, high at top
424
+ var TAB = (LAST && LAST.tab) || 'open'; // primary nav: Open is the default view, not All (also 'sources' = the sources panel)
425
+ var SOURCES = []; // current source list, for the Sources tab + its count
426
+
427
+ // Attach the hub token to every same-origin /api request so it passes the server's
428
+ // token gate. Injected into the page at serve time; keeps the board working while
429
+ // the API stays closed to anything that doesn't have the token.
430
+ var HUB_TOKEN = window.__HUB_TOKEN__ || '';
431
+ (function (orig) {
432
+ window.fetch = function (input, init) {
433
+ var url = typeof input === 'string' ? input : (input && input.url) || '';
434
+ if (url.slice(0, 5) === '/api/') {
435
+ init = init || {};
436
+ var h = Object.assign({}, init.headers || {});
437
+ h['x-hub-token'] = HUB_TOKEN;
438
+ init.headers = h;
439
+ }
440
+ return orig.call(this, input, init);
441
+ };
442
+ })(window.fetch);
443
+
444
+ function esc(s) {
445
+ return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
446
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
447
+ });
448
+ }
449
+
450
+ function fmtDate(v) {
451
+ if (!v) return '';
452
+ var d = typeof v === 'number' ? new Date(v) : new Date(String(v));
453
+ if (isNaN(d.getTime())) return '';
454
+ function p(x) { return (x < 10 ? '0' : '') + x; }
455
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
456
+ }
457
+
458
+ function uniq(arr) {
459
+ var s = {}; arr.forEach(function (v) { if (v) s[v] = 1; });
460
+ return Object.keys(s).sort();
461
+ }
462
+
463
+ // ---- View state: persist tab/sort/filters across reloads + named saved views ----
464
+ var FILTER_IDS = ['q', 'fProject', 'fSurface', 'fType', 'fPriority'];
465
+ function fval(id) { var el = document.getElementById(id); return el ? el.value : ''; }
466
+ function fset(id, v) { var el = document.getElementById(id); if (el && v != null) el.value = v; }
467
+ function currentState() {
468
+ var s = { tab: TAB, sortKey: SORT.key, sortDir: SORT.dir };
469
+ FILTER_IDS.forEach(function (id) { s[id] = fval(id); });
470
+ return s;
471
+ }
472
+ function persistLast() { writeJson('rphub.last', currentState()); }
473
+ function applyState(s) {
474
+ if (!s) return;
475
+ if (s.tab) TAB = s.tab;
476
+ if (s.sortKey) SORT.key = s.sortKey;
477
+ if (s.sortDir) SORT.dir = s.sortDir;
478
+ FILTER_IDS.forEach(function (id) { fset(id, s[id]); });
479
+ }
480
+ function rebuildViewSel(sel) {
481
+ var el = document.getElementById('viewSel');
482
+ var names = Object.keys(VIEWS).sort();
483
+ el.innerHTML = '<option value="">Saved views...</option>' +
484
+ names.map(function (n) { return '<option value="' + esc(n) + '">' + esc(n) + '</option>'; }).join('');
485
+ el.value = names.indexOf(sel) === -1 ? '' : sel;
486
+ document.getElementById('viewDel').style.display = el.value ? '' : 'none';
487
+ }
488
+
489
+ // ---- CSV export (spreadsheet / CI sibling of the Markdown export) ----
490
+ function csvCell(s) { s = String(s == null ? '' : s); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
491
+ function buildCsv() {
492
+ var head = ['Project', 'Surface', 'Type', 'Priority', 'Severity', 'Status', 'Note', 'Author', 'Assignee', 'Replies', 'Location', 'Anchor', 'Created', 'Resolved', 'Resolved By'];
493
+ var lines = [head.join(',')];
494
+ filtered().forEach(function (n) {
495
+ lines.push([n.project, n.surface, n.type, n.priority, n.severity, n.status, n.body, n.author, n.assignee, n.replyCount || 0, n.url, n.anchor, fmtDate(n.createdAt), fmtDate(n.resolvedAt), n.resolvedBy].map(csvCell).join(','));
496
+ });
497
+ return lines.join('\r\n') + '\r\n';
498
+ }
499
+ function downloadCsv() {
500
+ var date = new Date().toISOString().slice(0, 10);
501
+ var blob = new Blob(['' + buildCsv()], { type: 'text/csv' }); // BOM so Excel reads UTF-8
502
+ var a = document.createElement('a');
503
+ a.href = URL.createObjectURL(blob);
504
+ a.download = 'red-pen-' + TAB + '-' + date + '.csv';
505
+ document.body.appendChild(a); a.click();
506
+ setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 0);
507
+ }
508
+
509
+ function fillSelect(id, vals) {
510
+ var sel = document.getElementById(id);
511
+ var keep = sel.value;
512
+ var first = sel.options[0].outerHTML;
513
+ sel.innerHTML = first + vals.map(function (v) {
514
+ return '<option value="' + esc(v) + '">' + esc(v) + '</option>';
515
+ }).join('');
516
+ sel.value = keep;
517
+ }
518
+
519
+ function typeColor(n) {
520
+ // n.typeColor comes from other projects/sites and is injected into a style
521
+ // attribute unescaped, so only allow a plain color token (hex or CSS word).
522
+ var raw = n.typeColor;
523
+ if (raw && (/^#[0-9a-fA-F]{3,8}$/.test(raw) || /^[a-zA-Z]+$/.test(raw))) return raw;
524
+ var map = { bug: '#D32F2F', note: '#5a6acf', idea: '#1a7f37', question: '#b26a00', todo: '#6d28d9' };
525
+ return map[(n.type || '').toLowerCase()] || '#5a6acf';
526
+ }
527
+
528
+ // Only allow http(s) or root-relative links in href - blocks javascript:/data: URLs.
529
+ function safeUrl(u) {
530
+ u = String(u == null ? '' : u);
531
+ return /^(https?:|\/)/i.test(u) ? u : '#';
532
+ }
533
+
534
+ function surfaceColor(s) {
535
+ return s === 'wordpress' ? '#21759b' : s === 'express' ? '#1a7f37' : '#6d28d9';
536
+ }
537
+
538
+ function srcKind(s) {
539
+ if (s.connected) return 'push';
540
+ if (typeof s.source === 'string' && /^https?:/i.test(s.source)) return 'wp-rest';
541
+ return 'file';
542
+ }
543
+ function relTime(iso) {
544
+ if (!iso) return '';
545
+ var t = +new Date(iso); if (!t) return '';
546
+ var secs = Math.max(0, Math.round((Date.now() - t) / 1000));
547
+ if (secs < 60) return 'now';
548
+ var m = Math.round(secs / 60); if (m < 60) return m + 'm ago';
549
+ var h = Math.round(m / 60); if (h < 24) return h + 'h ago';
550
+ return Math.round(h / 24) + 'd ago';
551
+ }
552
+ function renderSources(sources) {
553
+ var ok = sources.filter(function (s) { return !s.error; }).length;
554
+ document.getElementById('sourcesSummary').textContent =
555
+ sources.length + ' source' + (sources.length === 1 ? '' : 's') + ', ' + (sources.length - ok) + ' need attention';
556
+ document.getElementById('sourcesRows').innerHTML = sources.map(function (s) {
557
+ var kind = srcKind(s);
558
+ var status = s.error
559
+ ? '<span class="err">' + esc(s.error) + '</span>'
560
+ : (kind === 'push' ? '<span class="ok">synced ' + esc(relTime(s.lastSync) || 'now') + '</span>' : '<span class="ok">ok</span>');
561
+ var editBtn = kind === 'wp-rest' ? '<button class="sact" data-act="edit" type="button" title="Edit URL + credentials">edit</button>' : '';
562
+ var rmTitle = kind === 'push' ? 'Forget this connection' : 'Remove this source';
563
+ return '<tr data-key="' + esc(s.key) + '" data-project="' + esc(s.label) + '" data-kind="' + kind + '">' +
564
+ '<td><span class="lab">' + esc(s.label) + '</span><span class="sub" title="' + esc(s.source) + '">' + esc(s.source) + '</span></td>' +
565
+ '<td><span class="badge" style="background:' + surfaceColor(s.surface) + '">' + esc(s.surface) + '</span></td>' +
566
+ '<td>' + (kind === 'push' ? 'push' : 'pull') + '</td>' +
567
+ '<td>' + status + '</td>' +
568
+ '<td>' + (s.error ? '-' : s.count) + '</td>' +
569
+ '<td class="repo"><span class="repo-none">...</span></td>' +
570
+ '<td class="acts">' +
571
+ '<button class="sact" data-act="path" type="button" title="Set this project\'s local git repo (shows clean / dirty status)">repo</button>' +
572
+ '<button class="sact" data-act="recheck" type="button" title="Re-check (reloads the board)">re-check</button>' +
573
+ editBtn +
574
+ '<button class="sact danger" data-act="remove" type="button" title="' + rmTitle + '">remove</button>' +
575
+ '</td></tr>';
576
+ }).join('');
577
+ }
578
+ function loadRepoStatus() {
579
+ var rows = document.querySelectorAll('#sourcesRows tr[data-project]');
580
+ if (!rows.length) return;
581
+ var projects = [];
582
+ rows.forEach(function (r) { projects.push(r.getAttribute('data-project')); });
583
+ fetch('/api/git-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projects: projects }) })
584
+ .then(function (r) { return r.json(); }).then(function (d) {
585
+ var st = d.status || {};
586
+ rows.forEach(function (r) {
587
+ var s = st[r.getAttribute('data-project')] || {};
588
+ var cell = r.querySelector('.repo'); if (!cell) return;
589
+ if (s.isRepo) cell.innerHTML = '<span class="' + (s.clean ? 'repo-ok' : 'repo-warn') + '">git ' + esc(s.branch || '') + (s.clean ? '' : ' (' + s.dirty + ' dirty)') + '</span>';
590
+ else if (s.hasPath && s.exists === false) cell.innerHTML = '<span class="repo-warn">path not found</span>';
591
+ else if (s.hasPath) cell.innerHTML = '<span class="repo-warn">not a repo</span>';
592
+ else cell.innerHTML = '<span class="repo-none">set path</span>';
593
+ });
594
+ }).catch(function () { /* leave placeholders */ });
595
+ }
596
+ // The small per-source modal (repo path / wp-rest credentials).
597
+ var srcSaveFn = null;
598
+ function closeSrcModal() { document.getElementById('srcModal').classList.remove('show'); }
599
+ function openRepoModal(project) {
600
+ document.getElementById('srcModalBody').innerHTML =
601
+ '<p class="hint">Local git repo for <b>' + esc(project) + '</b> - the Sources tab shows its clean / dirty status. Point it at the project source repo, not a deployed copy.</p>' +
602
+ '<input id="srcModalInput" type="text" placeholder="C:\\path\\to\\repo">';
603
+ document.getElementById('srcModalNote').textContent = '';
604
+ document.getElementById('srcModal').classList.add('show');
605
+ fetch('/api/repo-paths').then(function (r) { return r.json(); }).then(function (d) {
606
+ var inp = document.getElementById('srcModalInput'); if (inp) inp.value = (d.paths || {})[project] || '';
607
+ });
608
+ srcSaveFn = function () {
609
+ var path = (document.getElementById('srcModalInput').value || '').trim();
610
+ fetch('/api/repo-paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: project, path: path }) })
611
+ .then(function () { closeSrcModal(); if (TAB === 'sources') loadRepoStatus(); });
612
+ };
613
+ }
614
+ function openCredModal(tr, project) {
615
+ var key = tr.getAttribute('data-key');
616
+ var sub = tr.querySelector('.sub');
617
+ document.getElementById('srcModalBody').innerHTML =
618
+ '<p class="hint">WordPress REST credentials for <b>' + esc(project) + '</b>. Use an Application Password, not the login password.</p>' +
619
+ '<input id="srcUrl" type="text" placeholder="Site URL" value="' + esc(sub ? sub.textContent : '') + '">' +
620
+ '<input id="srcUser" type="text" placeholder="WP username">' +
621
+ '<input id="srcPw" type="text" placeholder="Application password">';
622
+ document.getElementById('srcModalNote').textContent = '';
623
+ document.getElementById('srcModal').classList.add('show');
624
+ srcSaveFn = function () {
625
+ var url = document.getElementById('srcUrl').value.trim();
626
+ var user = document.getElementById('srcUser').value.trim();
627
+ var pw = document.getElementById('srcPw').value.trim();
628
+ fetch('/api/sources/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key }) })
629
+ .then(function () { return fetch('/api/sources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add: [{ type: 'wp-rest', label: project, url: url, user: user, appPassword: pw }] }) }); })
630
+ .then(function () { closeSrcModal(); load(); });
631
+ };
632
+ }
633
+
634
+ // Status buckets the tabs select. Anything not in-progress/resolved counts as Open.
635
+ function statusBucket(n) {
636
+ if (/progress/.test(n.status)) return 'progress';
637
+ if (n.status === 'resolved') return 'resolved';
638
+ return 'open';
639
+ }
640
+ // Every filter EXCEPT status (the tabs own status). Tab counts use this set so they
641
+ // reflect the active project/surface/type/priority/search.
642
+ function passNonStatus(n) {
643
+ var q = document.getElementById('q').value.toLowerCase().trim();
644
+ var fp = document.getElementById('fProject').value;
645
+ var fs = document.getElementById('fSurface').value;
646
+ var ft = document.getElementById('fType').value;
647
+ var fpr = document.getElementById('fPriority').value;
648
+ if (fp && n.project !== fp) return false;
649
+ if (fs && n.surface !== fs) return false;
650
+ if (ft && n.type !== ft) return false;
651
+ if (fpr && n.priority !== fpr) return false;
652
+ if (q) {
653
+ var hay = (n.body + ' ' + n.project + ' ' + n.page + ' ' + n.type).toLowerCase();
654
+ if (hay.indexOf(q) === -1) return false;
655
+ }
656
+ return true;
657
+ }
658
+ function filtered() {
659
+ var out = ALL.filter(function (n) {
660
+ return passNonStatus(n) && (TAB === 'all' || statusBucket(n) === TAB);
661
+ });
662
+ out.sort(function (a, b) {
663
+ var x, y;
664
+ if (SORT.key === 'priority') { x = priRank(a.priority); y = priRank(b.priority); }
665
+ else if (SORT.key === 'createdAt') { x = +new Date(a.createdAt) || 0; y = +new Date(b.createdAt) || 0; }
666
+ else if (SORT.key === 'resolvedAt') { x = +new Date(a.resolvedAt) || 0; y = +new Date(b.resolvedAt) || 0; }
667
+ else { x = a[SORT.key]; y = b[SORT.key]; }
668
+ if (x < y) return -1 * SORT.dir;
669
+ if (x > y) return 1 * SORT.dir;
670
+ return (+new Date(b.createdAt) || 0) - (+new Date(a.createdAt) || 0); // tie-break: newest first
671
+ });
672
+ return out;
673
+ }
674
+
675
+ // Sources is its own view (the old foldable, promoted to a tab): when active it
676
+ // replaces the filters + notes table with the source list.
677
+ function applyView() {
678
+ var view = TAB === 'sources' ? 'sources' : (TAB === 'projects' ? 'projects' : 'notes');
679
+ document.getElementById('sourcesView').style.display = view === 'sources' ? 'block' : 'none';
680
+ document.getElementById('projectsView').style.display = view === 'projects' ? 'block' : 'none';
681
+ document.querySelector('.filters').style.display = view === 'notes' ? '' : 'none';
682
+ document.querySelector('table').style.display = view === 'notes' ? '' : 'none';
683
+ if (view !== 'notes') document.getElementById('empty').style.display = 'none';
684
+ if (view === 'sources') loadRepoStatus();
685
+ }
686
+ function render() {
687
+ applyView();
688
+ if (TAB === 'sources' || TAB === 'projects') return; // these views have no note rows to build
689
+ var rows = filtered();
690
+ var tb = document.getElementById('rows');
691
+ document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
692
+ tb.innerHTML = rows.map(function (n) {
693
+ var where = n.url
694
+ ? '<a class="jump" href="' + esc(safeUrl(n.url)) + '" target="_blank" rel="noopener">open</a>'
695
+ : '';
696
+ var anchor = n.anchor ? '<div class="where" title="' + esc(n.anchor) + '">' + esc(n.anchor) + '</div>' : '';
697
+ var st = String(n.status || 'open').replace(/-/g, '_');
698
+ var key = noteKey(n);
699
+ // Compact identity/thread byline - only the parts a surface actually supplied.
700
+ var bits = [];
701
+ if (n.author) bits.push('by ' + esc(n.author));
702
+ if (n.assignee) bits.push('&rarr; ' + esc(n.assignee));
703
+ if (n.severity) bits.push('severity: ' + esc(n.severity));
704
+ if (n.dupeOf) bits.push('dup of ' + esc(n.dupeOf));
705
+ if (n.relatedTo) bits.push('rel: ' + esc(n.relatedTo));
706
+ if (n.replyCount) bits.push(n.replyCount + ' repl' + (n.replyCount === 1 ? 'y' : 'ies'));
707
+ var byline = bits.length ? '<div class="byline">' + bits.join(' &middot; ') + '</div>' : '';
708
+ var resolvedCell = esc(fmtDate(n.resolvedAt)) + (n.resolvedBy ? '<div class="byline">' + esc(n.resolvedBy) + '</div>' : '');
709
+ return '<tr data-key="' + esc(key) + '">'
710
+ + '<td class="col-proj"><span class="project">' + esc(n.project) + '</span></td>'
711
+ + '<td><span class="surface">' + esc(n.surface) + '</span></td>'
712
+ + '<td><span class="badge" style="background:' + typeColor(n) + '">' + esc(n.type) + '</span></td>'
713
+ + '<td><span class="pill pri-' + esc(n.priority) + '">' + esc(n.priority) + '</span></td>'
714
+ + '<td><span class="status-' + esc(st) + '">' + esc(n.status) + '</span></td>'
715
+ + '<td class="body">' + esc(n.body) + byline + '</td>'
716
+ + '<td class="col-where">' + where + anchor + '</td>'
717
+ + '<td class="col-created">' + esc(fmtDate(n.createdAt)) + '</td>'
718
+ + '<td class="col-resolved">' + resolvedCell + '</td>'
719
+ + '<td class="col-acts">' + statusBtn(n) + '</td>'
720
+ + '</tr>';
721
+ }).join('');
722
+ setSortArrows();
723
+ }
724
+ function priRank(p) { var r = { high: 3, normal: 2, medium: 2, low: 1 }; return r[String(p).toLowerCase()] || 0; }
725
+ function setSortArrows() {
726
+ var ths = document.querySelectorAll('th[data-sort]');
727
+ for (var i = 0; i < ths.length; i++) {
728
+ var th = ths[i], a = th.querySelector('.arrow');
729
+ if (!a) { a = document.createElement('span'); a.className = 'arrow'; th.appendChild(a); }
730
+ var active = th.getAttribute('data-sort') === SORT.key;
731
+ a.textContent = active ? (SORT.dir === 1 ? '▲' : '▼') : '';
732
+ th.classList.toggle('sorted', active);
733
+ th.setAttribute('aria-sort', active ? (SORT.dir === 1 ? 'ascending' : 'descending') : 'none');
734
+ }
735
+ }
736
+
737
+ // The Projects view: one row per project with its note breakdown; click to drill in.
738
+ function renderProjects() {
739
+ var byProj = {};
740
+ function ensure(name, surface) {
741
+ var p = byProj[name] || (byProj[name] = { project: name, surfaces: {}, open: 0, progress: 0, resolved: 0, total: 0 });
742
+ if (surface) p.surfaces[surface] = 1;
743
+ return p;
744
+ }
745
+ // Seed from every connected source so projects with 0 notes still appear.
746
+ SOURCES.forEach(function (s) { ensure(s.label, s.surface); });
747
+ ALL.forEach(function (n) {
748
+ var p = ensure(n.project, n.surface);
749
+ p.total++;
750
+ var b = statusBucket(n);
751
+ if (b === 'resolved') p.resolved++; else if (b === 'progress') p.progress++; else p.open++;
752
+ });
753
+ var rows = Object.keys(byProj).map(function (k) { return byProj[k]; })
754
+ .sort(function (a, b) { return (b.open - a.open) || (b.total - a.total); });
755
+ document.getElementById('projectsSummary').textContent = rows.length + ' project' + (rows.length === 1 ? '' : 's') + ' - click one to see its notes';
756
+ document.getElementById('projectsRows').innerHTML = rows.map(function (p) {
757
+ return '<tr class="prow" data-project="' + esc(p.project) + '">' +
758
+ '<td><span class="lab">' + esc(p.project) + '</span></td>' +
759
+ '<td>' + esc(Object.keys(p.surfaces).join(', ')) + '</td>' +
760
+ '<td>' + p.open + '</td>' +
761
+ '<td>' + p.progress + '</td>' +
762
+ '<td>' + p.resolved + '</td>' +
763
+ '<td>' + p.total + '</td></tr>';
764
+ }).join('');
765
+ }
766
+
767
+ function renderTabs() {
768
+ var base = ALL.filter(passNonStatus);
769
+ var counts = { open: 0, progress: 0, resolved: 0, all: base.length };
770
+ base.forEach(function (n) { counts[statusBucket(n)]++; });
771
+ ['open', 'progress', 'resolved', 'all'].forEach(function (k) {
772
+ var c = document.getElementById('tc-' + k);
773
+ if (c) c.textContent = counts[k];
774
+ });
775
+ var prj = document.getElementById('tc-projects');
776
+ if (prj) prj.textContent = uniq(SOURCES.map(function (s) { return s.label; }).concat(ALL.map(function (n) { return n.project; }))).length;
777
+ var src = document.getElementById('tc-sources');
778
+ if (src) src.textContent = SOURCES.length;
779
+ var errs = SOURCES.filter(function (s) { return s.error; }).length;
780
+ var srcTab = document.querySelector('#tabs .tab[data-tab="sources"]');
781
+ if (srcTab) srcTab.classList.toggle('attn', errs > 0);
782
+ var tabs = document.querySelectorAll('#tabs .tab');
783
+ for (var i = 0; i < tabs.length; i++) {
784
+ var on = tabs[i].getAttribute('data-tab') === TAB;
785
+ tabs[i].classList.toggle('active', on);
786
+ tabs[i].setAttribute('aria-pressed', on ? 'true' : 'false');
787
+ }
788
+ }
789
+
790
+ // ---- Write-back (resolve / reopen from the board) ----
791
+ function statusBtn(n) {
792
+ var b = statusBucket(n);
793
+ if (b === 'resolved') return '<button class="rb reopen" data-to="open" type="button">Reopen</button>';
794
+ // Open notes get a Start control (the In-progress bucket/tab existed but nothing
795
+ // could populate it from the board); in-progress notes just get Resolve.
796
+ var start = (b === 'open') ? '<button class="rb start" data-to="progress" type="button">Start</button> ' : '';
797
+ return start + '<button class="rb" data-to="resolved" type="button">Resolve</button>';
798
+ }
799
+ // Low-level: POST a status change for one note; resolves to { ok, d }.
800
+ function postNoteStatus(note, to) {
801
+ return fetch('/api/note-status', {
802
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
803
+ body: JSON.stringify({ key: note.sourceKey, id: note.id, status: to })
804
+ }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
805
+ }
806
+ // POST the new status to the note's source. The Hub routes it to the right
807
+ // adapter (file store / connected store / WP REST) by the note's sourceKey.
808
+ // On a Resolve, show an undo popup (5s) so a mis-click is one click to reverse.
809
+ function setNoteStatus(note, to, btn) {
810
+ if (!note || !note.sourceKey) { toast('No source recorded for this note', true); return; }
811
+ if (btn) btn.disabled = true;
812
+ postNoteStatus(note, to)
813
+ .then(function (res) {
814
+ if (!res.ok || !res.d.ok) { toast((res.d && res.d.error) || 'Could not update note', true); if (btn) btn.disabled = false; return; }
815
+ // Connected (push) sources now hold Hub-side status changes across the site's
816
+ // future pushes (merge-on-ingest), so a resolve here is durable on the board -
817
+ // no revert warning needed. It still isn't written back to the origin site.
818
+ if (to === 'resolved') {
819
+ undoToast('Resolved', note, function () {
820
+ postNoteStatus(note, 'open')
821
+ .then(function (r2) { if (!r2.ok || !r2.d.ok) { toast((r2.d && r2.d.error) || 'Could not reopen note', true); return; } load(); })
822
+ .catch(function () { toast('Network error - note not reopened', true); });
823
+ });
824
+ }
825
+ load(); // re-pull so counts + tabs reflect the change
826
+ })
827
+ .catch(function () { toast('Network error - note not updated', true); if (btn) btn.disabled = false; });
828
+ }
829
+ // Minimal toast - surfaces write-back failures (and could be reused elsewhere).
830
+ function toast(msg, isErr) {
831
+ var t = document.getElementById('rphToast');
832
+ if (!t) { t = document.createElement('div'); t.id = 'rphToast'; document.body.appendChild(t); }
833
+ t.setAttribute('role', isErr ? 'alert' : 'status'); // errors announce assertively to screen readers
834
+ t.textContent = msg;
835
+ t.className = isErr ? 'err show' : 'show';
836
+ clearTimeout(toast._t);
837
+ toast._t = setTimeout(function () { t.className = t.className.replace('show', '').trim(); }, 3200);
838
+ }
839
+ // Bottom-center confirmation with an Undo action + a 5s draining countdown ring, then fades.
840
+ var undoTimer = null;
841
+ function undoToast(label, note, onUndo) {
842
+ var u = document.getElementById('rphUndo');
843
+ if (!u) { u = document.createElement('div'); u.id = 'rphUndo'; u.setAttribute('role', 'status'); document.body.appendChild(u); }
844
+ // Rebuild content each time so the ring animation restarts from full.
845
+ u.innerHTML =
846
+ '<span class="ut-msg"></span>' +
847
+ '<button class="ut-undo" type="button">Undo</button>' +
848
+ '<svg class="ut-ring" viewBox="0 0 24 24" aria-hidden="true">' +
849
+ '<circle class="ut-track" cx="12" cy="12" r="9"></circle>' +
850
+ '<circle class="ut-prog" cx="12" cy="12" r="9"></circle>' +
851
+ '</svg>';
852
+ u.querySelector('.ut-msg').textContent = label + (note && note.project ? ' - ' + note.project : '');
853
+ var undoBtn = u.querySelector('.ut-undo');
854
+ var hide = function () { clearTimeout(undoTimer); undoTimer = null; u.classList.remove('show', 'paused'); document.body.classList.remove('undo-open'); };
855
+ var arm = function () { clearTimeout(undoTimer); undoTimer = setTimeout(function () { hide(); }, 5000); };
856
+ undoBtn.onclick = function () { hide(); if (onUndo) onUndo(); };
857
+ // Keyboard reach: Esc dismisses; while the popup holds focus the countdown pauses so a
858
+ // keyboard user isn't timed out mid-decision, then re-arms with a fresh window on blur.
859
+ u.onkeydown = function (e) { if (e.key === 'Escape') { e.preventDefault(); hide(); } };
860
+ u.onfocusin = function () { u.classList.add('paused'); clearTimeout(undoTimer); undoTimer = null; };
861
+ u.onfocusout = function () { u.classList.remove('paused'); arm(); };
862
+ document.body.classList.add('undo-open');
863
+ u.classList.remove('show'); void u.offsetWidth; u.classList.add('show'); // reflow so the show transition + ring restart
864
+ arm();
865
+ undoBtn.focus();
866
+ }
867
+
868
+ // ---- Note identity (used by the write-back row lookup) ----
869
+ function noteKey(n) { return n.project + '||' + n.id; }
870
+
871
+ // ---- Markdown digest export (the current view -> Obsidian-friendly markdown) ----
872
+ function buildMarkdown() {
873
+ var rows = filtered();
874
+ var tabLabel = { open: 'Open', progress: 'In progress', resolved: 'Resolved', all: 'All' }[TAB] || TAB;
875
+ var date = new Date().toISOString().slice(0, 10);
876
+ var projs = uniq(rows.map(function (n) { return n.project; }));
877
+ var lines = [];
878
+ lines.push('# Red Pen - ' + tabLabel + ' notes (' + date + ')');
879
+ lines.push('');
880
+ lines.push(rows.length + ' note' + (rows.length === 1 ? '' : 's') + ' across ' + projs.length + ' project' + (projs.length === 1 ? '' : 's') + '.');
881
+ lines.push('');
882
+ var byProj = {};
883
+ rows.forEach(function (n) { (byProj[n.project] = byProj[n.project] || []).push(n); });
884
+ Object.keys(byProj).forEach(function (proj) {
885
+ var ns = byProj[proj];
886
+ lines.push('## ' + proj + ' (' + ns.length + ')');
887
+ ns.forEach(function (n) {
888
+ var box = (n.status === 'resolved') ? '- [x]' : '- [ ]';
889
+ var meta = '**' + (n.type || 'note') + (n.priority ? ' / ' + n.priority : '') + '**';
890
+ var body = (n.body || '').replace(/\s+/g, ' ').trim();
891
+ var link = n.url ? ' ([source](' + n.url + '))' : '';
892
+ lines.push(box + ' ' + meta + ' - ' + body + link);
893
+ });
894
+ lines.push('');
895
+ });
896
+ return lines.join('\n').trim() + '\n';
897
+ }
898
+ function openMdExport() {
899
+ document.getElementById('mdText').value = buildMarkdown();
900
+ document.getElementById('mdNote').textContent = '';
901
+ document.getElementById('mdOverlay').classList.add('show');
902
+ }
903
+ function copyMd() {
904
+ var ta = document.getElementById('mdText');
905
+ function ok() { document.getElementById('mdNote').textContent = 'Copied to clipboard.'; }
906
+ if (navigator.clipboard && navigator.clipboard.writeText) {
907
+ navigator.clipboard.writeText(ta.value).then(ok, function () { ta.focus(); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) {} });
908
+ } else { ta.focus(); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) {} }
909
+ }
910
+ function downloadMd() {
911
+ var date = new Date().toISOString().slice(0, 10);
912
+ var blob = new Blob([document.getElementById('mdText').value], { type: 'text/markdown' });
913
+ var a = document.createElement('a');
914
+ a.href = URL.createObjectURL(blob);
915
+ a.download = 'red-pen-' + TAB + '-' + date + '.md';
916
+ document.body.appendChild(a); a.click();
917
+ setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 0);
918
+ document.getElementById('mdNote').textContent = 'Downloaded ' + a.download + '.';
919
+ }
920
+
921
+ var firstLoad = true;
922
+ function load() {
923
+ var refresh = document.getElementById('refreshBtn');
924
+ refresh.disabled = true;
925
+ refresh.textContent = 'Loading...';
926
+ document.getElementById('foot').innerHTML = '<span class="rp-spin"></span> Loading notes...';
927
+ // Preserve the active filter selection across the option rebuild below; on the very
928
+ // first load, seed it from the persisted view state instead so filters survive a reload.
929
+ var keep = (firstLoad && LAST) ? LAST : currentState();
930
+ fetch('/api/notes').then(function (r) { return r.json(); }).then(function (data) {
931
+ ALL = data.notes || [];
932
+ fillSelect('fProject', uniq(ALL.map(function (n) { return n.project; })));
933
+ fillSelect('fSurface', uniq(ALL.map(function (n) { return n.surface; })));
934
+ fillSelect('fType', uniq(ALL.map(function (n) { return n.type; })));
935
+ fillSelect('fPriority', uniq(ALL.map(function (n) { return n.priority; })));
936
+ FILTER_IDS.forEach(function (id) { fset(id, keep[id]); });
937
+ firstLoad = false;
938
+ SOURCES = data.sources || [];
939
+ renderLicense(data.license || null);
940
+ renderSources(SOURCES);
941
+ renderProjects();
942
+ renderTabs();
943
+ render();
944
+ document.getElementById('foot').textContent =
945
+ 'Aggregated ' + ALL.length + ' notes from ' + data.sources.length + ' sources - ' +
946
+ (data.generatedAt || '');
947
+ }).catch(function (e) {
948
+ document.getElementById('foot').textContent = 'Error loading notes: ' + e;
949
+ toast('Could not load notes: ' + e, true);
950
+ }).then(function () {
951
+ refresh.disabled = false;
952
+ refresh.textContent = 'Refresh';
953
+ });
954
+ }
955
+
956
+ // PRO license: the same offline-signed key that unlocks the WordPress plugin lifts
957
+ // the free 3-project cap on the board. Verified server-side (local, no phone-home).
958
+ var LIC = null;
959
+ function renderLicense(lic) {
960
+ LIC = lic;
961
+ // At the free-tier project cap, hide the add / connect controls and their hint -
962
+ // adding more projects does nothing until you unlock. Restored once licensed.
963
+ var atCap = !!(lic && !lic.licensed && lic.totalProjects >= lic.projectCap);
964
+ ['addBtn', 'connectBtn', 'sourcesHint'].forEach(function (id) {
965
+ var el = document.getElementById(id); if (el) { el.style.display = atCap ? 'none' : ''; }
966
+ });
967
+ var banner = document.getElementById('licenseBanner');
968
+ if (lic && lic.capped) {
969
+ banner.style.display = '';
970
+ banner.innerHTML = '<span>Free tier - the board is showing <b>' + lic.projectCap + ' of ' + lic.totalProjects +
971
+ '</b> projects. Unlock unlimited with a Red Pen PRO key.</span><span class="spacer"></span>' +
972
+ '<button type="button" id="licUnlockBtn">Unlock unlimited</button>';
973
+ document.getElementById('licUnlockBtn').onclick = function () {
974
+ TAB = 'sources'; renderTabs(); render(); persistLast();
975
+ var el = document.getElementById('licKeyInput'); if (el) { el.focus(); }
976
+ };
977
+ } else {
978
+ banner.style.display = 'none';
979
+ }
980
+ var panel = document.getElementById('licensePanel');
981
+ if (!panel) return;
982
+ if (lic && lic.licensed) {
983
+ panel.innerHTML = '<h3>Red Pen PRO</h3><p class="ok">PRO unlocked - licensed to ' + esc(lic.licensedTo || '') +
984
+ '. Unlimited projects.</p><div class="row" style="margin-top:8px"><button type="button" id="licRemoveBtn">Remove key</button></div>';
985
+ document.getElementById('licRemoveBtn').onclick = function () { saveLicense(''); };
986
+ } else {
987
+ var cap = lic ? lic.projectCap : 3;
988
+ panel.innerHTML = '<h3>Red Pen PRO</h3>' +
989
+ '<p class="hint" style="margin:0 0 8px">Unlicensed - the board shows up to <b>' + cap + '</b> projects. Paste your PRO license key to unlock unlimited. Verified locally - no account, no phone-home.</p>' +
990
+ '<div class="row"><input type="text" id="licKeyInput" placeholder="paste your license key" spellcheck="false" autocomplete="off"><button type="button" id="licSaveBtn">Unlock</button></div>' +
991
+ '<p class="hint" id="licMsg"></p>';
992
+ document.getElementById('licSaveBtn').onclick = function () { saveLicense(document.getElementById('licKeyInput').value); };
993
+ }
994
+ }
995
+ function saveLicense(key) {
996
+ fetch('/api/license', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key }) })
997
+ .then(function (r) { return r.json(); })
998
+ .then(function (res) {
999
+ if (key && !res.licensed) {
1000
+ var m = document.getElementById('licMsg'); if (m) { m.textContent = 'That key did not validate - check you pasted the whole key.'; }
1001
+ toast('License key did not validate.', true);
1002
+ return;
1003
+ }
1004
+ toast(res.licensed ? ('PRO unlocked - licensed to ' + res.email) : 'License removed.', false);
1005
+ load();
1006
+ })
1007
+ .catch(function (e) { toast('Could not save license: ' + e, true); });
1008
+ }
1009
+
1010
+ ['q', 'fProject', 'fSurface', 'fType', 'fPriority'].forEach(function (id) {
1011
+ // Other filters change the per-tab counts, so refresh the tabs too.
1012
+ document.getElementById(id).addEventListener('input', function () { renderTabs(); render(); persistLast(); });
1013
+ });
1014
+ document.getElementById('tabs').addEventListener('click', function (e) {
1015
+ var t = e.target.closest('.tab'); if (!t) return;
1016
+ TAB = t.getAttribute('data-tab');
1017
+ renderTabs();
1018
+ render();
1019
+ persistLast();
1020
+ });
1021
+ document.getElementById('exportMdBtn').addEventListener('click', openMdExport);
1022
+ var mdOverlay = document.getElementById('mdOverlay');
1023
+ function closeMd() { mdOverlay.classList.remove('show'); }
1024
+ document.getElementById('mdClose').addEventListener('click', closeMd);
1025
+ document.getElementById('mdCancel').addEventListener('click', closeMd);
1026
+ mdOverlay.addEventListener('click', function (e) { if (e.target === mdOverlay) closeMd(); });
1027
+ document.getElementById('mdCopy').addEventListener('click', copyMd);
1028
+ document.getElementById('mdDownload').addEventListener('click', downloadMd);
1029
+ document.getElementById('sourcesRows').addEventListener('click', function (e) {
1030
+ var b = e.target.closest('button[data-act]'); if (!b) return;
1031
+ var tr = b.closest('tr[data-key]'); if (!tr) return;
1032
+ var act = b.getAttribute('data-act'), key = tr.getAttribute('data-key'), kind = tr.getAttribute('data-kind'), project = tr.getAttribute('data-project');
1033
+ if (act === 'recheck') {
1034
+ if (kind === 'push') {
1035
+ fetch('/api/refresh-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key }) }).then(function () { load(); });
1036
+ } else { load(); }
1037
+ }
1038
+ else if (act === 'path') { openRepoModal(project); }
1039
+ else if (act === 'edit') { openCredModal(tr, project); }
1040
+ else if (act === 'remove') {
1041
+ var url = kind === 'push' ? '/api/connected/remove' : '/api/sources/remove';
1042
+ fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key }) }).then(function () { load(); });
1043
+ }
1044
+ });
1045
+ document.getElementById('projectsRows').addEventListener('click', function (e) {
1046
+ var tr = e.target.closest('tr.prow'); if (!tr) return;
1047
+ document.getElementById('fProject').value = tr.getAttribute('data-project');
1048
+ TAB = 'open';
1049
+ renderTabs(); render();
1050
+ });
1051
+ var srcModalEl = document.getElementById('srcModal');
1052
+ document.getElementById('srcModalClose').addEventListener('click', closeSrcModal);
1053
+ document.getElementById('srcModalCancel').addEventListener('click', closeSrcModal);
1054
+ srcModalEl.addEventListener('click', function (e) { if (e.target === srcModalEl) closeSrcModal(); });
1055
+ document.getElementById('srcModalSave').addEventListener('click', function () { if (srcSaveFn) srcSaveFn(); });
1056
+ // Row clicks: the only interactive control on a note row is the Resolve/Reopen button.
1057
+ document.getElementById('rows').addEventListener('click', function (e) {
1058
+ var rb = e.target.closest('.rb');
1059
+ if (!rb) return;
1060
+ var rtr = e.target.closest('tr[data-key]'); if (!rtr) return;
1061
+ var rkey = rtr.getAttribute('data-key'), rnote = null;
1062
+ for (var j = 0; j < ALL.length; j++) { if (noteKey(ALL[j]) === rkey) { rnote = ALL[j]; break; } }
1063
+ setNoteStatus(rnote, rb.getAttribute('data-to'), rb);
1064
+ });
1065
+ document.querySelectorAll('th[data-sort]').forEach(function (th) {
1066
+ th.tabIndex = 0;
1067
+ th.setAttribute('role', 'button');
1068
+ function doSort() {
1069
+ var k = th.getAttribute('data-sort');
1070
+ if (SORT.key === k) SORT.dir *= -1; else { SORT.key = k; SORT.dir = 1; }
1071
+ render();
1072
+ persistLast();
1073
+ }
1074
+ th.addEventListener('click', doSort);
1075
+ th.addEventListener('keydown', function (e) {
1076
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); doSort(); }
1077
+ });
1078
+ });
1079
+ document.getElementById('refreshBtn').addEventListener('click', load);
1080
+ document.getElementById('exportCsvBtn').addEventListener('click', downloadCsv);
1081
+
1082
+ // ---- Theme toggle (announces state via aria-checked, persists in localStorage) ----
1083
+ var darkBtn = document.getElementById('darkBtn');
1084
+ function setDark(on) {
1085
+ document.body.classList.toggle('dark', on);
1086
+ darkBtn.setAttribute('aria-checked', on ? 'true' : 'false');
1087
+ try { localStorage.setItem('rphub.dark', on ? '1' : '0'); } catch (e) {}
1088
+ }
1089
+ darkBtn.addEventListener('click', function () { setDark(!document.body.classList.contains('dark')); });
1090
+ try { setDark(localStorage.getItem('rphub.dark') === '1'); } catch (e) {}
1091
+
1092
+ // ---- Saved views ----
1093
+ rebuildViewSel('');
1094
+ document.getElementById('viewSel').addEventListener('change', function () {
1095
+ var name = this.value;
1096
+ if (name && VIEWS[name]) { applyState(VIEWS[name]); persistLast(); renderTabs(); render(); }
1097
+ document.getElementById('viewDel').style.display = name ? '' : 'none';
1098
+ });
1099
+ document.getElementById('viewSave').addEventListener('click', function () {
1100
+ var name = (window.prompt('Save current filters as:') || '').trim();
1101
+ if (!name) return;
1102
+ VIEWS[name] = currentState();
1103
+ writeJson('rphub.views', VIEWS);
1104
+ rebuildViewSel(name);
1105
+ });
1106
+ document.getElementById('viewDel').addEventListener('click', function () {
1107
+ var name = document.getElementById('viewSel').value;
1108
+ if (!name) return;
1109
+ delete VIEWS[name];
1110
+ writeJson('rphub.views', VIEWS);
1111
+ rebuildViewSel('');
1112
+ });
1113
+
1114
+ // ---- Add Projects modal ----
1115
+ var CANDS = [];
1116
+ var overlay = document.getElementById('overlay');
1117
+
1118
+ function openModal() {
1119
+ overlay.classList.add('show');
1120
+ document.getElementById('scanResults').innerHTML = '';
1121
+ document.getElementById('modalNote').textContent = '';
1122
+ CANDS = [];
1123
+ updateAddBtn();
1124
+ fetch('/api/sources').then(function (r) { return r.json(); }).then(function (d) {
1125
+ var root = (d.scanRoots && d.scanRoots[0]) || '';
1126
+ document.getElementById('scanRoot').value = root;
1127
+ renderCurrent(d.sources || []);
1128
+ });
1129
+ }
1130
+ function closeModal() { overlay.classList.remove('show'); }
1131
+
1132
+ function renderCurrent(sources) {
1133
+ var box = document.getElementById('currentSources');
1134
+ if (!sources.length) { box.innerHTML = '<p class="hint">No sources yet - scan a directory and add some.</p>'; return; }
1135
+ box.innerHTML = sources.map(function (s) {
1136
+ return '<div class="srow"><span class="badge" style="background:' +
1137
+ (s.surface === 'wordpress' ? '#21759b' : s.surface === 'express' ? '#1a7f37' : '#6d28d9') +
1138
+ '">' + esc(s.surface) + '</span><span>' + esc(s.label) + '</span>' +
1139
+ '<button class="rm" data-key="' + esc(s.key) + '" type="button">remove</button></div>';
1140
+ }).join('');
1141
+ box.querySelectorAll('.rm').forEach(function (b) {
1142
+ b.addEventListener('click', function () {
1143
+ fetch('/api/sources/remove', {
1144
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1145
+ body: JSON.stringify({ key: b.getAttribute('data-key') })
1146
+ }).then(function (r) { return r.json(); }).then(function (d) {
1147
+ renderCurrent(d.sources.map(function (s) { return { key: s.key, label: s.label, surface: s.type === 'wp-rest' ? 'wordpress' : 'file' }; }));
1148
+ load();
1149
+ });
1150
+ });
1151
+ });
1152
+ }
1153
+
1154
+ function doScan() {
1155
+ var root = document.getElementById('scanRoot').value.trim();
1156
+ var box = document.getElementById('scanResults');
1157
+ var scanBtn = document.getElementById('scanBtn');
1158
+ scanBtn.disabled = true; scanBtn.textContent = 'Scanning...';
1159
+ box.innerHTML = '<p class="hint"><span class="rp-spin"></span> Detecting Red Pen projects under ' + esc(root) + ' ...</p>';
1160
+ fetch('/api/scan?root=' + encodeURIComponent(root)).then(function (r) { return r.json(); }).then(function (d) {
1161
+ CANDS = d.candidates || [];
1162
+ if (d.error) { box.innerHTML = '<p class="hint">Error: ' + esc(d.error) + '</p>'; return; }
1163
+ if (!CANDS.length) { box.innerHTML = '<p class="hint">No Red Pen installs found under that directory.</p>'; return; }
1164
+ box.innerHTML = '<p class="hint">Found ' + CANDS.length + ' install(s). Uncheck any you do not want, then Add selected.</p>' +
1165
+ CANDS.map(function (c, i) {
1166
+ var checked = c.already ? '' : 'checked';
1167
+ var dim = c.already ? ' dim' : '';
1168
+ var meta = c.type === 'wp-rest'
1169
+ ? 'WordPress' + (c.already ? ' - already added' : '')
1170
+ : esc(c.surface) + ' - ' + c.count + ' note' + (c.count === 1 ? '' : 's') + (c.already ? ' - already added' : '');
1171
+ var sub = c.type === 'wp-rest' ? esc(c.install || c.url) : esc(c.path);
1172
+ var auth = c.type === 'wp-rest'
1173
+ ? '<div class="auth">' +
1174
+ '<input id="wpurl_' + i + '" placeholder="Site URL" value="' + esc(c.url || '') + '">' +
1175
+ '<input id="wpuser_' + i + '" placeholder="WP username">' +
1176
+ '<input id="wppw_' + i + '" placeholder="Application password">' +
1177
+ '</div>'
1178
+ : '';
1179
+ return '<div class="cand' + dim + '"><div class="top">' +
1180
+ '<input type="checkbox" class="ck" id="cand_' + i + '" data-i="' + i + '" ' + (c.already ? 'disabled' : checked) + '>' +
1181
+ '<div><div class="lab">' + esc(c.label) + ' <span class="sub">(' + meta + ')</span></div>' +
1182
+ '<div class="sub">' + sub + '</div></div></div>' + auth + '</div>';
1183
+ }).join('');
1184
+ box.querySelectorAll('.ck').forEach(function (ck) { ck.addEventListener('change', updateAddBtn); });
1185
+ updateAddBtn();
1186
+ }).catch(function (e) { box.innerHTML = '<p class="hint">Scan failed: ' + esc(e) + '</p>'; })
1187
+ .then(function () { scanBtn.disabled = false; scanBtn.textContent = 'Scan'; });
1188
+ }
1189
+
1190
+ function selectedSources() {
1191
+ var out = [];
1192
+ document.querySelectorAll('.ck:checked').forEach(function (ck) {
1193
+ var c = CANDS[+ck.getAttribute('data-i')];
1194
+ if (!c) return;
1195
+ if (c.type === 'wp-rest') {
1196
+ var i = ck.getAttribute('data-i');
1197
+ out.push({
1198
+ type: 'wp-rest', label: c.label,
1199
+ url: (document.getElementById('wpurl_' + i).value || '').trim(),
1200
+ user: (document.getElementById('wpuser_' + i).value || '').trim(),
1201
+ appPassword: (document.getElementById('wppw_' + i).value || '').trim()
1202
+ });
1203
+ } else {
1204
+ out.push({ type: 'file', label: c.label, path: c.path, surface: c.surface });
1205
+ }
1206
+ });
1207
+ return out;
1208
+ }
1209
+
1210
+ function updateAddBtn() {
1211
+ var n = document.querySelectorAll('.ck:checked').length;
1212
+ var btn = document.getElementById('addSelectedBtn');
1213
+ btn.disabled = n === 0;
1214
+ btn.textContent = n ? 'Add ' + n + ' selected' : 'Add selected';
1215
+ }
1216
+
1217
+ function addSelected() {
1218
+ var add = selectedSources();
1219
+ if (!add.length) return;
1220
+ fetch('/api/sources', {
1221
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1222
+ body: JSON.stringify({ add: add })
1223
+ }).then(function (r) { return r.json(); }).then(function (d) {
1224
+ document.getElementById('modalNote').textContent = 'Added ' + d.added + '.';
1225
+ load();
1226
+ closeModal();
1227
+ });
1228
+ }
1229
+
1230
+ document.getElementById('addBtn').addEventListener('click', openModal);
1231
+ document.getElementById('modalClose').addEventListener('click', closeModal);
1232
+ document.getElementById('modalCancel').addEventListener('click', closeModal);
1233
+ document.getElementById('scanBtn').addEventListener('click', doScan);
1234
+ document.getElementById('addSelectedBtn').addEventListener('click', addSelected);
1235
+ overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
1236
+
1237
+ // ---- Connect a Site modal ----
1238
+ var connectOverlay = document.getElementById('connectOverlay');
1239
+ function openConnect() {
1240
+ fetch('/api/connect-info').then(function (r) { return r.json(); }).then(function (d) {
1241
+ document.getElementById('connUrl').value = d.url;
1242
+ document.getElementById('connToken').value = d.token;
1243
+ document.getElementById('connectNote').textContent = '';
1244
+ connectOverlay.classList.add('show');
1245
+ });
1246
+ }
1247
+ function closeConnect() { connectOverlay.classList.remove('show'); }
1248
+ document.getElementById('connectBtn').addEventListener('click', openConnect);
1249
+ document.getElementById('connectClose').addEventListener('click', closeConnect);
1250
+ document.getElementById('connectCancel').addEventListener('click', closeConnect);
1251
+ connectOverlay.addEventListener('click', function (e) { if (e.target === connectOverlay) closeConnect(); });
1252
+ document.querySelectorAll('[data-copy]').forEach(function (b) {
1253
+ b.addEventListener('click', function () {
1254
+ var el = document.getElementById(b.getAttribute('data-copy'));
1255
+ el.select();
1256
+ try { navigator.clipboard.writeText(el.value); } catch (e) { try { document.execCommand('copy'); } catch (e2) {} }
1257
+ document.getElementById('connectNote').textContent = 'Copied.';
1258
+ });
1259
+ });
1260
+
1261
+ // ---- Modal focus management (shared across all four overlays) ----
1262
+ var OVERLAY_IDS = ['mdOverlay', 'srcModal', 'overlay', 'connectOverlay'];
1263
+ function openOverlay() {
1264
+ for (var i = 0; i < OVERLAY_IDS.length; i++) {
1265
+ var el = document.getElementById(OVERLAY_IDS[i]);
1266
+ if (el && el.classList.contains('show')) return el;
1267
+ }
1268
+ return null;
1269
+ }
1270
+ function focusables(root) {
1271
+ return Array.prototype.slice.call(root.querySelectorAll(
1272
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
1273
+ )).filter(function (el) { return el.offsetParent !== null; });
1274
+ }
1275
+ // Focus the first control when a modal opens (each open path just toggles the 'show'
1276
+ // class, so observe that rather than wiring every opener).
1277
+ OVERLAY_IDS.forEach(function (id) {
1278
+ var el = document.getElementById(id);
1279
+ if (!el) return;
1280
+ new MutationObserver(function () {
1281
+ if (!el.classList.contains('show')) return;
1282
+ var f = focusables(el.querySelector('.modal') || el);
1283
+ if (f.length) f[0].focus();
1284
+ }).observe(el, { attributes: true, attributeFilter: ['class'] });
1285
+ });
1286
+ // Esc closes the open overlay; Tab is trapped within it.
1287
+ document.addEventListener('keydown', function (e) {
1288
+ var ov = openOverlay();
1289
+ if (e.key === 'Escape') {
1290
+ if (ov) ov.classList.remove('show');
1291
+ return;
1292
+ }
1293
+ if (e.key !== 'Tab' || !ov) return;
1294
+ var f = focusables(ov.querySelector('.modal') || ov);
1295
+ if (!f.length) return;
1296
+ var first = f[0], last = f[f.length - 1];
1297
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
1298
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
1299
+ });
1300
+
1301
+ load();
1302
+ })();
1303
+ </script>
1304
+ </body>
1305
+ </html>