This commit is contained in:
Mateusz Gruszczyński committed 2026-09-25 15:31:55 +02:00
1 parent 90e3c399ea
commit 57ad26878b
12 files changed
+477 -31

No files matched your search

+26 -17
View File
@@ -26,7 +26,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast";
import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n";
import { formatDateTime, formatNumber, formatTime, getLocale, t, tp, translateSource } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security";
import { syncSelectControl } from "@rustpad/select";
@@ -2442,16 +2442,17 @@ export function startNoteEditor(adapter) {
}
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>`;
const emptyNote = t("editor.historyEmptyNote", {}, "Empty note");
const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || emptyNote);
const author = revision.author || t("editor.historyUnknownAuthor", {}, "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}">${escapeHtml(t("common.preview", {}, "Preview"))}</button><button type="button" data-revision="${revision.id}">${escapeHtml(t("editor.historyRestore", {}, "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>';
: `<p class="empty">${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}</p>`;
}
function dayKey(date) {
@@ -2468,8 +2469,8 @@ export function startNoteEditor(adapter) {
.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>';
? `<p class="empty">${escapeHtml(t("editor.historyArchiveInvalidDates", {}, "Archive view needs valid revision timestamps."))}</p>`
: `<p class="empty">${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}</p>`;
return;
}
@@ -2486,13 +2487,18 @@ export function startNoteEditor(adapter) {
});
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 locale = getLocale();
const monthNames = new Intl.DateTimeFormat(locale, { month: "short" });
const monthNamesLong = new Intl.DateTimeFormat(locale, { month: "long" });
const weekdayNames = Array.from({ length: 7 }, (_, index) =>
new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(new Date(2024, 0, 1 + index))
);
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>`;
const revisionLabel = tp("editor.historyRevisionCount", count, {}, "{count} revisions");
return `<div class="history-archive__bar-cell" title="${escapeHtml(monthNamesLong.format(new Date(historyArchiveYear, month, 1)))}: ${escapeHtml(revisionLabel)}"><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 => {
@@ -2505,19 +2511,22 @@ export function startNoteEditor(adapter) {
const key = `${historyArchiveYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const revisions = byDay.get(key) || [];
const active = historyArchiveDay === key;
const revisionLabel = tp("editor.historyRevisionCount", revisions.length, {}, "{count} revisions");
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>`
? `<button type="button" class="history-calendar__day${active ? " active" : ""}" data-history-day="${key}" title="${escapeHtml(revisionLabel)}"><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>`;
const monthRevisionLabel = tp("editor.historyRevisionCount", monthCounts[month], {}, "{count} revisions");
const weekdayHtml = weekdayNames.map(name => `<span>${escapeHtml(name)}</span>`).join("");
return `<section class="history-calendar__month"><div class="history-calendar__month-head"><strong>${escapeHtml(monthNamesLong.format(first))}</strong><span>${escapeHtml(monthRevisionLabel)}</span></div><div class="history-calendar__weekdays">${weekdayHtml}</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>';
? `<section class="history-archive__selected"><div class="history-archive__selected-head"><strong>${escapeHtml(historyArchiveDay)}</strong><span>${escapeHtml(tp("editor.historySavedVersionCount", selected.length, {}, "{count} saved versions"))}</span></div>${selected.map(revision => historyRevisionCard(revision, true)).join("")}</section>`
: `<p class="history-archive__hint">${escapeHtml(t("editor.historyArchiveHint", {}, "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>`;
historyList.innerHTML = `<div class="history-archive"><div class="history-archive__years" aria-label="${escapeHtml(t("editor.historyArchiveYear", {}, "Archive year"))}">${yearButtons}</div><div class="history-archive__histogram" aria-label="${escapeHtml(t("editor.historyArchiveActivity", {}, "Revision activity by month"))}">${histogram}</div><div class="history-calendar">${calendars}</div>${selectedHtml}</div>`;
}
function renderHistory() {
@@ -2581,7 +2590,7 @@ export function startNoteEditor(adapter) {
historyPanel.classList.add("open");
historyPanel.setAttribute("aria-hidden", "false");
document.body.classList.add("history-open");
historyList.innerHTML = '<p class="empty">Loading…</p>';
historyList.innerHTML = `<p class="empty">${escapeHtml(t("common.loading", {}, "Loading…"))}</p>`;
try {
historyRevisions = await adapter.loadHistory(accessToken);
historyArchiveYear = null;
@@ -2589,7 +2598,7 @@ export function startNoteEditor(adapter) {
renderHistory();
} catch (e) {
historyList.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`;
toast.danger(e.message, { title: "Could not load version history" });
toast.danger(e.message, { title: t("editor.historyLoadFailed", {}, "Could not load version history") });
}
});
document.querySelector("#close-history").addEventListener("click", () => {
+97 -5
View File
@@ -46,20 +46,31 @@ 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 workspaceSettingsButton = document.querySelector("#workspace-settings-button");
const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes");
const workspaceSettingsStatus = document.querySelector("#workspace-settings-status");
const archivedNotesButton = document.querySelector("#archived-notes-button");
const archivedNotesDialog = document.querySelector("#archived-notes-dialog");
const archivedNotesList = document.querySelector("#archived-notes-list");
let notesPage = 1;
let notesSearchTimer;
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
let notesCache = [];
let archivedNotesCache = [];
function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
}
function updateWorkspaceSettingsControl() {
workspaceSettingsForm.hidden = !info?.can_manage_settings;
const canManage = Boolean(info?.can_manage_settings);
workspaceSettingsButton.hidden = !canManage;
archivedNotesButton.hidden = !canManage;
if (!canManage) {
workspaceSettingsForm.hidden = true;
workspaceSettingsButton.setAttribute("aria-expanded", "false");
}
archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes);
}
@@ -165,6 +176,36 @@ function formatDate(value) {
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date);
}
function renderArchivedNotes(notes) {
archivedNotesCache = Array.isArray(notes) ? notes : [];
if (!archivedNotesCache.length) {
archivedNotesList.innerHTML = `<p class="empty">${escapeHtml(t("workspace.archive.empty", {}, "No archived notes."))}</p>`;
return;
}
archivedNotesList.innerHTML = archivedNotesCache.map(note => {
const author = note.created_by || t("workspace.unknown", {}, "Unknown");
return `<article class="archived-note-card">
<div class="archived-note-card__copy">
<strong>${escapeHtml(note.title)}</strong>
<span>${escapeHtml(t("workspace.archive.archivedAt", { date: formatDate(note.deleted_at) }, `Archived: ${formatDate(note.deleted_at)}`))}</span>
<small>${escapeHtml(t("workspace.archive.createdBy", { author }, `Created by: ${author}`))}</small>
</div>
<button class="secondary-button inline-button" type="button" data-restore-archived-note="${escapeHtml(note.slug)}">${escapeHtml(t("workspace.archive.restore", {}, "Restore"))}</button>
</article>`;
}).join("");
}
async function loadArchivedNotes() {
archivedNotesList.innerHTML = `<p class="empty">${escapeHtml(t("common.loading", {}, "Loading…"))}</p>`;
try {
const notes = await api(`/api/workspaces/${encodeURIComponent(slug)}/archived-notes`);
renderArchivedNotes(notes);
} catch (error) {
archivedNotesList.innerHTML = `<p class="form-message error">${escapeHtml(error.message)}</p>`;
toast.danger(error.message, { title: t("workspace.archive.loadFailed", {}, "Could not load archived notes") });
}
}
function setNotesView(view) {
notesView = view === "table" ? "table" : "grid";
localStorage.setItem(notesViewKey, notesView);
@@ -374,6 +415,52 @@ workspacePasswordForm.addEventListener("submit", async event => {
submit.disabled = false;
}
});
workspaceSettingsButton.addEventListener("click", () => {
if (workspaceSettingsButton.hidden) return;
const willOpen = workspaceSettingsForm.hidden;
workspaceSettingsForm.hidden = !willOpen;
workspaceSettingsButton.setAttribute("aria-expanded", String(willOpen));
if (willOpen) {
workspaceSettingsStatus.textContent = "";
archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes);
}
});
archivedNotesButton.addEventListener("click", () => {
if (!archivedNotesDialog.open) archivedNotesDialog.showModal();
void loadArchivedNotes();
});
document.querySelector("#close-archived-notes").addEventListener("click", () => archivedNotesDialog.close());
archivedNotesDialog.addEventListener("click", event => {
if (event.target === archivedNotesDialog) archivedNotesDialog.close();
});
archivedNotesList.addEventListener("click", async event => {
const button = event.target.closest("[data-restore-archived-note]");
if (!button) return;
const noteSlug = button.dataset.restoreArchivedNote;
const archivedNote = archivedNotesCache.find(note => note.slug === noteSlug);
const title = archivedNote?.title || noteSlug;
const confirmed = await askConfirm(
t("workspace.archive.restoreAsk", { title }, `Restore note “${title}” to this workspace?`),
{
title: t("workspace.archive.restoreTitle", {}, "Restore archived note"),
confirmText: t("workspace.archive.restore", {}, "Restore"),
},
);
if (!confirmed) return;
button.disabled = true;
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/archived-notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST" });
toast.success(
t("workspace.archive.restored", { title }, `The note "${title}" was restored.`),
{ title: t("workspace.archive.restoredTitle", {}, "Note restored") },
);
await Promise.all([loadArchivedNotes(), openWorkspace()]);
} catch (error) {
toast.danger(error.message, { title: t("workspace.archive.restoreFailed", {}, "Could not restore note") });
if (button.isConnected) button.disabled = false;
}
});
workspaceSettingsForm.addEventListener("submit", async event => {
event.preventDefault();
workspaceSettingsStatus.textContent = "";
@@ -387,12 +474,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
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" });
? t("workspace.settings.archiveEnabled", {}, "Deleted notes will be archived in the database.")
: t("workspace.settings.archiveDisabled", {}, "Deleted notes will be permanently removed.");
workspaceSettingsForm.hidden = true;
workspaceSettingsButton.setAttribute("aria-expanded", "false");
toast.success(
t("workspace.settings.saved", {}, "Workspace deletion policy saved."),
{ title: t("workspace.settings.title", {}, "Workspace settings") },
);
} catch (error) {
workspaceSettingsStatus.textContent = error.message;
toast.danger(error.message, { title: "Could not save workspace settings" });
toast.danger(error.message, { title: t("workspace.settings.saveFailed", {}, "Could not save workspace settings") });
} finally {
submit.disabled = false;
}