license and some functions

This commit is contained in:
Mateusz Gruszczyński
2026-07-29 14:03:46 +02:00
parent 0655cfe48c
commit ef8dfc67c1
56 changed files with 2586 additions and 547 deletions
+183 -11
View File
@@ -1,5 +1,31 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
const DEFAULT_ERRORS = {
400: "Invalid request.",
401: "Authentication required.",
403: "Access denied.",
404: "The requested resource was not found.",
405: "This operation is not allowed.",
408: "The request timed out. Try again.",
409: "The requested change conflicts with existing data.",
413: "The selected file exceeds the allowed upload limit.",
425: "The request was sent too early. Try again.",
429: "Too many requests. Try again later.",
500: "Server error. Try again later.",
502: "The server returned an invalid response. Try again.",
503: "Service temporarily unavailable.",
504: "The server took too long to respond. Try again.",
};
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
@@ -42,15 +68,33 @@ function validateUploadSize(body) {
}
}
function requestHeaders(options, body) {
const headers = new Headers(options.headers || {});
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
return headers;
}
function requestError(status, data = {}) {
const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`);
error.status = status;
return error;
}
function formDataFileSize(body) {
if (!(body instanceof FormData)) return 0;
let size = 0;
for (const value of body.values()) if (value instanceof File) size += value.size;
return size;
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const headers = new Headers(options.headers || {});
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
const headers = requestHeaders(options, options.body);
const started = performance.now();
logDebug("api.request", { method: options.method || "GET", path });
const response = await fetch(path, { ...options, headers, signal: controller.signal });
@@ -60,19 +104,147 @@ export async function api(path, options = {}) {
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
if (!response.ok) {
if (response.status === 401) await clearSessionIfInvalid();
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 selected file exceeds the allowed upload limit.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
requestError.status = response.status;
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: requestError.message });
throw requestError;
const error = requestError(response.status, data);
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: error.message });
throw error;
}
return data;
} catch (error) {
if (error.name === "AbortError") { logWarn("api.timeout", { method: options.method || "GET", path }); throw new Error("Timed out"); }
if (error.name === "AbortError") {
logWarn("api.timeout", { method: options.method || "GET", path });
throw new Error("Timed out");
}
logError("api.network_error", error, { method: options.method || "GET", path });
if (options.body instanceof FormData && error instanceof TypeError) {
throw new Error("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.");
}
throw error;
} finally { clearTimeout(timeout); }
} finally {
clearTimeout(timeout);
}
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const headers = requestHeaders(options, options.body);
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", abortFromSignal);
};
const abortFromSignal = () => {
externallyAborted = true;
xhr.abort();
};
const fail = error => {
cleanup();
reject(error);
};
xhr.open(method, path, true);
xhr.responseType = "text";
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
xhr.upload.addEventListener("loadstart", () => {
armStallTimer();
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
});
xhr.upload.addEventListener("progress", event => {
const now = performance.now();
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
const instantaneousSpeed = deltaBytes / elapsedSeconds;
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
lastAt = now;
lastLoaded = event.loaded;
const total = event.lengthComputable ? event.total : fallbackTotal;
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
if (total > 0 && event.loaded >= total) clearStallTimer();
else armStallTimer();
});
xhr.upload.addEventListener("load", event => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = setTimeout(() => {
responseTimedOut = true;
xhr.abort();
}, responseTimeoutMs);
const total = event.lengthComputable ? event.total : fallbackTotal;
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
});
xhr.addEventListener("load", () => {
cleanup();
const durationMs = Math.round(performance.now() - started);
logDebug("api.response", { method, path, status: xhr.status, durationMs });
let data = {};
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data);
return;
}
if (xhr.status === 401) void clearSessionIfInvalid();
const error = requestError(xhr.status, data);
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
logError("api.network_error", error, { method, path });
fail(error);
});
xhr.addEventListener("abort", () => {
const error = new Error(stalled
? "Upload stopped making progress. Check the connection and try again."
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
});
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true;
const error = new Error("Upload cancelled.");
error.name = "AbortError";
fail(error);
return;
}
options.signal.addEventListener("abort", abortFromSignal, { once: true });
}
logDebug("api.request", { method, path });
xhr.send(options.body ?? null);
});
}
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const VERSION = 2;
const OWNER_COLOR_SEPARATOR = "\u001f";
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
function selection(editor) {
return { start: editor.selectionStart, end: editor.selectionEnd };
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
const RECENTS_KEY = "rustpad:recent-emojis";
+39 -1
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
@@ -89,6 +98,8 @@ 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 userLabelPrimary = userLabel?.querySelector("[data-account-primary]");
const userLabelSecondary = userLabel?.querySelector("[data-account-secondary]");
const registerLink = document.querySelector("#footer-register");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
const resourcesDialog = document.querySelector("#resources-dialog");
@@ -251,7 +262,34 @@ function renderAccount(session) {
currentSession = session;
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
if (session) {
const nickname = String(session.nickname || "").trim();
let primaryIdentity = String(session.email || nickname).trim();
let secondaryIdentity = nickname;
if (session.directory_managed) {
const organization = String(session.directory_organization || "").trim();
const displayName = String(session.directory_display_name || session.email || nickname).trim();
primaryIdentity = organization ? `${organization}\\${displayName}` : displayName;
const suggestedNickname = String(session.suggested_nickname || "").trim();
secondaryIdentity = nickname && suggestedNickname
&& nickname.toLowerCase() !== suggestedNickname.toLowerCase()
? nickname
: "";
}
userLabelPrimary.textContent = `Signed as ${primaryIdentity}`;
userLabelPrimary.title = `Signed as ${primaryIdentity}`;
userLabelSecondary.textContent = secondaryIdentity;
userLabelSecondary.title = secondaryIdentity;
userLabelSecondary.hidden = !secondaryIdentity;
} else if (userLabelPrimary && userLabelSecondary) {
userLabelPrimary.textContent = "";
userLabelPrimary.removeAttribute("title");
userLabelSecondary.textContent = "";
userLabelSecondary.removeAttribute("title");
userLabelSecondary.hidden = true;
}
registerLink.hidden = !registrationEnabled;
}
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; }
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const PREFIX = "[RustPad]";
const LEVELS = Object.freeze({ off: 0, error: 1, warn: 2, info: 3, debug: 4 });
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
function escapeHtml(value) {
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
+10 -1
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { api } from "@rustpad/api";
import { askConfirm } from "@rustpad/modal";
import { NoteSocket, PadSocket } from "@rustpad/socket";
@@ -40,7 +49,7 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, revision_id: revisionId }),
}),
configureView() {},
configureView() { },
deleteNote: null,
};
}
+15 -4
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
@@ -22,6 +31,7 @@ export function startNoteEditor(adapter) {
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
@@ -439,7 +449,7 @@ export function startNoteEditor(adapter) {
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; 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}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function activeView() {
return compactLayoutQuery.matches ? compactView : uiState.view;
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyUi({ write = false, replace = false } = {}) {
@@ -448,7 +458,7 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => {
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
@@ -555,7 +565,7 @@ export function startNoteEditor(adapter) {
}
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
if (compactLayoutQuery.matches) {
if (singlePaneQuery.matches) {
compactView = button.dataset.view === "preview" ? "preview" : "edit";
applyUi();
return;
@@ -563,6 +573,7 @@ export function startNoteEditor(adapter) {
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
}));
singlePaneQuery.addEventListener("change", () => applyUi());
compactLayoutQuery.addEventListener("change", () => applyUi());
const headerMenuToggle = document.querySelector("#header-menu-toggle");
const headerActions = document.querySelector("#header-actions");
@@ -621,7 +632,7 @@ export function startNoteEditor(adapter) {
mobileBubble.style.right = "auto";
mobileBubble.style.bottom = "auto";
}
try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch {}
try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch { }
mobileBubbleDrag?.addEventListener("pointerdown", event => {
if (!mobileBubble || !compactBubbleQuery.matches) return;
event.preventDefault();
+50 -14
View File
@@ -1,9 +1,19 @@
import { api } from "@rustpad/api";
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
import { askConfirm } from "@rustpad/modal";
import { getAuthToken } from "@rustpad/session";
import { safeAppUrl } from "@rustpad/security";
import { createUploadToast } from "@rustpad/toast";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, character => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[character]));
@@ -57,22 +67,48 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
let file = event.target.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
file = await prepareImageFile(file);
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
input.value = "";
return;
}
if (!file) { input.value = ""; return; }
}
const form = new FormData();
form.append("access_token", getAccessToken() || "");
form.append("file", file);
try {
const result = await api(endpoints.upload, { method: "POST", body: form, headers: {} });
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
toast("File uploaded");
await loadFiles();
} catch (error) { toast(error.message); }
const uploadToast = createUploadToast(file.name);
let completed = false;
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
const uploadFile = async () => {
uploadToast.start();
const form = new FormData();
form.append("access_token", getAccessToken() || "");
form.append("file", file);
try {
const result = await uploadWithProgress(endpoints.upload, {
method: "POST",
body: form,
headers: {},
onProgress: progress => uploadToast.update(progress),
});
if (completed) return;
completed = true;
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
uploadToast.success();
await loadFiles();
} catch (error) {
const retryable = !error.status || retryableStatuses.has(error.status);
uploadToast.fail(error.message, { retryable, onRetry: uploadFile });
}
};
input.value = "";
await uploadFile();
});
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { createWorkspaceNoteAdapter } from "@rustpad/note-api";
import { startNoteEditor } from "@rustpad/note-editor";
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { createPadAdapter } from "@rustpad/note-api";
import { startNoteEditor } from "@rustpad/note-editor";
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
export function safeAppUrl(value, fallback = "/") {
try {
const url = new URL(String(value || ""), location.origin);
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
export function accessTokenKey(resourceKind, resourceSlug) { return `rustpad:access:${resourceKind}:${resourceSlug}`; }
export function getAccessToken(resourceKind, resourceSlug) {
return localStorage.getItem(accessTokenKey(resourceKind, resourceSlug)) || "";
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { logError, logInfo, logWarn } from "@rustpad/logger";
class RoomSocket {
+155 -5
View File
@@ -1,10 +1,160 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
let hideTimer;
export function toast(message, options = {}) {
const element = document.querySelector(options.selector || "#toast");
if (!element) return;
element.textContent = String(message ?? "");
function toastElement(selector = "#toast") {
return document.querySelector(selector);
}
function show(element, duration = null) {
element.classList.add("visible");
clearTimeout(hideTimer);
hideTimer = setTimeout(() => element.classList.remove("visible"), options.duration ?? 1800);
if (duration != null) hideTimer = setTimeout(() => element.classList.remove("visible"), duration);
}
function reset(element) {
element.className = "toast";
element.removeAttribute("aria-busy");
element.removeAttribute("aria-label");
}
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10240 ? 0 : 1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function toast(message, options = {}) {
const element = toastElement(options.selector);
if (!element) return;
reset(element);
element.textContent = String(message ?? "");
show(element, options.duration ?? 1800);
}
export function createUploadToast(filename, options = {}) {
const element = toastElement(options.selector);
if (!element) {
return { start() { }, update() { }, fail() { }, success() { }, dismiss() { } };
}
reset(element);
element.classList.add("toast--upload", "toast--interactive");
element.setAttribute("aria-live", "polite");
element.innerHTML = `
<div class="upload-toast__header">
<div class="upload-toast__heading">
<strong class="upload-toast__title">Uploading file</strong>
<span class="upload-toast__filename"></span>
</div>
</div>
<div class="upload-toast__progress" role="progressbar" aria-label="File upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>Preparing</span><span data-upload-speed></span></div>
<p class="upload-toast__error" data-upload-error hidden></p>
<div class="upload-toast__actions" data-upload-actions hidden>
<button type="button" class="upload-toast__retry">Retry</button>
<button type="button" class="upload-toast__dismiss">Dismiss</button>
</div>`;
const title = element.querySelector(".upload-toast__title");
const filenameElement = element.querySelector(".upload-toast__filename");
const progress = element.querySelector(".upload-toast__progress");
const progressFill = progress.querySelector("span");
const amount = element.querySelector("[data-upload-amount]");
const speedElement = element.querySelector("[data-upload-speed]");
const errorElement = element.querySelector("[data-upload-error]");
const actions = element.querySelector("[data-upload-actions]");
const retryButton = element.querySelector(".upload-toast__retry");
const dismissButton = element.querySelector(".upload-toast__dismiss");
let retryHandler = null;
filenameElement.textContent = String(filename || "file");
function dismiss() {
clearTimeout(hideTimer);
element.classList.remove("visible");
}
function start() {
reset(element);
element.classList.add("toast--upload", "toast--interactive", "visible");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-busy", "true");
title.textContent = "Uploading file";
filenameElement.textContent = String(filename || "file");
progress.classList.remove("is-error", "is-complete");
progress.setAttribute("aria-valuenow", "0");
progressFill.style.width = "0%";
amount.textContent = "Preparing…";
speedElement.textContent = "—";
errorElement.hidden = true;
errorElement.textContent = "";
actions.hidden = true;
retryButton.disabled = false;
clearTimeout(hideTimer);
}
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
const normalizedPercent = Number.isFinite(percent)
? Math.max(0, Math.min(100, percent))
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
if (normalizedPercent != null) {
progress.setAttribute("aria-valuenow", String(Math.round(normalizedPercent)));
progressFill.style.width = `${normalizedPercent}%`;
} else {
progress.removeAttribute("aria-valuenow");
progressFill.style.width = "24%";
progress.classList.add("is-indeterminate");
}
amount.textContent = total > 0
? `${Math.round(normalizedPercent || 0)}% · ${formatBytes(loaded)} / ${formatBytes(total)}`
: formatBytes(loaded);
speedElement.textContent = phase === "processing" ? "Processing…" : speed > 0 ? `${formatBytes(speed)}/s` : "Starting…";
}
function fail(message, { retryable = true, onRetry = null } = {}) {
element.removeAttribute("aria-busy");
title.textContent = "Upload failed";
progress.classList.remove("is-indeterminate", "is-complete");
progress.classList.add("is-error");
errorElement.textContent = String(message || "Upload failed.");
errorElement.hidden = false;
actions.hidden = false;
retryButton.hidden = !retryable;
retryHandler = typeof onRetry === "function" ? onRetry : null;
show(element);
}
function success(message = "File uploaded") {
element.removeAttribute("aria-busy");
title.textContent = message;
progress.classList.remove("is-error", "is-indeterminate");
progress.classList.add("is-complete");
progress.setAttribute("aria-valuenow", "100");
progressFill.style.width = "100%";
amount.textContent = "100%";
speedElement.textContent = "Complete";
errorElement.hidden = true;
actions.hidden = true;
show(element, 1800);
}
retryButton.addEventListener("click", async () => {
if (!retryHandler) return;
retryButton.disabled = true;
await retryHandler();
});
dismissButton.addEventListener("click", dismiss);
start();
return { start, update, fail, success, dismiss };
}
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
+9
View File
@@ -1,3 +1,12 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();