@bvdm/delano 0.2.7 → 0.2.9
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/.delano/viewer/public/app.jsx +202 -36
- package/.delano/viewer/public/delano-logo.svg +60 -0
- package/.delano/viewer/public/delano-mark.svg +53 -3
- package/.delano/viewer/public/favicon.png +0 -0
- package/.delano/viewer/public/index.html +3 -0
- package/.delano/viewer/public/styles.css +116 -13
- package/.delano/viewer/server.js +6 -0
- package/README.md +72 -9
- package/assets/install-manifest.json +3 -0
- package/assets/payload/.delano/viewer/public/app.jsx +2478 -0
- package/assets/payload/.delano/viewer/public/delano-logo.svg +60 -0
- package/assets/payload/.delano/viewer/public/favicon.png +0 -0
- package/assets/payload/.delano/viewer/public/index.html +3 -0
- package/assets/payload/.delano/viewer/public/styles.css +116 -13
- package/assets/payload/.delano/viewer/server.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2478 @@
|
|
|
1
|
+
const { useState, useEffect, useMemo, useCallback } = React;
|
|
2
|
+
|
|
3
|
+
/* ================================================================
|
|
4
|
+
Icons — hairline 1.4px stroke, 24×24 viewBox
|
|
5
|
+
================================================================ */
|
|
6
|
+
const Icon = ({ d, size = 16, fill = "none", stroke = "currentColor" }) => (
|
|
7
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke}
|
|
8
|
+
strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
9
|
+
{typeof d === "string" ? <path d={d} /> : d}
|
|
10
|
+
</svg>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const I = {
|
|
14
|
+
home: <><path d="M3 11.5 12 4l9 7.5"/><path d="M5 10v10h14V10"/></>,
|
|
15
|
+
list: <><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></>,
|
|
16
|
+
block: <><circle cx="12" cy="12" r="8.5"/><path d="M6 6l12 12"/></>,
|
|
17
|
+
trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>,
|
|
18
|
+
check: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M8 12.5l3 3 5-6"/></>,
|
|
19
|
+
checkMark: <path d="M5 12.5l4 4 10-11"/>,
|
|
20
|
+
copy: <><rect x="8" y="4" width="11" height="14" rx="1.5"/><path d="M16 18v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2"/></>,
|
|
21
|
+
warn: <><path d="M12 3.5 21 19H3z"/><path d="M12 10v4.5"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/></>,
|
|
22
|
+
doc: <><path d="M6 3.5h8l4 4V20.5H6z"/><path d="M14 3.5V8h4"/></>,
|
|
23
|
+
plan: <><path d="M4 5.5h16"/><path d="M4 12h16"/><path d="M4 18.5h10"/></>,
|
|
24
|
+
scale: <><path d="M12 4v16"/><path d="M5 8h14"/><path d="M5 8 3 13h4z"/><path d="M19 8l-2 5h4z"/></>,
|
|
25
|
+
clock: <><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></>,
|
|
26
|
+
grid: <><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></>,
|
|
27
|
+
task: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M7 8.5h10"/><path d="M7 13h7"/></>,
|
|
28
|
+
folder: <><path d="M3.5 6.5a2 2 0 0 1 2-2h3.5l2 2h7.5a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/></>,
|
|
29
|
+
gear: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5h.1a1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
|
|
30
|
+
code: <><path d="m9 8-5 4 5 4"/><path d="m15 8 5 4-5 4"/></>,
|
|
31
|
+
folderOpen:<><path d="M3 7a2 2 0 0 1 2-2h3l2 2h7a2 2 0 0 1 2 2v1H3z"/><path d="M3 10h18l-2 8a2 2 0 0 1-2 1.5H5a2 2 0 0 1-2-1.5z"/></>,
|
|
32
|
+
user: <><circle cx="12" cy="8" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/></>,
|
|
33
|
+
chevR: <path d="m9 6 6 6-6 6"/>,
|
|
34
|
+
chevD: <path d="m6 9 6 6 6-6"/>,
|
|
35
|
+
chevU: <path d="m6 15 6-6 6 6"/>,
|
|
36
|
+
arrowL: <><path d="M19 12H5"/><path d="m12 5-7 7 7 7"/></>,
|
|
37
|
+
lock: <><rect x="4.5" y="10.5" width="15" height="10" rx="1.5"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></>,
|
|
38
|
+
search: <><circle cx="11" cy="11" r="6"/><path d="m20 20-4.3-4.3"/></>,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* ================================================================
|
|
42
|
+
Status utilities
|
|
43
|
+
================================================================ */
|
|
44
|
+
const STATUS_TONE = {
|
|
45
|
+
"Planned": { dot: "var(--ink-40)" },
|
|
46
|
+
"In Progress": { dot: "var(--accent)" },
|
|
47
|
+
"Complete": { dot: "var(--ok)" },
|
|
48
|
+
"Blocked": { dot: "var(--warn)" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const NAV_STATE_KEY = "delano.viewer.navigation.v1";
|
|
52
|
+
const NAV_STATE_VERSION = 1;
|
|
53
|
+
const DEFAULT_WORKSPACE_ROUTE = "workspace-projects";
|
|
54
|
+
const WORKSPACE_PAGE_SIZE = 10;
|
|
55
|
+
|
|
56
|
+
function statusLabel(raw) {
|
|
57
|
+
if (!raw) return "Planned";
|
|
58
|
+
const s = String(raw).toLowerCase().replace(/[-_]+/g, " ").trim();
|
|
59
|
+
if (s.includes("progress") || s === "active") return "In Progress";
|
|
60
|
+
if (s === "blocked") return "Blocked";
|
|
61
|
+
if (["complete", "done", "approved", "closed"].includes(s)) return "Complete";
|
|
62
|
+
if (["planned", "draft", "ready"].includes(s)) return "Planned";
|
|
63
|
+
return "Planned";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const StatusChip = ({ children }) => {
|
|
67
|
+
const label = statusLabel(children);
|
|
68
|
+
const tone = STATUS_TONE[label] || STATUS_TONE["Planned"];
|
|
69
|
+
return (
|
|
70
|
+
<span className="chip">
|
|
71
|
+
<span className="chip-dot" style={{ background: tone.dot }} />
|
|
72
|
+
{label}
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/* ================================================================
|
|
78
|
+
HTML / text utilities
|
|
79
|
+
================================================================ */
|
|
80
|
+
const escapeHtml = (s) =>
|
|
81
|
+
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
82
|
+
|
|
83
|
+
const titleCase = (s) =>
|
|
84
|
+
String(s || "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
85
|
+
|
|
86
|
+
const normalizeCopyValue = (value) => {
|
|
87
|
+
if (value == null) return "";
|
|
88
|
+
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).join(", ");
|
|
89
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
90
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
91
|
+
return String(value).trim();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isCopyableMetaKey = (key) => {
|
|
95
|
+
const normalized = String(key || "").toLowerCase();
|
|
96
|
+
return (
|
|
97
|
+
normalized === "id" ||
|
|
98
|
+
normalized === "slug" ||
|
|
99
|
+
normalized === "workstream" ||
|
|
100
|
+
normalized === "depends_on" ||
|
|
101
|
+
normalized === "conflicts_with" ||
|
|
102
|
+
/(?:^|_)(?:id|ids)$/.test(normalized)
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const copyLabelFromMetaKey = (key, role) => {
|
|
107
|
+
const normalized = String(key || "").toLowerCase();
|
|
108
|
+
if (normalized === "id" && role) return `${titleCase(role)} ID`;
|
|
109
|
+
if (normalized === "workstream") return "workstream ID";
|
|
110
|
+
if (normalized === "depends_on") return "dependency IDs";
|
|
111
|
+
if (normalized === "conflicts_with") return "conflict IDs";
|
|
112
|
+
return normalized.replace(/_/g, " ") || "value";
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
async function copyTextToClipboard(text) {
|
|
116
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
117
|
+
try {
|
|
118
|
+
await navigator.clipboard.writeText(text);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (_) {
|
|
121
|
+
/* fall through */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const textArea = document.createElement("textarea");
|
|
126
|
+
textArea.value = text;
|
|
127
|
+
textArea.setAttribute("readonly", "");
|
|
128
|
+
textArea.style.position = "fixed";
|
|
129
|
+
textArea.style.top = "-1000px";
|
|
130
|
+
textArea.style.opacity = "0";
|
|
131
|
+
document.body.appendChild(textArea);
|
|
132
|
+
textArea.select();
|
|
133
|
+
let ok = false;
|
|
134
|
+
try {
|
|
135
|
+
ok = document.execCommand("copy");
|
|
136
|
+
} catch (_) {
|
|
137
|
+
ok = false;
|
|
138
|
+
}
|
|
139
|
+
document.body.removeChild(textArea);
|
|
140
|
+
return ok;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function announceCopy(label) {
|
|
144
|
+
const live = document.getElementById("copy-live");
|
|
145
|
+
if (!live) return;
|
|
146
|
+
live.textContent = "";
|
|
147
|
+
window.setTimeout(() => {
|
|
148
|
+
live.textContent = `Copied ${label || "value"}`;
|
|
149
|
+
}, 10);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const formatShortDateTime = (value) => {
|
|
153
|
+
if (!value) return "";
|
|
154
|
+
const date = new Date(value);
|
|
155
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
156
|
+
return date.toLocaleString("en-US", {
|
|
157
|
+
month: "short",
|
|
158
|
+
day: "numeric",
|
|
159
|
+
hour: "2-digit",
|
|
160
|
+
minute: "2-digit",
|
|
161
|
+
hour12: false,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const stripRepeatedTitle = (title, text) => {
|
|
166
|
+
const source = String(text || "").trim();
|
|
167
|
+
const heading = String(title || "").trim();
|
|
168
|
+
if (!source || !heading) return source;
|
|
169
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
170
|
+
return source
|
|
171
|
+
.replace(new RegExp(`^#{1,6}\\s+${escaped}\\s*`, "i"), "")
|
|
172
|
+
.replace(new RegExp(`^${escaped}\\s+`, "i"), "")
|
|
173
|
+
.trim();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/* ================================================================
|
|
177
|
+
Markdown rendering (ported from original Delano viewer)
|
|
178
|
+
================================================================ */
|
|
179
|
+
function inlineMd(text) {
|
|
180
|
+
let s = escapeHtml(text);
|
|
181
|
+
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
182
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
183
|
+
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, "$1<em>$2</em>");
|
|
184
|
+
s = s.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink">$1</span>');
|
|
185
|
+
s = s.replace(
|
|
186
|
+
/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\.\.?\/|\/)[^)]+)\)/g,
|
|
187
|
+
'<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'
|
|
188
|
+
);
|
|
189
|
+
return s;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isTableSeparator(line) {
|
|
193
|
+
return line ? /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line) : false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isBlockStart(line, nextLine) {
|
|
197
|
+
if (!line) return false;
|
|
198
|
+
if (/^\s*```/.test(line)) return true;
|
|
199
|
+
if (/^#{1,6}\s+/.test(line)) return true;
|
|
200
|
+
if (/^\s*-{3,}\s*$/.test(line)) return true;
|
|
201
|
+
if (/^\s*>/.test(line)) return true;
|
|
202
|
+
if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) return true;
|
|
203
|
+
if (line.includes("|") && nextLine && isTableSeparator(nextLine)) return true;
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderCodeBlock(text, lang) {
|
|
208
|
+
const langBadge = lang ? `<span class="code-lang">${escapeHtml(lang)}</span>` : "";
|
|
209
|
+
const padClass = lang ? " has-lang" : "";
|
|
210
|
+
return `<pre class="code${padClass}">${langBadge}<code>${escapeHtml(text)}</code></pre>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderTable(tableLines) {
|
|
214
|
+
const parseRow = (line) => {
|
|
215
|
+
let s = line.trim();
|
|
216
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
217
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
218
|
+
return s.split("|").map((c) => c.trim());
|
|
219
|
+
};
|
|
220
|
+
const aligns = parseRow(tableLines[1]).map((s) => {
|
|
221
|
+
if (/^:-+:$/.test(s)) return "center";
|
|
222
|
+
if (/^-+:$/.test(s)) return "right";
|
|
223
|
+
if (/^:-+$/.test(s)) return "left";
|
|
224
|
+
return null;
|
|
225
|
+
});
|
|
226
|
+
const headers = parseRow(tableLines[0]);
|
|
227
|
+
const headerHtml =
|
|
228
|
+
"<thead><tr>" +
|
|
229
|
+
headers.map((h, j) => {
|
|
230
|
+
const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : "";
|
|
231
|
+
return `<th${a}>${inlineMd(h)}</th>`;
|
|
232
|
+
}).join("") +
|
|
233
|
+
"</tr></thead>";
|
|
234
|
+
const rowsHtml = tableLines
|
|
235
|
+
.slice(2)
|
|
236
|
+
.map((line) => {
|
|
237
|
+
const cells = parseRow(line);
|
|
238
|
+
return (
|
|
239
|
+
"<tr>" +
|
|
240
|
+
cells.map((c, j) => {
|
|
241
|
+
const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : "";
|
|
242
|
+
return `<td${a}>${inlineMd(c)}</td>`;
|
|
243
|
+
}).join("") +
|
|
244
|
+
"</tr>"
|
|
245
|
+
);
|
|
246
|
+
})
|
|
247
|
+
.join("");
|
|
248
|
+
return `<div class="table-wrap"><table>${headerHtml}<tbody>${rowsHtml}</tbody></table></div>`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseList(lines, start, baseIndent) {
|
|
252
|
+
const items = [];
|
|
253
|
+
let i = start;
|
|
254
|
+
let listType = null;
|
|
255
|
+
let isTaskList = false;
|
|
256
|
+
|
|
257
|
+
while (i < lines.length) {
|
|
258
|
+
const line = lines[i];
|
|
259
|
+
const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
260
|
+
if (!m) break;
|
|
261
|
+
const indent = m[1].length;
|
|
262
|
+
if (indent !== baseIndent) break;
|
|
263
|
+
const isOrdered = /^\d+\./.test(m[2]);
|
|
264
|
+
if (listType === null) listType = isOrdered ? "ol" : "ul";
|
|
265
|
+
else if ((listType === "ol") !== isOrdered) break;
|
|
266
|
+
|
|
267
|
+
const content = m[3];
|
|
268
|
+
const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/);
|
|
269
|
+
let itemHtml;
|
|
270
|
+
const itemClasses = [];
|
|
271
|
+
if (taskMatch) {
|
|
272
|
+
isTaskList = true;
|
|
273
|
+
const checked = taskMatch[1].toLowerCase() === "x";
|
|
274
|
+
itemClasses.push("task-item");
|
|
275
|
+
if (checked) itemClasses.push("checked");
|
|
276
|
+
const checkSvg = checked
|
|
277
|
+
? '<svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,7 6,10 11,4"/></svg>'
|
|
278
|
+
: "";
|
|
279
|
+
itemHtml = `<span class="task-marker">${checkSvg}</span><span class="task-text">${inlineMd(taskMatch[2])}</span>`;
|
|
280
|
+
} else {
|
|
281
|
+
itemHtml = inlineMd(content);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
i++;
|
|
285
|
+
let nestedHtml = "";
|
|
286
|
+
while (i < lines.length) {
|
|
287
|
+
const nextLine = lines[i];
|
|
288
|
+
if (!nextLine.trim()) {
|
|
289
|
+
if (i + 1 < lines.length) {
|
|
290
|
+
const peek = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/);
|
|
291
|
+
if (peek && peek[1].length > baseIndent) { i++; continue; }
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
const nextMatch = nextLine.match(/^(\s*)([-*+]|\d+\.)\s+/);
|
|
296
|
+
if (nextMatch && nextMatch[1].length > baseIndent) {
|
|
297
|
+
const nested = parseList(lines, i, nextMatch[1].length);
|
|
298
|
+
nestedHtml += nested.html;
|
|
299
|
+
i = nested.next;
|
|
300
|
+
} else {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const cls = itemClasses.length ? ` class="${itemClasses.join(" ")}"` : "";
|
|
306
|
+
items.push(`<li${cls}>${itemHtml}${nestedHtml}</li>`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const tag = listType || "ul";
|
|
310
|
+
const cls = isTaskList ? ' class="task-list"' : "";
|
|
311
|
+
return { html: `<${tag}${cls}>${items.join("")}</${tag}>`, next: i };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseBlocks(lines) {
|
|
315
|
+
const out = [];
|
|
316
|
+
let i = 0;
|
|
317
|
+
while (i < lines.length) {
|
|
318
|
+
const line = lines[i];
|
|
319
|
+
|
|
320
|
+
const fence = line.match(/^\s*```(.*)$/);
|
|
321
|
+
if (fence) {
|
|
322
|
+
const lang = (fence[1] || "").trim();
|
|
323
|
+
const codeLines = [];
|
|
324
|
+
i++;
|
|
325
|
+
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; }
|
|
326
|
+
i++;
|
|
327
|
+
out.push(renderCodeBlock(codeLines.join("\n"), lang));
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!line.trim()) { i++; continue; }
|
|
332
|
+
|
|
333
|
+
if (/^\s*-{3,}\s*$/.test(line) || /^\s*\*{3,}\s*$/.test(line)) {
|
|
334
|
+
out.push("<hr/>");
|
|
335
|
+
i++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
340
|
+
if (heading) {
|
|
341
|
+
const level = heading[1].length;
|
|
342
|
+
out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`);
|
|
343
|
+
i++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
348
|
+
const tableLines = [lines[i], lines[i + 1]];
|
|
349
|
+
i += 2;
|
|
350
|
+
while (i < lines.length && lines[i].trim() && lines[i].includes("|")) { tableLines.push(lines[i]); i++; }
|
|
351
|
+
out.push(renderTable(tableLines));
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (/^\s*>/.test(line)) {
|
|
356
|
+
const quoteLines = [];
|
|
357
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) { quoteLines.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
|
358
|
+
out.push(`<blockquote>${parseBlocks(quoteLines)}</blockquote>`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
|
|
363
|
+
const indent = (line.match(/^(\s*)/)[1] || "").length;
|
|
364
|
+
const list = parseList(lines, i, indent);
|
|
365
|
+
out.push(list.html);
|
|
366
|
+
i = list.next;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const paraLines = [];
|
|
371
|
+
while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i], lines[i + 1])) {
|
|
372
|
+
paraLines.push(lines[i]);
|
|
373
|
+
i++;
|
|
374
|
+
}
|
|
375
|
+
if (paraLines.length) {
|
|
376
|
+
out.push(`<p>${inlineMd(paraLines.join(" "))}</p>`);
|
|
377
|
+
} else {
|
|
378
|
+
i++;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return out.join("\n");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function renderMarkdown(markdown) {
|
|
385
|
+
const body = markdown.replace(/^---[\s\S]*?\n---\r?\n/, "");
|
|
386
|
+
return parseBlocks(body.split(/\r?\n/)) || '<p class="empty-state">This document is empty.</p>';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* ================================================================
|
|
390
|
+
Data helpers
|
|
391
|
+
================================================================ */
|
|
392
|
+
function byPath(docs, path) {
|
|
393
|
+
return docs.find((d) => d.path === path);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getProjectData(index, slug) {
|
|
397
|
+
if (!index) return { project: null, docs: [] };
|
|
398
|
+
const project = index.projects.find((p) => p.slug === slug) || index.projects[0];
|
|
399
|
+
if (!project) return { project: null, docs: [] };
|
|
400
|
+
const docs = project.docs.map((p) => byPath(index.docs, p)).filter(Boolean);
|
|
401
|
+
return { project, docs };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function computeHealth(docs) {
|
|
405
|
+
const tasks = docs.filter((d) => d.role === "task");
|
|
406
|
+
if (!tasks.length) return { pct: 0, label: "No tasks" };
|
|
407
|
+
const done = tasks.filter((d) => statusLabel(d.status) === "Complete").length;
|
|
408
|
+
const pct = Math.round((done / tasks.length) * 100);
|
|
409
|
+
const remaining = tasks.length - done;
|
|
410
|
+
return { pct, label: remaining > 0 ? `${remaining} of ${tasks.length} incomplete` : "All tasks complete" };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function computeWarnings(project, docs) {
|
|
414
|
+
const warnings = [];
|
|
415
|
+
const tasks = docs.filter((d) => d.role === "task");
|
|
416
|
+
const noStatus = tasks.filter((d) => !d.status);
|
|
417
|
+
if (noStatus.length) {
|
|
418
|
+
warnings.push({ sev: "Medium", note: `${noStatus.length} task(s) missing status field`, ws: "Tasks" });
|
|
419
|
+
}
|
|
420
|
+
if (project.outline) {
|
|
421
|
+
const ws = project.outline.workstreams || [];
|
|
422
|
+
const emptyWs = ws.filter((w) => !w.tasks || !w.tasks.length);
|
|
423
|
+
if (emptyWs.length) {
|
|
424
|
+
warnings.push({ sev: "Low", note: `${emptyWs.length} workstream(s) have no linked tasks`, ws: "Workstreams" });
|
|
425
|
+
}
|
|
426
|
+
if (project.outline.unassignedTasks?.length) {
|
|
427
|
+
warnings.push({ sev: "Low", note: `${project.outline.unassignedTasks.length} task(s) not assigned to a workstream`, ws: "Tasks" });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const blocked = tasks.filter((d) => statusLabel(d.status) === "Blocked");
|
|
431
|
+
if (blocked.length) {
|
|
432
|
+
warnings.push({ sev: "Medium", note: `${blocked.length} task(s) currently blocked`, ws: "Tasks" });
|
|
433
|
+
}
|
|
434
|
+
return warnings;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getDashboardModel(project, docs) {
|
|
438
|
+
const tasks = docs.filter((d) => d.role === "task");
|
|
439
|
+
const currentWork = tasks.filter((d) => statusLabel(d.status) === "In Progress");
|
|
440
|
+
const blockers = tasks.filter((d) => statusLabel(d.status) === "Blocked");
|
|
441
|
+
const progressDocs = docs
|
|
442
|
+
.filter((d) => d.role === "progress")
|
|
443
|
+
.sort((a, b) => (b.updated || "").localeCompare(a.updated || ""));
|
|
444
|
+
const health = computeHealth(docs);
|
|
445
|
+
const warnings = computeWarnings(project, docs);
|
|
446
|
+
const workstreams = project.outline?.workstreams || [];
|
|
447
|
+
const wsLookup = {};
|
|
448
|
+
|
|
449
|
+
workstreams.forEach((ws) => {
|
|
450
|
+
(ws.tasks || []).forEach((taskPath) => {
|
|
451
|
+
wsLookup[taskPath] = ws;
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return { tasks, currentWork, blockers, progressDocs, health, warnings, workstreams, wsLookup };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function getWorkspaceModel(index) {
|
|
459
|
+
const model = {
|
|
460
|
+
current: [],
|
|
461
|
+
blockers: [],
|
|
462
|
+
validation: [],
|
|
463
|
+
progress: [],
|
|
464
|
+
warnings: [],
|
|
465
|
+
counts: { projects: 0, current: 0, blockers: 0, validation: 0, progress: 0, warnings: 0 },
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
if (!index) return model;
|
|
469
|
+
|
|
470
|
+
for (const project of index.projects || []) {
|
|
471
|
+
if (!project.outline) continue;
|
|
472
|
+
const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean);
|
|
473
|
+
const dashboard = getDashboardModel(project, docs);
|
|
474
|
+
const withProject = (item, extra = {}) => ({ ...item, project, ...extra });
|
|
475
|
+
|
|
476
|
+
dashboard.currentWork.forEach((task) => {
|
|
477
|
+
model.current.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
|
|
478
|
+
});
|
|
479
|
+
dashboard.blockers.forEach((task) => {
|
|
480
|
+
model.blockers.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
|
|
481
|
+
});
|
|
482
|
+
dashboard.tasks.forEach((task) => {
|
|
483
|
+
model.validation.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
|
|
484
|
+
});
|
|
485
|
+
dashboard.progressDocs.forEach((doc) => {
|
|
486
|
+
model.progress.push(withProject(doc));
|
|
487
|
+
});
|
|
488
|
+
dashboard.warnings.forEach((warning) => {
|
|
489
|
+
model.warnings.push({ ...warning, project });
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const byUpdatedDesc = (a, b) => (b.updated || b.project?.updated || "").localeCompare(a.updated || a.project?.updated || "");
|
|
494
|
+
model.current.sort(byUpdatedDesc);
|
|
495
|
+
model.blockers.sort(byUpdatedDesc);
|
|
496
|
+
model.validation.sort(byUpdatedDesc);
|
|
497
|
+
model.progress.sort(byUpdatedDesc);
|
|
498
|
+
model.warnings.sort((a, b) => (b.project?.updated || "").localeCompare(a.project?.updated || ""));
|
|
499
|
+
|
|
500
|
+
model.counts.current = model.current.length;
|
|
501
|
+
model.counts.blockers = model.blockers.length;
|
|
502
|
+
model.counts.validation = model.validation.length;
|
|
503
|
+
model.counts.progress = model.progress.length;
|
|
504
|
+
model.counts.warnings = model.warnings.length;
|
|
505
|
+
model.counts.projects = (index.projects || []).filter((p) => p.outline).length;
|
|
506
|
+
|
|
507
|
+
return model;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function getProjectStats(index, project) {
|
|
511
|
+
const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean);
|
|
512
|
+
const dashboard = getDashboardModel(project, docs);
|
|
513
|
+
const relatedAssets = docs.filter((doc) => !["task", "workstream"].includes(doc.role)).length;
|
|
514
|
+
const openTasks = dashboard.tasks.filter((task) => statusLabel(task.status) !== "Complete");
|
|
515
|
+
const latestDoc = docs
|
|
516
|
+
.slice()
|
|
517
|
+
.sort((a, b) => (b.updated || "").localeCompare(a.updated || ""))[0];
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
project,
|
|
521
|
+
docs,
|
|
522
|
+
dashboard,
|
|
523
|
+
tasks: dashboard.tasks,
|
|
524
|
+
openTasks,
|
|
525
|
+
workstreams: dashboard.workstreams,
|
|
526
|
+
relatedAssets,
|
|
527
|
+
updated: latestDoc?.updated || project.created || "",
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function formatShortDate(value) {
|
|
532
|
+
if (!value) return "-";
|
|
533
|
+
const date = new Date(value);
|
|
534
|
+
if (Number.isNaN(date.getTime())) return "-";
|
|
535
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function pageCountFor(items, pageSize = WORKSPACE_PAGE_SIZE) {
|
|
539
|
+
return Math.max(1, Math.ceil((items?.length || 0) / pageSize));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function clampPage(page, totalPages) {
|
|
543
|
+
const parsed = Number(page);
|
|
544
|
+
const next = Number.isFinite(parsed) ? Math.floor(parsed) : 1;
|
|
545
|
+
return Math.min(Math.max(1, next), Math.max(1, totalPages));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function paginateItems(items, page, pageSize = WORKSPACE_PAGE_SIZE) {
|
|
549
|
+
const totalPages = pageCountFor(items, pageSize);
|
|
550
|
+
const safePage = clampPage(page, totalPages);
|
|
551
|
+
const start = (safePage - 1) * pageSize;
|
|
552
|
+
return {
|
|
553
|
+
visible: (items || []).slice(start, start + pageSize),
|
|
554
|
+
safePage,
|
|
555
|
+
totalPages,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const LinkButton = ({ children, title, className = "", ...props }) => (
|
|
560
|
+
<button
|
|
561
|
+
{...props}
|
|
562
|
+
className={`link${className ? ` ${className}` : ""}`}
|
|
563
|
+
title={title || (typeof children === "string" ? children : undefined)}
|
|
564
|
+
type={props.type || "button"}
|
|
565
|
+
>
|
|
566
|
+
{children}
|
|
567
|
+
</button>
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const Pagination = ({ page, totalPages, onPageChange }) => {
|
|
571
|
+
if (totalPages <= 1) return null;
|
|
572
|
+
return (
|
|
573
|
+
<div className="pagination" aria-label="Pagination">
|
|
574
|
+
<button
|
|
575
|
+
className="btn"
|
|
576
|
+
type="button"
|
|
577
|
+
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
578
|
+
disabled={page === 1}
|
|
579
|
+
>
|
|
580
|
+
Previous
|
|
581
|
+
</button>
|
|
582
|
+
<span className="mono">Page {page} of {totalPages}</span>
|
|
583
|
+
<button
|
|
584
|
+
className="btn"
|
|
585
|
+
type="button"
|
|
586
|
+
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
|
587
|
+
disabled={page === totalPages}
|
|
588
|
+
>
|
|
589
|
+
Next
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
/* ================================================================
|
|
596
|
+
Reusable components
|
|
597
|
+
================================================================ */
|
|
598
|
+
const CopyButton = ({ value, label = "value", className = "" }) => {
|
|
599
|
+
const [copied, setCopied] = useState(false);
|
|
600
|
+
const text = normalizeCopyValue(value);
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (!copied) return undefined;
|
|
604
|
+
const timeout = window.setTimeout(() => setCopied(false), 1200);
|
|
605
|
+
return () => window.clearTimeout(timeout);
|
|
606
|
+
}, [copied]);
|
|
607
|
+
|
|
608
|
+
if (!text) return null;
|
|
609
|
+
|
|
610
|
+
const stateLabel = copied ? `Copied ${label}` : `Copy ${label}`;
|
|
611
|
+
const handleClick = async (event) => {
|
|
612
|
+
event.preventDefault();
|
|
613
|
+
event.stopPropagation();
|
|
614
|
+
const ok = await copyTextToClipboard(text);
|
|
615
|
+
if (!ok) return;
|
|
616
|
+
setCopied(true);
|
|
617
|
+
announceCopy(label);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<button
|
|
622
|
+
className={`copy-btn${copied ? " is-copied" : ""}${className ? ` ${className}` : ""}`}
|
|
623
|
+
type="button"
|
|
624
|
+
onClick={handleClick}
|
|
625
|
+
aria-label={stateLabel}
|
|
626
|
+
title={stateLabel}
|
|
627
|
+
>
|
|
628
|
+
<Icon d={copied ? I.checkMark : I.copy} size={13} />
|
|
629
|
+
</button>
|
|
630
|
+
);
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const Field = ({ label, children, mono, copyValue, copyLabel }) => (
|
|
634
|
+
<div className="field">
|
|
635
|
+
<div className="field-label">{label}</div>
|
|
636
|
+
<div className={"field-value" + (mono ? " mono" : "")}>
|
|
637
|
+
{children}
|
|
638
|
+
<CopyButton value={copyValue} label={copyLabel || label} />
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
const SectionHeader = ({ title, count, right, collapsible, open, onToggle }) => (
|
|
644
|
+
<div
|
|
645
|
+
className={"section-head" + (collapsible ? " is-collapsible" : "")}
|
|
646
|
+
onClick={collapsible ? onToggle : undefined}
|
|
647
|
+
role={collapsible ? "button" : undefined}
|
|
648
|
+
>
|
|
649
|
+
<div className="section-title">
|
|
650
|
+
<span>{title}</span>
|
|
651
|
+
{count != null && <span className="count">{count}</span>}
|
|
652
|
+
</div>
|
|
653
|
+
<div className="section-right">
|
|
654
|
+
{right}
|
|
655
|
+
{collapsible && (
|
|
656
|
+
<span className="caret">
|
|
657
|
+
<Icon d={open ? I.chevU : I.chevD} size={16} />
|
|
658
|
+
</span>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const Block = ({ title, children }) => (
|
|
665
|
+
<section className="ws-block">
|
|
666
|
+
<h3 className="ws-h">{title}</h3>
|
|
667
|
+
<div className="ws-body">{children}</div>
|
|
668
|
+
</section>
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
/* ================================================================
|
|
672
|
+
Navigation definitions
|
|
673
|
+
================================================================ */
|
|
674
|
+
const NAV = [
|
|
675
|
+
{ id: "overview", label: "Overview", icon: I.home },
|
|
676
|
+
{ id: "current", label: "Current Work", icon: I.list },
|
|
677
|
+
{ id: "blockers", label: "Blockers", icon: I.block },
|
|
678
|
+
{ id: "progress", label: "Progress", icon: I.trend },
|
|
679
|
+
{ id: "validation", label: "Validation", icon: I.check },
|
|
680
|
+
{ id: "warnings", label: "Warnings", icon: I.warn },
|
|
681
|
+
];
|
|
682
|
+
|
|
683
|
+
const GLOBAL_NAV = [
|
|
684
|
+
{ id: "workspace-projects", label: "Projects", icon: I.grid, countKey: "projects" },
|
|
685
|
+
{ id: "workspace-current", label: "Open work", icon: I.list, countKey: "current" },
|
|
686
|
+
{ id: "workspace-progress", label: "Progress", icon: I.trend, countKey: "progress" },
|
|
687
|
+
{ id: "workspace-validation", label: "Validation", icon: I.check, countKey: "validation" },
|
|
688
|
+
{ id: "workspace-warnings", label: "Warnings", icon: I.warn, countKey: "warnings" },
|
|
689
|
+
{ id: "workspace-blockers", label: "Blockers", icon: I.block, countKey: "blockers" },
|
|
690
|
+
];
|
|
691
|
+
|
|
692
|
+
const GLOBAL_ROUTES = new Set(GLOBAL_NAV.map((item) => item.id));
|
|
693
|
+
|
|
694
|
+
function readStoredNavigation() {
|
|
695
|
+
try {
|
|
696
|
+
const raw = window.localStorage.getItem(NAV_STATE_KEY);
|
|
697
|
+
if (!raw) return null;
|
|
698
|
+
const parsed = JSON.parse(raw);
|
|
699
|
+
return parsed?.version === NAV_STATE_VERSION ? parsed : null;
|
|
700
|
+
} catch (_) {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function sanitizeWorkspacePages(value) {
|
|
706
|
+
const pages = {};
|
|
707
|
+
if (!value || typeof value !== "object") return pages;
|
|
708
|
+
GLOBAL_NAV.forEach((item) => {
|
|
709
|
+
const page = Number(value[item.id]);
|
|
710
|
+
if (Number.isFinite(page) && page > 1) pages[item.id] = Math.floor(page);
|
|
711
|
+
});
|
|
712
|
+
return pages;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function findProjectForDoc(index, docPath) {
|
|
716
|
+
if (!docPath) return null;
|
|
717
|
+
return (index.projects || []).find((project) => (project.docs || []).includes(docPath)) || null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function projectHasWorkstream(project, wsPath) {
|
|
721
|
+
return !!project?.outline?.workstreams?.some((ws) => ws.path === wsPath);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function fallbackRouteForProject(project) {
|
|
725
|
+
return project?.outline ? "overview" : "list";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function makeDefaultNavigation(index) {
|
|
729
|
+
const firstProject = (index.projects || []).find((p) => p.outline) || (index.projects || [])[0] || null;
|
|
730
|
+
return {
|
|
731
|
+
projectSlug: firstProject?.slug || null,
|
|
732
|
+
route: DEFAULT_WORKSPACE_ROUTE,
|
|
733
|
+
section: null,
|
|
734
|
+
docPath: null,
|
|
735
|
+
wsPath: null,
|
|
736
|
+
workspacePages: {},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function restoreNavigation(index) {
|
|
741
|
+
const fallback = makeDefaultNavigation(index);
|
|
742
|
+
const stored = readStoredNavigation();
|
|
743
|
+
if (!stored) return fallback;
|
|
744
|
+
|
|
745
|
+
let project = (index.projects || []).find((p) => p.slug === stored.projectSlug) || null;
|
|
746
|
+
if (!project) project = (index.projects || []).find((p) => p.slug === fallback.projectSlug) || null;
|
|
747
|
+
if (!project) return fallback;
|
|
748
|
+
|
|
749
|
+
const workspacePages = sanitizeWorkspacePages(stored.workspacePages);
|
|
750
|
+
if (GLOBAL_ROUTES.has(stored.route)) {
|
|
751
|
+
return {
|
|
752
|
+
...fallback,
|
|
753
|
+
projectSlug: project.slug,
|
|
754
|
+
route: stored.route,
|
|
755
|
+
workspacePages,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (stored.route === "document" && stored.docPath) {
|
|
760
|
+
const docProject = findProjectForDoc(index, stored.docPath);
|
|
761
|
+
if (docProject) {
|
|
762
|
+
return {
|
|
763
|
+
projectSlug: docProject.slug,
|
|
764
|
+
route: "document",
|
|
765
|
+
section: stored.docPath,
|
|
766
|
+
docPath: stored.docPath,
|
|
767
|
+
wsPath: projectHasWorkstream(docProject, stored.wsPath) ? stored.wsPath : null,
|
|
768
|
+
workspacePages,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (stored.route === "workstream" && projectHasWorkstream(project, stored.wsPath)) {
|
|
774
|
+
return {
|
|
775
|
+
projectSlug: project.slug,
|
|
776
|
+
route: "workstream",
|
|
777
|
+
section: null,
|
|
778
|
+
docPath: null,
|
|
779
|
+
wsPath: stored.wsPath,
|
|
780
|
+
workspacePages,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const projectRoutes = project.outline ? new Set(["overview", "workstreams", "tasks"]) : new Set(["list"]);
|
|
785
|
+
if (projectRoutes.has(stored.route)) {
|
|
786
|
+
return {
|
|
787
|
+
projectSlug: project.slug,
|
|
788
|
+
route: stored.route,
|
|
789
|
+
section: stored.section || (stored.route === "overview" ? "overview" : null),
|
|
790
|
+
docPath: null,
|
|
791
|
+
wsPath: null,
|
|
792
|
+
workspacePages,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
...fallback,
|
|
798
|
+
projectSlug: project.slug,
|
|
799
|
+
route: fallbackRouteForProject(project),
|
|
800
|
+
section: project.outline ? "overview" : null,
|
|
801
|
+
workspacePages,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function getTaskNavigation(index, project, taskDoc) {
|
|
806
|
+
if (!index || !project?.outline || !taskDoc || taskDoc.role !== "task") return null;
|
|
807
|
+
const workstreams = project.outline.workstreams || [];
|
|
808
|
+
let parent = workstreams.find((ws) => (ws.tasks || []).includes(taskDoc.path)) || null;
|
|
809
|
+
if (!parent && taskDoc.workstreamId) {
|
|
810
|
+
parent = workstreams.find((ws) => ws.id === taskDoc.workstreamId) || null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const tasks = (project.docs || [])
|
|
814
|
+
.map((path) => byPath(index.docs, path))
|
|
815
|
+
.filter((doc) => doc?.role === "task");
|
|
816
|
+
const tasksById = {};
|
|
817
|
+
tasks.forEach((task) => {
|
|
818
|
+
if (task.taskId) tasksById[String(task.taskId).toUpperCase()] = task;
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
parent,
|
|
823
|
+
siblings: parent ? (parent.tasks || []).map((path) => byPath(index.docs, path)).filter(Boolean) : [],
|
|
824
|
+
tasksById,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function listValue(value) {
|
|
829
|
+
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
|
830
|
+
if (value == null || value === "") return [];
|
|
831
|
+
return [String(value)];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/* ================================================================
|
|
835
|
+
Sidebar
|
|
836
|
+
================================================================ */
|
|
837
|
+
function Sidebar({ index, projectSlug, route, section, onNavigate, onSelectProject }) {
|
|
838
|
+
const projects = index?.projects || [];
|
|
839
|
+
const current = projects.find((p) => p.slug === projectSlug);
|
|
840
|
+
const hasOutline = current?.outline;
|
|
841
|
+
const globalCounts = useMemo(
|
|
842
|
+
() => (index ? getWorkspaceModel(index).counts : {}),
|
|
843
|
+
[index]
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const contractItems = useMemo(() => {
|
|
847
|
+
if (!hasOutline) return [];
|
|
848
|
+
const items = [];
|
|
849
|
+
if (current.outline.spec) items.push({ id: "spec", label: "Spec", icon: I.doc, path: current.outline.spec });
|
|
850
|
+
if (current.outline.plan) items.push({ id: "plan", label: "Plan", icon: I.plan, path: current.outline.plan });
|
|
851
|
+
(current.outline.decisions || []).forEach((p, i) =>
|
|
852
|
+
items.push({ id: `dec-${i}`, label: "Decisions", icon: I.scale, path: p })
|
|
853
|
+
);
|
|
854
|
+
(current.outline.progress || []).forEach((p, i) =>
|
|
855
|
+
items.push({ id: `prog-${i}`, label: i === 0 ? "Progress log" : `Progress ${i + 1}`, icon: I.clock, path: p })
|
|
856
|
+
);
|
|
857
|
+
return items;
|
|
858
|
+
}, [current, hasOutline]);
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
<aside className="sidebar">
|
|
862
|
+
<div className="brand" aria-label="Delano">
|
|
863
|
+
<img className="brand-logo" src="/delano-logo.svg" alt="Delano" />
|
|
864
|
+
</div>
|
|
865
|
+
|
|
866
|
+
<div className="nav-section">Workspace</div>
|
|
867
|
+
<nav className="nav">
|
|
868
|
+
{GLOBAL_NAV.map((it) => (
|
|
869
|
+
<button
|
|
870
|
+
key={it.id}
|
|
871
|
+
className={"nav-item nav-item-count" + (route === it.id ? " is-active" : "")}
|
|
872
|
+
onClick={() => onNavigate(it.id)}
|
|
873
|
+
type="button"
|
|
874
|
+
>
|
|
875
|
+
<span className="nav-ico">
|
|
876
|
+
<Icon d={it.icon} size={16} />
|
|
877
|
+
</span>
|
|
878
|
+
<span className="nav-label">{it.label}</span>
|
|
879
|
+
<span className="nav-count mono">{globalCounts[it.countKey] || 0}</span>
|
|
880
|
+
</button>
|
|
881
|
+
))}
|
|
882
|
+
</nav>
|
|
883
|
+
|
|
884
|
+
<div className="nav-section">Selected project</div>
|
|
885
|
+
<div className="project-select-v3">
|
|
886
|
+
<span className="project-select-mark">
|
|
887
|
+
<Icon d={current?.outline ? I.grid : I.folder} size={15} />
|
|
888
|
+
</span>
|
|
889
|
+
<select
|
|
890
|
+
className="project-select-control"
|
|
891
|
+
value={projectSlug || ""}
|
|
892
|
+
onChange={(e) => onSelectProject(e.target.value)}
|
|
893
|
+
aria-label="Project"
|
|
894
|
+
>
|
|
895
|
+
{projects.map((p) => (
|
|
896
|
+
<option key={p.slug} value={p.slug}>
|
|
897
|
+
{p.title}
|
|
898
|
+
</option>
|
|
899
|
+
))}
|
|
900
|
+
</select>
|
|
901
|
+
</div>
|
|
902
|
+
|
|
903
|
+
{hasOutline && (
|
|
904
|
+
<>
|
|
905
|
+
<nav className="nav">
|
|
906
|
+
<button
|
|
907
|
+
className={"nav-item" + (route === "overview" ? " is-active" : "")}
|
|
908
|
+
onClick={() => onNavigate("overview")}
|
|
909
|
+
type="button"
|
|
910
|
+
>
|
|
911
|
+
<span className="nav-ico">
|
|
912
|
+
<Icon d={I.home} size={16} />
|
|
913
|
+
</span>
|
|
914
|
+
<span>Project overview</span>
|
|
915
|
+
</button>
|
|
916
|
+
</nav>
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
<div className="nav-section">Source contracts</div>
|
|
920
|
+
<nav className="nav source-nav-v2">
|
|
921
|
+
{contractItems.filter((it) => !it.id.startsWith("prog-")).map((it) => (
|
|
922
|
+
<button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button">
|
|
923
|
+
<span className="nav-ico"><Icon d={it.icon} size={16} /></span>
|
|
924
|
+
<span>{it.label}</span>
|
|
925
|
+
</button>
|
|
926
|
+
))}
|
|
927
|
+
<button className={"nav-item" + (route === "workstreams" ? " is-active" : "")} onClick={() => onNavigate("workstreams")} type="button">
|
|
928
|
+
<span className="nav-ico"><Icon d={I.grid} size={16} /></span>
|
|
929
|
+
<span>Workstreams</span>
|
|
930
|
+
</button>
|
|
931
|
+
<button className={"nav-item" + (route === "tasks" ? " is-active" : "")} onClick={() => onNavigate("tasks")} type="button">
|
|
932
|
+
<span className="nav-ico"><Icon d={I.task} size={16} /></span>
|
|
933
|
+
<span>Tasks</span>
|
|
934
|
+
</button>
|
|
935
|
+
<div className="nav-break">Progress</div>
|
|
936
|
+
{contractItems.filter((it) => it.id.startsWith("prog-")).map((it) => (
|
|
937
|
+
<button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button">
|
|
938
|
+
<span className="nav-ico"><Icon d={it.icon} size={16} /></span>
|
|
939
|
+
<span>{it.label}</span>
|
|
940
|
+
</button>
|
|
941
|
+
))}
|
|
942
|
+
</nav>
|
|
943
|
+
</>
|
|
944
|
+
)}
|
|
945
|
+
|
|
946
|
+
<div className="sidebar-foot">
|
|
947
|
+
<button className="nav-item" type="button">
|
|
948
|
+
<span className="nav-ico">
|
|
949
|
+
<Icon d={I.gear} size={16} />
|
|
950
|
+
</span>
|
|
951
|
+
<span>Viewer settings</span>
|
|
952
|
+
</button>
|
|
953
|
+
</div>
|
|
954
|
+
</aside>
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/* ================================================================
|
|
959
|
+
Topbar
|
|
960
|
+
================================================================ */
|
|
961
|
+
function Topbar({ project, index, docPath, onOpenAction }) {
|
|
962
|
+
const spec = project?.outline?.spec;
|
|
963
|
+
const specDoc = spec && index ? byPath(index.docs, spec) : null;
|
|
964
|
+
const title = project?.title || "Delano";
|
|
965
|
+
const status = specDoc?.status || project?.status;
|
|
966
|
+
const updated = specDoc?.updated || "";
|
|
967
|
+
const dateStr = updated
|
|
968
|
+
? new Date(updated).toLocaleString("en-US", {
|
|
969
|
+
month: "short", day: "numeric", year: "numeric",
|
|
970
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
971
|
+
})
|
|
972
|
+
: "";
|
|
973
|
+
|
|
974
|
+
return (
|
|
975
|
+
<header className="topbar">
|
|
976
|
+
<div className="tb-project">
|
|
977
|
+
<span className="tb-title">{title}</span>
|
|
978
|
+
{status && <StatusChip>{status}</StatusChip>}
|
|
979
|
+
</div>
|
|
980
|
+
<div className="tb-meta">
|
|
981
|
+
{dateStr && (
|
|
982
|
+
<>
|
|
983
|
+
<span>Last updated <strong>{dateStr}</strong></span>
|
|
984
|
+
<span className="tb-sep" />
|
|
985
|
+
</>
|
|
986
|
+
)}
|
|
987
|
+
<span className="tb-readonly">
|
|
988
|
+
<Icon d={I.lock} size={13} /> Read-only
|
|
989
|
+
</span>
|
|
990
|
+
</div>
|
|
991
|
+
<div className="tb-actions">
|
|
992
|
+
{docPath && (
|
|
993
|
+
<>
|
|
994
|
+
<button className="btn" onClick={() => onOpenAction("code", docPath)}>
|
|
995
|
+
<Icon d={I.code} size={14} /> Open in IDE
|
|
996
|
+
</button>
|
|
997
|
+
<button className="btn" onClick={() => onOpenAction("explorer", docPath)}>
|
|
998
|
+
<Icon d={I.folderOpen} size={14} /> Open folder
|
|
999
|
+
</button>
|
|
1000
|
+
</>
|
|
1001
|
+
)}
|
|
1002
|
+
</div>
|
|
1003
|
+
</header>
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/* ================================================================
|
|
1008
|
+
Overview
|
|
1009
|
+
================================================================ */
|
|
1010
|
+
function Overview({ index, project, docs, scrollTarget, onOpenWorkstream, onOpenDoc, onOpenTasks }) {
|
|
1011
|
+
const [open, setOpen] = useState({ current: true, blockers: true, validation: false, progress: false, warnings: false });
|
|
1012
|
+
const toggle = (k) => setOpen((o) => ({ ...o, [k]: !o[k] }));
|
|
1013
|
+
|
|
1014
|
+
const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
|
|
1015
|
+
const { tasks, blockers, progressDocs, health, warnings, workstreams, wsLookup } = dashboard;
|
|
1016
|
+
const taskByPath = {};
|
|
1017
|
+
tasks.forEach((task) => {
|
|
1018
|
+
taskByPath[task.path] = task;
|
|
1019
|
+
});
|
|
1020
|
+
const openTasks = tasks
|
|
1021
|
+
.filter((task) => statusLabel(task.status) !== "Complete")
|
|
1022
|
+
.sort((a, b) => {
|
|
1023
|
+
const rank = { Blocked: 0, "In Progress": 1, Planned: 2, Complete: 3 };
|
|
1024
|
+
const statusDelta = (rank[statusLabel(a.status)] ?? 4) - (rank[statusLabel(b.status)] ?? 4);
|
|
1025
|
+
if (statusDelta !== 0) return statusDelta;
|
|
1026
|
+
return (b.updated || "").localeCompare(a.updated || "");
|
|
1027
|
+
});
|
|
1028
|
+
const workstreamRows = workstreams
|
|
1029
|
+
.map((ws) => {
|
|
1030
|
+
const wsTasks = (ws.tasks || []).map((path) => taskByPath[path]).filter(Boolean);
|
|
1031
|
+
const openCount = wsTasks.filter((task) => statusLabel(task.status) !== "Complete").length;
|
|
1032
|
+
return { ...ws, tasks: wsTasks, openCount };
|
|
1033
|
+
})
|
|
1034
|
+
.sort((a, b) => b.openCount - a.openCount || a.title.localeCompare(b.title));
|
|
1035
|
+
|
|
1036
|
+
const nextAction = blockers.length
|
|
1037
|
+
? "Resolve blocked tasks"
|
|
1038
|
+
: health.pct < 100
|
|
1039
|
+
? "Complete remaining tasks"
|
|
1040
|
+
: "All tasks complete";
|
|
1041
|
+
|
|
1042
|
+
// Auto-open + scroll to section from sidebar nav
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
if (!scrollTarget || scrollTarget === "overview") return;
|
|
1045
|
+
const sectionKey = scrollTarget.replace("-nav", "");
|
|
1046
|
+
if (["blockers", "validation", "progress", "warnings", "current"].includes(sectionKey)) {
|
|
1047
|
+
setOpen((o) => ({ ...o, [sectionKey]: true }));
|
|
1048
|
+
setTimeout(() => {
|
|
1049
|
+
const el = document.getElementById(`section-${sectionKey}`);
|
|
1050
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1051
|
+
}, 100);
|
|
1052
|
+
}
|
|
1053
|
+
}, [scrollTarget]);
|
|
1054
|
+
|
|
1055
|
+
return (
|
|
1056
|
+
<div className="page overview-v1">
|
|
1057
|
+
<div className="overview-v1-head">
|
|
1058
|
+
<h1 className="page-title">Overview</h1>
|
|
1059
|
+
<div className="overview-signal-strip signal-color-filled">
|
|
1060
|
+
<button className={"signal-pill signal-pill-warning" + (warnings.length ? " has-count" : " is-zero")} onClick={() => toggle("warnings")} type="button">
|
|
1061
|
+
<Icon d={I.warn} size={14} />
|
|
1062
|
+
<span>Warnings</span>
|
|
1063
|
+
<span className="mono">{warnings.length}</span>
|
|
1064
|
+
</button>
|
|
1065
|
+
<button className={"signal-pill signal-pill-blocker" + (blockers.length ? " has-count" : " is-zero")} onClick={() => toggle("blockers")} type="button">
|
|
1066
|
+
<Icon d={I.block} size={14} />
|
|
1067
|
+
<span>Blockers</span>
|
|
1068
|
+
<span className="mono">{blockers.length}</span>
|
|
1069
|
+
</button>
|
|
1070
|
+
<button className={"signal-pill signal-pill-validation" + (tasks.length ? " has-count" : " is-zero")} onClick={() => toggle("validation")} type="button">
|
|
1071
|
+
<Icon d={I.check} size={14} />
|
|
1072
|
+
<span>Validation</span>
|
|
1073
|
+
<span className="mono">{tasks.length}</span>
|
|
1074
|
+
</button>
|
|
1075
|
+
<button className={"signal-pill signal-pill-progress" + (progressDocs.length ? " has-count" : " is-zero")} onClick={() => toggle("progress")} type="button">
|
|
1076
|
+
<Icon d={I.trend} size={14} />
|
|
1077
|
+
<span>Progress</span>
|
|
1078
|
+
<span className="mono">{progressDocs.length}</span>
|
|
1079
|
+
</button>
|
|
1080
|
+
</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
|
|
1083
|
+
<section className="summary overview-summary">
|
|
1084
|
+
<Field label="Project" copyValue={project.slug} copyLabel="project ID">
|
|
1085
|
+
<span>{project.title}</span>
|
|
1086
|
+
<span className="field-id mono">{project.slug}</span>
|
|
1087
|
+
</Field>
|
|
1088
|
+
<Field label="Status"><StatusChip>{project.status || "Planned"}</StatusChip></Field>
|
|
1089
|
+
<Field label="Health">
|
|
1090
|
+
<span className="health">
|
|
1091
|
+
<span className="health-bar"><span className="health-fill" style={{ width: `${health.pct}%`, background: health.pct === 100 ? "var(--ok)" : undefined }} /></span>
|
|
1092
|
+
<span className="health-label">{health.label}</span>
|
|
1093
|
+
</span>
|
|
1094
|
+
</Field>
|
|
1095
|
+
<Field label="Next action">{nextAction}</Field>
|
|
1096
|
+
</section>
|
|
1097
|
+
|
|
1098
|
+
<section className="overview-delivery">
|
|
1099
|
+
<div className="delivery-panel">
|
|
1100
|
+
<SectionHeader title="Workstreams" count={workstreams.length} />
|
|
1101
|
+
{workstreamRows.length > 0 ? (
|
|
1102
|
+
<div className="delivery-list">
|
|
1103
|
+
{workstreamRows.map((ws) => (
|
|
1104
|
+
<button className="delivery-row" key={ws.path} type="button" onClick={() => onOpenWorkstream(ws.path)}>
|
|
1105
|
+
<span className="delivery-row-main">
|
|
1106
|
+
<span className="delivery-title">{ws.title}</span>
|
|
1107
|
+
<span className="delivery-meta">{ws.tasks.length} task{ws.tasks.length !== 1 ? "s" : ""}</span>
|
|
1108
|
+
</span>
|
|
1109
|
+
<span className="delivery-row-right">
|
|
1110
|
+
<span className="mono delivery-count">{ws.openCount} open</span>
|
|
1111
|
+
<StatusChip>{ws.status || "Planned"}</StatusChip>
|
|
1112
|
+
</span>
|
|
1113
|
+
</button>
|
|
1114
|
+
))}
|
|
1115
|
+
</div>
|
|
1116
|
+
) : (
|
|
1117
|
+
<div className="empty-state">No workstreams in this project.</div>
|
|
1118
|
+
)}
|
|
1119
|
+
</div>
|
|
1120
|
+
|
|
1121
|
+
<div className="delivery-panel">
|
|
1122
|
+
<SectionHeader
|
|
1123
|
+
title="Open tasks"
|
|
1124
|
+
count={openTasks.length}
|
|
1125
|
+
right={<button className="link-muted" type="button" onClick={onOpenTasks}>All tasks</button>}
|
|
1126
|
+
/>
|
|
1127
|
+
{openTasks.length > 0 ? (
|
|
1128
|
+
<div className="delivery-list">
|
|
1129
|
+
{openTasks.slice(0, 7).map((task) => {
|
|
1130
|
+
const ws = wsLookup[task.path];
|
|
1131
|
+
return (
|
|
1132
|
+
<div className="delivery-task-row" key={task.path}>
|
|
1133
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
|
|
1134
|
+
<span className="td-muted">{ws?.title || "Unassigned"}</span>
|
|
1135
|
+
<StatusChip>{task.status || "Planned"}</StatusChip>
|
|
1136
|
+
</div>
|
|
1137
|
+
);
|
|
1138
|
+
})}
|
|
1139
|
+
{openTasks.length > 7 && (
|
|
1140
|
+
<button className="delivery-more" type="button" onClick={onOpenTasks}>
|
|
1141
|
+
{openTasks.length - 7} more open task{openTasks.length - 7 !== 1 ? "s" : ""}
|
|
1142
|
+
</button>
|
|
1143
|
+
)}
|
|
1144
|
+
</div>
|
|
1145
|
+
) : (
|
|
1146
|
+
<div className="empty-state">No open tasks.</div>
|
|
1147
|
+
)}
|
|
1148
|
+
</div>
|
|
1149
|
+
</section>
|
|
1150
|
+
|
|
1151
|
+
<section className="overview-priority">
|
|
1152
|
+
<div className="overview-priority-main">
|
|
1153
|
+
<SectionHeader title="Warnings" count={warnings.length} collapsible open={open.warnings} onToggle={() => toggle("warnings")} />
|
|
1154
|
+
{warnings.length > 0 ? (
|
|
1155
|
+
<div className="preview-list">
|
|
1156
|
+
{warnings.slice(0, open.warnings ? warnings.length : 3).map((w, i) => (
|
|
1157
|
+
<div className="preview-row preview-row-warn" key={i}>
|
|
1158
|
+
<span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}><span className="chip-dot" /> {w.sev}</span>
|
|
1159
|
+
<span className="td-primary">{w.note}</span>
|
|
1160
|
+
<span className="td-muted">{w.ws}</span>
|
|
1161
|
+
</div>
|
|
1162
|
+
))}
|
|
1163
|
+
</div>
|
|
1164
|
+
) : (
|
|
1165
|
+
<div className="empty-state">No warnings.</div>
|
|
1166
|
+
)}
|
|
1167
|
+
</div>
|
|
1168
|
+
</section>
|
|
1169
|
+
|
|
1170
|
+
<section className="block">
|
|
1171
|
+
<SectionHeader title="Progress" count={progressDocs.length} collapsible open={open.progress} onToggle={() => toggle("progress")} />
|
|
1172
|
+
{progressDocs.length > 0 ? (
|
|
1173
|
+
<div className="preview-list">
|
|
1174
|
+
{progressDocs.slice(0, open.progress ? progressDocs.length : 3).map((doc, i) => {
|
|
1175
|
+
const progressMeta = formatShortDateTime(doc.updated);
|
|
1176
|
+
const progressSnippet = stripRepeatedTitle(doc.title, doc.snippet);
|
|
1177
|
+
return (
|
|
1178
|
+
<div className="preview-row preview-row-progress" key={i}>
|
|
1179
|
+
<span className="mono preview-meta-time">{progressMeta}</span>
|
|
1180
|
+
<LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}>{doc.title}</LinkButton>
|
|
1181
|
+
<span className="preview-copy">{progressSnippet}</span>
|
|
1182
|
+
</div>
|
|
1183
|
+
);
|
|
1184
|
+
})}
|
|
1185
|
+
</div>
|
|
1186
|
+
) : (
|
|
1187
|
+
<div className="empty-state">No progress entries.</div>
|
|
1188
|
+
)}
|
|
1189
|
+
</section>
|
|
1190
|
+
|
|
1191
|
+
<section className="block">
|
|
1192
|
+
<SectionHeader title="Validation" count={tasks.length} collapsible open={open.validation} onToggle={() => toggle("validation")} />
|
|
1193
|
+
<div className="preview-list preview-list-validation">
|
|
1194
|
+
{tasks.slice(0, open.validation ? tasks.length : 3).map((task, i) => {
|
|
1195
|
+
const label = statusLabel(task.status);
|
|
1196
|
+
const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low";
|
|
1197
|
+
return (
|
|
1198
|
+
<div className="preview-row validation-row" key={i}>
|
|
1199
|
+
<span className={`chip ${chipClass}`}><span className="chip-dot" /> {label}</span>
|
|
1200
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
|
|
1201
|
+
<span className="td-muted">{(wsLookup[task.path]?.title) || "Unassigned"}</span>
|
|
1202
|
+
</div>
|
|
1203
|
+
);
|
|
1204
|
+
})}
|
|
1205
|
+
</div>
|
|
1206
|
+
</section>
|
|
1207
|
+
|
|
1208
|
+
<div className="page-foot mono">viewer · read-only · generated from contracts at <span>{index.generatedAt || ""}</span></div>
|
|
1209
|
+
</div>
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/* ================================================================
|
|
1214
|
+
Workspace Pages
|
|
1215
|
+
================================================================ */
|
|
1216
|
+
function WorkspacePage({ index, view, page, onPageChange, onOpenProject, onOpenProjectDoc, onOpenProjectWorkstream }) {
|
|
1217
|
+
const [projectFilter, setProjectFilter] = useState("all");
|
|
1218
|
+
const workspace = useMemo(() => getWorkspaceModel(index), [index]);
|
|
1219
|
+
const projectStats = useMemo(
|
|
1220
|
+
() => (index.projects || []).filter((p) => p.outline).map((project) => getProjectStats(index, project)),
|
|
1221
|
+
[index]
|
|
1222
|
+
);
|
|
1223
|
+
const projectFilterCounts = useMemo(() => {
|
|
1224
|
+
const active = projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete").length;
|
|
1225
|
+
return {
|
|
1226
|
+
all: projectStats.length,
|
|
1227
|
+
active,
|
|
1228
|
+
complete: projectStats.length - active,
|
|
1229
|
+
};
|
|
1230
|
+
}, [projectStats]);
|
|
1231
|
+
const filteredProjectStats = useMemo(() => {
|
|
1232
|
+
if (projectFilter === "active") {
|
|
1233
|
+
return projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete");
|
|
1234
|
+
}
|
|
1235
|
+
if (projectFilter === "complete") {
|
|
1236
|
+
return projectStats.filter((stat) => statusLabel(stat.project.status) === "Complete");
|
|
1237
|
+
}
|
|
1238
|
+
return projectStats;
|
|
1239
|
+
}, [projectFilter, projectStats]);
|
|
1240
|
+
const currentPage = page || 1;
|
|
1241
|
+
|
|
1242
|
+
const titleMap = {
|
|
1243
|
+
"workspace-projects": "Projects",
|
|
1244
|
+
"workspace-current": "Open work",
|
|
1245
|
+
"workspace-progress": "Progress",
|
|
1246
|
+
"workspace-validation": "Validation",
|
|
1247
|
+
"workspace-warnings": "Warnings",
|
|
1248
|
+
"workspace-blockers": "Blockers",
|
|
1249
|
+
};
|
|
1250
|
+
const title = titleMap[view] || "Workspace";
|
|
1251
|
+
const itemsForView =
|
|
1252
|
+
view === "workspace-projects" ? filteredProjectStats :
|
|
1253
|
+
view === "workspace-current" ? workspace.current :
|
|
1254
|
+
view === "workspace-blockers" ? workspace.blockers :
|
|
1255
|
+
view === "workspace-validation" ? workspace.validation :
|
|
1256
|
+
view === "workspace-progress" ? workspace.progress :
|
|
1257
|
+
view === "workspace-warnings" ? workspace.warnings :
|
|
1258
|
+
[];
|
|
1259
|
+
|
|
1260
|
+
useEffect(() => {
|
|
1261
|
+
const totalPages = pageCountFor(itemsForView);
|
|
1262
|
+
const safePage = clampPage(currentPage, totalPages);
|
|
1263
|
+
if (safePage !== currentPage) onPageChange(safePage);
|
|
1264
|
+
}, [view, itemsForView.length, currentPage, onPageChange]);
|
|
1265
|
+
|
|
1266
|
+
const projectButton = (project) => (
|
|
1267
|
+
<LinkButton onClick={() => onOpenProject(project.slug)} title={project.title}>
|
|
1268
|
+
{project.title}
|
|
1269
|
+
</LinkButton>
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
const workstreamButton = (item) =>
|
|
1273
|
+
item.workstream ? (
|
|
1274
|
+
<LinkButton onClick={() => onOpenProjectWorkstream(item.project.slug, item.workstream.path)} title={item.workstream.title}>
|
|
1275
|
+
{item.workstream.title}
|
|
1276
|
+
</LinkButton>
|
|
1277
|
+
) : (
|
|
1278
|
+
<span className="td-muted">-</span>
|
|
1279
|
+
);
|
|
1280
|
+
|
|
1281
|
+
const renderPagination = (pagination) => (
|
|
1282
|
+
<Pagination page={pagination.safePage} totalPages={pagination.totalPages} onPageChange={onPageChange} />
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
const projectFilterControl = view === "workspace-projects" ? (
|
|
1286
|
+
<div className="project-filter" aria-label="Project status filter">
|
|
1287
|
+
{[
|
|
1288
|
+
["all", "All"],
|
|
1289
|
+
["active", "Active"],
|
|
1290
|
+
["complete", "Complete"],
|
|
1291
|
+
].map(([value, label]) => (
|
|
1292
|
+
<button
|
|
1293
|
+
className={"project-filter-option" + (projectFilter === value ? " is-active" : "")}
|
|
1294
|
+
type="button"
|
|
1295
|
+
onClick={() => setProjectFilter(value)}
|
|
1296
|
+
aria-pressed={projectFilter === value}
|
|
1297
|
+
key={value}
|
|
1298
|
+
>
|
|
1299
|
+
<span>{label}</span>
|
|
1300
|
+
<span className="mono">{projectFilterCounts[value]}</span>
|
|
1301
|
+
</button>
|
|
1302
|
+
))}
|
|
1303
|
+
</div>
|
|
1304
|
+
) : null;
|
|
1305
|
+
|
|
1306
|
+
const renderProjects = () =>
|
|
1307
|
+
filteredProjectStats.length > 0 ? (
|
|
1308
|
+
<div className="project-grid">
|
|
1309
|
+
{filteredProjectStats.map((stat) => (
|
|
1310
|
+
<button className="project-card" key={stat.project.slug} type="button" onClick={() => onOpenProject(stat.project.slug)}>
|
|
1311
|
+
<span className="project-card-head">
|
|
1312
|
+
<span className="project-card-title">{stat.project.title}</span>
|
|
1313
|
+
<StatusChip>{stat.project.status || "Planned"}</StatusChip>
|
|
1314
|
+
</span>
|
|
1315
|
+
<span className="project-card-dates">
|
|
1316
|
+
<span><span>Created</span> {formatShortDate(stat.project.created)}</span>
|
|
1317
|
+
<span><span>Updated</span> {formatShortDate(stat.updated)}</span>
|
|
1318
|
+
</span>
|
|
1319
|
+
<span className="project-card-stats">
|
|
1320
|
+
<span><strong>{stat.workstreams.length}</strong> Workstreams</span>
|
|
1321
|
+
<span><strong>{stat.openTasks.length}</strong> Open tasks</span>
|
|
1322
|
+
<span><strong>{stat.tasks.length}</strong> Tasks</span>
|
|
1323
|
+
<span><strong>{stat.relatedAssets}</strong> Assets</span>
|
|
1324
|
+
</span>
|
|
1325
|
+
</button>
|
|
1326
|
+
))}
|
|
1327
|
+
</div>
|
|
1328
|
+
) : (
|
|
1329
|
+
<div className="empty-state">No projects match this filter.</div>
|
|
1330
|
+
);
|
|
1331
|
+
|
|
1332
|
+
const renderTaskRows = (items, emptyText, kind) => {
|
|
1333
|
+
const pagination = paginateItems(items, currentPage);
|
|
1334
|
+
return (
|
|
1335
|
+
<>
|
|
1336
|
+
{pagination.visible.length > 0 ? (
|
|
1337
|
+
<div className="table table-workspace">
|
|
1338
|
+
<div className="tr th">
|
|
1339
|
+
<div>Task</div>
|
|
1340
|
+
<div>Project</div>
|
|
1341
|
+
<div>Workstream</div>
|
|
1342
|
+
<div>State</div>
|
|
1343
|
+
</div>
|
|
1344
|
+
{pagination.visible.map((task) => (
|
|
1345
|
+
<div className="tr" key={`${task.project.slug}:${task.path}`}>
|
|
1346
|
+
<div className="td-primary">
|
|
1347
|
+
<LinkButton onClick={() => onOpenProjectDoc(task.project.slug, task.path)} title={task.title}>
|
|
1348
|
+
{kind === "blocked" && <span className="dot dot-warn" />} {task.title}
|
|
1349
|
+
</LinkButton>
|
|
1350
|
+
</div>
|
|
1351
|
+
<div>{projectButton(task.project)}</div>
|
|
1352
|
+
<div>{workstreamButton(task)}</div>
|
|
1353
|
+
<div>
|
|
1354
|
+
<StatusChip>{task.status}</StatusChip>
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
))}
|
|
1358
|
+
</div>
|
|
1359
|
+
) : (
|
|
1360
|
+
<div className="empty-state">{emptyText}</div>
|
|
1361
|
+
)}
|
|
1362
|
+
{renderPagination(pagination)}
|
|
1363
|
+
</>
|
|
1364
|
+
);
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
const renderProgress = () => {
|
|
1368
|
+
const pagination = paginateItems(workspace.progress, currentPage);
|
|
1369
|
+
|
|
1370
|
+
return (
|
|
1371
|
+
<>
|
|
1372
|
+
{pagination.visible.length > 0 ? (
|
|
1373
|
+
<div className="timeline timeline-workspace">
|
|
1374
|
+
{pagination.visible.map((doc) => {
|
|
1375
|
+
const date = doc.updated
|
|
1376
|
+
? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
1377
|
+
: "";
|
|
1378
|
+
return (
|
|
1379
|
+
<div className="tl-row" key={`${doc.project.slug}:${doc.path}`}>
|
|
1380
|
+
<div className="tl-date mono">{date}</div>
|
|
1381
|
+
<div className="tl-bullet">
|
|
1382
|
+
<span />
|
|
1383
|
+
</div>
|
|
1384
|
+
<div className="tl-body">
|
|
1385
|
+
<div className="workspace-line">
|
|
1386
|
+
<LinkButton onClick={() => onOpenProjectDoc(doc.project.slug, doc.path)} title={doc.title}>
|
|
1387
|
+
{doc.title}
|
|
1388
|
+
</LinkButton>
|
|
1389
|
+
<span className="td-muted-inline">{doc.project.title}</span>
|
|
1390
|
+
</div>
|
|
1391
|
+
<div className="td-muted small">{doc.snippet}</div>
|
|
1392
|
+
</div>
|
|
1393
|
+
</div>
|
|
1394
|
+
);
|
|
1395
|
+
})}
|
|
1396
|
+
</div>
|
|
1397
|
+
) : (
|
|
1398
|
+
<div className="empty-state">No progress entries across projects.</div>
|
|
1399
|
+
)}
|
|
1400
|
+
{renderPagination(pagination)}
|
|
1401
|
+
</>
|
|
1402
|
+
);
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
const renderWarnings = () => {
|
|
1406
|
+
const pagination = paginateItems(workspace.warnings, currentPage);
|
|
1407
|
+
return (
|
|
1408
|
+
<>
|
|
1409
|
+
{pagination.visible.length > 0 ? (
|
|
1410
|
+
<div className="table table-workspace-warnings">
|
|
1411
|
+
<div className="tr th">
|
|
1412
|
+
<div>Severity</div>
|
|
1413
|
+
<div>Project</div>
|
|
1414
|
+
<div>Note</div>
|
|
1415
|
+
<div>Source</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
{pagination.visible.map((w, i) => (
|
|
1418
|
+
<div className="tr" key={`${w.project.slug}:${w.note}:${i}`}>
|
|
1419
|
+
<div>
|
|
1420
|
+
<span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}>
|
|
1421
|
+
<span className="chip-dot" /> {w.sev}
|
|
1422
|
+
</span>
|
|
1423
|
+
</div>
|
|
1424
|
+
<div>{projectButton(w.project)}</div>
|
|
1425
|
+
<div className="td-primary">{w.note}</div>
|
|
1426
|
+
<div className="td-muted">{w.ws}</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
))}
|
|
1429
|
+
</div>
|
|
1430
|
+
) : (
|
|
1431
|
+
<div className="empty-state" style={{ color: "var(--ok)" }}>
|
|
1432
|
+
No warnings across projects.
|
|
1433
|
+
</div>
|
|
1434
|
+
)}
|
|
1435
|
+
{renderPagination(pagination)}
|
|
1436
|
+
</>
|
|
1437
|
+
);
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const renderBody = () => {
|
|
1441
|
+
if (view === "workspace-projects") return renderProjects();
|
|
1442
|
+
if (view === "workspace-current") return renderTaskRows(workspace.current, "No active tasks across projects.");
|
|
1443
|
+
if (view === "workspace-blockers") return renderTaskRows(workspace.blockers, "No blockers across projects.", "blocked");
|
|
1444
|
+
if (view === "workspace-validation") return renderTaskRows(workspace.validation, "No tasks to validate across projects.");
|
|
1445
|
+
if (view === "workspace-progress") return renderProgress();
|
|
1446
|
+
if (view === "workspace-warnings") return renderWarnings();
|
|
1447
|
+
return null;
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
const count =
|
|
1451
|
+
view === "workspace-projects" ? filteredProjectStats.length :
|
|
1452
|
+
view === "workspace-current" ? workspace.counts.current :
|
|
1453
|
+
view === "workspace-blockers" ? workspace.counts.blockers :
|
|
1454
|
+
view === "workspace-validation" ? workspace.counts.validation :
|
|
1455
|
+
view === "workspace-progress" ? workspace.counts.progress :
|
|
1456
|
+
view === "workspace-warnings" ? workspace.counts.warnings :
|
|
1457
|
+
null;
|
|
1458
|
+
|
|
1459
|
+
return (
|
|
1460
|
+
<div className="page">
|
|
1461
|
+
<section className="block">
|
|
1462
|
+
<SectionHeader title={title} count={count} right={projectFilterControl} />
|
|
1463
|
+
{renderBody()}
|
|
1464
|
+
</section>
|
|
1465
|
+
</div>
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/* ================================================================
|
|
1470
|
+
Project Outline Pages
|
|
1471
|
+
================================================================ */
|
|
1472
|
+
function ProjectWorkstreamsPage({ index, project, docs, onOpenWorkstream, onOpenDoc }) {
|
|
1473
|
+
const workstreams = project.outline?.workstreams || [];
|
|
1474
|
+
const taskDocs = useMemo(() => {
|
|
1475
|
+
const byTaskPath = {};
|
|
1476
|
+
docs.filter((doc) => doc.role === "task").forEach((task) => {
|
|
1477
|
+
byTaskPath[task.path] = task;
|
|
1478
|
+
});
|
|
1479
|
+
return byTaskPath;
|
|
1480
|
+
}, [docs]);
|
|
1481
|
+
|
|
1482
|
+
return (
|
|
1483
|
+
<div className="page">
|
|
1484
|
+
<h1 className="page-title">Workstreams</h1>
|
|
1485
|
+
<section className="block">
|
|
1486
|
+
<SectionHeader title="Project outline" count={workstreams.length} />
|
|
1487
|
+
{workstreams.length > 0 ? (
|
|
1488
|
+
<div className="outline-list">
|
|
1489
|
+
{workstreams.map((ws) => {
|
|
1490
|
+
const tasks = (ws.tasks || []).map((path) => taskDocs[path]).filter(Boolean);
|
|
1491
|
+
return (
|
|
1492
|
+
<div className="outline-row" key={ws.path}>
|
|
1493
|
+
<button className="outline-main" onClick={() => onOpenWorkstream(ws.path)} type="button">
|
|
1494
|
+
<span className="outline-title">{ws.title}</span>
|
|
1495
|
+
<StatusChip>{ws.status || "Planned"}</StatusChip>
|
|
1496
|
+
</button>
|
|
1497
|
+
{tasks.length > 0 && (
|
|
1498
|
+
<div className="outline-sublist">
|
|
1499
|
+
{tasks.map((task) => (
|
|
1500
|
+
<button className="outline-subitem" key={task.path} onClick={() => onOpenDoc(task.path)} type="button">
|
|
1501
|
+
<span className="mono">{task.taskId || task.path.split("/").pop()?.replace(/\.md$/, "")}</span>
|
|
1502
|
+
<span title={task.title}>{task.title}</span>
|
|
1503
|
+
<StatusChip>{task.status || "Planned"}</StatusChip>
|
|
1504
|
+
</button>
|
|
1505
|
+
))}
|
|
1506
|
+
</div>
|
|
1507
|
+
)}
|
|
1508
|
+
</div>
|
|
1509
|
+
);
|
|
1510
|
+
})}
|
|
1511
|
+
</div>
|
|
1512
|
+
) : (
|
|
1513
|
+
<div className="empty-state">No workstreams in this project.</div>
|
|
1514
|
+
)}
|
|
1515
|
+
</section>
|
|
1516
|
+
</div>
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function ProjectTasksPage({ project, docs, onOpenDoc, onOpenWorkstream }) {
|
|
1521
|
+
const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
|
|
1522
|
+
const { tasks, wsLookup } = dashboard;
|
|
1523
|
+
|
|
1524
|
+
return (
|
|
1525
|
+
<div className="page">
|
|
1526
|
+
<h1 className="page-title">Tasks</h1>
|
|
1527
|
+
<section className="block">
|
|
1528
|
+
<SectionHeader title="Project tasks" count={tasks.length} />
|
|
1529
|
+
{tasks.length > 0 ? (
|
|
1530
|
+
<div className="table table-4">
|
|
1531
|
+
<div className="tr th">
|
|
1532
|
+
<div>Task</div>
|
|
1533
|
+
<div>Workstream</div>
|
|
1534
|
+
<div>Status</div>
|
|
1535
|
+
<div>Source</div>
|
|
1536
|
+
</div>
|
|
1537
|
+
{tasks.map((task) => {
|
|
1538
|
+
const ws = wsLookup[task.path];
|
|
1539
|
+
return (
|
|
1540
|
+
<div className="tr" key={task.path}>
|
|
1541
|
+
<div className="td-primary">
|
|
1542
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
|
|
1543
|
+
</div>
|
|
1544
|
+
<div>
|
|
1545
|
+
{ws ? (
|
|
1546
|
+
<LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>{ws.title}</LinkButton>
|
|
1547
|
+
) : (
|
|
1548
|
+
<span className="td-muted">Unassigned</span>
|
|
1549
|
+
)}
|
|
1550
|
+
</div>
|
|
1551
|
+
<div><StatusChip>{task.status || "Planned"}</StatusChip></div>
|
|
1552
|
+
<div className="mono td-muted">{task.path.split("/").pop()}</div>
|
|
1553
|
+
</div>
|
|
1554
|
+
);
|
|
1555
|
+
})}
|
|
1556
|
+
</div>
|
|
1557
|
+
) : (
|
|
1558
|
+
<div className="empty-state">No tasks in this project.</div>
|
|
1559
|
+
)}
|
|
1560
|
+
</section>
|
|
1561
|
+
</div>
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/* ================================================================
|
|
1566
|
+
Dashboard Pages
|
|
1567
|
+
================================================================ */
|
|
1568
|
+
function DashboardPage({ project, docs, view, onOpenWorkstream, onOpenDoc }) {
|
|
1569
|
+
const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
|
|
1570
|
+
const { tasks, currentWork, blockers, progressDocs, warnings, wsLookup } = dashboard;
|
|
1571
|
+
const title = NAV.find((item) => item.id === view)?.label || "Dashboard";
|
|
1572
|
+
|
|
1573
|
+
const renderCurrentWork = () =>
|
|
1574
|
+
currentWork.length > 0 ? (
|
|
1575
|
+
<div className="table table-4">
|
|
1576
|
+
<div className="tr th">
|
|
1577
|
+
<div>Task</div>
|
|
1578
|
+
<div>Workstream</div>
|
|
1579
|
+
<div>Status</div>
|
|
1580
|
+
<div>Source</div>
|
|
1581
|
+
</div>
|
|
1582
|
+
{currentWork.map((task, i) => {
|
|
1583
|
+
const ws = wsLookup[task.path];
|
|
1584
|
+
return (
|
|
1585
|
+
<div className="tr" key={i}>
|
|
1586
|
+
<div className="td-primary">
|
|
1587
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
|
|
1588
|
+
{task.title}
|
|
1589
|
+
</LinkButton>
|
|
1590
|
+
</div>
|
|
1591
|
+
<div>
|
|
1592
|
+
{ws ? (
|
|
1593
|
+
<LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
|
|
1594
|
+
{ws.title}
|
|
1595
|
+
</LinkButton>
|
|
1596
|
+
) : (
|
|
1597
|
+
<span className="td-muted">-</span>
|
|
1598
|
+
)}
|
|
1599
|
+
</div>
|
|
1600
|
+
<div>
|
|
1601
|
+
<StatusChip>{task.status}</StatusChip>
|
|
1602
|
+
</div>
|
|
1603
|
+
<div className="mono td-muted">{task.path.split("/").pop()}</div>
|
|
1604
|
+
</div>
|
|
1605
|
+
);
|
|
1606
|
+
})}
|
|
1607
|
+
</div>
|
|
1608
|
+
) : (
|
|
1609
|
+
<div className="empty-state">No active tasks.</div>
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
const renderBlockers = () =>
|
|
1613
|
+
blockers.length > 0 ? (
|
|
1614
|
+
<div className="table table-3">
|
|
1615
|
+
<div className="tr th">
|
|
1616
|
+
<div>Task</div>
|
|
1617
|
+
<div>Workstream</div>
|
|
1618
|
+
<div>Details</div>
|
|
1619
|
+
</div>
|
|
1620
|
+
{blockers.map((task, i) => {
|
|
1621
|
+
const ws = wsLookup[task.path];
|
|
1622
|
+
return (
|
|
1623
|
+
<div className="tr" key={i}>
|
|
1624
|
+
<div className="td-primary">
|
|
1625
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
|
|
1626
|
+
<span className="dot dot-warn" /> {task.title}
|
|
1627
|
+
</LinkButton>
|
|
1628
|
+
</div>
|
|
1629
|
+
<div>
|
|
1630
|
+
{ws ? (
|
|
1631
|
+
<LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
|
|
1632
|
+
{ws.title}
|
|
1633
|
+
</LinkButton>
|
|
1634
|
+
) : (
|
|
1635
|
+
<span className="td-muted">-</span>
|
|
1636
|
+
)}
|
|
1637
|
+
</div>
|
|
1638
|
+
<div className="td-muted">{task.snippet || "-"}</div>
|
|
1639
|
+
</div>
|
|
1640
|
+
);
|
|
1641
|
+
})}
|
|
1642
|
+
</div>
|
|
1643
|
+
) : (
|
|
1644
|
+
<div className="empty-state">No blockers.</div>
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
const renderValidation = () =>
|
|
1648
|
+
tasks.length > 0 ? (
|
|
1649
|
+
<div className="table table-3">
|
|
1650
|
+
<div className="tr th">
|
|
1651
|
+
<div>Task</div>
|
|
1652
|
+
<div>Workstream</div>
|
|
1653
|
+
<div>State</div>
|
|
1654
|
+
</div>
|
|
1655
|
+
{tasks.map((task, i) => {
|
|
1656
|
+
const ws = wsLookup[task.path];
|
|
1657
|
+
const label = statusLabel(task.status);
|
|
1658
|
+
const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low";
|
|
1659
|
+
return (
|
|
1660
|
+
<div className="tr" key={i}>
|
|
1661
|
+
<div className="td-primary">
|
|
1662
|
+
<LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
|
|
1663
|
+
{task.title}
|
|
1664
|
+
</LinkButton>
|
|
1665
|
+
</div>
|
|
1666
|
+
<div>
|
|
1667
|
+
{ws ? (
|
|
1668
|
+
<LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
|
|
1669
|
+
{ws.title}
|
|
1670
|
+
</LinkButton>
|
|
1671
|
+
) : (
|
|
1672
|
+
<span className="td-muted">-</span>
|
|
1673
|
+
)}
|
|
1674
|
+
</div>
|
|
1675
|
+
<div>
|
|
1676
|
+
<span className={`chip ${chipClass}`}>
|
|
1677
|
+
<span className="chip-dot" /> {label}
|
|
1678
|
+
</span>
|
|
1679
|
+
</div>
|
|
1680
|
+
</div>
|
|
1681
|
+
);
|
|
1682
|
+
})}
|
|
1683
|
+
</div>
|
|
1684
|
+
) : (
|
|
1685
|
+
<div className="empty-state">No tasks to validate.</div>
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
const renderProgress = () =>
|
|
1689
|
+
progressDocs.length > 0 ? (
|
|
1690
|
+
<div className="timeline">
|
|
1691
|
+
{progressDocs.map((doc, i) => {
|
|
1692
|
+
const date = doc.updated
|
|
1693
|
+
? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
1694
|
+
: "";
|
|
1695
|
+
return (
|
|
1696
|
+
<div className="tl-row" key={i}>
|
|
1697
|
+
<div className="tl-date mono">{date}</div>
|
|
1698
|
+
<div className="tl-bullet">
|
|
1699
|
+
<span />
|
|
1700
|
+
</div>
|
|
1701
|
+
<div className="tl-body">
|
|
1702
|
+
<div>
|
|
1703
|
+
<LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}>
|
|
1704
|
+
{doc.title}
|
|
1705
|
+
</LinkButton>
|
|
1706
|
+
</div>
|
|
1707
|
+
<div className="td-muted small">{doc.snippet}</div>
|
|
1708
|
+
</div>
|
|
1709
|
+
</div>
|
|
1710
|
+
);
|
|
1711
|
+
})}
|
|
1712
|
+
</div>
|
|
1713
|
+
) : (
|
|
1714
|
+
<div className="empty-state">No progress entries.</div>
|
|
1715
|
+
);
|
|
1716
|
+
|
|
1717
|
+
const renderWarnings = () =>
|
|
1718
|
+
warnings.length > 0 ? (
|
|
1719
|
+
<div className="table table-3">
|
|
1720
|
+
<div className="tr th">
|
|
1721
|
+
<div>Severity</div>
|
|
1722
|
+
<div>Note</div>
|
|
1723
|
+
<div>Source</div>
|
|
1724
|
+
</div>
|
|
1725
|
+
{warnings.map((w, i) => (
|
|
1726
|
+
<div className="tr" key={i}>
|
|
1727
|
+
<div>
|
|
1728
|
+
<span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}>
|
|
1729
|
+
<span className="chip-dot" /> {w.sev}
|
|
1730
|
+
</span>
|
|
1731
|
+
</div>
|
|
1732
|
+
<div className="td-primary">{w.note}</div>
|
|
1733
|
+
<div className="td-muted">{w.ws}</div>
|
|
1734
|
+
</div>
|
|
1735
|
+
))}
|
|
1736
|
+
</div>
|
|
1737
|
+
) : (
|
|
1738
|
+
<div className="empty-state" style={{ color: "var(--ok)" }}>
|
|
1739
|
+
No warnings.
|
|
1740
|
+
</div>
|
|
1741
|
+
);
|
|
1742
|
+
|
|
1743
|
+
const renderBody = () => {
|
|
1744
|
+
if (view === "current") return renderCurrentWork();
|
|
1745
|
+
if (view === "blockers") return renderBlockers();
|
|
1746
|
+
if (view === "validation") return renderValidation();
|
|
1747
|
+
if (view === "progress") return renderProgress();
|
|
1748
|
+
if (view === "warnings") return renderWarnings();
|
|
1749
|
+
return null;
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
const count =
|
|
1753
|
+
view === "current" ? currentWork.length :
|
|
1754
|
+
view === "blockers" ? blockers.length :
|
|
1755
|
+
view === "validation" ? tasks.length :
|
|
1756
|
+
view === "progress" ? progressDocs.length :
|
|
1757
|
+
view === "warnings" ? warnings.length :
|
|
1758
|
+
null;
|
|
1759
|
+
|
|
1760
|
+
return (
|
|
1761
|
+
<div className="page">
|
|
1762
|
+
<h1 className="page-title">{title}</h1>
|
|
1763
|
+
<section className="block">
|
|
1764
|
+
<SectionHeader title={title} count={count} />
|
|
1765
|
+
{renderBody()}
|
|
1766
|
+
</section>
|
|
1767
|
+
</div>
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/* ================================================================
|
|
1772
|
+
Workstream Detail
|
|
1773
|
+
================================================================ */
|
|
1774
|
+
function WorkstreamDetail({ index, project, wsPath, onBack, onOpenDoc }) {
|
|
1775
|
+
const [wsDoc, setWsDoc] = useState(null);
|
|
1776
|
+
|
|
1777
|
+
useEffect(() => {
|
|
1778
|
+
if (!wsPath) return;
|
|
1779
|
+
fetch(`/api/doc?path=${encodeURIComponent(wsPath)}`)
|
|
1780
|
+
.then((r) => r.json())
|
|
1781
|
+
.then(setWsDoc);
|
|
1782
|
+
}, [wsPath]);
|
|
1783
|
+
|
|
1784
|
+
const outline = project.outline;
|
|
1785
|
+
const wsOutline = outline?.workstreams?.find((w) => w.path === wsPath);
|
|
1786
|
+
const tasks = useMemo(
|
|
1787
|
+
() => (wsOutline?.tasks || []).map((p) => byPath(index.docs, p)).filter(Boolean),
|
|
1788
|
+
[wsOutline, index.docs]
|
|
1789
|
+
);
|
|
1790
|
+
|
|
1791
|
+
if (!wsDoc) {
|
|
1792
|
+
return (
|
|
1793
|
+
<div className="page">
|
|
1794
|
+
<div className="empty-state">Loading workstream...</div>
|
|
1795
|
+
</div>
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const title = wsDoc.title || wsOutline?.title || "Workstream";
|
|
1800
|
+
const status = wsDoc.status || wsOutline?.status;
|
|
1801
|
+
const owner = wsDoc.frontmatter?.owner || "";
|
|
1802
|
+
const created = wsDoc.frontmatter?.created || "";
|
|
1803
|
+
const updated = wsDoc.updated || "";
|
|
1804
|
+
|
|
1805
|
+
// Parse markdown into named sections
|
|
1806
|
+
const body = (wsDoc.markdown || "").replace(/^---[\s\S]*?\n---\r?\n/, "");
|
|
1807
|
+
const sections = {};
|
|
1808
|
+
let currentSection = "__body";
|
|
1809
|
+
sections[currentSection] = [];
|
|
1810
|
+
for (const line of body.split(/\r?\n/)) {
|
|
1811
|
+
const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
|
|
1812
|
+
if (heading) {
|
|
1813
|
+
currentSection = heading[1].toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1814
|
+
sections[currentSection] = [];
|
|
1815
|
+
} else {
|
|
1816
|
+
if (!sections[currentSection]) sections[currentSection] = [];
|
|
1817
|
+
sections[currentSection].push(line);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
const sectionHtml = (key) => {
|
|
1821
|
+
const lines = sections[key];
|
|
1822
|
+
if (!lines || !lines.filter((l) => l.trim()).length) return null;
|
|
1823
|
+
return parseBlocks(lines);
|
|
1824
|
+
};
|
|
1825
|
+
|
|
1826
|
+
const summaryHtml = sectionHtml("summary") || sectionHtml("__body");
|
|
1827
|
+
const goalHtml = sectionHtml("goal") || sectionHtml("objective");
|
|
1828
|
+
const scopeHtml = sectionHtml("scope");
|
|
1829
|
+
const notesHtml = sectionHtml("notes");
|
|
1830
|
+
const decisionsHtml = sectionHtml("decisions");
|
|
1831
|
+
|
|
1832
|
+
const fmtDate = (d) =>
|
|
1833
|
+
d ? new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
|
|
1834
|
+
|
|
1835
|
+
return (
|
|
1836
|
+
<div className="page">
|
|
1837
|
+
<button className="back" onClick={onBack}>
|
|
1838
|
+
<Icon d={I.arrowL} size={14} /> Back to Overview
|
|
1839
|
+
</button>
|
|
1840
|
+
<div className="ws-eyebrow">Workstream</div>
|
|
1841
|
+
<h1 className="page-title">{title}</h1>
|
|
1842
|
+
|
|
1843
|
+
<section className="summary summary-tight">
|
|
1844
|
+
<Field label="Status">
|
|
1845
|
+
<StatusChip>{status || "Planned"}</StatusChip>
|
|
1846
|
+
</Field>
|
|
1847
|
+
<Field label="Owner">{owner || "—"}</Field>
|
|
1848
|
+
<Field label="Created">{fmtDate(created)}</Field>
|
|
1849
|
+
<Field label="Last updated">{fmtDate(updated)}</Field>
|
|
1850
|
+
</section>
|
|
1851
|
+
|
|
1852
|
+
<div className="two-col">
|
|
1853
|
+
<div className="col-main">
|
|
1854
|
+
{summaryHtml && (
|
|
1855
|
+
<Block title="Summary">
|
|
1856
|
+
<div dangerouslySetInnerHTML={{ __html: summaryHtml }} />
|
|
1857
|
+
</Block>
|
|
1858
|
+
)}
|
|
1859
|
+
{goalHtml && (
|
|
1860
|
+
<Block title="Goal">
|
|
1861
|
+
<div dangerouslySetInnerHTML={{ __html: goalHtml }} />
|
|
1862
|
+
</Block>
|
|
1863
|
+
)}
|
|
1864
|
+
{scopeHtml && (
|
|
1865
|
+
<Block title="Scope">
|
|
1866
|
+
<div dangerouslySetInnerHTML={{ __html: scopeHtml }} />
|
|
1867
|
+
</Block>
|
|
1868
|
+
)}
|
|
1869
|
+
|
|
1870
|
+
<Block title="Tasks">
|
|
1871
|
+
<ul className="checklist">
|
|
1872
|
+
{tasks.map((t, i) => {
|
|
1873
|
+
const done = statusLabel(t.status) === "Complete";
|
|
1874
|
+
const taskId = t.taskId || t.path.split("/").pop()?.replace(/\.md$/, "");
|
|
1875
|
+
return (
|
|
1876
|
+
<li key={i} className={done ? "done" : ""}>
|
|
1877
|
+
<span className="cb">
|
|
1878
|
+
{done && <Icon d={<path d="M5 12.5l4 4 10-11" />} size={11} />}
|
|
1879
|
+
</span>
|
|
1880
|
+
<LinkButton onClick={() => onOpenDoc(t.path, wsPath)} title={t.title}>
|
|
1881
|
+
{t.title}
|
|
1882
|
+
</LinkButton>
|
|
1883
|
+
<span className="checklist-meta">
|
|
1884
|
+
<StatusChip>{t.status || "Planned"}</StatusChip>
|
|
1885
|
+
<span className="task-id-copy mono">
|
|
1886
|
+
<span>{taskId}</span>
|
|
1887
|
+
<CopyButton value={taskId} label="task ID" />
|
|
1888
|
+
</span>
|
|
1889
|
+
</span>
|
|
1890
|
+
</li>
|
|
1891
|
+
);
|
|
1892
|
+
})}
|
|
1893
|
+
{!tasks.length && (
|
|
1894
|
+
<li style={{ color: "var(--ink-50)", listStyle: "none" }}>
|
|
1895
|
+
No tasks linked to this workstream.
|
|
1896
|
+
</li>
|
|
1897
|
+
)}
|
|
1898
|
+
</ul>
|
|
1899
|
+
</Block>
|
|
1900
|
+
|
|
1901
|
+
{notesHtml && (
|
|
1902
|
+
<Block title="Notes">
|
|
1903
|
+
<div dangerouslySetInnerHTML={{ __html: notesHtml }} />
|
|
1904
|
+
</Block>
|
|
1905
|
+
)}
|
|
1906
|
+
{decisionsHtml && (
|
|
1907
|
+
<Block title="Decisions">
|
|
1908
|
+
<div dangerouslySetInnerHTML={{ __html: decisionsHtml }} />
|
|
1909
|
+
</Block>
|
|
1910
|
+
)}
|
|
1911
|
+
</div>
|
|
1912
|
+
|
|
1913
|
+
<aside className="col-side">
|
|
1914
|
+
<div className="side-block">
|
|
1915
|
+
<div className="side-head">Details</div>
|
|
1916
|
+
<dl className="dl">
|
|
1917
|
+
<dt>Status</dt>
|
|
1918
|
+
<dd>
|
|
1919
|
+
<StatusChip>{status || "Planned"}</StatusChip>
|
|
1920
|
+
</dd>
|
|
1921
|
+
{owner && (
|
|
1922
|
+
<>
|
|
1923
|
+
<dt>Owner</dt>
|
|
1924
|
+
<dd>{owner}</dd>
|
|
1925
|
+
</>
|
|
1926
|
+
)}
|
|
1927
|
+
<dt>
|
|
1928
|
+
<span>Source path</span>
|
|
1929
|
+
<CopyButton value={wsPath} label="source path" />
|
|
1930
|
+
</dt>
|
|
1931
|
+
<dd className="mono small">
|
|
1932
|
+
<span className="copy-value">{wsPath}</span>
|
|
1933
|
+
</dd>
|
|
1934
|
+
{wsOutline?.id && (
|
|
1935
|
+
<>
|
|
1936
|
+
<dt>
|
|
1937
|
+
<span>ID</span>
|
|
1938
|
+
<CopyButton value={wsOutline.id} label="workstream ID" />
|
|
1939
|
+
</dt>
|
|
1940
|
+
<dd className="mono">
|
|
1941
|
+
<span>{wsOutline.id}</span>
|
|
1942
|
+
</dd>
|
|
1943
|
+
</>
|
|
1944
|
+
)}
|
|
1945
|
+
</dl>
|
|
1946
|
+
</div>
|
|
1947
|
+
|
|
1948
|
+
<div className="side-block">
|
|
1949
|
+
<div className="side-head">Task summary</div>
|
|
1950
|
+
<dl className="dl">
|
|
1951
|
+
<dt>Total</dt>
|
|
1952
|
+
<dd className="mono">{tasks.length}</dd>
|
|
1953
|
+
<dt>Open</dt>
|
|
1954
|
+
<dd className="mono">{tasks.filter((task) => statusLabel(task.status) !== "Complete").length}</dd>
|
|
1955
|
+
<dt>Complete</dt>
|
|
1956
|
+
<dd className="mono">{tasks.filter((task) => statusLabel(task.status) === "Complete").length}</dd>
|
|
1957
|
+
</dl>
|
|
1958
|
+
</div>
|
|
1959
|
+
</aside>
|
|
1960
|
+
</div>
|
|
1961
|
+
</div>
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/* ================================================================
|
|
1966
|
+
Document Reader
|
|
1967
|
+
================================================================ */
|
|
1968
|
+
function DocumentReader({ doc, project, index, onBack, onOpenAction, onOpenDoc, onOpenWorkstream, onOpenTasks }) {
|
|
1969
|
+
if (!doc) {
|
|
1970
|
+
return (
|
|
1971
|
+
<div className="page">
|
|
1972
|
+
<div className="empty-state">Loading document...</div>
|
|
1973
|
+
</div>
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const props = Object.entries(doc.frontmatter || {});
|
|
1978
|
+
const taskNav = getTaskNavigation(index, project, doc);
|
|
1979
|
+
const taskReference = (value) => {
|
|
1980
|
+
if (!taskNav) return null;
|
|
1981
|
+
return taskNav.tasksById[String(value || "").trim().toUpperCase()] || null;
|
|
1982
|
+
};
|
|
1983
|
+
const renderMetaValue = (key, value) => {
|
|
1984
|
+
const values = listValue(value);
|
|
1985
|
+
|
|
1986
|
+
if (doc.role === "task" && key === "workstream" && taskNav?.parent) {
|
|
1987
|
+
return (
|
|
1988
|
+
<LinkButton onClick={() => onOpenWorkstream(taskNav.parent.path)} title={taskNav.parent.title}>
|
|
1989
|
+
{values.join(", ") || taskNav.parent.id || taskNav.parent.title}
|
|
1990
|
+
</LinkButton>
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (doc.role === "task" && ["depends_on", "conflicts_with"].includes(key) && values.length) {
|
|
1995
|
+
return values.map((item, index) => {
|
|
1996
|
+
const relatedTask = taskReference(item);
|
|
1997
|
+
return (
|
|
1998
|
+
<React.Fragment key={`${key}:${item}`}>
|
|
1999
|
+
{index > 0 && <span className="meta-separator">,</span>}
|
|
2000
|
+
{relatedTask ? (
|
|
2001
|
+
<LinkButton onClick={() => onOpenDoc(relatedTask.path, taskNav?.parent?.path || null)} title={relatedTask.title}>
|
|
2002
|
+
{item}
|
|
2003
|
+
</LinkButton>
|
|
2004
|
+
) : (
|
|
2005
|
+
<span>{item}</span>
|
|
2006
|
+
)}
|
|
2007
|
+
</React.Fragment>
|
|
2008
|
+
);
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
2013
|
+
return String(value ?? "");
|
|
2014
|
+
};
|
|
2015
|
+
const fmtDate = (d) =>
|
|
2016
|
+
d
|
|
2017
|
+
? new Date(d).toLocaleString("en-US", {
|
|
2018
|
+
month: "short", day: "numeric", year: "numeric",
|
|
2019
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
2020
|
+
})
|
|
2021
|
+
: "—";
|
|
2022
|
+
|
|
2023
|
+
return (
|
|
2024
|
+
<div className="page doc-reader-page">
|
|
2025
|
+
<div className="doc-reader-main">
|
|
2026
|
+
{onBack && (
|
|
2027
|
+
<button className="back" onClick={onBack}>
|
|
2028
|
+
<Icon d={I.arrowL} size={14} /> Back
|
|
2029
|
+
</button>
|
|
2030
|
+
)}
|
|
2031
|
+
<div className="ws-eyebrow">{titleCase(doc.role)}</div>
|
|
2032
|
+
<h1 className="page-title">{doc.title}</h1>
|
|
2033
|
+
|
|
2034
|
+
<article
|
|
2035
|
+
className="md-body"
|
|
2036
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(doc.markdown || "") }}
|
|
2037
|
+
/>
|
|
2038
|
+
</div>
|
|
2039
|
+
|
|
2040
|
+
<aside className="doc-side" aria-label="Document context">
|
|
2041
|
+
{doc.role === "task" && taskNav && (
|
|
2042
|
+
<div className="side-block task-nav-block">
|
|
2043
|
+
<div className="side-head">Task navigation</div>
|
|
2044
|
+
<div className="task-parent">
|
|
2045
|
+
<div className="side-label">Parent workstream</div>
|
|
2046
|
+
{taskNav.parent ? (
|
|
2047
|
+
<button className="task-parent-link" type="button" onClick={() => onOpenWorkstream(taskNav.parent.path)}>
|
|
2048
|
+
<span>{taskNav.parent.title}</span>
|
|
2049
|
+
<Icon d={I.chevR} size={13} />
|
|
2050
|
+
</button>
|
|
2051
|
+
) : (
|
|
2052
|
+
<div className="empty-state">No parent workstream found.</div>
|
|
2053
|
+
)}
|
|
2054
|
+
</div>
|
|
2055
|
+
<div className="task-nav-actions">
|
|
2056
|
+
<button className="link-muted" type="button" onClick={onOpenTasks}>All project tasks</button>
|
|
2057
|
+
</div>
|
|
2058
|
+
<div className="side-label side-label-list">
|
|
2059
|
+
Sibling tasks <span className="count">{taskNav.siblings.length}</span>
|
|
2060
|
+
</div>
|
|
2061
|
+
<ul className="side-list task-sibling-list">
|
|
2062
|
+
{taskNav.siblings.map((task) => {
|
|
2063
|
+
const isCurrent = task.path === doc.path;
|
|
2064
|
+
return (
|
|
2065
|
+
<li key={task.path} className={isCurrent ? "is-current" : ""}>
|
|
2066
|
+
<button
|
|
2067
|
+
className="side-list-button"
|
|
2068
|
+
type="button"
|
|
2069
|
+
onClick={() => onOpenDoc(task.path, taskNav.parent?.path || null)}
|
|
2070
|
+
disabled={isCurrent}
|
|
2071
|
+
>
|
|
2072
|
+
<span className="sl-name">{task.title}</span>
|
|
2073
|
+
<span className="sl-right">
|
|
2074
|
+
<StatusChip>{task.status || "Planned"}</StatusChip>
|
|
2075
|
+
{!isCurrent && <Icon d={I.chevR} size={13} />}
|
|
2076
|
+
</span>
|
|
2077
|
+
</button>
|
|
2078
|
+
</li>
|
|
2079
|
+
);
|
|
2080
|
+
})}
|
|
2081
|
+
{!taskNav.siblings.length && (
|
|
2082
|
+
<li className="side-list-empty">No siblings</li>
|
|
2083
|
+
)}
|
|
2084
|
+
</ul>
|
|
2085
|
+
</div>
|
|
2086
|
+
)}
|
|
2087
|
+
|
|
2088
|
+
<div className="doc-meta-panel" aria-label="Document metadata">
|
|
2089
|
+
<div className="doc-meta-title">Metadata</div>
|
|
2090
|
+
<dl className="dl doc-meta-list">
|
|
2091
|
+
<dt>
|
|
2092
|
+
<span>Path</span>
|
|
2093
|
+
<CopyButton value={doc.path} label="source path" />
|
|
2094
|
+
</dt>
|
|
2095
|
+
<dd className="mono">
|
|
2096
|
+
<span className="copy-value">{doc.path}</span>
|
|
2097
|
+
</dd>
|
|
2098
|
+
{doc.status && (
|
|
2099
|
+
<>
|
|
2100
|
+
<dt>Status</dt>
|
|
2101
|
+
<dd><StatusChip>{doc.status}</StatusChip></dd>
|
|
2102
|
+
</>
|
|
2103
|
+
)}
|
|
2104
|
+
<dt>Updated</dt>
|
|
2105
|
+
<dd>{fmtDate(doc.updated)}</dd>
|
|
2106
|
+
{props.map(([k, v]) => {
|
|
2107
|
+
const copyable = isCopyableMetaKey(k);
|
|
2108
|
+
return (
|
|
2109
|
+
<React.Fragment key={k}>
|
|
2110
|
+
<dt>
|
|
2111
|
+
<span>{k}</span>
|
|
2112
|
+
{copyable && (
|
|
2113
|
+
<CopyButton value={v} label={copyLabelFromMetaKey(k, doc.role)} />
|
|
2114
|
+
)}
|
|
2115
|
+
</dt>
|
|
2116
|
+
<dd>
|
|
2117
|
+
{renderMetaValue(k, v)}
|
|
2118
|
+
</dd>
|
|
2119
|
+
</React.Fragment>
|
|
2120
|
+
);
|
|
2121
|
+
})}
|
|
2122
|
+
</dl>
|
|
2123
|
+
</div>
|
|
2124
|
+
</aside>
|
|
2125
|
+
</div>
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/* ================================================================
|
|
2130
|
+
Document List (for non-project folders like context, templates)
|
|
2131
|
+
================================================================ */
|
|
2132
|
+
function DocumentList({ index, project, docs, onOpenDoc }) {
|
|
2133
|
+
const [query, setQuery] = useState("");
|
|
2134
|
+
const filtered = useMemo(() => {
|
|
2135
|
+
if (!query) return docs;
|
|
2136
|
+
const q = query.toLowerCase();
|
|
2137
|
+
return docs.filter((d) => {
|
|
2138
|
+
const haystack = [d.title, d.path, d.snippet, d.role].join(" ").toLowerCase();
|
|
2139
|
+
return haystack.includes(q);
|
|
2140
|
+
});
|
|
2141
|
+
}, [docs, query]);
|
|
2142
|
+
|
|
2143
|
+
return (
|
|
2144
|
+
<div className="page">
|
|
2145
|
+
<h1 className="page-title">{project.title}</h1>
|
|
2146
|
+
|
|
2147
|
+
<div style={{ maxWidth: "400px" }}>
|
|
2148
|
+
<input
|
|
2149
|
+
className="search-input"
|
|
2150
|
+
placeholder="Search documents..."
|
|
2151
|
+
value={query}
|
|
2152
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2153
|
+
/>
|
|
2154
|
+
</div>
|
|
2155
|
+
|
|
2156
|
+
<div className="table table-list">
|
|
2157
|
+
<div className="tr th">
|
|
2158
|
+
<div>Document</div>
|
|
2159
|
+
<div>Role</div>
|
|
2160
|
+
<div>Status</div>
|
|
2161
|
+
<div>Updated</div>
|
|
2162
|
+
</div>
|
|
2163
|
+
{filtered.map((doc, i) => (
|
|
2164
|
+
<div className="tr" key={i} style={{ cursor: "pointer" }} onClick={() => onOpenDoc(doc.path)}>
|
|
2165
|
+
<div>
|
|
2166
|
+
<div className="td-primary">{doc.title}</div>
|
|
2167
|
+
<div className="mono td-muted" style={{ fontSize: "11px", marginTop: "2px" }}>
|
|
2168
|
+
{doc.path}
|
|
2169
|
+
</div>
|
|
2170
|
+
</div>
|
|
2171
|
+
<div className="td-muted">{titleCase(doc.role)}</div>
|
|
2172
|
+
<div>
|
|
2173
|
+
{doc.status ? <StatusChip>{doc.status}</StatusChip> : <span className="td-muted">—</span>}
|
|
2174
|
+
</div>
|
|
2175
|
+
<div className="td-muted">
|
|
2176
|
+
{doc.updated
|
|
2177
|
+
? new Date(doc.updated).toLocaleDateString("en-US", {
|
|
2178
|
+
month: "short",
|
|
2179
|
+
day: "numeric",
|
|
2180
|
+
year: "numeric",
|
|
2181
|
+
})
|
|
2182
|
+
: "—"}
|
|
2183
|
+
</div>
|
|
2184
|
+
</div>
|
|
2185
|
+
))}
|
|
2186
|
+
{!filtered.length && (
|
|
2187
|
+
<div style={{ padding: "32px 0", color: "var(--ink-50)", textAlign: "center" }}>
|
|
2188
|
+
{query ? "No documents match your search." : "No documents in this folder."}
|
|
2189
|
+
</div>
|
|
2190
|
+
)}
|
|
2191
|
+
</div>
|
|
2192
|
+
|
|
2193
|
+
<div className="page-foot mono">
|
|
2194
|
+
viewer · read-only · {docs.length} document{docs.length !== 1 ? "s" : ""} · generated at{" "}
|
|
2195
|
+
<span>{index.generatedAt || ""}</span>
|
|
2196
|
+
</div>
|
|
2197
|
+
</div>
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/* ================================================================
|
|
2202
|
+
App — root component
|
|
2203
|
+
================================================================ */
|
|
2204
|
+
function App() {
|
|
2205
|
+
const [index, setIndex] = useState(null);
|
|
2206
|
+
const [projectSlug, setProjectSlug] = useState(null);
|
|
2207
|
+
const [route, setRoute] = useState(DEFAULT_WORKSPACE_ROUTE);
|
|
2208
|
+
const [section, setSection] = useState(null);
|
|
2209
|
+
const [docPath, setDocPath] = useState(null);
|
|
2210
|
+
const [doc, setDoc] = useState(null);
|
|
2211
|
+
const [wsPath, setWsPath] = useState(null);
|
|
2212
|
+
const [workspacePages, setWorkspacePages] = useState({});
|
|
2213
|
+
|
|
2214
|
+
// Load index on mount
|
|
2215
|
+
useEffect(() => {
|
|
2216
|
+
fetch("/api/index")
|
|
2217
|
+
.then((r) => r.json())
|
|
2218
|
+
.then((data) => {
|
|
2219
|
+
const nav = restoreNavigation(data);
|
|
2220
|
+
setIndex(data);
|
|
2221
|
+
setProjectSlug(nav.projectSlug);
|
|
2222
|
+
setRoute(nav.route);
|
|
2223
|
+
setSection(nav.section);
|
|
2224
|
+
setDocPath(nav.docPath);
|
|
2225
|
+
setWsPath(nav.wsPath);
|
|
2226
|
+
setWorkspacePages(nav.workspacePages);
|
|
2227
|
+
});
|
|
2228
|
+
}, []);
|
|
2229
|
+
|
|
2230
|
+
useEffect(() => {
|
|
2231
|
+
if (!index || !projectSlug) return;
|
|
2232
|
+
try {
|
|
2233
|
+
window.localStorage.setItem(NAV_STATE_KEY, JSON.stringify({
|
|
2234
|
+
version: NAV_STATE_VERSION,
|
|
2235
|
+
projectSlug,
|
|
2236
|
+
route,
|
|
2237
|
+
section,
|
|
2238
|
+
docPath,
|
|
2239
|
+
wsPath,
|
|
2240
|
+
workspacePages,
|
|
2241
|
+
}));
|
|
2242
|
+
} catch (_) {
|
|
2243
|
+
/* ignore unavailable storage */
|
|
2244
|
+
}
|
|
2245
|
+
}, [index, projectSlug, route, section, docPath, wsPath, workspacePages]);
|
|
2246
|
+
|
|
2247
|
+
// Load doc when docPath changes
|
|
2248
|
+
useEffect(() => {
|
|
2249
|
+
if (!docPath) {
|
|
2250
|
+
setDoc(null);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
setDoc(null);
|
|
2254
|
+
fetch(`/api/doc?path=${encodeURIComponent(docPath)}`)
|
|
2255
|
+
.then((r) => r.json())
|
|
2256
|
+
.then(setDoc);
|
|
2257
|
+
}, [docPath]);
|
|
2258
|
+
|
|
2259
|
+
// Scroll to top on route change
|
|
2260
|
+
useEffect(() => {
|
|
2261
|
+
const main = document.querySelector(".main");
|
|
2262
|
+
if (main) main.scrollTo(0, 0);
|
|
2263
|
+
}, [route, wsPath, docPath]);
|
|
2264
|
+
|
|
2265
|
+
const handleWorkspacePageChange = useCallback((view, nextPage) => {
|
|
2266
|
+
const page = Math.max(1, Math.floor(Number(nextPage) || 1));
|
|
2267
|
+
setWorkspacePages((pages) => ({ ...pages, [view]: page }));
|
|
2268
|
+
}, []);
|
|
2269
|
+
|
|
2270
|
+
const updateCurrentWorkspacePage = useCallback(
|
|
2271
|
+
(nextPage) => handleWorkspacePageChange(route, nextPage),
|
|
2272
|
+
[handleWorkspacePageChange, route]
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2275
|
+
if (!index) {
|
|
2276
|
+
return (
|
|
2277
|
+
<div style={{ padding: "48px", color: "var(--ink-50)", fontFamily: "var(--font-sans)" }}>
|
|
2278
|
+
Loading Delano viewer...
|
|
2279
|
+
</div>
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
const { project, docs } = getProjectData(index, projectSlug);
|
|
2284
|
+
const hasOutline = project?.outline;
|
|
2285
|
+
|
|
2286
|
+
const handleSelectProject = (slug) => {
|
|
2287
|
+
setProjectSlug(slug);
|
|
2288
|
+
const p = index.projects.find((pp) => pp.slug === slug);
|
|
2289
|
+
const nextRoute = fallbackRouteForProject(p);
|
|
2290
|
+
setRoute(nextRoute);
|
|
2291
|
+
setSection(nextRoute === "overview" ? "overview" : null);
|
|
2292
|
+
setDocPath(null);
|
|
2293
|
+
setDoc(null);
|
|
2294
|
+
setWsPath(null);
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
const handleNavigate = (newRoute, newSection) => {
|
|
2298
|
+
if (newRoute === "document" && newSection) {
|
|
2299
|
+
setRoute("document");
|
|
2300
|
+
setDocPath(newSection);
|
|
2301
|
+
setSection(newSection);
|
|
2302
|
+
} else {
|
|
2303
|
+
setRoute(newRoute);
|
|
2304
|
+
setSection(newSection || null);
|
|
2305
|
+
setDocPath(null);
|
|
2306
|
+
setDoc(null);
|
|
2307
|
+
}
|
|
2308
|
+
setWsPath(null);
|
|
2309
|
+
};
|
|
2310
|
+
|
|
2311
|
+
const handleOpenWorkstream = (path) => {
|
|
2312
|
+
setRoute("workstream");
|
|
2313
|
+
setWsPath(path);
|
|
2314
|
+
setSection(null);
|
|
2315
|
+
setDocPath(null);
|
|
2316
|
+
setDoc(null);
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
const handleOpenDoc = (path, contextWsPath) => {
|
|
2320
|
+
setRoute("document");
|
|
2321
|
+
setDocPath(path);
|
|
2322
|
+
setSection(path);
|
|
2323
|
+
if (contextWsPath !== undefined) setWsPath(contextWsPath);
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
const handleOpenProject = (slug) => {
|
|
2327
|
+
const p = index.projects.find((pp) => pp.slug === slug);
|
|
2328
|
+
setProjectSlug(slug);
|
|
2329
|
+
const nextRoute = fallbackRouteForProject(p);
|
|
2330
|
+
setRoute(nextRoute);
|
|
2331
|
+
setSection(nextRoute === "overview" ? "overview" : null);
|
|
2332
|
+
setWsPath(null);
|
|
2333
|
+
setDocPath(null);
|
|
2334
|
+
setDoc(null);
|
|
2335
|
+
};
|
|
2336
|
+
|
|
2337
|
+
const handleOpenProjectDoc = (slug, path) => {
|
|
2338
|
+
setProjectSlug(slug);
|
|
2339
|
+
setRoute("document");
|
|
2340
|
+
setDocPath(path);
|
|
2341
|
+
setSection(path);
|
|
2342
|
+
setWsPath(null);
|
|
2343
|
+
};
|
|
2344
|
+
|
|
2345
|
+
const handleOpenProjectWorkstream = (slug, path) => {
|
|
2346
|
+
setProjectSlug(slug);
|
|
2347
|
+
setRoute("workstream");
|
|
2348
|
+
setWsPath(path);
|
|
2349
|
+
setSection(null);
|
|
2350
|
+
setDocPath(null);
|
|
2351
|
+
setDoc(null);
|
|
2352
|
+
};
|
|
2353
|
+
|
|
2354
|
+
const handleOpenAction = async (target, path) => {
|
|
2355
|
+
try {
|
|
2356
|
+
await fetch(`/api/open?target=${encodeURIComponent(target)}&path=${encodeURIComponent(path)}`, {
|
|
2357
|
+
method: "POST",
|
|
2358
|
+
});
|
|
2359
|
+
} catch (_) {
|
|
2360
|
+
/* ignore */
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
const handleBack = () => {
|
|
2365
|
+
if (route === "document" && wsPath) {
|
|
2366
|
+
setRoute("workstream");
|
|
2367
|
+
setDocPath(null);
|
|
2368
|
+
setDoc(null);
|
|
2369
|
+
setSection(null);
|
|
2370
|
+
} else {
|
|
2371
|
+
setRoute(hasOutline ? "overview" : "list");
|
|
2372
|
+
setSection(hasOutline ? "overview" : null);
|
|
2373
|
+
setWsPath(null);
|
|
2374
|
+
setDocPath(null);
|
|
2375
|
+
setDoc(null);
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
let mainContent;
|
|
2380
|
+
if (route === "workstream" && wsPath && hasOutline) {
|
|
2381
|
+
mainContent = (
|
|
2382
|
+
<WorkstreamDetail
|
|
2383
|
+
index={index}
|
|
2384
|
+
project={project}
|
|
2385
|
+
wsPath={wsPath}
|
|
2386
|
+
onBack={handleBack}
|
|
2387
|
+
onOpenDoc={handleOpenDoc}
|
|
2388
|
+
/>
|
|
2389
|
+
);
|
|
2390
|
+
} else if (route === "document" && docPath) {
|
|
2391
|
+
mainContent = (
|
|
2392
|
+
<DocumentReader
|
|
2393
|
+
doc={doc}
|
|
2394
|
+
project={project}
|
|
2395
|
+
index={index}
|
|
2396
|
+
onBack={handleBack}
|
|
2397
|
+
onOpenAction={handleOpenAction}
|
|
2398
|
+
onOpenDoc={handleOpenDoc}
|
|
2399
|
+
onOpenWorkstream={handleOpenWorkstream}
|
|
2400
|
+
onOpenTasks={() => handleNavigate("tasks")}
|
|
2401
|
+
/>
|
|
2402
|
+
);
|
|
2403
|
+
} else if (GLOBAL_ROUTES.has(route)) {
|
|
2404
|
+
mainContent = (
|
|
2405
|
+
<WorkspacePage
|
|
2406
|
+
index={index}
|
|
2407
|
+
view={route}
|
|
2408
|
+
page={workspacePages[route] || 1}
|
|
2409
|
+
onPageChange={updateCurrentWorkspacePage}
|
|
2410
|
+
onOpenProject={handleOpenProject}
|
|
2411
|
+
onOpenProjectDoc={handleOpenProjectDoc}
|
|
2412
|
+
onOpenProjectWorkstream={handleOpenProjectWorkstream}
|
|
2413
|
+
/>
|
|
2414
|
+
);
|
|
2415
|
+
} else if (route === "workstreams" && hasOutline) {
|
|
2416
|
+
mainContent = (
|
|
2417
|
+
<ProjectWorkstreamsPage
|
|
2418
|
+
index={index}
|
|
2419
|
+
project={project}
|
|
2420
|
+
docs={docs}
|
|
2421
|
+
onOpenWorkstream={handleOpenWorkstream}
|
|
2422
|
+
onOpenDoc={handleOpenDoc}
|
|
2423
|
+
/>
|
|
2424
|
+
);
|
|
2425
|
+
} else if (route === "tasks" && hasOutline) {
|
|
2426
|
+
mainContent = (
|
|
2427
|
+
<ProjectTasksPage
|
|
2428
|
+
project={project}
|
|
2429
|
+
docs={docs}
|
|
2430
|
+
onOpenDoc={handleOpenDoc}
|
|
2431
|
+
onOpenWorkstream={handleOpenWorkstream}
|
|
2432
|
+
/>
|
|
2433
|
+
);
|
|
2434
|
+
} else if (route === "overview" && hasOutline) {
|
|
2435
|
+
mainContent = (
|
|
2436
|
+
<Overview
|
|
2437
|
+
index={index}
|
|
2438
|
+
project={project}
|
|
2439
|
+
docs={docs}
|
|
2440
|
+
scrollTarget={section}
|
|
2441
|
+
onOpenWorkstream={handleOpenWorkstream}
|
|
2442
|
+
onOpenDoc={handleOpenDoc}
|
|
2443
|
+
onOpenTasks={() => handleNavigate("tasks")}
|
|
2444
|
+
/>
|
|
2445
|
+
);
|
|
2446
|
+
} else {
|
|
2447
|
+
mainContent = (
|
|
2448
|
+
<DocumentList index={index} project={project} docs={docs} onOpenDoc={handleOpenDoc} />
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
return (
|
|
2453
|
+
<div className="app">
|
|
2454
|
+
<Sidebar
|
|
2455
|
+
index={index}
|
|
2456
|
+
projectSlug={projectSlug}
|
|
2457
|
+
route={route}
|
|
2458
|
+
section={section}
|
|
2459
|
+
onNavigate={handleNavigate}
|
|
2460
|
+
onSelectProject={handleSelectProject}
|
|
2461
|
+
/>
|
|
2462
|
+
<div className="main">
|
|
2463
|
+
<Topbar
|
|
2464
|
+
project={project}
|
|
2465
|
+
index={index}
|
|
2466
|
+
docPath={docPath || (hasOutline ? project.outline.spec : null)}
|
|
2467
|
+
onOpenAction={handleOpenAction}
|
|
2468
|
+
/>
|
|
2469
|
+
|
|
2470
|
+
<div className="content content-reader-head-c">{mainContent}</div>
|
|
2471
|
+
|
|
2472
|
+
</div>
|
|
2473
|
+
<div id="copy-live" className="sr-only" aria-live="polite" aria-atomic="true"></div>
|
|
2474
|
+
</div>
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|