Skip to content

Commit 2a83946

Browse files
committed
Add Admin Notes directory links legend and header path polish - PR_26156_187-admin-notes-directory-links-and-legend
1 parent f298df1 commit 2a83946

9 files changed

Lines changed: 545 additions & 45 deletions

File tree

admin/notes.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,25 @@ <h1>Admin Notes</h1>
4141
<a href="admin/tools-progress.html">Tools Progress</a>
4242
<a href="admin/users.html">Users</a>
4343
</aside>
44-
<article class="card" aria-labelledby="admin-notes-title">
44+
<article class="card" aria-labelledby="admin-notes-title" data-admin-notes-viewer>
4545
<div class="card-body content-stack">
4646
<div>
4747
<div class="kicker">Project Notes</div>
48-
<h2 id="admin-notes-title" data-admin-notes-title>index.txt</h2>
48+
<div class="content-cluster">
49+
<h2 id="admin-notes-title" data-admin-notes-title>index.txt</h2>
50+
<span class="status" role="status" data-admin-notes-status>Loading admin notes.</span>
51+
</div>
4952
</div>
5053
<a href="admin/notes.html" data-admin-notes-return hidden>Return to index.txt</a>
51-
<div class="status" role="status" data-admin-notes-status>Loading admin notes.</div>
5254
<div class="content-stack" data-admin-notes-content aria-live="polite"></div>
55+
<nav class="callout" aria-labelledby="admin-notes-directory-title" data-admin-notes-directory hidden>
56+
<h3 id="admin-notes-directory-title">Current Folder</h3>
57+
<div class="action-group action-group--tight" data-admin-notes-directory-links></div>
58+
</nav>
59+
<section class="callout content-stack" aria-labelledby="admin-notes-legend-title" data-admin-notes-legend>
60+
<h3 id="admin-notes-legend-title">Status Legend</h3>
61+
<ul data-admin-notes-legend-list></ul>
62+
</section>
5363
</div>
5464
</article>
5565
</div>

admin/notes.js

Lines changed: 193 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,63 @@
11
const NOTES_DIRECTORY = "docs_build/dev/admin-notes";
2+
const DIRECTORY_INDEX_PATH = `${NOTES_DIRECTORY}/directory-index.json`;
23
const DEFAULT_NOTE = "index";
34
const LINK_CLASS = "btn btn--compact primary";
45
const NOTE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
56
const STATUS_ICON_PATTERN = /^\[([ xX.!?])\]\s*(.*)$/;
7+
const NOTE_INDEX_FILE = "index.txt";
8+
const STATUS_MARKERS = {
9+
" ": {
10+
icon: "⬜",
11+
label: "Not Started",
12+
name: "not-started",
13+
title: "Not Started"
14+
},
15+
".": {
16+
icon: "🟡",
17+
label: "In Progress",
18+
name: "in-progress",
19+
title: "In Progress"
20+
},
21+
x: {
22+
icon: "✅",
23+
label: "Complete",
24+
name: "complete",
25+
title: "Complete"
26+
},
27+
"!": {
28+
icon: "⛔",
29+
label: "Blocker",
30+
name: "blocker",
31+
title: "Blocker"
32+
},
33+
"?": {
34+
icon: "❓",
35+
label: "Decide",
36+
name: "decide",
37+
title: "Decide which project questions need their own subnote files."
38+
}
39+
};
640

741
class AdminNotesViewer {
842
constructor(documentRef = document, historyRef = window.history) {
943
this.documentRef = documentRef;
1044
this.historyRef = historyRef;
45+
this.surface = documentRef.querySelector("[data-admin-notes-viewer]");
1146
this.title = documentRef.querySelector("[data-admin-notes-title]");
1247
this.returnLink = documentRef.querySelector("[data-admin-notes-return]");
1348
this.status = documentRef.querySelector("[data-admin-notes-status]");
1449
this.content = documentRef.querySelector("[data-admin-notes-content]");
50+
this.directory = documentRef.querySelector("[data-admin-notes-directory]");
51+
this.directoryLinks = documentRef.querySelector("[data-admin-notes-directory-links]");
52+
this.legendList = documentRef.querySelector("[data-admin-notes-legend-list]");
53+
this.directoryIndex = null;
1554
this.bindEvents();
1655
}
1756

1857
bindEvents() {
19-
this.content?.addEventListener("click", (event) => {
58+
this.surface?.addEventListener("click", (event) => {
2059
const link = event.target.closest("[data-admin-note-link], [data-admin-note-file]");
21-
if (!link || !this.content.contains(link)) {
60+
if (!link || !this.surface.contains(link)) {
2261
return;
2362
}
2463
event.preventDefault();
@@ -36,6 +75,7 @@ class AdminNotesViewer {
3675
}
3776

3877
start() {
78+
this.renderLegend();
3979
this.openFromLocation(window.location, false);
4080
}
4181

@@ -64,14 +104,14 @@ class AdminNotesViewer {
64104

65105
notePath(noteName) {
66106
return noteName === DEFAULT_NOTE
67-
? `${NOTES_DIRECTORY}/index.txt`
68-
: `${NOTES_DIRECTORY}/${noteName}/index.txt`;
107+
? `${NOTES_DIRECTORY}/${NOTE_INDEX_FILE}`
108+
: `${NOTES_DIRECTORY}/${noteName}/${NOTE_INDEX_FILE}`;
69109
}
70110

71111
async openNote(noteName, pushHistory) {
72112
const normalizedNoteName = this.noteNameFromValue(noteName);
73113
this.clearContent();
74-
const title = normalizedNoteName === DEFAULT_NOTE ? "index.txt" : `${normalizedNoteName}/index.txt`;
114+
const title = NOTE_INDEX_FILE;
75115
this.setTitle(title);
76116
this.setReturnVisible(normalizedNoteName !== DEFAULT_NOTE);
77117

@@ -86,6 +126,7 @@ class AdminNotesViewer {
86126
await this.loadTextFile({
87127
filePath: notePath,
88128
title,
129+
currentFilePath: notePath,
89130
missingMessage: `Missing note file ${notePath}. Add this file under ${NOTES_DIRECTORY}/ or return to index.txt.`,
90131
errorMessage: `Unable to load ${notePath}. Check the note file and reload the page.`,
91132
pushHistory,
@@ -96,7 +137,7 @@ class AdminNotesViewer {
96137
async openFile(filePath, pushHistory) {
97138
const normalizedFilePath = this.rootRelativeTextPathFromValue(filePath);
98139
this.clearContent();
99-
this.setTitle(normalizedFilePath || "linked file");
140+
this.setTitle(normalizedFilePath ? this.fileNameForPath(normalizedFilePath) : "linked file");
100141
this.setReturnVisible(true);
101142

102143
if (!normalizedFilePath) {
@@ -108,15 +149,16 @@ class AdminNotesViewer {
108149

109150
await this.loadTextFile({
110151
filePath: normalizedFilePath,
111-
title: normalizedFilePath,
152+
title: this.fileNameForPath(normalizedFilePath),
153+
currentFilePath: normalizedFilePath,
112154
missingMessage: `Missing linked file ${normalizedFilePath}. Add this text file under the repository root or return to index.txt.`,
113155
errorMessage: `Unable to load ${normalizedFilePath}. Check the linked text file and reload the page.`,
114156
pushHistory,
115157
pushUrl: this.hrefForFile(normalizedFilePath)
116158
});
117159
}
118160

119-
async loadTextFile({ filePath, title, missingMessage, errorMessage, pushHistory, pushUrl }) {
161+
async loadTextFile({ filePath, title, currentFilePath, missingMessage, errorMessage, pushHistory, pushUrl }) {
120162
try {
121163
const response = await fetch(filePath, { cache: "no-store" });
122164
if (!response.ok) {
@@ -126,6 +168,7 @@ class AdminNotesViewer {
126168
const text = await response.text();
127169
this.renderNote(text, title);
128170
this.setStatus(`Loaded ${filePath}.`);
171+
await this.renderDirectoryLinks(this.folderPathForFile(currentFilePath), currentFilePath);
129172
if (pushHistory) {
130173
this.historyRef.pushState({}, "", pushUrl);
131174
}
@@ -156,8 +199,36 @@ class AdminNotesViewer {
156199
return normalizedPath;
157200
}
158201

202+
rootRelativeFolderPathFromValue(value) {
203+
const rawValue = String(value || "").trim();
204+
if (/^[A-Za-z]:/.test(rawValue) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawValue)) {
205+
return null;
206+
}
207+
const normalizedPath = rawValue.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
208+
const segments = normalizedPath.split("/");
209+
const invalidSegments = segments.some((segment) => !segment || segment === "." || segment === "..");
210+
if (!normalizedPath || invalidSegments) {
211+
return null;
212+
}
213+
return normalizedPath;
214+
}
215+
216+
fileNameForPath(filePath) {
217+
return String(filePath || "").split("/").pop() || NOTE_INDEX_FILE;
218+
}
219+
220+
folderPathForFile(filePath) {
221+
const segments = String(filePath || "").split("/");
222+
segments.pop();
223+
return segments.join("/");
224+
}
225+
159226
clearContent() {
160227
this.content?.replaceChildren();
228+
this.directoryLinks?.replaceChildren();
229+
if (this.directory) {
230+
this.directory.hidden = true;
231+
}
161232
}
162233

163234
setTitle(value) {
@@ -187,6 +258,112 @@ class AdminNotesViewer {
187258
this.content?.append(errorMessage);
188259
}
189260

261+
async renderDirectoryLinks(folderPath, currentFilePath) {
262+
const entries = await this.directoryEntriesForFolder(folderPath);
263+
const safeEntries = entries
264+
.map((entry) => this.safeDirectoryEntry(entry))
265+
.filter(Boolean)
266+
.filter((entry) => !(entry.path === currentFilePath && this.fileNameForPath(entry.path) === NOTE_INDEX_FILE))
267+
.sort((left, right) => left.label.localeCompare(right.label));
268+
269+
this.directoryLinks?.replaceChildren();
270+
if (!safeEntries.length) {
271+
if (this.directory) {
272+
this.directory.hidden = true;
273+
}
274+
return;
275+
}
276+
277+
safeEntries.forEach((entry) => {
278+
this.directoryLinks?.append(this.directoryLink(entry));
279+
});
280+
if (this.directory) {
281+
this.directory.hidden = false;
282+
}
283+
}
284+
285+
async directoryEntriesForFolder(folderPath) {
286+
const safeFolderPath = this.rootRelativeFolderPathFromValue(folderPath);
287+
if (!safeFolderPath) {
288+
return [];
289+
}
290+
const directoryIndex = await this.loadDirectoryIndex();
291+
const entries = directoryIndex.folders?.[safeFolderPath];
292+
return Array.isArray(entries) ? entries : [];
293+
}
294+
295+
async loadDirectoryIndex() {
296+
if (this.directoryIndex) {
297+
return this.directoryIndex;
298+
}
299+
try {
300+
const response = await fetch(DIRECTORY_INDEX_PATH, { cache: "no-store" });
301+
this.directoryIndex = response.ok ? await response.json() : { folders: {} };
302+
} catch {
303+
this.directoryIndex = { folders: {} };
304+
}
305+
return this.directoryIndex;
306+
}
307+
308+
safeDirectoryEntry(entry) {
309+
const label = String(entry?.label || "").trim();
310+
const path = this.rootRelativeTextPathFromValue(entry?.path);
311+
if (!label || !path) {
312+
return null;
313+
}
314+
if (entry.type === "folder") {
315+
if (!path.endsWith(`/${NOTE_INDEX_FILE}`)) {
316+
return null;
317+
}
318+
const folderPath = this.folderPathForFile(path);
319+
const noteName = this.noteNameForDirectoryPath(folderPath);
320+
return { label, noteName, path, type: "folder" };
321+
}
322+
if (entry.type === "file") {
323+
return { label, path, type: "file" };
324+
}
325+
return null;
326+
}
327+
328+
noteNameForDirectoryPath(folderPath) {
329+
const prefix = `${NOTES_DIRECTORY}/`;
330+
if (!folderPath.startsWith(prefix)) {
331+
return null;
332+
}
333+
const noteName = folderPath.slice(prefix.length);
334+
return this.validNoteName(noteName) ? noteName : null;
335+
}
336+
337+
directoryLink(entry) {
338+
const link = this.documentRef.createElement("a");
339+
link.className = LINK_CLASS;
340+
link.textContent = entry.label;
341+
if (entry.type === "folder" && entry.noteName) {
342+
link.href = this.hrefForNote(entry.noteName);
343+
link.dataset.adminNoteLink = entry.noteName;
344+
} else {
345+
link.href = this.hrefForFile(entry.path);
346+
link.dataset.adminNoteFile = entry.path;
347+
}
348+
link.dataset.adminNotesDirectoryLink = entry.type;
349+
return link;
350+
}
351+
352+
renderLegend() {
353+
this.legendList?.replaceChildren();
354+
Object.values(STATUS_MARKERS).forEach((marker) => {
355+
const item = this.documentRef.createElement("li");
356+
item.title = marker.title;
357+
const icon = this.documentRef.createElement("span");
358+
icon.dataset.adminNotesLegendIcon = marker.name;
359+
icon.title = marker.title;
360+
icon.textContent = marker.icon;
361+
item.append(icon);
362+
item.append(this.documentRef.createTextNode(` ${marker.label}`));
363+
this.legendList?.append(item);
364+
});
365+
}
366+
190367
renderNote(text, title) {
191368
this.setTitle(title);
192369
const lines = text.split(/\r?\n/);
@@ -262,27 +439,18 @@ class AdminNotesViewer {
262439
}
263440

264441
statusIcon(rawStatus) {
265-
const icon = this.documentRef.createElement("strong");
266-
icon.dataset.adminNotesStatusIcon = this.statusIconName(rawStatus);
267-
icon.textContent = `[${rawStatus}]`;
442+
const marker = this.statusMarker(rawStatus);
443+
const icon = this.documentRef.createElement("span");
444+
icon.dataset.adminNotesStatusIcon = marker.name;
445+
icon.title = marker.title;
446+
icon.setAttribute("aria-label", marker.label);
447+
icon.textContent = marker.icon;
268448
return icon;
269449
}
270450

271-
statusIconName(rawStatus) {
451+
statusMarker(rawStatus) {
272452
const normalizedStatus = rawStatus.toLowerCase();
273-
if (normalizedStatus === "x") {
274-
return "done";
275-
}
276-
if (normalizedStatus === ".") {
277-
return "active";
278-
}
279-
if (normalizedStatus === "!") {
280-
return "blocked";
281-
}
282-
if (normalizedStatus === "?") {
283-
return "question";
284-
}
285-
return "open";
453+
return STATUS_MARKERS[normalizedStatus] || STATUS_MARKERS[" "];
286454
}
287455

288456
appendLinkedText(parent, text) {

0 commit comments

Comments
 (0)