favourited notes

This commit is contained in:
Mateusz Gruszczyński
2026-09-07 16:39:58 +02:00
parent 87b61d6088
commit f9cf17554a
25 changed files with 1534 additions and 22 deletions
+75 -6
View File
@@ -8,6 +8,7 @@
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
import { api } from "@rustpad/api";
installGlobalDiagnostics();
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
@@ -36,7 +37,7 @@ export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
const saveState = document.querySelector("#save-state");
const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle");
editor.readOnly = true;
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
@@ -49,7 +50,7 @@ export function startNoteEditor(adapter) {
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false, accountSession = null;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null;
const editHistory = {
@@ -225,6 +226,41 @@ export function startNoteEditor(adapter) {
: {};
}
function accountHeaders() { return {}; }
function favoriteParams() {
const target = adapter.favorite;
if (!target) return "";
const params = new URLSearchParams({ kind: target.kind, slug: target.slug });
if (target.workspace_slug) params.set("workspace_slug", target.workspace_slug);
return params.toString();
}
function renderFavoriteButton(active) {
if (!favoriteToggle) return;
const favorite = Boolean(active);
favoriteToggle.hidden = !accountSession || !adapter.favorite;
favoriteToggle.setAttribute("aria-pressed", String(favorite));
favoriteToggle.querySelector("span").textContent = favorite ? "★" : "☆";
const label = favorite
? t("favorites.remove", {}, "Remove from favorites")
: t("favorites.add", {}, "Add to favorites");
favoriteToggle.title = label;
favoriteToggle.setAttribute("aria-label", label);
}
async function syncFavoriteButton() {
if (!favoriteToggle || !adapter.favorite || !accountSession) {
renderFavoriteButton(false);
return;
}
try {
const status = await api(`/api/auth/favorites/status?${favoriteParams()}`);
renderFavoriteButton(Boolean(status.favorite));
} catch (error) {
if (error.status === 401 || error.status === 403 || error.status === 404) {
favoriteToggle.hidden = true;
return;
}
renderFavoriteButton(false);
}
}
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
const documentTitle = document.querySelector("#document-title");
@@ -1574,7 +1610,7 @@ export function startNoteEditor(adapter) {
});
socket.connect();
}
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
try {
@@ -1594,8 +1630,8 @@ export function startNoteEditor(adapter) {
async function initialize() {
applyUi();
try {
const session = await validateCurrentSession();
nickname = session?.nickname || getNickname();
accountSession = await validateCurrentSession();
nickname = accountSession?.nickname || getNickname();
if (!nickname) {
if (!identityDialog.open) identityDialog.showModal();
return;
@@ -1608,6 +1644,7 @@ export function startNoteEditor(adapter) {
adapter.configureView?.(info);
applyUi({ write: true, replace: true });
updateCurrentUser();
await syncFavoriteButton();
if (info.protected && info.access_level === "none") passwordDialog.showModal();
else { loadFiles(); connect(); }
} catch (e) {
@@ -1943,6 +1980,37 @@ export function startNoteEditor(adapter) {
event.preventDefault();
copyCurrentLink();
});
favoriteToggle?.addEventListener("click", async () => {
if (!accountSession || !adapter.favorite) return;
const currentlyFavorite = favoriteToggle.getAttribute("aria-pressed") === "true";
favoriteToggle.disabled = true;
try {
const result = await api("/api/auth/favorites", {
method: currentlyFavorite ? "DELETE" : "PUT",
body: JSON.stringify(adapter.favorite),
});
renderFavoriteButton(Boolean(result.favorite));
toast.success(
result.favorite
? t("favorites.added", {}, "Added to favorites.")
: t("favorites.removed", {}, "Removed from favorites."),
{ title: t("favorites.title", {}, "Favorites") },
);
} catch (error) {
toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });
await syncFavoriteButton();
} finally {
favoriteToggle.disabled = false;
}
});
window.addEventListener("rustpad:session-change", event => {
accountSession = event.detail?.session || null;
syncFavoriteButton();
});
window.addEventListener("rustpad:session-expired", () => {
accountSession = null;
renderFavoriteButton(false);
});
let pendingPreviewFormatRange = null;
document.querySelectorAll("[data-format]").forEach(button => {
@@ -2161,6 +2229,7 @@ export function startNoteEditor(adapter) {
password = "";
setPagePasswordInput.value = "";
await loadNoteInfo();
await syncFavoriteButton();
resourceUnlocked = false;
socket?.stop();
loadFiles();
@@ -2258,7 +2327,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus();
}
});
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(); 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" }); } });
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(); await 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 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" }); } });