v0.2.84
This commit is contained in:
1 parent
6329ac33d1
commit
90e3c399ea
22 files changed
+729
-46
No files matched your search
+16
-1
@@ -531,11 +531,27 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
|
||||
const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
|
||||
const listItem = listLine(line);
|
||||
const quote = line.match(/^>\s?(.*)$/);
|
||||
|
||||
if (listItem) {
|
||||
const rendered = renderList(lines, index, lineOffset, listItem.indent, listItem.type);
|
||||
html += rendered.html;
|
||||
index = rendered.end - 1;
|
||||
} else if (quote) {
|
||||
closeList();
|
||||
let quoteBody = "";
|
||||
let quoteIndex = index;
|
||||
while (quoteIndex < lines.length) {
|
||||
const quoteLine = lines[quoteIndex].match(/^>\s?(.*)$/);
|
||||
if (!quoteLine) break;
|
||||
const prefix = lines[quoteIndex].startsWith("> ") ? "> " : ">";
|
||||
const lineAttrs = attrs(quoteIndex, true, prefix, "", lineOffset)
|
||||
.replace(' class="', ' class="markdown-quote-line ');
|
||||
quoteBody += `<div${lineAttrs}>${quoteLine[1] ? inline(quoteLine[1]) : "<br>"}</div>`;
|
||||
quoteIndex++;
|
||||
}
|
||||
html += `<blockquote class="markdown-quote-group">${quoteBody}</blockquote>`;
|
||||
index = quoteIndex - 1;
|
||||
} else if (heading) {
|
||||
closeList();
|
||||
const n = heading[1].length;
|
||||
@@ -555,7 +571,6 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
html += `</dl>`;
|
||||
} else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
|
||||
else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
|
||||
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
|
||||
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
||||
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
||||
}
|
||||
|
||||
@@ -112,18 +112,22 @@ export function createWorkspaceNoteAdapter() {
|
||||
: "Delete note";
|
||||
},
|
||||
async deleteNote(info, accessToken) {
|
||||
if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, {
|
||||
const archive = Boolean(info.archive_deleted_notes);
|
||||
const message = archive
|
||||
? `Delete note “${info.title}”? It will be archived and hidden from this workspace, but kept in the database.`
|
||||
: `Delete note “${info.title}”? This cannot be undone.`;
|
||||
if (!await askConfirm(message, {
|
||||
title: "Delete note",
|
||||
confirmText: "Delete",
|
||||
confirmText: archive ? "Archive" : "Delete",
|
||||
danger: true,
|
||||
})) return;
|
||||
await api(base, {
|
||||
const result = await api(base, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ access_token: accessToken || null }),
|
||||
});
|
||||
queueToast(`The note "${info.title}" was deleted.`, {
|
||||
queueToast(result.archived ? `The note "${info.title}" was archived.` : `The note "${info.title}" was deleted.`, {
|
||||
type: "success",
|
||||
title: "Note deleted",
|
||||
title: result.archived ? "Note archived" : "Note deleted",
|
||||
});
|
||||
location.assign(`/w/${encode(workspaceSlug)}`);
|
||||
},
|
||||
|
||||
+173
-1
@@ -2424,7 +2424,179 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
});
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); void syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
|
||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
const historyPanel = document.querySelector("#history-panel");
|
||||
const historyList = document.querySelector("#history-list");
|
||||
let historyRevisions = [];
|
||||
let historyView = "list";
|
||||
let historyArchiveYear = null;
|
||||
let historyArchiveDay = null;
|
||||
|
||||
function parseHistoryDate(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
let normalized = raw;
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00";
|
||||
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T");
|
||||
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z";
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function historyRevisionCard(revision, compact = false) {
|
||||
const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || "Empty note");
|
||||
const author = revision.author || "Unknown author";
|
||||
return `<article class="revision${compact ? " revision--compact" : ""}" data-history-revision="${revision.id}"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(revision.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button type="button" data-preview="${revision.id}">Preview</button><button type="button" data-revision="${revision.id}">Restore</button><div class="revision__preview" hidden></div></div></article>`;
|
||||
}
|
||||
|
||||
function renderHistoryListView() {
|
||||
historyList.classList.remove("history-list--archive");
|
||||
historyList.innerHTML = historyRevisions.length
|
||||
? historyRevisions.map(revision => historyRevisionCard(revision)).join("")
|
||||
: '<p class="empty">No history yet.</p>';
|
||||
}
|
||||
|
||||
function dayKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function renderHistoryArchiveView() {
|
||||
historyList.classList.add("history-list--archive");
|
||||
const dated = historyRevisions
|
||||
.map(revision => ({ revision, date: parseHistoryDate(revision.created_at) }))
|
||||
.filter(item => item.date);
|
||||
if (!dated.length) {
|
||||
historyList.innerHTML = historyRevisions.length
|
||||
? '<p class="empty">Archive view needs valid revision timestamps.</p>'
|
||||
: '<p class="empty">No history yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const years = [...new Set(dated.map(item => item.date.getFullYear()))].sort((a, b) => b - a);
|
||||
if (!historyArchiveYear || !years.includes(historyArchiveYear)) historyArchiveYear = years[0];
|
||||
const yearItems = dated.filter(item => item.date.getFullYear() === historyArchiveYear);
|
||||
const monthCounts = Array.from({ length: 12 }, () => 0);
|
||||
const byDay = new Map();
|
||||
yearItems.forEach(item => {
|
||||
monthCounts[item.date.getMonth()] += 1;
|
||||
const key = dayKey(item.date);
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key).push(item.revision);
|
||||
});
|
||||
const maxMonthCount = Math.max(1, ...monthCounts);
|
||||
const months = Array.from({ length: 12 }, (_, month) => month);
|
||||
const monthNames = new Intl.DateTimeFormat(undefined, { month: "short" });
|
||||
const monthNamesLong = new Intl.DateTimeFormat(undefined, { month: "long" });
|
||||
const yearButtons = years.map(year => `<button type="button" data-history-year="${year}" class="${year === historyArchiveYear ? "active" : ""}" aria-pressed="${year === historyArchiveYear}">${year}</button>`).join("");
|
||||
const histogram = months.map(month => {
|
||||
const count = monthCounts[month];
|
||||
const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3;
|
||||
return `<div class="history-archive__bar-cell" title="${escapeHtml(monthNamesLong.format(new Date(historyArchiveYear, month, 1)))}: ${count} revisions"><span class="history-archive__bar" style="--history-bar-height:${height}px"></span><small>${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}</small></div>`;
|
||||
}).join("");
|
||||
|
||||
const calendars = months.filter(month => monthCounts[month] > 0).map(month => {
|
||||
const first = new Date(historyArchiveYear, month, 1);
|
||||
const daysInMonth = new Date(historyArchiveYear, month + 1, 0).getDate();
|
||||
const mondayOffset = (first.getDay() + 6) % 7;
|
||||
const blanks = Array.from({ length: mondayOffset }, () => '<span class="history-calendar__blank"></span>').join("");
|
||||
let days = "";
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const key = `${historyArchiveYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const revisions = byDay.get(key) || [];
|
||||
const active = historyArchiveDay === key;
|
||||
days += revisions.length
|
||||
? `<button type="button" class="history-calendar__day${active ? " active" : ""}" data-history-day="${key}" title="${revisions.length} revision${revisions.length === 1 ? "" : "s"}"><span>${day}</span><small>${revisions.length}</small></button>`
|
||||
: `<span class="history-calendar__day history-calendar__day--empty"><span>${day}</span></span>`;
|
||||
}
|
||||
return `<section class="history-calendar__month"><div class="history-calendar__month-head"><strong>${escapeHtml(monthNamesLong.format(first))}</strong><span>${monthCounts[month]} revisions</span></div><div class="history-calendar__weekdays"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div><div class="history-calendar__days">${blanks}${days}</div></section>`;
|
||||
}).join("");
|
||||
|
||||
const selected = historyArchiveDay && byDay.get(historyArchiveDay);
|
||||
const selectedHtml = selected?.length
|
||||
? `<section class="history-archive__selected"><div class="history-archive__selected-head"><strong>${escapeHtml(historyArchiveDay)}</strong><span>${selected.length} saved version${selected.length === 1 ? "" : "s"}</span></div>${selected.map(revision => historyRevisionCard(revision, true)).join("")}</section>`
|
||||
: '<p class="history-archive__hint">Select a highlighted day to inspect saved versions.</p>';
|
||||
|
||||
historyList.innerHTML = `<div class="history-archive"><div class="history-archive__years" aria-label="Archive year">${yearButtons}</div><div class="history-archive__histogram" aria-label="Revision activity by month">${histogram}</div><div class="history-calendar">${calendars}</div>${selectedHtml}</div>`;
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
document.querySelectorAll("[data-history-view]").forEach(button => {
|
||||
const active = button.dataset.historyView === historyView;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
if (historyView === "archive") renderHistoryArchiveView();
|
||||
else renderHistoryListView();
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-history-view]").forEach(button => button.addEventListener("click", () => {
|
||||
historyView = button.dataset.historyView === "archive" ? "archive" : "list";
|
||||
renderHistory();
|
||||
}));
|
||||
|
||||
historyList.addEventListener("click", async event => {
|
||||
const yearButton = event.target.closest("[data-history-year]");
|
||||
if (yearButton) {
|
||||
historyArchiveYear = Number(yearButton.dataset.historyYear);
|
||||
historyArchiveDay = null;
|
||||
renderHistoryArchiveView();
|
||||
return;
|
||||
}
|
||||
const dayButton = event.target.closest("[data-history-day]");
|
||||
if (dayButton) {
|
||||
historyArchiveDay = dayButton.dataset.historyDay;
|
||||
renderHistoryArchiveView();
|
||||
return;
|
||||
}
|
||||
const previewButton = event.target.closest("[data-preview]");
|
||||
if (previewButton) {
|
||||
const revision = historyRevisions.find(item => String(item.id) === previewButton.dataset.preview);
|
||||
const previewNode = previewButton.closest(".revision")?.querySelector(".revision__preview");
|
||||
if (!revision || !previewNode) return;
|
||||
previewNode.hidden = !previewNode.hidden;
|
||||
previewNode.textContent = revision.content;
|
||||
return;
|
||||
}
|
||||
const restoreButton = event.target.closest("[data-revision]");
|
||||
if (!restoreButton) return;
|
||||
restoreButton.disabled = true;
|
||||
try {
|
||||
await adapter.restoreRevision(Number(restoreButton.dataset.revision), accessToken);
|
||||
toast.success("The selected revision is now the current version.", { title: "Version restored" });
|
||||
historyRevisions = await adapter.loadHistory(accessToken);
|
||||
renderHistory();
|
||||
} catch (error) {
|
||||
toast.danger(error.message, { title: "Could not restore version" });
|
||||
restoreButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#history-button").addEventListener("click", async () => {
|
||||
if (info?.protected && !resourceUnlocked) {
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
return;
|
||||
}
|
||||
historyPanel.classList.add("open");
|
||||
historyPanel.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("history-open");
|
||||
historyList.innerHTML = '<p class="empty">Loading…</p>';
|
||||
try {
|
||||
historyRevisions = await adapter.loadHistory(accessToken);
|
||||
historyArchiveYear = null;
|
||||
historyArchiveDay = null;
|
||||
renderHistory();
|
||||
} catch (e) {
|
||||
historyList.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`;
|
||||
toast.danger(e.message, { title: "Could not load version history" });
|
||||
}
|
||||
});
|
||||
document.querySelector("#close-history").addEventListener("click", () => {
|
||||
historyPanel.classList.remove("open");
|
||||
historyPanel.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("history-open");
|
||||
});
|
||||
|
||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
|
||||
initialize();
|
||||
|
||||
+43
-3
@@ -45,6 +45,9 @@ const notesPagination = document.querySelector("#notes-pagination");
|
||||
const workspacePasswordForm = document.querySelector("#workspace-password-form");
|
||||
const workspacePasswordInput = document.querySelector("#workspace-set-password");
|
||||
const workspacePasswordError = document.querySelector("#workspace-password-error");
|
||||
const workspaceSettingsForm = document.querySelector("#workspace-settings-form");
|
||||
const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes");
|
||||
const workspaceSettingsStatus = document.querySelector("#workspace-settings-status");
|
||||
let notesPage = 1;
|
||||
let notesSearchTimer;
|
||||
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
||||
@@ -55,6 +58,11 @@ function updateWorkspacePasswordControl() {
|
||||
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
||||
}
|
||||
|
||||
function updateWorkspaceSettingsControl() {
|
||||
workspaceSettingsForm.hidden = !info?.can_manage_settings;
|
||||
archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes);
|
||||
}
|
||||
|
||||
function stopWorkspaceWatch() {
|
||||
clearInterval(workspaceWatchPingTimer);
|
||||
clearTimeout(workspaceWatchReconnectTimer);
|
||||
@@ -293,6 +301,7 @@ async function openWorkspace() {
|
||||
renderNotes();
|
||||
renderNotesPagination(data.pagination);
|
||||
updateWorkspacePasswordControl();
|
||||
updateWorkspaceSettingsControl();
|
||||
workspaceLockedForPassword = false;
|
||||
workspaceContent.hidden = false;
|
||||
if (dialog.open) dialog.close();
|
||||
@@ -313,6 +322,7 @@ async function init() {
|
||||
document.querySelector("#workspace-title").textContent = info.title;
|
||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||
updateWorkspacePasswordControl();
|
||||
updateWorkspaceSettingsControl();
|
||||
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
|
||||
else await openWorkspace();
|
||||
} catch (e) {
|
||||
@@ -364,6 +374,29 @@ workspacePasswordForm.addEventListener("submit", async event => {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
workspaceSettingsForm.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
workspaceSettingsStatus.textContent = "";
|
||||
const submit = workspaceSettingsForm.querySelector('button[type="submit"]');
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const updated = await api(`/api/workspaces/${encodeURIComponent(slug)}/settings`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ archive_deleted_notes: archiveDeletedNotesToggle.checked }),
|
||||
});
|
||||
info = { ...info, ...updated };
|
||||
updateWorkspaceSettingsControl();
|
||||
workspaceSettingsStatus.textContent = info.archive_deleted_notes
|
||||
? "Deleted notes will be archived in the database."
|
||||
: "Deleted notes will be permanently removed.";
|
||||
toast.success("Workspace deletion policy saved.", { title: "Workspace settings" });
|
||||
} catch (error) {
|
||||
workspaceSettingsStatus.textContent = error.message;
|
||||
toast.danger(error.message, { title: "Could not save workspace settings" });
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
||||
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
||||
document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||
@@ -387,13 +420,20 @@ notesList.addEventListener("click", async event => {
|
||||
const button = event.target.closest("[data-delete-note]");
|
||||
if (!button) return;
|
||||
const title = button.dataset.noteTitle;
|
||||
if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return;
|
||||
const archive = Boolean(info?.archive_deleted_notes);
|
||||
const message = archive
|
||||
? `Delete note “${title}”? It will be archived and hidden from this workspace, but kept in the database.`
|
||||
: `Delete note “${title}”? This cannot be undone.`;
|
||||
if (!await askConfirm(message, { title: "Delete note", confirmText: archive ? "Archive" : "Delete", danger: true })) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
|
||||
const result = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
|
||||
method: "DELETE", body: JSON.stringify({ access_token: accessToken || null })
|
||||
});
|
||||
toast.success(`The note "${title}" was deleted.`, { title: "Note deleted" });
|
||||
toast.success(
|
||||
result.archived ? `The note "${title}" was archived.` : `The note "${title}" was deleted.`,
|
||||
{ title: result.archived ? "Note archived" : "Note deleted" },
|
||||
);
|
||||
await openWorkspace();
|
||||
} catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; }
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user