license and some functions
This commit is contained in:
+244
-30
@@ -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.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
@@ -2544,13 +2553,40 @@ dialog::backdrop {
|
||||
|
||||
.home-footer__account {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-footer__user {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: min(360px, 42vw);
|
||||
gap: 2px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.home-footer__user-primary,
|
||||
.home-footer__user-secondary {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-footer__user-secondary {
|
||||
color: var(--muted);
|
||||
font-size: .68rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.home-footer__user-secondary[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-footer__author {
|
||||
@@ -2670,6 +2706,13 @@ dialog::backdrop {
|
||||
|
||||
.home-footer__account {
|
||||
justify-content: center;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.home-footer__user {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-footer__author {
|
||||
@@ -4167,7 +4210,9 @@ dialog::backdrop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4287,10 +4332,14 @@ dialog::backdrop {
|
||||
}
|
||||
|
||||
.pad-page .editor-layout {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 320px;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pad-page .editor-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pad-page .editor-toolbar {
|
||||
@@ -4326,32 +4375,6 @@ dialog::backdrop {
|
||||
background: #0d1015;
|
||||
}
|
||||
|
||||
.pad-page .view-switch button[data-view="split"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pad-page .workspace,
|
||||
.pad-page .workspace.view-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pad-page .workspace.view-edit .editor-column,
|
||||
.pad-page .workspace.view-preview .preview-column {
|
||||
display: grid;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.pad-page .workspace.view-edit .preview-column,
|
||||
.pad-page .workspace.view-preview .editor-column {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pad-page .preview-column {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.pad-page textarea,
|
||||
.pad-page .preview,
|
||||
.pad-page .authorship-layer {
|
||||
@@ -4374,6 +4397,52 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pad-page .editor-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pad-page .view-switch {
|
||||
position: static;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pad-page .view-switch button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pad-page .view-switch button[data-view="split"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pad-page .workspace,
|
||||
.pad-page .workspace.view-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pad-page .workspace.view-edit .editor-column,
|
||||
.pad-page .workspace.view-preview .preview-column,
|
||||
.pad-page .workspace.view-split .editor-column {
|
||||
display: grid;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.pad-page .workspace.view-edit .preview-column,
|
||||
.pad-page .workspace.view-preview .editor-column,
|
||||
.pad-page .workspace.view-split .preview-column {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pad-page .preview-column {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pad-page #save-state {
|
||||
display: none;
|
||||
@@ -5084,4 +5153,149 @@ dialog::backdrop {
|
||||
.share-link-row .share-link-info {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Upload progress toast --------------------------------------------------- */
|
||||
.toast--interactive {
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast--upload {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.upload-toast__header,
|
||||
.upload-toast__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-toast__heading {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.upload-toast__title {
|
||||
font-size: .82rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.upload-toast__filename {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: .73rem;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-toast__progress {
|
||||
position: relative;
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #0b0e13;
|
||||
}
|
||||
|
||||
.upload-toast__progress>span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width .14s linear;
|
||||
}
|
||||
|
||||
.upload-toast__progress.is-complete>span {
|
||||
background: var(--success, #42b883);
|
||||
}
|
||||
|
||||
.upload-toast__progress.is-error>span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.upload-toast__progress.is-indeterminate>span {
|
||||
animation: upload-toast-indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes upload-toast-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(180%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(420%);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-toast__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted-2);
|
||||
font-size: .7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upload-toast__meta span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-toast__meta span:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.upload-toast__error {
|
||||
margin: 0;
|
||||
color: #ffb6bd;
|
||||
font-size: .73rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-toast__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.upload-toast__actions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-toast__actions button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: .72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-toast__actions button:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.upload-toast__retry {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong)) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.pad-page .toast--interactive {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -78,7 +78,10 @@
|
||||
nickname</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user"></span>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
<span class="home-footer__user-primary" data-account-primary></span>
|
||||
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
|
||||
</span>
|
||||
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
|
||||
<button id="footer-profile" class="footer-action" type="button">Profile</button>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
|
||||
+183
-11
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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})`;
|
||||
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})`;
|
||||
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 }));
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) || "";
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user