big features

This commit is contained in:
Mateusz Gruszczyński
2026-07-22 11:03:50 +02:00
parent 0d77624adc
commit 1be2023e36
30 changed files with 1077 additions and 77 deletions
+6 -2
View File
@@ -5,8 +5,12 @@ export async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { ...options, headers, signal: controller.signal });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Error ${response.status}`);
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
if (!response.ok) {
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
throw new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
}
return data;
} catch (error) {
if (error.name === "AbortError") throw new Error("Timed out");
+276
View File
@@ -0,0 +1,276 @@
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askInput, showMessage } from "./modal.js";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:nickname");
});
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const message = dialog.querySelector("#identity-error");
const title = dialog.querySelector("#identity-title");
const copy = dialog.querySelector("#identity-copy");
const emailField = dialog.querySelector("#auth-email-field");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const submit = dialog.querySelector("#auth-submit");
const switchMode = dialog.querySelector("#auth-switch-mode");
const resetButton = dialog.querySelector("#show-reset");
const backButton = dialog.querySelector("#reset-back");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
let mode = initialMode;
const setMode = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
const resetting = mode === "reset";
const registering = mode === "register";
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
copy.textContent = resetting
? "Enter the e-mail address assigned to your account."
: registering
? "Reserve your nickname with an e-mail address and password."
: "Use the e-mail address and password assigned to your account.";
nickname.closest("label").hidden = !registering;
nickname.disabled = !registering;
emailField.hidden = false;
email.disabled = false;
password.closest("label").hidden = resetting;
password.disabled = resetting;
submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in";
switchMode.hidden = resetting || !registrationEnabled;
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
resetButton.hidden = resetting || registering;
backButton.hidden = !resetting;
password.autocomplete = registering ? "new-password" : "current-password";
message.textContent = "";
queueMicrotask(() => {
email.focus();
});
};
switchMode?.addEventListener("click", () => setMode(mode === "register" ? "login" : "register"));
resetButton?.addEventListener("click", () => setMode("reset"));
backButton?.addEventListener("click", () => setMode("login"));
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", () => {
form.reset();
message.textContent = "";
setMode(initialMode);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
submit.disabled = true;
try {
if (mode === "reset") {
const result = await api("/api/auth/password-reset", {
method: "POST",
body: JSON.stringify({ email: email.value.trim() }),
});
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
return;
}
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = nickname.value.trim();
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
setAuthSession(session);
await onIdentity(session.nickname, session);
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
} finally {
submit.disabled = false;
}
});
setMode(initialMode);
return { setMode };
}
function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const authPanel = dialog.querySelector("#auth-panel");
const message = dialog.querySelector("#identity-error");
const modeTitle = dialog.querySelector("#auth-mode-title");
const emailField = dialog.querySelector("#auth-email-field");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const authSubmit = dialog.querySelector("#auth-submit");
const guestSubmit = dialog.querySelector("#guest-continue");
const registerButton = dialog.querySelector("#show-register");
const loginButton = dialog.querySelector("#show-login");
const backButton = dialog.querySelector("#auth-back");
const logoutButton = dialog.querySelector("#logout-account");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
let mode = "login";
const updateActions = () => {
if (logoutButton) logoutButton.hidden = !getAuthToken();
if (registerButton) registerButton.hidden = !registrationEnabled;
};
const collapse = () => {
authPanel.hidden = true;
nickname.disabled = false;
nickname.required = true;
password.value = "";
message.textContent = "";
guestSubmit?.focus();
};
const showAuth = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
authPanel.hidden = false;
// Keep the guest nickname form visible so the user can still continue anonymously.
nickname.disabled = mode === "login";
nickname.required = mode === "register";
emailField.hidden = false;
email.disabled = false;
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
password.autocomplete = mode === "register" ? "new-password" : "current-password";
message.textContent = "";
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
};
registerButton?.addEventListener("click", () => showAuth("register"));
loginButton?.addEventListener("click", () => showAuth("login"));
backButton?.addEventListener("click", collapse);
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener("close", collapse);
dialog.addEventListener("cancel", collapse);
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
const value = email.value.trim() || await askInput({
title: "Reset password",
message: "Enter the e-mail address assigned to your account.",
label: "E-mail",
type: "email",
autocomplete: "off",
confirmText: "Send reset link",
bitwardenIgnore: true,
}) || "";
if (!value) return;
try {
const result = await api("/api/auth/password-reset", { method: "POST", body: JSON.stringify({ email: value }) });
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
}
});
logoutButton?.addEventListener("click", async () => {
await logoutCurrentSession();
updateActions();
collapse();
nickname.value = "";
message.textContent = "Logged out.";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
const name = nickname.value.trim();
const authRequested = event.submitter === authSubmit;
try {
if (authRequested) {
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = name;
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
setAuthSession(session);
await onIdentity(session.nickname, session);
return;
}
nickname.disabled = false;
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/registered|session|account/i.test(error.message)) showAuth("login");
}
});
updateActions();
collapse();
return { setMode: showAuth };
}
export async function validateCurrentSession() {
const token = getAuthToken();
if (!token) return null;
try {
return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
} catch {
clearAuthSession();
return null;
}
}
export async function logoutCurrentSession() {
const token = getAuthToken();
if (token) {
try {
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch {}
}
clearAuthSession();
}
export async function handleResetToken() {
const url = new URL(location.href);
const token = url.searchParams.get("reset_token");
if (!token) return;
// Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog.
url.searchParams.delete("reset_token");
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
const password = await askInput({
title: "Set a new password",
message: "The password must contain at least 8 characters.",
label: "New password",
type: "password",
autocomplete: "off",
minLength: 8,
confirmText: "Change password",
bitwardenIgnore: true,
});
if (!password) return;
try {
await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) });
await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" });
} catch (error) {
await showMessage(error.message, { title: "Password reset failed" });
}
}
+40
View File
@@ -1,3 +1,4 @@
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
import { api } from "@rustpad/api";
function slugify(value, fallback) {
@@ -74,3 +75,42 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
setBusy(button, false, "Create workspace", "Creating…");
}
});
handleResetToken();
const identityDialog = document.querySelector("#identity-dialog");
const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label");
const registerLink = document.querySelector("#footer-register");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
function renderAccount(session) {
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
registerLink.hidden = !registrationEnabled;
}
if (identityDialog) {
const authDialog = bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (_nickname, session) => renderAccount(session),
});
document.querySelector("#footer-login")?.addEventListener("click", () => {
authDialog.setMode("login");
identityDialog.showModal();
});
registerLink?.addEventListener("click", () => {
authDialog.setMode("register");
identityDialog.showModal();
});
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
await logoutCurrentSession();
renderAccount(null);
});
renderAccount(null);
validateCurrentSession().then(renderAccount);
}
+62
View File
@@ -0,0 +1,62 @@
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
dialog = document.createElement("dialog");
dialog.id = "system-dialog";
dialog.className = "app-dialog";
dialog.innerHTML = `<form method="dialog" class="dialog-panel system-dialog-panel">
<button class="modal-close" value="cancel" aria-label="Close dialog">×</button>
<h2 data-title></h2><p class="dialog-copy" data-message></p>
<label data-input-wrap hidden><span data-input-label></span><input data-input name="modal-input" data-bwignore="true"></label>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>Cancel</button><button class="primary-button" value="confirm" data-confirm>OK</button></div>
</form>`;
document.body.append(dialog);
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
return dialog;
}
export function showMessage(message, { title = "Information", button = "OK" } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = true;
dialog.querySelector("[data-confirm]").textContent = button;
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => resolve(), { once: true }));
}
export function askConfirm(message, { title = "Confirm", confirmText = "Confirm", danger = false } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = false;
const confirm = dialog.querySelector("[data-confirm]");
confirm.textContent = confirmText;
confirm.classList.toggle("danger-button", danger);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => {
confirm.classList.remove("danger-button");
resolve(dialog.returnValue === "confirm");
}, { once: true }));
}
export function askInput({ title, message = "", label, type = "text", autocomplete = "off", minLength, placeholder = "", confirmText = "Continue", bitwardenIgnore = false }) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
const wrap = dialog.querySelector("[data-input-wrap]");
const input = dialog.querySelector("[data-input]");
wrap.hidden = false;
dialog.querySelector("[data-input-label]").textContent = label;
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = placeholder;
input.name = type === "password" ? "new-password" : type === "email" ? "email" : "modal-input";
if (bitwardenIgnore) input.setAttribute("data-bwignore", "true"); else input.removeAttribute("data-bwignore");
input.minLength = minLength || 0;
dialog.querySelector("[data-cancel]").hidden = false;
dialog.querySelector("[data-confirm]").textContent = confirmText;
dialog.showModal();
queueMicrotask(() => input.focus());
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true }));
}
+9 -5
View File
@@ -3,8 +3,10 @@ import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session";
import { getNickname, getPassword, getAuthToken, setPassword } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { NoteSocket } from "@rustpad/socket";
import { askConfirm } from "./modal.js";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3];
@@ -69,7 +71,7 @@ function replaceTableCell(line,index,value){
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
async function loadFiles({open=false}={}){
@@ -81,8 +83,10 @@ async function loadFiles({open=false}={}){
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
}catch(error){toast(error.message);}
}
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});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);}});
@@ -107,11 +111,11 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
const deleteButton=event.target.closest("[data-delete-file]");
if(deleteButton){
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
}
});
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
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();
+6 -3
View File
@@ -3,7 +3,8 @@ import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, setNickname } from "@rustpad/session";
import { getNickname, getAuthToken } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -78,9 +79,11 @@ async function loadFiles({open=false}={}){
if(open)document.querySelector("#files-dialog").showModal();
}catch(error){if(open)toast(error.message);}
}
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});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);}});
+27 -2
View File
@@ -1,5 +1,30 @@
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; }
export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); }
const NICKNAME_KEY = "rustpad:nickname";
// Nicknames belong to the current browser session. Remove the old persistent
// value so deleting/logging out of a session cannot leave an identity behind.
localStorage.removeItem(NICKNAME_KEY);
export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; }
export function setNickname(value) {
const nickname = value.trim();
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
else sessionStorage.removeItem(NICKNAME_KEY);
}
const AUTH_TOKEN_KEY = "rustpad:auth-token";
const legacyAuthToken = localStorage.getItem(AUTH_TOKEN_KEY);
if (legacyAuthToken && !sessionStorage.getItem(AUTH_TOKEN_KEY)) sessionStorage.setItem(AUTH_TOKEN_KEY, legacyAuthToken);
localStorage.removeItem(AUTH_TOKEN_KEY);
export function getAuthToken() { return sessionStorage.getItem(AUTH_TOKEN_KEY) || ""; }
export function setAuthSession(session) { sessionStorage.setItem(AUTH_TOKEN_KEY, session.token); setNickname(session.nickname); }
export function clearAuthSession() {
localStorage.removeItem(AUTH_TOKEN_KEY);
sessionStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY);
}
export async function resolveIdentity(api, nickname) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
setNickname(result.nickname); return result;
}
+4 -4
View File
@@ -1,11 +1,11 @@
export class NoteSocket {
constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
}
export class PadSocket {
constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { getNickname, getPassword, setPassword } from "@rustpad/session";
import { askConfirm } from "./modal.js";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
@@ -116,7 +117,7 @@ notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]");
if (!button) return;
const title = button.dataset.noteTitle;
if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return;
if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return;
button.disabled = true;
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {