From 351dd081b5d4b9b6a43ce2f53dc95d5a35f7fd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 28 Jul 2026 09:17:13 +0200 Subject: [PATCH] visual changes --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/assets.rs | 1 + src/auth/mod.rs | 18 ++- static/css/styles.css | 259 ++++++++++++++++++++++++++------------- static/home.html | 2 +- static/js/home.js | 16 +-- static/js/note-editor.js | 85 +++++++++++-- static/js/public.js | 2 +- static/js/toast.js | 10 ++ static/js/workspace.js | 7 +- static/note.html | 16 ++- static/pad.html | 16 ++- 13 files changed, 309 insertions(+), 127 deletions(-) create mode 100644 static/js/toast.js diff --git a/Cargo.lock b/Cargo.lock index 8e4dc6d..fa8bf76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.11" +version = "0.1.12" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index c085cfd..77aace5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.11" +version = "0.1.12" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/assets.rs b/src/assets.rs index ad9cd3d..fea10f7 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -20,6 +20,7 @@ const MODULES: &[&str] = &[ "note-files", "session", "socket", + "toast", "url-state", "security", ]; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 0780ac9..29490be 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -650,10 +650,20 @@ pub async fn update_profile( "E-mail and password are managed by LDAP/AD.", )); } - } else if !verify_password(&user.password_hash, &req.password) { - return Err(AuthError::unauthorized( - "The current password is incorrect.", - )); + } else { + let changes_credentials = req + .new_email + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || req + .new_password + .as_deref() + .is_some_and(|value| !value.is_empty()); + if changes_credentials && !verify_password(&user.password_hash, &req.password) { + return Err(AuthError::unauthorized( + "The current password is incorrect.", + )); + } } let mut nickname = user.nickname.clone(); diff --git a/static/css/styles.css b/static/css/styles.css index 96a4499..84a4ef9 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -209,10 +209,6 @@ textarea:focus { padding: 7px; } -[hidden] { - display: none !important; -} - .form-message { min-height: 1.2em; margin: -5px 0 0; @@ -256,7 +252,7 @@ textarea:focus { } .secondary-button:hover { - border-color: #4b5565; + background: var(--surface-3); } @@ -501,15 +497,15 @@ textarea::selection { .preview { overflow: auto; min-height: 0; - padding: 24px; + color: #dce2eb; - line-height: 1.72; + } .markdown-body h1, .markdown-body h2, .markdown-body h3 { - margin: 1.25em 0 .5em; + letter-spacing: -.03em; } @@ -527,10 +523,6 @@ textarea::selection { font-size: 1.5rem; } -.markdown-body p { - margin: .72em 0; -} - .markdown-body code { padding: .16em .36em; border: 1px solid #303745; @@ -553,7 +545,7 @@ textarea::selection { } .markdown-body blockquote { - margin: 1em 0; + padding: .2em 1em; border-left: 3px solid var(--accent); color: #abb5c3; @@ -664,7 +656,7 @@ textarea::selection { margin-top: 3px; border: 2px solid #8b7af4; border-radius: 50%; - background: #101319; + } .revision time { @@ -923,13 +915,6 @@ dialog::backdrop { font-size: .75rem; } -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - - .error-card { width: min(560px, 100%); padding: 32px; @@ -1032,7 +1017,7 @@ dialog::backdrop { } .line-gutter { - width: 62px; + overflow: hidden; padding: 24px 8px 24px 0; border-right: 1px solid var(--border); @@ -1044,7 +1029,7 @@ dialog::backdrop { .line-gutter div { height: 1.72em; - padding-right: 8px; + } .hide-editor-line-numbers .editor-shell { @@ -1207,11 +1192,9 @@ dialog::backdrop { } .markdown-body img { - display: block; - max-width: 100%; - height: auto; + margin: 16px auto; - border-radius: 10px; + } .markdown-body a { @@ -1244,7 +1227,7 @@ dialog::backdrop { } .public-document { - width: min(900px, calc(100% - 32px)); + margin: 0 auto; padding: 56px 0 96px; } @@ -1263,7 +1246,7 @@ dialog::backdrop { .public-content { min-height: 240px; - padding: 32px 32px 32px 62px; + border: 1px solid var(--border); border-radius: 14px; background: var(--surface); @@ -1291,7 +1274,6 @@ dialog::backdrop { } } - /* Layout safeguards and home footer */ .app-header__main, .document-heading { @@ -1314,19 +1296,12 @@ dialog::backdrop { .home-footer { flex: 0 0 auto; - width: min(1040px, calc(100% - 32px)); - margin: auto auto 28px; - padding-top: 24px; - color: var(--muted-2); - font-size: .76rem; + text-align: center; } .home-footer__inner { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; + flex-wrap: wrap; } @@ -1369,12 +1344,6 @@ dialog::backdrop { } } - -/* Show an author once at the start of each contiguous ownership block. */ -.line-gutter div { - position: relative; -} - .line-owner-label { position: absolute; left: 7px; @@ -1415,11 +1384,10 @@ dialog::backdrop { .owner-labels { position: absolute; - z-index: 2; - top: 0; + right: 12px; left: 48px; - height: 100%; + overflow: hidden; pointer-events: none; } @@ -1465,7 +1433,6 @@ dialog::backdrop { } } - /* Editor display preferences and author overlay */ .editor-shell { position: relative; @@ -1738,13 +1705,6 @@ dialog::backdrop { border-radius: 0; } -.note-card-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - .protect-badge, .file-flag { display: inline-flex; @@ -1770,10 +1730,6 @@ dialog::backdrop { color: var(--danger); } -.danger-button { - color: var(--danger); -} - .dialog-check { display: flex; align-items: center; @@ -1803,12 +1759,12 @@ dialog::backdrop { .files-panel { grid-template-rows: auto minmax(0, 1fr); - min-height: min(590px, calc(100vh - 28px)); + max-height: calc(100vh - 28px); } .files-list { - min-height: 0; + overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; @@ -1864,13 +1820,13 @@ dialog::backdrop { .file-actions { display: flex; - flex-wrap: wrap; + justify-content: flex-end; gap: 6px; } .file-actions button { - min-height: 30px; + border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); @@ -2217,8 +2173,6 @@ dialog::backdrop { display: none; } - - .markdown-body .table-wrap { overflow-x: auto; margin: .7em 0; @@ -2535,24 +2489,12 @@ dialog::backdrop { flex: 1 0 100%; } -.identity-panel { - position: relative; -} - .identity-panel__close { position: absolute; top: 14px; right: 14px; } -.identity-links { - justify-content: space-between; -} - -.identity-links .text-button { - padding: 3px 0; -} - .auth-panel { display: grid; gap: 12px; @@ -2832,7 +2774,7 @@ dialog::backdrop { min-width: 0; justify-content: space-between; gap: 16px; - align-items: center; + padding: 12px; border: 1px solid var(--border); border-radius: 10px; @@ -2997,7 +2939,6 @@ dialog::backdrop { margin-bottom: 0; } - /* Full-height editor: keep header/footer visible and scroll only the work area. */ .pad-page { height: 100dvh; @@ -3290,10 +3231,9 @@ dialog::backdrop { .share-section { display: grid; gap: 10px; - padding: 14px; + border: 1px solid var(--border); - border-radius: 10px; - background: var(--surface); + } .share-form-grid { @@ -3334,7 +3274,7 @@ dialog::backdrop { grid-template-columns: minmax(180px, 1fr) auto auto; gap: 8px; align-items: center; - padding: 9px 0; + border-top: 1px solid var(--border); } @@ -3588,7 +3528,6 @@ dialog::backdrop { padding: 7px 11px; } - @media (max-width: 800px) { .share-dialog { width: calc(100% - 16px); @@ -4009,7 +3948,6 @@ dialog::backdrop { } } - .editor-column-label { display: flex; align-items: center; @@ -4404,6 +4342,12 @@ dialog::backdrop { } } +@media (max-width: 720px) { + .pad-page #save-state { + display: none; + } +} + @media (max-width: 520px) { .pad-page .app-header { padding-inline: 10px; @@ -4450,7 +4394,6 @@ dialog::backdrop { } } - /* Per-note author color override */ .use-global-color { display: inline-grid; @@ -4469,3 +4412,145 @@ dialog::backdrop { border-color: var(--accent); color: var(--text); } + +/* Quick actions appear when the editor header switches to its compact menu. */ +.mobile-editor-bubble { display: none; } +@media (max-width: 1499px) { + .pad-page .mobile-editor-bubble { + position: fixed; + right: 10px; + bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + z-index: 45; + display: flex; + align-items: center; + gap: 5px; + max-width: calc(100vw - 20px); + padding: 5px; + border: 1px solid rgba(255,255,255,.12); + border-radius: 999px; + background: rgba(17,21,28,.88); + box-shadow: 0 8px 24px rgba(0,0,0,.28); + backdrop-filter: blur(10px); + } + .mobile-editor-bubble > button { + display: inline-grid; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(255,255,255,.07); + color: var(--text); + font-size: 1rem; + } + .mobile-color-dot { + width: 15px; + height: 15px; + border: 2px solid rgba(255,255,255,.75); + border-radius: 50%; + background: var(--owner, var(--accent)); + } + .mobile-connection-status { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 0 8px 0 4px; + color: var(--muted); + font-size: .72rem; + white-space: nowrap; + } + .mobile-connection-status #mobile-status-text { + max-width: 92px; + overflow: hidden; + text-overflow: ellipsis; + } + .pad-page .toast { bottom: calc(62px + env(safe-area-inset-bottom, 0px)); } +} + +/* Movable quick actions for compact editor screens. */ +@media (max-width: 1499px) { + .pad-page .mobile-editor-bubble { + touch-action: none; + user-select: none; + } + + .pad-page .footer-connection-block { + display: none; + } + + .pad-page .room-details.is-mobile-open .room-popover { + position: fixed; + right: 12px; + bottom: 72px; + left: auto; + z-index: 60; + width: min(560px, calc(100vw - 24px)); + max-height: min(560px, calc(100dvh - 96px)); + } + + .mobile-time-button { + position: relative; + } + + .mobile-time-preview { + position: absolute; + right: 8px; + bottom: calc(100% + 8px); + padding: 6px 9px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 8px 24px rgb(0 0 0 / 32%); + color: var(--text); + font-size: .78rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .mobile-time-preview[hidden] { + display: none; + } + + .pad-page .mobile-editor-bubble.is-dragging { + transition: none; + cursor: grabbing; + } + + .mobile-editor-bubble > .mobile-bubble-drag { + width: 24px; + background: transparent; + color: var(--muted-2); + cursor: grab; + font-size: .85rem; + letter-spacing: -3px; + touch-action: none; + } + + .mobile-chat-button { + position: relative; + } + + .mobile-chat-unread { + position: absolute; + top: -4px; + right: -4px; + display: inline-grid; + min-width: 17px; + height: 17px; + padding: 0 4px; + place-items: center; + border: 2px solid #11151c; + border-radius: 999px; + background: var(--danger); + color: white; + font-size: .58rem; + font-weight: 700; + line-height: 1; + } + + .mobile-chat-unread[hidden] { + display: none; + } +} diff --git a/static/home.html b/static/home.html index d69ae8a..d884962 100644 --- a/static/home.html +++ b/static/home.html @@ -137,7 +137,7 @@ - + diff --git a/static/js/home.js b/static/js/home.js index cbe33e2..2a62650 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -6,6 +6,7 @@ import { getAuthToken, setAccessToken } from "@rustpad/session"; import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { safeAppUrl } from "@rustpad/security"; +import { toast } from "@rustpad/toast"; function slugify(value, fallback) { return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; @@ -84,14 +85,6 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even handleAccountConfirmationToken(); handleResetToken(); -function toast(text) { - const element = document.querySelector("#toast"); - if (!element) return; - element.textContent = text; - element.classList.add("visible"); - clearTimeout(toast.timer); - toast.timer = setTimeout(() => element.classList.remove("visible"), 3000); -} { const params=new URLSearchParams(location.search); const token=params.get("account_action_token"); if(token){ api("/api/auth/account-action/confirm",{method:"POST",body:JSON.stringify({token})}).then(r=>toast(r.message)).catch(e=>toast(e.message)).finally(()=>{params.delete("account_action_token");history.replaceState({},"",`${location.pathname}${params.size?`?${params}`:""}${location.hash}`);}); } } @@ -256,7 +249,7 @@ function renderAccount(session) { if (identityDialog) { const authDialog = bindIdentityDialog({ dialog: identityDialog, - onIdentity: async (_nickname, session) => renderAccount(session), + onIdentity: async (_nickname, session) => { renderAccount(session); if (session) toast(`Logged in as ${session.nickname}.`); }, }); document.querySelector("#footer-login")?.addEventListener("click", () => { @@ -290,14 +283,14 @@ if (identityDialog) { const suggestion = currentSession?.suggested_nickname; const suggestionElement = document.querySelector("#profile-nickname-suggestion"); suggestionElement.textContent = suggestion ? `Suggested nickname: ${suggestion}` : "No automatic nickname suggestion is available."; - document.querySelector("#profile-password").required = !directoryManaged; + document.querySelector("#profile-password").required = false; profileDialog.showModal(); }); document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close()); profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); }); profileForm?.addEventListener("submit", async event => { event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error"); - try { const selectedColor=document.querySelector("#profile-color").value; const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); } + try { const selectedColor=document.querySelector("#profile-color").value; const newEmail=currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null); const newPassword=currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null); const password=currentSession?.directory_managed?"":document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,new_email:newEmail,new_password:newPassword,password})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); } }); document.querySelector("#profile-delete")?.addEventListener("click", async () => { const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value; @@ -313,6 +306,7 @@ if (identityDialog) { document.querySelector("#footer-logout")?.addEventListener("click", async () => { await logoutCurrentSession(); renderAccount(null); + toast("Logged out."); }); window.addEventListener("rustpad:session-expired", () => renderAccount(null)); diff --git a/static/js/note-editor.js b/static/js/note-editor.js index b5b6348..e56a28d 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -10,11 +10,12 @@ import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } 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"; 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"), 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"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"); + 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"), 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"); let unreadChat = 0; const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); @@ -46,8 +47,7 @@ export function startNoteEditor(adapter) { return /^#[0-9a-f]{6}$/i.test(parts.color) && parts.color.toLowerCase() === color.toLowerCase(); }, replacement, contentLength); } - function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; } - function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } + function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); } function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } async function loadNoteInfo() { @@ -64,10 +64,10 @@ export function startNoteEditor(adapter) { function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } 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); 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 clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; document.title = document.title.replace(/^● /, ""); } + 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 clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); } - function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; } + 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; } function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } @@ -285,9 +285,80 @@ export function startNoteEditor(adapter) { if (!event.matches) setHeaderMenuOpen(false); }); 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(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); 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(); }); + document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click()); + document.querySelector("#mobile-color-button")?.addEventListener("click", () => userColorPicker.click()); + const compactBubbleQuery = matchMedia("(max-width: 1499px)"); + document.querySelector("#mobile-chat-button")?.addEventListener("click", event => { + event.stopPropagation(); + roomDetails.classList.add("is-mobile-open"); + roomDetails.open = true; + clearUnread(); + requestAnimationFrame(() => chatInput.focus()); + }); + const mobileTimeButton = document.querySelector("#mobile-time-button"); + const mobileTimePreview = document.querySelector("#mobile-time-preview"); + let mobileTimeTimer = null; + function updateMobileTime() { + if (!mobileTimePreview) return; + mobileTimePreview.textContent = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date()); + } + mobileTimeButton?.addEventListener("click", event => { + event.stopPropagation(); + const willOpen = mobileTimePreview.hidden; + mobileTimePreview.hidden = !willOpen; + clearInterval(mobileTimeTimer); + mobileTimeTimer = null; + if (willOpen) { updateMobileTime(); mobileTimeTimer = setInterval(updateMobileTime, 1000); } + }); + document.addEventListener("click", event => { + if (!event.target.closest("#mobile-time-button, #mobile-time-preview") && mobileTimePreview) { mobileTimePreview.hidden = true; clearInterval(mobileTimeTimer); mobileTimeTimer = null; } + if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details")) { roomDetails.open = false; roomDetails.classList.remove("is-mobile-open"); } + }); + const mobileBubble = document.querySelector("#mobile-editor-bubble"); + const mobileBubbleDrag = document.querySelector("#mobile-bubble-drag"); + const bubblePositionKey = `rustpad:mobile-bubble:${adapter.access.kind}`; + function clampBubblePosition(left, top) { + const rect = mobileBubble.getBoundingClientRect(); + const margin = 8; + return { + left: Math.min(Math.max(margin, left), Math.max(margin, innerWidth - rect.width - margin)), + top: Math.min(Math.max(margin, top), Math.max(margin, innerHeight - rect.height - margin)), + }; + } + function placeMobileBubble(position) { + if (!mobileBubble || !position) return; + const next = clampBubblePosition(Number(position.left), Number(position.top)); + if (!Number.isFinite(next.left) || !Number.isFinite(next.top)) return; + mobileBubble.style.left = `${next.left}px`; + mobileBubble.style.top = `${next.top}px`; + mobileBubble.style.right = "auto"; + mobileBubble.style.bottom = "auto"; + } + try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch {} + mobileBubbleDrag?.addEventListener("pointerdown", event => { + if (!mobileBubble || !compactBubbleQuery.matches) return; + event.preventDefault(); + const rect = mobileBubble.getBoundingClientRect(); + const offsetX = event.clientX - rect.left, offsetY = event.clientY - rect.top; + mobileBubble.classList.add("is-dragging"); + mobileBubbleDrag.setPointerCapture(event.pointerId); + const move = moveEvent => placeMobileBubble({ left: moveEvent.clientX - offsetX, top: moveEvent.clientY - offsetY }); + const end = () => { + mobileBubble.classList.remove("is-dragging"); + mobileBubbleDrag.removeEventListener("pointermove", move); + mobileBubbleDrag.removeEventListener("pointerup", end); + mobileBubbleDrag.removeEventListener("pointercancel", end); + const finalRect = mobileBubble.getBoundingClientRect(); + localStorage.setItem(bubblePositionKey, JSON.stringify({ left: finalRect.left, top: finalRect.top })); + }; + mobileBubbleDrag.addEventListener("pointermove", move); + mobileBubbleDrag.addEventListener("pointerup", end); + mobileBubbleDrag.addEventListener("pointercancel", end); + }); + window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); }); 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); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); 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(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); 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 adapter.publish(accessToken, 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 adapter.publish(accessToken, 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); } }); - roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } }); + roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } }); 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); } diff --git a/static/js/public.js b/static/js/public.js index 9776062..013e72e 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -4,11 +4,11 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; +import { toast } from "@rustpad/toast"; const token = location.pathname.split("/").filter(Boolean)[1]; const content = document.querySelector("#public-content"); const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle"); -function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } } function lockPublicContent(allowTaskUpdates) { diff --git a/static/js/toast.js b/static/js/toast.js new file mode 100644 index 0000000..9493591 --- /dev/null +++ b/static/js/toast.js @@ -0,0 +1,10 @@ +let hideTimer; + +export function toast(message, options = {}) { + const element = document.querySelector(options.selector || "#toast"); + if (!element) return; + element.textContent = String(message ?? ""); + element.classList.add("visible"); + clearTimeout(hideTimer); + hideTimer = setTimeout(() => element.classList.remove("visible"), options.duration ?? 1800); +} diff --git a/static/js/workspace.js b/static/js/workspace.js index 0e1cad5..6f77628 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -7,6 +7,7 @@ import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rust import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; import { safeAppUrl } from "@rustpad/security"; +import { toast } from "@rustpad/toast"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; @@ -23,12 +24,6 @@ const notesViewKey = `rustpad:workspace:${slug}:notes-view`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesCache = []; -function toast(text) { - const el = document.querySelector("#toast"); - el.textContent = text; - el.classList.add("visible"); - setTimeout(() => el.classList.remove("visible"), 1600); -} function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } function formatDate(value) { if (value == null || value === "") return "—"; diff --git a/static/note.html b/static/note.html index ebc6098..318e87b 100644 --- a/static/note.html +++ b/static/note.html @@ -117,10 +117,9 @@