feat: add profile language preferences and refine toast, dropdown and history UI
This commit is contained in:
+88
-57
@@ -26,6 +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 { getTheme } from "@rustpad/theme";
|
||||
import { isResourceAccessError } from "@rustpad/security";
|
||||
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
||||
@@ -245,7 +246,7 @@ export function startNoteEditor(adapter) {
|
||||
syncMobileEditorControls();
|
||||
updateCurrentUser(); return info;
|
||||
}
|
||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
|
||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = t(entries.length === 1 ? "editor.user" : "editor.users", { count: entries.length }, `${entries.length} ${entries.length === 1 ? "user" : "users"}`); roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || t("editor.guest", {}, "Guest"); li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = t("editor.noActiveUsers", {}, "No active users"); roomUsers.append(li); } renderGutter(); }
|
||||
function updateLatency(ms) {
|
||||
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
|
||||
socketLatency.textContent = text;
|
||||
@@ -269,32 +270,56 @@ export function startNoteEditor(adapter) {
|
||||
const latency = runtime.latency || {};
|
||||
const client = server.client || {};
|
||||
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
|
||||
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
|
||||
const fallbackQuality = quality.charAt(0).toUpperCase() + quality.slice(1);
|
||||
const qualityLabel = t(`diagnostics.quality.${quality}`, {}, t(`diagnostics.state.${quality}`, {}, fallbackQuality));
|
||||
setDiagnosticField("quality", qualityLabel);
|
||||
setDiagnosticField("latency", Number.isFinite(latency.current)
|
||||
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`
|
||||
: "Waiting for heartbeat");
|
||||
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
|
||||
? t("diagnostics.latency", {
|
||||
current: formatNumber(latency.current),
|
||||
average: formatNumber(latency.average),
|
||||
minimum: formatNumber(latency.minimum),
|
||||
maximum: formatNumber(latency.maximum),
|
||||
}, `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`)
|
||||
: t("diagnostics.waitHeartbeat", {}, "Waiting for heartbeat"));
|
||||
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${formatNumber(latency.jitter)} ms` : "—");
|
||||
setDiagnosticField("uptime", runtime.authenticated_at
|
||||
? formatDiagnosticDuration(runtime.uptime_ms)
|
||||
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
|
||||
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
|
||||
: runtime.last_connection_uptime_ms
|
||||
? t("diagnostics.lastUptime", { duration: formatDiagnosticDuration(runtime.last_connection_uptime_ms) }, `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}`)
|
||||
: "—");
|
||||
setDiagnosticField("reconnects", runtime.reconnect_attempt
|
||||
? t("diagnostics.reconnectAttempt", { count: formatNumber(runtime.total_reconnects || 0), attempt: formatNumber(runtime.reconnect_attempt) }, `${runtime.total_reconnects || 0} · attempt ${runtime.reconnect_attempt}`)
|
||||
: formatNumber(runtime.total_reconnects || 0));
|
||||
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
|
||||
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
|
||||
const lastEvent = runtime.last_close
|
||||
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
|
||||
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
|
||||
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
|
||||
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
|
||||
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
|
||||
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || t("diagnostics.waitServer", {}, "Waiting for server data"));
|
||||
let lastEvent;
|
||||
if (runtime.last_close) {
|
||||
const reason = runtime.last_close.reason ? translateSource(runtime.last_close.reason) : "";
|
||||
lastEvent = reason
|
||||
? t("diagnostics.closedReason", { code: runtime.last_close.code, reason }, `Closed ${runtime.last_close.code}: ${reason}`)
|
||||
: t("diagnostics.closed", { code: runtime.last_close.code }, `Closed ${runtime.last_close.code}`);
|
||||
} else if (runtime.last_message_at) {
|
||||
const time = formatTime(runtime.last_message_at);
|
||||
lastEvent = t("diagnostics.messageAt", { time }, `Message ${time}`);
|
||||
} else {
|
||||
lastEvent = t("editor.noMessages", {}, "No messages yet");
|
||||
}
|
||||
const traffic = t("diagnostics.traffic", { received: formatBytes(runtime.bytes_received), sent: formatBytes(runtime.bytes_sent) }, `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`);
|
||||
const buffered = runtime.buffered_amount
|
||||
? t("diagnostics.buffered", { amount: formatBytes(runtime.buffered_amount) }, ` · ${formatBytes(runtime.buffered_amount)} buffered`)
|
||||
: "";
|
||||
const visibility = String(runtime.visibility || document.visibilityState || "visible");
|
||||
const visibilityLabel = t(`diagnostics.visibility.${visibility}`, {}, visibility);
|
||||
setDiagnosticField("last-event", `${lastEvent} · ${visibilityLabel} · ${traffic}${buffered}`);
|
||||
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
|
||||
if (!details) continue;
|
||||
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
|
||||
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
|
||||
}
|
||||
}
|
||||
|
||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(t("editor.notificationChat", { sender: message.sender }, `${message.sender} wrote in RustPad`), { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
||||
|
||||
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
|
||||
@@ -346,7 +371,7 @@ export function startNoteEditor(adapter) {
|
||||
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
|
||||
const message = document.createElement("p");
|
||||
message.className = "error mermaid-error";
|
||||
message.textContent = "Failed to load Mermaid.";
|
||||
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
||||
node.parentNode.insertBefore(message, node);
|
||||
});
|
||||
}
|
||||
@@ -358,7 +383,7 @@ export function startNoteEditor(adapter) {
|
||||
const people = new Map();
|
||||
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
|
||||
for (const user of presenceUsers) {
|
||||
const name = user.name || "Guest";
|
||||
const name = user.name || t("editor.guest", {}, "Guest");
|
||||
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
|
||||
people.set(name, { name, compactName: user.compact_name || name, color });
|
||||
}
|
||||
@@ -449,7 +474,7 @@ export function startNoteEditor(adapter) {
|
||||
gutter.querySelector(`[data-line="${line}"]`)?.classList.add("is-linked");
|
||||
syncEditorLayers();
|
||||
}
|
||||
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(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()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(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()) ? raw : formatDateTime(date, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
|
||||
function previewNodeMarkdownParts(current) {
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
|
||||
@@ -610,7 +635,7 @@ export function startNoteEditor(adapter) {
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
|
||||
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${formatNumber(index === 0 ? Math.round(size) : size, { maximumFractionDigits: index === 0 ? 0 : size >= 10 ? 1 : 2 })} ${units[index]}`; }
|
||||
|
||||
function replaceTableCell(line, index, value) {
|
||||
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
|
||||
@@ -674,24 +699,24 @@ export function startNoteEditor(adapter) {
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "image-alias-tools";
|
||||
tools.setAttribute("role", "toolbar");
|
||||
tools.setAttribute("aria-label", "Image layout");
|
||||
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="Image alignment">
|
||||
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>Auto</button>
|
||||
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>Left</button>
|
||||
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>Center</button>
|
||||
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>Right</button>
|
||||
tools.setAttribute("aria-label", t("files.imageLayout", {}, "Image layout"));
|
||||
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="${escapeHtml(t("files.imageAlignment", {}, "Image alignment"))}">
|
||||
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>${escapeHtml(t("editor.layout.auto", {}, "Auto"))}</button>
|
||||
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.left", {}, "Left"))}</button>
|
||||
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.center", {}, "Center"))}</button>
|
||||
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.right", {}, "Right"))}</button>
|
||||
</div><div class="image-alias-size">
|
||||
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="Image width"></label>
|
||||
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="${escapeHtml(t("files.imageWidth", {}, "Image width"))}"></label>
|
||||
<span aria-hidden="true">×</span>
|
||||
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="Image height"></label>
|
||||
<button type="button" data-image-size-apply>Set</button>
|
||||
<button type="button" data-image-size-reset>Natural</button>
|
||||
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="${escapeHtml(t("files.imageHeight", {}, "Image height"))}"></label>
|
||||
<button type="button" data-image-size-apply>${escapeHtml(t("common.set", {}, "Set"))}</button>
|
||||
<button type="button" data-image-size-reset>${escapeHtml(t("editor.layout.natural", {}, "Natural"))}</button>
|
||||
</div>`;
|
||||
const resize = document.createElement("button");
|
||||
resize.type = "button";
|
||||
resize.className = "image-alias-resize";
|
||||
resize.setAttribute("aria-label", "Resize image");
|
||||
resize.title = "Drag to resize";
|
||||
resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image"));
|
||||
resize.title = t("editor.dragResize", {}, "Drag to resize");
|
||||
frame.append(tools, resize);
|
||||
}
|
||||
|
||||
@@ -719,7 +744,7 @@ export function startNoteEditor(adapter) {
|
||||
const width = Math.round(Number(tools?.querySelector("[data-image-width-input]")?.value));
|
||||
const height = Math.round(Number(tools?.querySelector("[data-image-height-input]")?.value));
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1 || width > 10000 || height > 10000) {
|
||||
toast("Image size must be between 1 and 10000 px.");
|
||||
toast.warning("Enter a width and height between 1 and 10,000 px.", { title: "Invalid image size" });
|
||||
return;
|
||||
}
|
||||
updateImageFrameAlias(frame, { width, height });
|
||||
@@ -1295,7 +1320,7 @@ export function startNoteEditor(adapter) {
|
||||
const result = collaboration.resynchronize(message);
|
||||
if (result.replayed) {
|
||||
flushRequested = true;
|
||||
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
|
||||
toast.info(reason === "resync" ? "Pending edits were merged after reconnecting." : "A missed update was merged with your local edits.", { title: "Changes synchronized", duration: 5200 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to transform pending changes during resynchronization", error);
|
||||
@@ -1311,9 +1336,9 @@ export function startNoteEditor(adapter) {
|
||||
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
|
||||
);
|
||||
flushRequested = true;
|
||||
toast("A synchronization conflict was preserved as a local recovery block.");
|
||||
toast.warning("A sync conflict occurred. Your local changes were preserved in a recovery block.", { title: "Local changes recovered", duration: 7000 });
|
||||
} else {
|
||||
toast("Synchronization failed because the recoverable document exceeds the size limit.");
|
||||
toast.danger("The recovery copy is larger than the document size limit.", { title: "Recovery failed", duration: 7000 });
|
||||
}
|
||||
}
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
@@ -1374,7 +1399,7 @@ export function startNoteEditor(adapter) {
|
||||
try {
|
||||
const result = integrateCollaborativeEnvelope(message);
|
||||
if (result.duplicate) return;
|
||||
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
const timestamp = formatTime(message.updated_at, { hour: "2-digit", minute: "2-digit" });
|
||||
if (collaboration.hasPending()) saveState.textContent = "Saving…";
|
||||
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
|
||||
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
|
||||
@@ -1498,6 +1523,7 @@ export function startNoteEditor(adapter) {
|
||||
: "Password required";
|
||||
setDocumentReadOnly(true, "Password required");
|
||||
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
|
||||
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
try {
|
||||
@@ -1510,7 +1536,7 @@ export function startNoteEditor(adapter) {
|
||||
hideConnectionNotice();
|
||||
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
|
||||
if (/read-only access/i.test(message)) {
|
||||
toast(friendly);
|
||||
toast.warning(friendly, { title: "Read-only access", duration: 6500 });
|
||||
accessLevel.textContent = "Access: read only";
|
||||
setDocumentReadOnly(true, "Read only — changes not saved");
|
||||
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
|
||||
@@ -1533,7 +1559,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#open-password")?.focus();
|
||||
return;
|
||||
}
|
||||
toast(friendly);
|
||||
toast.danger(friendly, { title: "Editor connection error" });
|
||||
},
|
||||
});
|
||||
socket.connect();
|
||||
@@ -1869,7 +1895,7 @@ export function startNoteEditor(adapter) {
|
||||
await adapter.saveEditorSettings(sessionHeaders(), settings);
|
||||
if (savePersonal) info.personal_editor_settings = true;
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
toast.danger(error.message, { title: "Could not save editor settings" });
|
||||
} finally {
|
||||
editorSettingsSaveInFlight = false;
|
||||
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
|
||||
@@ -1890,14 +1916,14 @@ export function startNoteEditor(adapter) {
|
||||
gutter.querySelectorAll(".line-number-button.is-copied").forEach(item => item.classList.remove("is-copied"));
|
||||
button.classList.add("is-copied");
|
||||
setTimeout(() => button.classList.remove("is-copied"), 900);
|
||||
toast(`Link to line ${line} copied`);
|
||||
toast.success(`Link to line ${line} copied to the clipboard.`, { title: "Link copied" });
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
toast.danger(error.message, { title: "Could not copy line link" });
|
||||
}
|
||||
});
|
||||
async function copyCurrentLink() {
|
||||
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
|
||||
catch (error) { toast(error.message); }
|
||||
try { await copyText(currentShareUrl(uiState)); toast.success("Note link copied to the clipboard.", { title: "Link copied" }); }
|
||||
catch (error) { toast.danger(error.message, { title: "Could not copy note link" }); }
|
||||
}
|
||||
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
|
||||
const documentLinkCopy = document.querySelector("#document-link-copy");
|
||||
@@ -2129,11 +2155,12 @@ export function startNoteEditor(adapter) {
|
||||
socket?.stop();
|
||||
loadFiles();
|
||||
connect();
|
||||
toast("Password set. Page options are now available.");
|
||||
toast.success("Password protection is enabled. Publishing options are now available.", { title: "Protection enabled" });
|
||||
pageSettings.open = true;
|
||||
requestAnimationFrame(() => publicPageEnabled.focus());
|
||||
} catch (error) {
|
||||
setPagePasswordError.textContent = error.message;
|
||||
toast.danger(error.message, { title: "Could not set password" });
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
updatePageControls();
|
||||
@@ -2144,17 +2171,21 @@ export function startNoteEditor(adapter) {
|
||||
const previous = !publicPageEnabled.checked;
|
||||
updatePageControls();
|
||||
publicPageEnabled.disabled = true;
|
||||
try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); }
|
||||
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); }
|
||||
try {
|
||||
await savePublicOptions();
|
||||
if (publicPageEnabled.checked) toast.success("The published page is now available.", { title: "Publishing enabled" });
|
||||
else toast.info("The published page is no longer available.", { title: "Publishing disabled" });
|
||||
}
|
||||
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast.danger(error.message, { title: "Could not update publishing" }); }
|
||||
finally { publicPageEnabled.disabled = false; }
|
||||
});
|
||||
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } });
|
||||
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } });
|
||||
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
|
||||
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast.info(publicTaskUpdates.checked ? "Visitors can now update public tasks." : "Visitors can no longer update public tasks.", { title: publicTaskUpdates.checked ? "Task updates enabled" : "Task updates disabled" }); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast.danger(error.message, { title: "Could not update task permissions" }); } finally { updatePageControls(); } });
|
||||
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); if (unprotectPublicPage.checked) toast.warning("The published page can now be opened without the resource password.", { title: "Public page unprotected", duration: 6500 }); else toast.success("Password protection is required again for the published page.", { title: "Public page protected" }); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast.danger(error.message, { title: "Could not update page protection" }); } finally { updatePageControls(); } });
|
||||
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("The published page is disabled."); const url = new URL(result.url, location.origin).href; await copyText(url); toast.success("Published page link copied to the clipboard.", { title: "Page link copied" }); window.open(url, "_blank", "noopener"); } catch (error) { toast.danger(error.message, { title: "Could not open published page" }); } });
|
||||
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } });
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
|
||||
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
|
||||
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
|
||||
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = t("editor.noMessages", {}, "No messages yet"); chatMessages.append(empty); }
|
||||
currentUser.addEventListener("click", () => {
|
||||
if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker();
|
||||
else userColorPicker.click();
|
||||
@@ -2162,10 +2193,10 @@ export function startNoteEditor(adapter) {
|
||||
async function saveUserColor(color) {
|
||||
noteColor = color;
|
||||
if (getAuthToken()) {
|
||||
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
|
||||
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast.danger(error.message, { title: "Could not save editor color" }); await loadNoteInfo(); return; }
|
||||
} else {
|
||||
writeGuestColor(noteColor);
|
||||
toast("Color saved for this tab");
|
||||
toast.success("This color will be used for the current tab.", { title: "Editor color saved" });
|
||||
}
|
||||
const replacement = currentOwner();
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||
@@ -2177,7 +2208,7 @@ export function startNoteEditor(adapter) {
|
||||
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
|
||||
useGlobalColorButton.addEventListener("click", async () => {
|
||||
if (getAuthToken()) {
|
||||
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
|
||||
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast.danger(error.message, { title: "Could not restore profile color" }); return; }
|
||||
}
|
||||
noteColor = "";
|
||||
if (!getAuthToken()) writeGuestColor("");
|
||||
@@ -2186,7 +2217,7 @@ export function startNoteEditor(adapter) {
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(currentUserColor() || null);
|
||||
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
|
||||
toast("Global profile color restored");
|
||||
toast.info("The global profile color is active again.", { title: "Profile color restored" });
|
||||
});
|
||||
editor.addEventListener("keydown", continueIndentation);
|
||||
editor.addEventListener("scroll", () => {
|
||||
@@ -2217,10 +2248,10 @@ 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(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||
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("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
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" }); } });
|
||||
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(error.message); } });
|
||||
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" }); } });
|
||||
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
||||
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
||||
initialize();
|
||||
|
||||
Reference in New Issue
Block a user