This commit is contained in:
Mateusz Gruszczyński committed 2026-09-25 15:07:20 +02:00
1 parent 6329ac33d1
commit 90e3c399ea
22 files changed
+729 -46

No files matched your search

+16 -1
View File
@@ -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>`;
}
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
});