feat: add profile language preferences and refine toast, dropdown and history UI

This commit is contained in:
Mateusz Gruszczyński
2026-09-04 23:48:09 +02:00
parent f036240d5d
commit bf13587a71
42 changed files with 3971 additions and 357 deletions
+483 -74
View File
@@ -99,6 +99,7 @@
--link-hover: #c3bbff;
--caret: #9b89ff;
--owner-fallback: #8b7af4;
--info: #6fa8ff;
--warning: #e3a94d;
--warning-muted: #d6a84b;
--danger: #ff7b91;
@@ -244,6 +245,7 @@
--link-hover: #4c3f8b;
--caret: #6859b5;
--owner-fallback: #7060c1;
--info: #3568b8;
--warning: #9a6816;
--warning-muted: #7e5d1d;
--danger: #9b3443;
@@ -749,14 +751,13 @@ textarea:focus {
.editor-layout {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
grid-template-columns: minmax(0, 1fr);
height: calc(100vh - 70px);
overflow: hidden;
transition: grid-template-columns .18s ease;
}
.history-open .editor-layout {
grid-template-columns: minmax(0, 1fr) 340px;
grid-template-columns: minmax(0, 1fr);
}
.editor-panel {
@@ -1080,17 +1081,33 @@ textarea::selection {
}
.history-panel {
position: relative;
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 24;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: 340px;
max-width: 100%;
overflow: hidden;
border-left: 1px solid var(--border);
background: var(--surface-panel);
transform: translateX(100%);
transition: transform .18s ease;
box-shadow: -18px 0 42px transparent;
opacity: 0;
visibility: hidden;
transform: translateX(calc(100% + 12px));
pointer-events: none;
transition: transform .18s ease, opacity .14s ease, box-shadow .18s ease, visibility 0s linear .18s;
}
.history-panel.open {
box-shadow: -18px 0 42px var(--shadow-28);
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
transition-delay: 0s;
}
.history-header {
@@ -1115,7 +1132,8 @@ textarea::selection {
.history-list {
overflow: auto;
height: calc(100vh - 185px);
min-height: 0;
height: auto;
padding: 10px 18px 24px;
}
@@ -1216,26 +1234,258 @@ dialog::backdrop {
font-size: .78rem;
}
.toast {
.toast-region {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20;
padding: 11px 14px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-floating);
color: var(--text-bright);
font-size: .8rem;
opacity: 0;
transform: translateY(8px);
z-index: 100;
display: flex;
width: min(390px, calc(100vw - 32px));
max-height: min(720px, calc(100dvh - 40px));
flex-direction: column;
gap: 10px;
pointer-events: none;
transition: .16s ease;
}
.toast.visible {
.toast-card {
--toast-tone: var(--info);
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 10px;
align-items: start;
width: 100%;
overflow: hidden;
box-sizing: border-box;
padding: 12px 10px 13px 12px;
border: 1px solid color-mix(in srgb, var(--toast-tone) 28%, var(--border));
border-radius: 13px;
background: color-mix(in srgb, var(--surface-floating) 96%, var(--toast-tone) 4%);
box-shadow: 0 16px 42px rgba(0, 0, 0, .22), 0 2px 8px rgba(0, 0, 0, .12);
color: var(--text-bright);
opacity: 0;
transform: translateY(10px) scale(.985);
pointer-events: auto;
transition: opacity .16s ease, transform .16s ease, border-color .16s ease, background .16s ease;
backdrop-filter: blur(14px);
}
.toast-region--modal {
/* A manual popover enters the browser top layer above the active dialog.
Reset popover defaults while keeping the regular toast viewport geometry. */
top: auto;
left: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
z-index: 100;
}
.app-dialog.has-modal-toast-region {
/* Fallback for browsers without the Popover API. */
overflow: visible;
}
.toast-card.is-visible {
opacity: 1;
transform: translateY(0);
transform: translateY(0) scale(1);
}
.toast-card.is-leaving {
opacity: 0;
transform: translateY(6px) scale(.985);
pointer-events: none;
}
.toast-card--info {
--toast-tone: var(--info);
}
.toast-card--success {
--toast-tone: var(--success);
}
.toast-card--warning {
--toast-tone: var(--warning);
}
.toast-card--danger {
--toast-tone: var(--danger);
}
.toast-card__close svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.toast-card__content {
min-width: 0;
padding-top: 1px;
}
.toast-card__title {
display: block;
margin: 0 0 3px;
color: var(--text-max);
font-size: .8rem;
font-weight: 700;
line-height: 1.25;
}
.toast-card__message {
margin: 0;
color: var(--text-tertiary);
font-size: .74rem;
line-height: 1.42;
overflow-wrap: anywhere;
}
.toast-card__close {
display: grid;
width: 28px;
height: 28px;
margin: -2px -2px 0 0;
padding: 0;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted-2);
cursor: pointer;
transition: background .14s ease, color .14s ease;
}
.toast-card__close:hover {
background: color-mix(in srgb, var(--toast-tone) 10%, var(--surface-hover));
color: var(--text-max);
}
.toast-card__close:focus-visible {
outline: 2px solid color-mix(in srgb, var(--toast-tone) 62%, transparent);
outline-offset: 1px;
}
.toast-card__close[hidden] {
display: none;
}
.toast-card__timer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
overflow: hidden;
background: color-mix(in srgb, var(--toast-tone) 12%, transparent);
}
.toast-card__timer[hidden] {
display: none;
}
.toast-card__timer>span {
display: block;
width: 100%;
height: 100%;
background: var(--toast-tone);
transform-origin: left center;
}
@keyframes toast-countdown {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@media (max-width: 520px) {
.toast-region {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom, 0px));
left: auto;
width: min(300px, calc(100vw - 20px));
max-height: calc(100dvh - 20px);
gap: 7px;
}
.toast-card {
grid-template-columns: minmax(0, 1fr) 24px;
gap: 7px;
padding: 9px 8px 10px 9px;
border-radius: 11px;
box-shadow: 0 10px 28px rgba(0, 0, 0, .2), 0 2px 6px rgba(0, 0, 0, .1);
backdrop-filter: blur(11px);
}
.toast-card__close svg {
width: 16px;
height: 16px;
}
.toast-card__title {
margin-bottom: 2px;
font-size: .74rem;
}
.toast-card__message {
font-size: .68rem;
line-height: 1.34;
}
.toast-card__close {
width: 24px;
height: 24px;
margin: -1px -1px 0 0;
border-radius: 7px;
}
.toast-card__timer {
height: 2px;
}
.toast-card--upload {
grid-template-columns: minmax(0, 1fr) 24px;
}
.toast-card--upload .toast-card__content {
gap: 6px;
}
.upload-toast__filename,
.upload-toast__error {
font-size: .67rem;
}
.upload-toast__progress {
height: 5px;
}
.upload-toast__meta {
gap: 8px;
font-size: .63rem;
}
.upload-toast__actions {
gap: 5px;
}
.upload-toast__actions button {
min-height: 27px;
padding: 0 8px;
border-radius: 7px;
font-size: .67rem;
}
}
@media (prefers-reduced-motion: reduce) {
.toast-card {
transition-duration: .01ms;
}
}
.error-page {
@@ -3635,6 +3885,7 @@ dialog::backdrop {
}
#profile-dialog .profile-theme-field,
#profile-dialog .profile-language-field,
#profile-dialog .profile-suggestion {
grid-column: 1 / -1;
}
@@ -3645,6 +3896,7 @@ dialog::backdrop {
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .profile-theme-field,
#profile-dialog .profile-language-field,
#profile-dialog .theme-options,
#profile-dialog .theme-option {
min-width: 0;
@@ -5440,6 +5692,196 @@ dialog::backdrop {
cursor: pointer;
}
.profile-language-field {
display: grid;
gap: 7px;
min-width: 0;
}
.profile-language-field > span:first-child {
color: var(--text-label);
font-size: .86rem;
font-weight: 750;
}
.profile-language-control {
position: relative;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-2);
box-shadow: inset 0 1px 0 var(--wash-soft);
color: var(--muted);
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.profile-language-control:hover,
.profile-language-control[data-open="true"] {
border-color: var(--accent-border);
background: var(--surface-3);
}
.profile-language-control:focus-within {
border-color: var(--focus);
box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft);
}
.profile-language-control__icon,
.profile-language-control__chevron {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
pointer-events: none;
}
.profile-language-control__chevron {
width: 16px;
height: 16px;
justify-self: end;
transition: transform .16s ease;
}
.profile-language-control[data-open="true"] .profile-language-control__chevron {
transform: rotate(180deg);
}
.profile-language-trigger {
width: 100%;
min-width: 0;
min-height: 42px;
margin: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: .82rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.profile-language-current {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-language-menu {
position: absolute;
z-index: 60;
top: calc(100% + 8px);
left: -1px;
right: -1px;
display: grid;
gap: 2px;
max-height: min(260px, 42dvh);
padding: 4px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--surface-floating);
box-shadow: 0 16px 36px var(--shadow-38), inset 0 1px 0 var(--wash-soft);
scrollbar-width: thin;
}
.profile-language-menu[hidden] {
display: none;
}
.profile-language-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 7px;
width: 100%;
min-width: 0;
height: 38px;
min-height: 38px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color .12s ease, background-color .12s ease, color .12s ease;
}
.profile-language-option:hover,
.profile-language-option:focus-visible {
border-color: var(--border-strong);
outline: 0;
background: var(--surface-hover);
}
.profile-language-option[aria-selected="true"] {
border-color: var(--accent-a35);
background: var(--accent-soft);
}
.profile-language-option__check {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 5px;
color: var(--accent-text);
font-size: .76rem;
font-weight: 900;
}
.profile-language-option[aria-selected="true"] .profile-language-option__check {
background: var(--accent-a25);
}
.profile-language-option__copy {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.profile-language-option__copy strong,
.profile-language-option__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-language-option__copy strong {
color: var(--text);
flex: 0 1 auto;
font-size: .8rem;
line-height: 1;
}
.profile-language-option__copy small {
flex: 1 1 auto;
color: var(--text-muted);
font-size: .7rem;
line-height: 1;
}
.profile-language-field > small {
color: var(--text-muted);
line-height: 1.45;
}
.profile-theme-field {
min-width: 0;
margin: 0;
@@ -6054,7 +6496,7 @@ dialog::backdrop {
scrollbar-gutter: stable;
}
.pad-page .toast {
.pad-page .toast-region {
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
}
}
@@ -6639,40 +7081,23 @@ dialog::backdrop {
}
/* Upload progress toast --------------------------------------------------- */
.toast--interactive {
width: min(420px, calc(100vw - 40px));
box-sizing: border-box;
.toast-card--upload {
grid-template-columns: minmax(0, 1fr) 28px;
}
.toast--interactive.visible {
pointer-events: auto;
}
.toast--upload {
.toast-card--upload .toast-card__content {
display: grid;
gap: 9px;
padding: 13px 14px;
gap: 8px;
}
.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;
.toast-card--upload .toast-card__title {
margin-bottom: -4px;
}
.upload-toast__filename {
overflow: hidden;
color: var(--muted);
font-size: .73rem;
color: var(--text-tertiary);
font-size: .72rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
@@ -6680,10 +7105,10 @@ dialog::backdrop {
.upload-toast__progress {
position: relative;
height: 7px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--code-bg);
background: color-mix(in srgb, var(--toast-tone) 12%, var(--surface-3));
}
.upload-toast__progress>span {
@@ -6691,12 +7116,12 @@ dialog::backdrop {
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
background: var(--toast-tone);
transition: width .14s linear;
}
.upload-toast__progress.is-complete>span {
background: var(--success, var(--success-fallback));
background: var(--success);
}
.upload-toast__progress.is-error>span {
@@ -6708,17 +7133,9 @@ dialog::backdrop {
}
@keyframes upload-toast-indeterminate {
0% {
transform: translateX(-120%);
}
50% {
transform: translateX(180%);
}
100% {
transform: translateX(420%);
}
0% { transform: translateX(-120%); }
50% { transform: translateX(180%); }
100% { transform: translateX(420%); }
}
.upload-toast__meta {
@@ -6728,7 +7145,7 @@ dialog::backdrop {
justify-content: space-between;
gap: 12px;
color: var(--muted-2);
font-size: .7rem;
font-size: .69rem;
font-variant-numeric: tabular-nums;
}
@@ -6743,9 +7160,9 @@ dialog::backdrop {
}
.upload-toast__error {
margin: 0;
margin: -1px 0 0;
color: var(--danger-soft);
font-size: .73rem;
font-size: .72rem;
line-height: 1.4;
}
@@ -6763,10 +7180,10 @@ dialog::backdrop {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface-2);
color: var(--text);
font-size: .72rem;
font-size: .71rem;
cursor: pointer;
}
@@ -6778,14 +7195,6 @@ dialog::backdrop {
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;
}
}
/* Optional links to exact editor lines. */
.line-number-button {
display: block;
+3 -3
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
<title data-i18n-ignore>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
@@ -22,7 +22,7 @@
<span class="header-divider"></span>
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
title="Copy this link" aria-label="Copy this link">
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
<h1 id="document-title" __DOCUMENT_TITLE_I18N__>__DOCUMENT_TITLE__</h1>
<p id="document-url" class="document-url"></p>
</div>
</div>
@@ -406,7 +406,7 @@
</div>
</details>
</div>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>
+2
View File
@@ -9,6 +9,8 @@
<title>__ERROR_TITLE__ · RustPad</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_I18N__
</head>
<body>
+22 -1
View File
@@ -191,6 +191,27 @@
</label>
</div>
</fieldset>
<div class="profile-language-field">
<span id="profile-language-label" data-i18n="common.language">Language</span>
<div class="profile-language-control" data-language-picker>
<svg class="profile-language-control__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"></path>
</svg>
<button id="profile-language-button" class="profile-language-trigger" type="button"
aria-labelledby="profile-language-label profile-language-current" aria-haspopup="listbox"
aria-expanded="false" aria-controls="profile-language-options">
<span id="profile-language-current" class="profile-language-current" data-i18n-ignore>English</span>
</button>
<svg class="profile-language-control__chevron" viewBox="0 0 20 20" aria-hidden="true">
<path d="m6 8 4 4 4-4"></path>
</svg>
<input id="profile-language" type="hidden" value="en">
<div id="profile-language-options" class="profile-language-menu" role="listbox" data-i18n-ignore
aria-labelledby="profile-language-label" hidden></div>
</div>
<small data-i18n="profile.language.help">Saved with your profile and applied after you save these settings.</small>
</div>
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320"
placeholder="Leave empty to keep current"></label>
@@ -206,7 +227,7 @@
<p id="profile-message" class="form-message" role="status"></p>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
+21 -11
View File
@@ -8,6 +8,7 @@
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
import { formatNumber, setLanguage, translateApiMessage } from "@rustpad/i18n";
const DEFAULT_ERRORS = {
400: "Invalid request.",
@@ -58,9 +59,9 @@ async function csrfToken({ refresh = false } = {}) {
}
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`;
return `${bytes} B`;
if (bytes >= 1024 * 1024) return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: bytes % (1024 * 1024) ? 1 : 0 })} MB`;
if (bytes >= 1024) return `${formatNumber(Math.ceil(bytes / 1024))} KB`;
return `${formatNumber(bytes)} B`;
}
function clearExpiredSession() {
@@ -69,7 +70,9 @@ function clearExpiredSession() {
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname");
localStorage.removeItem("rustpad:language");
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0";
void setLanguage("en", { persist: false });
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
}
@@ -91,7 +94,9 @@ function validateUploadSize(body, configuredMaxBytes) {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
const error = new Error(`The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`);
const sourceMessage = `The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`;
const error = new Error(translateApiMessage(sourceMessage));
error.serverMessage = sourceMessage;
error.status = 413;
throw error;
}
@@ -110,7 +115,9 @@ async function requestHeaders(options, body) {
}
function requestError(status, data = {}) {
const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`);
const serverMessage = data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`;
const error = new Error(translateApiMessage(serverMessage));
error.serverMessage = serverMessage;
error.status = status;
return error;
}
@@ -153,11 +160,11 @@ export async function api(path, options = {}) {
} catch (error) {
if (error.name === "AbortError") {
logWarn("api.timeout", { method: options.method || "GET", path });
throw new Error("Timed out");
throw new Error(translateApiMessage("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 new Error(translateApiMessage("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit."));
}
throw error;
} finally {
@@ -261,15 +268,17 @@ export function uploadWithProgress(path, options = {}) {
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
const error = new Error(translateApiMessage("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
const sourceMessage = 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.");
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.";
const error = new Error(translateApiMessage(sourceMessage));
error.serverMessage = sourceMessage;
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
@@ -278,7 +287,8 @@ export function uploadWithProgress(path, options = {}) {
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true;
const error = new Error("Upload cancelled.");
const error = new Error(translateApiMessage("Upload cancelled."));
error.serverMessage = "Upload cancelled.";
error.name = "AbortError";
fail(error);
return;
+36 -8
View File
@@ -10,6 +10,7 @@
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
import { toast } from "@rustpad/toast";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
@@ -26,6 +27,21 @@ const clearResourceAccessState = sessionStore.clearResourceAccessState || (() =>
}
});
function authErrorTitle(mode) {
if (mode === "register") return "Registration failed";
if (mode === "reset") return "Password reset failed";
return "Sign-in failed";
}
function notifyAuthError(error, mode) {
toast.danger(error?.message || "Please try again.", { title: authErrorTitle(mode) });
}
if (typeof window !== "undefined") window.addEventListener("rustpad:session-expired", () => {
toast.warning("Your session expired. Sign in again to continue with account-only actions.", { title: "Session expired", duration: 7000 });
});
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
const directoryLogin = loginMode && externalAuth;
emailLabel.textContent = directoryLogin ? "E-mail / LDAP or AD Username" : "E-mail";
@@ -129,6 +145,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Reset link sent", duration: 6500 });
return;
}
@@ -141,16 +158,19 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.add("success");
message.textContent = session.message;
password.value = "";
toast.success(session.message, { title: "Check your inbox", duration: 7000 });
return;
}
setAuthSession(session);
await setAuthSession(session);
await onIdentity(session.nickname, session);
toast.success(`Signed in as ${session.nickname}.`, { title: "Signed in" });
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/confirm the account/i.test(error.message) && email.value.trim()) {
notifyAuthError(error, mode);
if (/confirm the account/i.test(error.serverMessage || error.message) && email.value.trim()) {
const resend = document.createElement("button");
resend.type = "button";
resend.className = "text-button resend-confirmation";
@@ -162,8 +182,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Confirmation e-mail sent", duration: 6500 });
} catch (resendError) {
message.textContent = resendError.message;
toast.danger(resendError.message, { title: "Could not resend confirmation" });
resend.disabled = false;
}
});
@@ -258,10 +280,12 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Reset link sent", duration: 6500 });
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
notifyAuthError(error, "reset");
}
});
@@ -270,7 +294,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
updateActions();
collapse();
nickname.value = "";
message.textContent = "Logged out.";
toast.info("You have been signed out.", { title: "Signed out" });
});
form.addEventListener("submit", async (event) => {
@@ -289,10 +313,12 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
message.classList.add("success");
message.textContent = session.message;
password.value = "";
toast.success(session.message, { title: "Check your inbox", duration: 7000 });
return;
}
setAuthSession(session);
await setAuthSession(session);
await onIdentity(session.nickname, session);
toast.success(`Signed in as ${session.nickname}.`, { title: "Signed in" });
dialog.close();
return;
}
@@ -300,12 +326,14 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
toast.info(`Continuing as ${result.nickname}.`, { title: "Guest session" });
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/registered|session|account/i.test(error.message)) showAuth("login");
notifyAuthError(error, authRequested ? mode : "login");
if (/registered|session|account/i.test(error.serverMessage || error.message)) showAuth("login");
}
});
@@ -318,10 +346,10 @@ export async function validateCurrentSession() {
const expectedSession = Boolean(getAuthToken());
try {
const session = await api("/api/auth/me");
setAuthSession(session);
await setAuthSession(session);
return session;
} catch {
if (expectedSession) clearAuthSession();
if (expectedSession) await clearAuthSession();
return null;
}
}
@@ -331,7 +359,7 @@ export async function logoutCurrentSession() {
await api("/api/auth/logout", { method: "POST" });
} catch { }
clearResourceAccessState();
clearAuthSession();
await clearAuthSession();
}
+14 -12
View File
@@ -7,11 +7,13 @@
* See LICENSE file in repository root for details.
*/
import { t } from "@rustpad/i18n";
function selection(editor) {
return { start: editor.selectionStart, end: editor.selectionEnd };
}
function toggleWrap(editor, before, after = before, placeholder = "tekst") {
function toggleWrap(editor, before, after = before, placeholder = t("editor.format.text", {}, "text")) {
let { start, end } = selection(editor);
const value = editor.value;
const selected = value.slice(start, end);
@@ -65,15 +67,15 @@ export function applyFormat(editor, format) {
if (format === "number") togglePrefix(editor, index => `${index + 1}. `);
if (format === "task") togglePrefix(editor, "- [ ] ");
if (format === "quote") togglePrefix(editor, "> ");
if (format === "link") toggleWrap(editor, "[", "](https://)", "description");
if (format === "inline-code") toggleWrap(editor, "`", "`", "code");
if (format === "highlight") toggleWrap(editor, "==", "==", "important");
if (format === "link") toggleWrap(editor, "[", "](https://)", t("editor.format.description", {}, "description"));
if (format === "inline-code") toggleWrap(editor, "`", "`", t("editor.format.code", {}, "code"));
if (format === "highlight") toggleWrap(editor, "==", "==", t("editor.format.important", {}, "important"));
if (format === "subscript") toggleWrap(editor, "~", "~", "2");
if (format === "superscript") toggleWrap(editor, "^", "^", "2");
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code");
if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", "code");
if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", "graph TD\n A[Start] --> B[End]");
if (format === "details") toggleWrap(editor, "<details>\n<summary>Click me</summary>\n\n", "\n</details>", "Content");
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", t("editor.format.code", {}, "code"));
if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", t("editor.format.code", {}, "code"));
if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", t("editor.format.diagram", {}, "graph TD\n A[Start] --> B[End]"));
if (format === "details") toggleWrap(editor, `<details>\n<summary>${t("editor.format.detailsSummary", {}, "Click me")}</summary>\n\n`, "\n</details>", t("editor.format.content", {}, "Content"));
if (format === "toc") {
const { start, end } = selection(editor);
const selected = editor.value.slice(start, end);
@@ -81,11 +83,11 @@ export function applyFormat(editor, format) {
}
if (format.startsWith("alert-")) {
const type = format.slice("alert-".length);
toggleWrap(editor, `:::${type}\n`, "\n:::", "Alert content");
toggleWrap(editor, `:::${type}\n`, "\n:::", t("editor.format.alertContent", {}, "Alert content"));
}
if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value");
if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote");
if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term");
if (format === "table") toggleWrap(editor, `| ${t("editor.format.column1", {}, "Column 1")} | ${t("editor.format.column2", {}, "Column 2")} |\n| --- | --- |\n| `, ` | ${t("editor.format.value", {}, "value")} |`, t("editor.format.value", {}, "value"));
if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote"));
if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term"));
if (format === "horizontal-rule") toggleWrap(editor, "\n---\n", "", "");
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
+35 -8
View File
@@ -8,9 +8,31 @@
*/
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
import { t } from "@rustpad/i18n";
const RECENTS_KEY = "rustpad:recent-emojis";
const MAX_RECENTS = 24;
const GROUP_KEYS = new Map([
["Smileys & Emotion", "emoji.group.smileysEmotion"],
["People & Body", "emoji.group.peopleBody"],
["Animals & Nature", "emoji.group.animalsNature"],
["Food & Drink", "emoji.group.foodDrink"],
["Travel & Places", "emoji.group.travelPlaces"],
["Activities", "emoji.group.activities"],
["Objects", "emoji.group.objects"],
["Symbols", "emoji.group.symbols"],
["Flags", "emoji.group.flags"],
]);
function groupLabel(name) {
if (name === "Recently Used") return t("emoji.recentGroup", {}, "Recently Used");
const key = GROUP_KEYS.get(name);
return key ? t(key, {}, name) : name;
}
function itemLabel(item) {
return t("emoji.itemLabel", { emoji: item.emoji }, `Emoji ${item.emoji}`);
}
function loadRecents() {
try {
@@ -50,7 +72,7 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
const found = group.items.find(item => item.emoji === value);
if (found) return found;
}
return { emoji: value, name: "Recent emoji", keywords: "recent" };
return { emoji: value, name: t("emoji.recentItem", {}, "Recent emoji"), keywords: "recent" };
});
return recentItems.length ? [{ name: "Recently Used", items: recentItems }, ...EMOJI_GROUPS] : EMOJI_GROUPS;
};
@@ -64,8 +86,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
button.className = "emoji-category";
button.dataset.emojiGroup = group.name;
button.textContent = group.items[0]?.emoji || "•";
button.title = group.name;
button.setAttribute("aria-label", group.name);
const label = groupLabel(group.name);
button.title = label;
button.setAttribute("aria-label", label);
button.setAttribute("aria-pressed", String(group.name === activeGroup));
return button;
}));
@@ -75,9 +98,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
const query = normalize(search.value.trim());
let items;
if (query) {
items = EMOJI_GROUPS.flatMap(group => group.items).filter(item =>
normalize(`${item.name} ${item.keywords}`).includes(query)
);
items = EMOJI_GROUPS.flatMap(group => group.items.map(item => ({ item, group })))
.filter(({ item, group }) => normalize(`${item.name} ${item.keywords} ${groupLabel(group.name)}`).includes(query))
.map(({ item }) => item);
} else {
items = groups().find(group => group.name === activeGroup)?.items || [];
}
@@ -88,8 +111,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
button.type = "button";
button.className = "emoji-item";
button.dataset.emoji = item.emoji;
button.title = item.name;
button.setAttribute("aria-label", item.name);
const label = itemLabel(item);
button.title = label;
button.setAttribute("aria-label", label);
button.textContent = item.emoji;
fragment.append(button);
}
@@ -126,4 +150,7 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
document.addEventListener("pointerdown", event => {
if (details.open && !details.contains(event.target)) details.removeAttribute("open");
});
document.addEventListener("rustpad:languagechange", () => {
if (details.open) render();
});
}
+104 -17
View File
@@ -17,6 +17,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { formatDateTime, populateLanguageSelect, setLanguage, t } from "@rustpad/i18n";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
@@ -68,6 +69,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
toast.danger(requestError.message, { title: "Could not create note" });
} finally {
setBusy(button, false, "Create note", "Creating…");
}
@@ -89,6 +91,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
toast.danger(requestError.message, { title: t("resources.createWorkspaceFailed", {}, "Could not create workspace") });
} finally {
setBusy(button, false, "Create workspace", "Creating…");
}
@@ -125,8 +128,8 @@ function resourceActionLabel(full, short = full) {
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
const full = isPrivate ? t("resource.makePublic", {}, "Make public") : t("resource.makePrivate", {}, "Make private");
const short = isPrivate ? t("common.public", {}, "Public") : t("common.private", {}, "Private");
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
@@ -134,13 +137,29 @@ function setResourcePrivacyLabel(button, isPrivate) {
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error(t("share.validityRange", {}, "Enter a validity between 1 and 87600 hours.")); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return t("share.neverExpires", {}, "Never expires"); const date = new Date(value); return Number.isNaN(date.getTime()) ? value : t("share.expires", { date: formatDateTime(date) }, `Expires ${formatDateTime(date)}`); }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : t("share.created", { date: formatDateTime(date) }, `Created ${formatDateTime(date)}`); }
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>${escapeHtml(t("common.previous", {}, "Previous"))}</button><span>${escapeHtml(t("pagination.items", { page: meta.page, pages: meta.total_pages, count: meta.total }, `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items`))}</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>${escapeHtml(t("common.next", {}, "Next"))}</button>` : "";
}
function setResourceMeta(element, item) {
if (!element) return;
const parts = [item.kind === "workspace" ? "Workspace" : "Note"];
if (item.private) parts.push("private");
if (!item.owned) parts.push(item.permission === "rw" ? "Read and write" : "Read only");
else if (item.protected) parts.push("password protected");
const nodes = [];
parts.forEach((part, index) => {
if (index) nodes.push(document.createTextNode(" · "));
const span = document.createElement("span");
span.textContent = part;
nodes.push(span);
});
element.replaceChildren(...nodes);
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
@@ -167,14 +186,16 @@ async function loadResources() {
const row = document.createElement("article");
row.className = "resource-row";
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
const permissionLabel = item.permission === "rw" ? t("permission.readWrite", {}, "Read and write") : t("permission.readOnly", {}, "Read only");
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small data-resource-meta></small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
setResourceMeta(row.querySelector("[data-resource-meta]"), item);
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -194,9 +215,11 @@ async function loadResources() {
item.private = nextPrivate;
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
setResourceMeta(meta, item);
toast.success(`${item.title} is now ${item.private ? "private" : "public"}.`, { title: "Visibility updated" });
} catch (e) {
resourcesError.textContent = e.message;
toast.danger(e.message, { title: "Could not update visibility" });
} finally {
button.disabled = false;
}
@@ -233,6 +256,9 @@ async function loadResources() {
const message = dialog.querySelector("[data-inline-message]");
message.className = `form-message resource-inline-message ${type}`.trim();
message.textContent = text;
if (type === "success") toast.success(text, { title: "Sharing updated" });
else if (type === "warning") toast.warning(text, { title: "Sharing needs attention" });
else if (type === "error") toast.danger(text, { title: "Sharing action failed" });
};
const userForm = dialog.querySelector("[data-user-share-form]");
const linkForm = dialog.querySelector("[data-link-form]");
@@ -317,9 +343,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password }) });
toast.success(`Password protection is enabled for ${item.title}.`, { title: "Password saved" });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not save password" });
submit.disabled = false;
}
});
@@ -336,9 +364,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
toast.warning(`Password protection was removed from ${item.title}.`, { title: "Password removed", duration: 6000 });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not remove password" });
event.currentTarget.disabled = false;
}
});
@@ -353,9 +383,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug }) });
toast.success(`${item.kind === "workspace" ? "Workspace" : "Note"} deleted.`, { title: "Item removed" });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not delete item" });
event.currentTarget.disabled = false;
}
});
@@ -363,7 +395,7 @@ async function loadResources() {
resourcesList.append(row);
}
renderResourcesPagination(data.pagination);
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; }
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); }
}
resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); });
@@ -413,7 +445,7 @@ aboutDialog?.addEventListener("click", event => { if (event.target === aboutDial
if (identityDialog) {
const authDialog = bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (_nickname, session) => { renderAccount(session); if (session) toast(`Logged in as ${session.nickname}.`); },
onIdentity: async (_nickname, session) => { renderAccount(session); },
});
document.querySelector("#footer-login")?.addEventListener("click", () => {
@@ -431,6 +463,7 @@ if (identityDialog) {
profileColor.value = currentSession?.editor_color || "#7c6cff";
const selectedTheme = currentSession?.theme || getTheme();
profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; });
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
document.querySelector("#profile-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = "";
@@ -440,7 +473,7 @@ if (identityDialog) {
profileMessage.classList.remove("success", "error");
const directoryManaged = Boolean(currentSession?.directory_managed);
document.querySelector("#profile-copy").textContent = directoryManaged
? "Directory account details are read-only. You can change the nickname, editor color, and interface theme."
? "Directory account details are read-only. You can change the nickname, editor color, interface theme, and language."
: "Manage your local RustPad account.";
document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; });
document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; });
@@ -452,18 +485,72 @@ if (identityDialog) {
document.querySelector("#profile-password").required = false;
profileDialog.showModal();
});
document.addEventListener("rustpad:languagechange", () => {
if (!profileDialog?.open) return;
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
});
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 selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark"; 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, theme: selectedTheme, 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; currentSession.theme = result.theme; applyTheme(result.theme); renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
event.preventDefault();
const message = document.querySelector("#profile-message");
const saveButton = profileForm.querySelector('button[type="submit"]');
message.textContent = "";
message.classList.remove("success", "error");
if (saveButton) saveButton.disabled = true;
try {
const selectedColor = document.querySelector("#profile-color").value;
const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark";
const selectedLanguage = document.querySelector("#profile-language")?.value || "en";
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,
theme: selectedTheme,
language: selectedLanguage,
new_email: newEmail,
new_password: newPassword,
password,
}),
});
currentSession.nickname = result.nickname;
currentSession.editor_color = result.editor_color;
currentSession.theme = result.theme;
currentSession.language = result.language || "en";
applyTheme(result.theme);
await setLanguage(currentSession.language);
populateLanguageSelect(document.querySelector("#profile-language"), currentSession.language);
renderAccount(currentSession);
message.textContent = result.message;
message.classList.add("success");
toast.success(
t("profile.saved", {}, "Your profile settings were saved."),
{ title: t("profile.updated", {}, "Profile updated") }
);
} catch (error) {
message.textContent = error.message;
message.classList.add("error");
toast.danger(error.message, { title: t("profile.updateFailed", {}, "Could not update profile") });
} finally {
if (saveButton) saveButton.disabled = false;
}
});
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value;
message.classList.remove("success", "error");
if (!password) { message.textContent = "Enter the current password first."; message.classList.add("error"); return; }
if (!confirm("Send an e-mail link to permanently delete this account?")) return;
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
if (!confirm(t("auth.delete.confirm", {}, "Send an e-mail link to permanently delete this account?"))) return;
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); toast.info(result.message, { title: "Check your inbox", duration: 6500 }); } catch (e) { message.textContent = e.message; message.classList.add("error"); toast.danger(e.message, { title: "Could not request account deletion" }); }
});
document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
@@ -472,7 +559,7 @@ if (identityDialog) {
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
await logoutCurrentSession();
renderAccount(null);
toast("Logged out.");
toast.info("You have been signed out.", { title: "Signed out" });
});
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
+526
View File
@@ -0,0 +1,526 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Frontend internationalization. Language files live in /lang at the repository
* root and are embedded into the Rust binary at build time.
*/
const STORAGE_KEY = "rustpad:language";
const FALLBACK_LANGUAGE = "en";
const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"];
const SKIP_SELECTOR = [
"script",
"style",
"textarea",
"#editor",
"#preview",
"#chat-messages",
".markdown-body",
".revision__snippet",
".revision__preview",
".chat-message",
".resource-title-line a",
".share-list-identity",
".note-card-title h3",
".note-table-link",
"#document-title",
"#workspace-title",
"#public-title",
"#room-users",
"[data-account-primary]",
"[data-account-secondary]",
"[contenteditable='true']",
"[data-i18n-ignore]",
].join(",");
const textState = new WeakMap();
const attributeState = new WeakMap();
const bundles = new Map();
let catalog = [];
let activeCode = FALLBACK_LANGUAGE;
let activeBundle = null;
let fallbackBundle = null;
let sourceIndex = new Map();
let sourcePatterns = [];
let observer = null;
let initialized = false;
let initialization = null;
function configVersion() {
return window.__RUSTPAD_CONFIG__?.assetVersion || "dev";
}
function normalizeSource(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileSourcePattern(source, key) {
const names = [];
let cursor = 0;
let expression = "^";
const placeholder = /\{([a-zA-Z0-9_]+)\}/g;
let match;
while ((match = placeholder.exec(source))) {
expression += escapeRegExp(source.slice(cursor, match.index));
expression += "(.+?)";
names.push(match[1]);
cursor = match.index + match[0].length;
}
expression += escapeRegExp(source.slice(cursor));
expression += "$";
try {
return { key, source, names, regex: new RegExp(expression, "u") };
} catch {
return null;
}
}
function rebuildSourceIndex() {
sourceIndex = new Map();
sourcePatterns = [];
const indexedBundles = new Set();
const candidates = [fallbackBundle, ...bundles.values()].filter(Boolean);
for (const bundle of candidates) {
if (indexedBundles.has(bundle)) continue;
indexedBundles.add(bundle);
for (const [key, rawValue] of Object.entries(bundle.translations || {})) {
if (typeof rawValue !== "string" || !rawValue.trim()) continue;
const value = normalizeSource(rawValue);
if (!sourceIndex.has(value)) sourceIndex.set(value, key);
if (/\{[a-zA-Z0-9_]+\}/.test(value)) {
const pattern = compileSourcePattern(value, key);
if (pattern) sourcePatterns.push(pattern);
}
}
}
sourcePatterns.sort((left, right) => right.source.length - left.source.length);
}
function interpolate(value, params = {}) {
return String(value ?? "").replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
);
}
export function t(key, params = {}, fallback = key) {
const active = activeBundle?.translations?.[key];
const base = fallbackBundle?.translations?.[key];
const value = typeof active === "string" ? active : typeof base === "string" ? base : fallback;
return interpolate(value, params);
}
export function tp(key, count, params = {}, fallback = "") {
const numericCount = Number(count);
let category = "other";
try { category = new Intl.PluralRules(getLocale()).select(numericCount); } catch { /* use other */ }
const candidates = [`${key}.${category}`, `${key}.other`, `${key}.many`, `${key}.few`, `${key}.one`];
for (const candidate of candidates) {
const active = activeBundle?.translations?.[candidate];
const base = fallbackBundle?.translations?.[candidate];
const value = typeof active === "string" ? active : typeof base === "string" ? base : null;
if (value != null) return interpolate(value, { ...params, count: numericCount });
}
return interpolate(fallback || String(numericCount), { ...params, count: numericCount });
}
function matchSource(value) {
const normalized = normalizeSource(value);
if (!normalized) return null;
const exactKey = sourceIndex.get(normalized);
if (exactKey) return { key: exactKey, params: {} };
for (const pattern of sourcePatterns) {
const match = normalized.match(pattern.regex);
if (!match) continue;
const params = {};
pattern.names.forEach((name, index) => { params[name] = match[index + 1]; });
return { key: pattern.key, params };
}
return null;
}
export function translateSource(value) {
const raw = String(value ?? "");
if (!raw.trim()) return raw;
const leading = raw.match(/^\s*/u)?.[0] || "";
const trailing = raw.match(/\s*$/u)?.[0] || "";
const match = matchSource(raw);
if (!match) return raw;
return `${leading}${t(match.key, match.params, normalizeSource(raw))}${trailing}`;
}
export function translateApiMessage(message) {
return translateSource(message);
}
function isSkipped(element) {
return element instanceof Element && Boolean(element.closest(SKIP_SELECTOR));
}
function translateTextNode(node, force = false) {
const parent = node.parentElement;
if (!parent || isSkipped(parent)) return;
const current = node.data;
let state = textState.get(node);
if (!state || (!force && current !== state.rendered)) {
state = { original: current, rendered: current };
textState.set(node, state);
}
const rendered = translateSource(state.original);
state.rendered = rendered;
if (node.data !== rendered) node.data = rendered;
}
function attributeMap(element) {
let map = attributeState.get(element);
if (!map) {
map = new Map();
attributeState.set(element, map);
}
return map;
}
function translateAttribute(element, name, force = false) {
if (!element.hasAttribute(name) || isSkipped(element)) return;
const current = element.getAttribute(name) || "";
const map = attributeMap(element);
let state = map.get(name);
if (!state || (!force && current !== state.rendered)) {
state = { original: current, rendered: current };
map.set(name, state);
}
const rendered = translateSource(state.original);
state.rendered = rendered;
if (current !== rendered) element.setAttribute(name, rendered);
}
function translateExplicit(element) {
const key = element.dataset.i18n;
if (key) element.textContent = t(key);
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
const attrKey = element.dataset[`i18n${attribute.replace(/(^|-)([a-z])/g, (_, __, char) => char.toUpperCase())}`];
if (attrKey) element.setAttribute(attribute, t(attrKey));
}
}
function translateElementAttributes(element, force = false) {
translateExplicit(element);
if (isSkipped(element)) return;
for (const name of TRANSLATABLE_ATTRIBUTES) translateAttribute(element, name, force);
}
function translateTree(root = document, force = false) {
if (root instanceof Element) translateElementAttributes(root, force);
const elementRoot = root instanceof Document ? root.documentElement : root;
if (!elementRoot) return;
const elementWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_ELEMENT);
let element = elementWalker.currentNode;
while (element) {
if (element instanceof Element) translateElementAttributes(element, force);
element = elementWalker.nextNode();
}
const textWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_TEXT);
let textNode = textWalker.nextNode();
while (textNode) {
translateTextNode(textNode, force);
textNode = textWalker.nextNode();
}
}
function startObserver() {
observer?.disconnect();
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === "characterData") {
translateTextNode(mutation.target);
continue;
}
if (mutation.type === "attributes") {
translateAttribute(mutation.target, mutation.attributeName);
continue;
}
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node);
else if (node instanceof Element) translateTree(node);
}
}
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATABLE_ATTRIBUTES,
});
}
async function fetchJson(path) {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(`${path}${separator}v=${encodeURIComponent(configVersion())}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(t("i18n.loadFailed", { status: response.status }, `Could not load language resource (${response.status})`));
return response.json();
}
async function loadCatalog() {
if (catalog.length) return catalog;
const result = await fetchJson("/lang");
catalog = Array.isArray(result) ? result.filter(item => item && item.code && item.locale) : [];
return catalog;
}
async function loadBundle(code) {
if (bundles.has(code)) return bundles.get(code);
const bundle = await fetchJson(`/lang/${encodeURIComponent(code)}.json`);
if (!bundle?.meta?.code || !bundle?.translations) throw new Error(`Invalid language bundle: ${code}`);
bundles.set(bundle.meta.code, bundle);
return bundle;
}
function preferredCode() {
// English is always the default. A cached language is only used while an
// authenticated profile is expected; the server session remains the source
// of truth and will re-apply its saved preference after validation.
let hasAccountSession = false;
let saved = "";
try {
hasAccountSession = localStorage.getItem("rustpad:auth-state") === "1";
if (hasAccountSession) saved = localStorage.getItem(STORAGE_KEY) || "";
} catch { /* storage may be blocked */ }
if (!hasAccountSession) return FALLBACK_LANGUAGE;
const available = new Set(catalog.map(item => item.code));
return available.has(saved) ? saved : FALLBACK_LANGUAGE;
}
function applyDocumentLanguage() {
const meta = activeBundle?.meta || fallbackBundle?.meta;
if (!meta) return;
document.documentElement.lang = meta.code;
document.documentElement.dataset.locale = meta.locale;
}
export async function setLanguage(code, { persist = true } = {}) {
await initI18n();
const available = catalog.find(item => item.code === code);
const target = available ? available.code : FALLBACK_LANGUAGE;
activeBundle = await loadBundle(target);
activeCode = activeBundle.meta.code;
rebuildSourceIndex();
if (persist) {
try { localStorage.setItem(STORAGE_KEY, activeCode); } catch { /* storage may be blocked */ }
}
applyDocumentLanguage();
translateTree(document, true);
document.dispatchEvent(new CustomEvent("rustpad:languagechange", {
detail: { language: activeCode, locale: activeBundle.meta.locale },
}));
return activeCode;
}
export function getLanguage() {
return activeCode;
}
export function getLocale() {
return activeBundle?.meta?.locale || fallbackBundle?.meta?.locale || "en-US";
}
export function getAvailableLanguages() {
return catalog.map(item => ({ ...item }));
}
function languageLabel(language) {
const nativeName = language.native_name || language.name || language.code;
const englishName = language.name || nativeName;
return nativeName === englishName ? nativeName : `${nativeName} · ${englishName}`;
}
function closeLanguagePicker(picker, { focusTrigger = false } = {}) {
if (!(picker instanceof HTMLElement)) return;
const menu = picker.querySelector(".profile-language-menu");
const trigger = picker.querySelector(".profile-language-trigger");
if (menu instanceof HTMLElement) menu.hidden = true;
picker.removeAttribute("data-open");
trigger?.setAttribute("aria-expanded", "false");
if (focusTrigger) trigger?.focus();
}
function focusLanguageOption(menu, direction = 1) {
const options = [...menu.querySelectorAll(".profile-language-option")];
if (!options.length) return;
const current = document.activeElement;
const index = options.indexOf(current);
const next = index < 0
? (direction > 0 ? 0 : options.length - 1)
: (index + direction + options.length) % options.length;
options[next].focus();
}
function bindLanguagePicker(input, picker) {
if (picker.dataset.languagePickerBound === "true") return;
picker.dataset.languagePickerBound = "true";
const trigger = picker.querySelector(".profile-language-trigger");
const menu = picker.querySelector(".profile-language-menu");
if (!(trigger instanceof HTMLButtonElement) || !(menu instanceof HTMLElement)) return;
const open = (focusSelected = false) => {
menu.hidden = false;
picker.dataset.open = "true";
trigger.setAttribute("aria-expanded", "true");
if (focusSelected) {
const selected = menu.querySelector('[aria-selected="true"]') || menu.querySelector(".profile-language-option");
selected?.focus();
}
};
trigger.addEventListener("click", () => {
if (menu.hidden) open(false);
else closeLanguagePicker(picker);
});
trigger.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
open(true);
}
});
menu.addEventListener("click", event => {
const option = event.target.closest(".profile-language-option");
if (!(option instanceof HTMLElement)) return;
input.value = option.dataset.languageCode || FALLBACK_LANGUAGE;
populateLanguageSelect(input, input.value);
closeLanguagePicker(picker, { focusTrigger: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
menu.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
focusLanguageOption(menu, event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const options = [...menu.querySelectorAll(".profile-language-option")];
(event.key === "Home" ? options[0] : options.at(-1))?.focus();
} else if (event.key === "Escape") {
event.preventDefault();
closeLanguagePicker(picker, { focusTrigger: true });
}
});
document.addEventListener("pointerdown", event => {
if (!picker.contains(event.target)) closeLanguagePicker(picker);
});
picker.closest("dialog")?.addEventListener("close", () => closeLanguagePicker(picker));
}
export function populateLanguageSelect(input, selectedCode = getLanguage()) {
const selected = catalog.find(item => item.code === selectedCode)
|| catalog.find(item => item.code === FALLBACK_LANGUAGE)
|| catalog[0];
if (!selected) return;
if (input instanceof HTMLSelectElement) {
input.replaceChildren(...catalog.map(language => {
const option = document.createElement("option");
option.value = language.code;
option.textContent = languageLabel(language);
option.title = `${language.native_name || language.name || language.code} (${language.locale})`;
option.lang = language.code;
return option;
}));
input.value = selected.code;
return;
}
if (!(input instanceof HTMLInputElement)) return;
const picker = input.closest("[data-language-picker]");
if (!(picker instanceof HTMLElement)) return;
bindLanguagePicker(input, picker);
const current = picker.querySelector(".profile-language-current");
const trigger = picker.querySelector(".profile-language-trigger");
const menu = picker.querySelector(".profile-language-menu");
input.value = selected.code;
if (current instanceof HTMLElement) {
current.textContent = languageLabel(selected);
current.lang = selected.code;
}
if (trigger instanceof HTMLElement) {
trigger.title = `${selected.native_name || selected.name || selected.code} (${selected.locale})`;
}
if (!(menu instanceof HTMLElement)) return;
menu.replaceChildren(...catalog.map(language => {
const option = document.createElement("button");
const nativeName = language.native_name || language.name || language.code;
const englishName = language.name || nativeName;
const isSelected = language.code === selected.code;
option.type = "button";
option.className = "profile-language-option";
option.dataset.languageCode = language.code;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", isSelected ? "true" : "false");
option.lang = language.code;
option.innerHTML = `<span class="profile-language-option__check" aria-hidden="true">${isSelected ? "✓" : ""}</span><span class="profile-language-option__copy"><strong></strong><small></small></span>`;
option.querySelector("strong").textContent = nativeName;
option.querySelector("small").textContent = nativeName === englishName ? language.locale : `${englishName} · ${language.locale}`;
return option;
}));
}
export function formatDateTime(value, options) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value ?? "");
return new Intl.DateTimeFormat(getLocale(), options).format(date);
}
export function formatTime(value, options = { hour: "2-digit", minute: "2-digit" }) {
return formatDateTime(value, options);
}
export function formatNumber(value, options) {
return new Intl.NumberFormat(getLocale(), options).format(value);
}
function revealDocumentAfterI18n() {
const root = document.documentElement;
root.removeAttribute("data-i18n-pending");
if (root.style.visibility === "hidden") root.style.removeProperty("visibility");
}
export async function initI18n() {
if (initialized) return;
if (initialization) return initialization;
initialization = (async () => {
try {
await loadCatalog();
const code = preferredCode();
const [fallback, preferred] = await Promise.all([
loadBundle(FALLBACK_LANGUAGE),
code === FALLBACK_LANGUAGE ? Promise.resolve(null) : loadBundle(code),
]);
fallbackBundle = fallback;
activeBundle = preferred || fallbackBundle;
activeCode = activeBundle.meta.code;
rebuildSourceIndex();
} catch (error) {
console.warn("RustPad i18n initialization failed; using source language.", error);
catalog = catalog.length ? catalog : [{ code: "en", name: "English", native_name: "English", locale: "en-US" }];
fallbackBundle = fallbackBundle || { meta: catalog[0], translations: {} };
activeBundle = fallbackBundle;
activeCode = FALLBACK_LANGUAGE;
rebuildSourceIndex();
}
applyDocumentLanguage();
translateTree(document);
startObserver();
revealDocumentAfterI18n();
initialized = true;
})();
await initialization;
}
+7 -6
View File
@@ -8,6 +8,7 @@
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
import { t } from "@rustpad/i18n";
import { parseImageAlias } from "@rustpad/image-alias";
function escapeHtml(value) {
@@ -224,7 +225,7 @@ function renderStandaloneMedia(line, sourceLine) {
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>Playback is unavailable. <a href="${downloadUrl}" download="${escapeHtml(filename)}">Download ${escapeHtml(label)}</a>.</p></div>`;
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>${escapeHtml(t("markdown.playbackUnavailable", {}, "Playback is unavailable."))} <a href="${downloadUrl}" download="${escapeHtml(filename)}">${escapeHtml(t("markdown.download", { label }, `Download ${label}`))}</a>.</p></div>`;
}
const trimmed = String(line).trim();
@@ -232,8 +233,8 @@ function renderStandaloneMedia(line, sourceLine) {
const candidate = markdownLink ? markdownLink[2] : trimmed;
const youtube = youtubeVideo(candidate);
if (!youtube) return null;
const title = markdownLink?.[1]?.trim() || "YouTube video";
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(title)}</a></p></div></div>`;
const title = markdownLink?.[1]?.trim() || t("markdown.youtubeVideo", {}, "YouTube video");
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t("markdown.open", { title }, `Open ${title}`))}</a></p></div></div>`;
}
function listLine(line) {
@@ -462,11 +463,11 @@ export function renderMarkdown(source, lineOffset = 0) {
while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++;
if (end < lines.length) {
let bodyStart = index + 1;
let summary = "Details";
let summary = t("markdown.details", {}, "Details");
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
if (bodyStart < end) {
const summaryMatch = lines[bodyStart].trim().match(/^<summary>([\s\S]*?)<\/summary>$/i);
if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; bodyStart++; }
if (summaryMatch) { summary = summaryMatch[1].trim() || t("markdown.details", {}, "Details"); bodyStart++; }
}
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
const body = lines.slice(bodyStart, end).join("\n");
@@ -480,7 +481,7 @@ export function renderMarkdown(source, lineOffset = 0) {
closeList();
const tocHeadings = headings.filter(item => item.index > index);
if (tocHeadings.length) {
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="${escapeHtml(t("markdown.toc", {}, "Table of contents"))}">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
}
continue;
}
+15 -13
View File
@@ -7,6 +7,8 @@
* See LICENSE file in repository root for details.
*/
import { t, translateSource } from "@rustpad/i18n";
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
@@ -14,10 +16,10 @@ function ensureDialog() {
dialog.id = "system-dialog";
dialog.className = "app-dialog";
dialog.innerHTML = `<form method="dialog" class="dialog-panel system-dialog-panel">
<button class="modal-close" value="cancel" aria-label="Close dialog">×</button>
<button class="modal-close" value="cancel" aria-label="${t("common.closeDialog", {}, "Close dialog")}">×</button>
<h2 data-title></h2><p class="dialog-copy" data-message></p>
<label data-input-wrap hidden><span data-input-label></span><input data-input name="modal-input" data-bwignore="true"></label>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>Cancel</button><button class="primary-button" value="confirm" data-confirm>OK</button></div>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>${t("common.cancel", {}, "Cancel")}</button><button class="primary-button" value="confirm" data-confirm>${t("common.ok", {}, "OK")}</button></div>
</form>`;
document.body.append(dialog);
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
@@ -26,23 +28,23 @@ function ensureDialog() {
export function showMessage(message, { title = "Information", button = "OK" } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = true;
dialog.querySelector("[data-confirm]").textContent = button;
dialog.querySelector("[data-confirm]").textContent = translateSource(button);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => resolve(), { once: true }));
}
export function askConfirm(message, { title = "Confirm", confirmText = "Confirm", danger = false } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = false;
const confirm = dialog.querySelector("[data-confirm]");
confirm.textContent = confirmText;
confirm.textContent = translateSource(confirmText);
confirm.classList.toggle("danger-button", danger);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => {
@@ -53,18 +55,18 @@ export function askConfirm(message, { title = "Confirm", confirmText = "Confirm"
export function askInput({ title, message = "", label, type = "text", autocomplete = "off", minLength, placeholder = "", confirmText = "Continue", bitwardenIgnore = false }) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
const wrap = dialog.querySelector("[data-input-wrap]");
const input = dialog.querySelector("[data-input]");
wrap.hidden = false;
dialog.querySelector("[data-input-label]").textContent = label;
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = placeholder;
dialog.querySelector("[data-input-label]").textContent = translateSource(label);
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = translateSource(placeholder);
input.name = type === "password" ? "new-password" : type === "email" ? "email" : "modal-input";
if (bitwardenIgnore) input.setAttribute("data-bwignore", "true"); else input.removeAttribute("data-bwignore");
input.minLength = minLength || 0;
dialog.querySelector("[data-cancel]").hidden = false;
dialog.querySelector("[data-confirm]").textContent = confirmText;
dialog.querySelector("[data-confirm]").textContent = translateSource(confirmText);
dialog.showModal();
queueMicrotask(() => input.focus());
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true }));
+5
View File
@@ -10,6 +10,7 @@
import { api } from "@rustpad/api";
import { askConfirm } from "@rustpad/modal";
import { NoteSocket, PadSocket } from "@rustpad/socket";
import { queueToast } from "@rustpad/toast";
function encode(value) {
return encodeURIComponent(value);
@@ -118,6 +119,10 @@ export function createWorkspaceNoteAdapter() {
method: "DELETE",
body: JSON.stringify({ access_token: accessToken || null }),
});
queueToast(`The note "${info.title}" was deleted.`, {
type: "success",
title: "Note deleted",
});
location.assign(`/w/${encode(workspaceSlug)}`);
},
};
+88 -57
View File
@@ -26,6 +26,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast";
import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
@@ -245,7 +246,7 @@ export function startNoteEditor(adapter) {
syncMobileEditorControls();
updateCurrentUser(); return info;
}
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = t(entries.length === 1 ? "editor.user" : "editor.users", { count: entries.length }, `${entries.length} ${entries.length === 1 ? "user" : "users"}`); roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || t("editor.guest", {}, "Guest"); li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = t("editor.noActiveUsers", {}, "No active users"); roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) {
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
socketLatency.textContent = text;
@@ -269,32 +270,56 @@ export function startNoteEditor(adapter) {
const latency = runtime.latency || {};
const client = server.client || {};
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
const fallbackQuality = quality.charAt(0).toUpperCase() + quality.slice(1);
const qualityLabel = t(`diagnostics.quality.${quality}`, {}, t(`diagnostics.state.${quality}`, {}, fallbackQuality));
setDiagnosticField("quality", qualityLabel);
setDiagnosticField("latency", Number.isFinite(latency.current)
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`
: "Waiting for heartbeat");
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
? t("diagnostics.latency", {
current: formatNumber(latency.current),
average: formatNumber(latency.average),
minimum: formatNumber(latency.minimum),
maximum: formatNumber(latency.maximum),
}, `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`)
: t("diagnostics.waitHeartbeat", {}, "Waiting for heartbeat"));
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${formatNumber(latency.jitter)} ms` : "—");
setDiagnosticField("uptime", runtime.authenticated_at
? formatDiagnosticDuration(runtime.uptime_ms)
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
: runtime.last_connection_uptime_ms
? t("diagnostics.lastUptime", { duration: formatDiagnosticDuration(runtime.last_connection_uptime_ms) }, `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}`)
: "—");
setDiagnosticField("reconnects", runtime.reconnect_attempt
? t("diagnostics.reconnectAttempt", { count: formatNumber(runtime.total_reconnects || 0), attempt: formatNumber(runtime.reconnect_attempt) }, `${runtime.total_reconnects || 0} · attempt ${runtime.reconnect_attempt}`)
: formatNumber(runtime.total_reconnects || 0));
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
const lastEvent = runtime.last_close
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || t("diagnostics.waitServer", {}, "Waiting for server data"));
let lastEvent;
if (runtime.last_close) {
const reason = runtime.last_close.reason ? translateSource(runtime.last_close.reason) : "";
lastEvent = reason
? t("diagnostics.closedReason", { code: runtime.last_close.code, reason }, `Closed ${runtime.last_close.code}: ${reason}`)
: t("diagnostics.closed", { code: runtime.last_close.code }, `Closed ${runtime.last_close.code}`);
} else if (runtime.last_message_at) {
const time = formatTime(runtime.last_message_at);
lastEvent = t("diagnostics.messageAt", { time }, `Message ${time}`);
} else {
lastEvent = t("editor.noMessages", {}, "No messages yet");
}
const traffic = t("diagnostics.traffic", { received: formatBytes(runtime.bytes_received), sent: formatBytes(runtime.bytes_sent) }, `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`);
const buffered = runtime.buffered_amount
? t("diagnostics.buffered", { amount: formatBytes(runtime.buffered_amount) }, ` · ${formatBytes(runtime.buffered_amount)} buffered`)
: "";
const visibility = String(runtime.visibility || document.visibilityState || "visible");
const visibilityLabel = t(`diagnostics.visibility.${visibility}`, {}, visibility);
setDiagnosticField("last-event", `${lastEvent} · ${visibilityLabel} · ${traffic}${buffered}`);
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
if (!details) continue;
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
}
}
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(t("editor.notificationChat", { sender: message.sender }, `${message.sender} wrote in RustPad`), { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
@@ -346,7 +371,7 @@ export function startNoteEditor(adapter) {
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = "Failed to load Mermaid.";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
node.parentNode.insertBefore(message, node);
});
}
@@ -358,7 +383,7 @@ export function startNoteEditor(adapter) {
const people = new Map();
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
for (const user of presenceUsers) {
const name = user.name || "Guest";
const name = user.name || t("editor.guest", {}, "Guest");
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
people.set(name, { name, compactName: user.compact_name || name, color });
}
@@ -449,7 +474,7 @@ export function startNoteEditor(adapter) {
gutter.querySelector(`[data-line="${line}"]`)?.classList.add("is-linked");
syncEditorLayers();
}
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : formatDateTime(date, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function previewNodeMarkdownParts(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
@@ -610,7 +635,7 @@ export function startNoteEditor(adapter) {
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${formatNumber(index === 0 ? Math.round(size) : size, { maximumFractionDigits: index === 0 ? 0 : size >= 10 ? 1 : 2 })} ${units[index]}`; }
function replaceTableCell(line, index, value) {
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
@@ -674,24 +699,24 @@ export function startNoteEditor(adapter) {
const tools = document.createElement("div");
tools.className = "image-alias-tools";
tools.setAttribute("role", "toolbar");
tools.setAttribute("aria-label", "Image layout");
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="Image alignment">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>Auto</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>Left</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>Center</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>Right</button>
tools.setAttribute("aria-label", t("files.imageLayout", {}, "Image layout"));
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="${escapeHtml(t("files.imageAlignment", {}, "Image alignment"))}">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>${escapeHtml(t("editor.layout.auto", {}, "Auto"))}</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.left", {}, "Left"))}</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.center", {}, "Center"))}</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.right", {}, "Right"))}</button>
</div><div class="image-alias-size">
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="Image width"></label>
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="${escapeHtml(t("files.imageWidth", {}, "Image width"))}"></label>
<span aria-hidden="true">×</span>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="Image height"></label>
<button type="button" data-image-size-apply>Set</button>
<button type="button" data-image-size-reset>Natural</button>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="${escapeHtml(t("files.imageHeight", {}, "Image height"))}"></label>
<button type="button" data-image-size-apply>${escapeHtml(t("common.set", {}, "Set"))}</button>
<button type="button" data-image-size-reset>${escapeHtml(t("editor.layout.natural", {}, "Natural"))}</button>
</div>`;
const resize = document.createElement("button");
resize.type = "button";
resize.className = "image-alias-resize";
resize.setAttribute("aria-label", "Resize image");
resize.title = "Drag to resize";
resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image"));
resize.title = t("editor.dragResize", {}, "Drag to resize");
frame.append(tools, resize);
}
@@ -719,7 +744,7 @@ export function startNoteEditor(adapter) {
const width = Math.round(Number(tools?.querySelector("[data-image-width-input]")?.value));
const height = Math.round(Number(tools?.querySelector("[data-image-height-input]")?.value));
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1 || width > 10000 || height > 10000) {
toast("Image size must be between 1 and 10000 px.");
toast.warning("Enter a width and height between 1 and 10,000 px.", { title: "Invalid image size" });
return;
}
updateImageFrameAlias(frame, { width, height });
@@ -1295,7 +1320,7 @@ export function startNoteEditor(adapter) {
const result = collaboration.resynchronize(message);
if (result.replayed) {
flushRequested = true;
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
toast.info(reason === "resync" ? "Pending edits were merged after reconnecting." : "A missed update was merged with your local edits.", { title: "Changes synchronized", duration: 5200 });
}
} catch (error) {
console.error("Failed to transform pending changes during resynchronization", error);
@@ -1311,9 +1336,9 @@ export function startNoteEditor(adapter) {
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
);
flushRequested = true;
toast("A synchronization conflict was preserved as a local recovery block.");
toast.warning("A sync conflict occurred. Your local changes were preserved in a recovery block.", { title: "Local changes recovered", duration: 7000 });
} else {
toast("Synchronization failed because the recoverable document exceeds the size limit.");
toast.danger("The recovery copy is larger than the document size limit.", { title: "Recovery failed", duration: 7000 });
}
}
applyCollaborativeView({ resetHistory: true });
@@ -1374,7 +1399,7 @@ export function startNoteEditor(adapter) {
try {
const result = integrateCollaborativeEnvelope(message);
if (result.duplicate) return;
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
const timestamp = formatTime(message.updated_at, { hour: "2-digit", minute: "2-digit" });
if (collaboration.hasPending()) saveState.textContent = "Saving…";
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
@@ -1498,6 +1523,7 @@ export function startNoteEditor(adapter) {
: "Password required";
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
try {
@@ -1510,7 +1536,7 @@ export function startNoteEditor(adapter) {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
if (/read-only access/i.test(message)) {
toast(friendly);
toast.warning(friendly, { title: "Read-only access", duration: 6500 });
accessLevel.textContent = "Access: read only";
setDocumentReadOnly(true, "Read only — changes not saved");
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
@@ -1533,7 +1559,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus();
return;
}
toast(friendly);
toast.danger(friendly, { title: "Editor connection error" });
},
});
socket.connect();
@@ -1869,7 +1895,7 @@ export function startNoteEditor(adapter) {
await adapter.saveEditorSettings(sessionHeaders(), settings);
if (savePersonal) info.personal_editor_settings = true;
} catch (error) {
toast(error.message);
toast.danger(error.message, { title: "Could not save editor settings" });
} finally {
editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
@@ -1890,14 +1916,14 @@ export function startNoteEditor(adapter) {
gutter.querySelectorAll(".line-number-button.is-copied").forEach(item => item.classList.remove("is-copied"));
button.classList.add("is-copied");
setTimeout(() => button.classList.remove("is-copied"), 900);
toast(`Link to line ${line} copied`);
toast.success(`Link to line ${line} copied to the clipboard.`, { title: "Link copied" });
} catch (error) {
toast(error.message);
toast.danger(error.message, { title: "Could not copy line link" });
}
});
async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
try { await copyText(currentShareUrl(uiState)); toast.success("Note link copied to the clipboard.", { title: "Link copied" }); }
catch (error) { toast.danger(error.message, { title: "Could not copy note link" }); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
@@ -2129,11 +2155,12 @@ export function startNoteEditor(adapter) {
socket?.stop();
loadFiles();
connect();
toast("Password set. Page options are now available.");
toast.success("Password protection is enabled. Publishing options are now available.", { title: "Protection enabled" });
pageSettings.open = true;
requestAnimationFrame(() => publicPageEnabled.focus());
} catch (error) {
setPagePasswordError.textContent = error.message;
toast.danger(error.message, { title: "Could not set password" });
} finally {
submit.disabled = false;
updatePageControls();
@@ -2144,17 +2171,21 @@ export function startNoteEditor(adapter) {
const previous = !publicPageEnabled.checked;
updatePageControls();
publicPageEnabled.disabled = true;
try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); }
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); }
try {
await savePublicOptions();
if (publicPageEnabled.checked) toast.success("The published page is now available.", { title: "Publishing enabled" });
else toast.info("The published page is no longer available.", { title: "Publishing disabled" });
}
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast.danger(error.message, { title: "Could not update publishing" }); }
finally { publicPageEnabled.disabled = false; }
});
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast.info(publicTaskUpdates.checked ? "Visitors can now update public tasks." : "Visitors can no longer update public tasks.", { title: publicTaskUpdates.checked ? "Task updates enabled" : "Task updates disabled" }); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast.danger(error.message, { title: "Could not update task permissions" }); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); if (unprotectPublicPage.checked) toast.warning("The published page can now be opened without the resource password.", { title: "Public page unprotected", duration: 6500 }); else toast.success("Password protection is required again for the published page.", { title: "Public page protected" }); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast.danger(error.message, { title: "Could not update page protection" }); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("The published page is disabled."); const url = new URL(result.url, location.origin).href; await copyText(url); toast.success("Published page link copied to the clipboard.", { title: "Page link copied" }); window.open(url, "_blank", "noopener"); } catch (error) { toast.danger(error.message, { title: "Could not open published page" }); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = t("editor.noMessages", {}, "No messages yet"); chatMessages.append(empty); }
currentUser.addEventListener("click", () => {
if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker();
else userColorPicker.click();
@@ -2162,10 +2193,10 @@ export function startNoteEditor(adapter) {
async function saveUserColor(color) {
noteColor = color;
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast.danger(error.message, { title: "Could not save editor color" }); await loadNoteInfo(); return; }
} else {
writeGuestColor(noteColor);
toast("Color saved for this tab");
toast.success("This color will be used for the current tab.", { title: "Editor color saved" });
}
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
@@ -2177,7 +2208,7 @@ export function startNoteEditor(adapter) {
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
useGlobalColorButton.addEventListener("click", async () => {
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast.danger(error.message, { title: "Could not restore profile color" }); return; }
}
noteColor = "";
if (!getAuthToken()) writeGuestColor("");
@@ -2186,7 +2217,7 @@ export function startNoteEditor(adapter) {
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
toast("Global profile color restored");
toast.info("The global profile color is active again.", { title: "Profile color restored" });
});
editor.addEventListener("keydown", continueIndentation);
editor.addEventListener("scroll", () => {
@@ -2217,10 +2248,10 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus();
}
});
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
initialize();
+17 -14
View File
@@ -7,6 +7,8 @@
* See LICENSE file in repository root for details.
*/
import { formatDateTime, formatNumber, tp } from "@rustpad/i18n";
import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
@@ -20,14 +22,14 @@ function escapeHtml(value) {
function formatBytes(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024) return `${formatNumber(bytes)} B`;
if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: 1 })} KB`;
return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: 1 })} MB`;
}
function formatDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
return Number.isNaN(date.getTime()) ? "" : formatDateTime(date);
}
function isVideo(mimeType) {
@@ -114,6 +116,7 @@ function createVideoInsertDialog() {
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", toast, onFilesChanged = () => { } }) {
const notify = (type, message, options = {}) => toast(message, { ...options, type });
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -173,7 +176,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
try {
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
footer.textContent = tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
const restrictionMessage = getDeleteRestrictionMessage();
const restrictionNotice = restrictionMessage
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
@@ -191,7 +194,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
onFilesChanged(files);
if (open && !dialog.open) dialog.showModal();
} catch (error) {
if (open) toast(error.message);
if (open) notify("danger", error.message, { title: "Could not load files" });
}
}
@@ -258,7 +261,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
function requestUpload() {
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to upload files.");
notify("warning", "Read-write access and file uploads are required to upload files.", { title: "Upload unavailable" });
return;
}
input.click();
@@ -276,7 +279,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not prepare image" });
return;
}
if (!file) return;
@@ -293,7 +296,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (!files.length) return;
event.preventDefault();
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to paste files.");
notify("warning", "Read-write access and file uploads are required to paste files.", { title: "Paste upload unavailable" });
return;
}
@@ -333,7 +336,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
if (mode === "player") insertVideoPlayer(text);
else insertAttachmentText(text);
toast("Added to note");
notify("success", "The file reference was inserted into the note.", { title: "Added to note" });
return;
}
@@ -366,9 +369,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (copyButton) {
try {
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
toast("Copied");
notify("success", "Generated file code copied to the clipboard.", { title: "Copied" });
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not copy file code" });
}
return;
}
@@ -378,10 +381,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
try {
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
toast("File deleted");
notify("success", "The file was permanently deleted.", { title: "File deleted" });
await loadFiles();
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not delete file" });
}
});
+15 -11
View File
@@ -15,6 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink } from "@rustpad/line-links";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
import { formatDateTime, t } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
@@ -43,7 +44,7 @@ async function renderMermaid() {
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = "Failed to load Mermaid.";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
node.parentNode.insertBefore(message, node);
});
}
@@ -53,7 +54,7 @@ async function renderMediaPlayers() { const nodes = content.querySelectorAll("[d
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? 'Update this task' : 'Task updates are disabled by the owner'; });
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
}
function publicAnchorTarget(hash) {
const rawId = String(hash || "").replace(/^#/, "");
@@ -154,8 +155,8 @@ function installPublicPermalinks() {
anchor.dataset.line = String(line);
anchor.dataset.linkKind = heading ? "heading" : "line";
anchor.textContent = String(line);
anchor.setAttribute("aria-label", heading ? `Copy link to heading on line ${line}` : `Copy link to line ${line}`);
anchor.title = heading ? `Copy link to this heading (line ${line})` : `Copy link to line ${line}`;
anchor.setAttribute("aria-label", heading ? t("public.copyHeadingAria", { line }, `Copy link to heading on line ${line}`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`));
anchor.title = heading ? t("public.copyHeadingTitle", { line }, `Copy link to this heading (line ${line})`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`);
target.append(anchor);
});
}
@@ -179,7 +180,9 @@ async function initialize() {
if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = "";
document.querySelector("#public-title").textContent = page.title;
document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`;
document.querySelector("#public-meta").textContent = page.allow_task_updates
? t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`)
: t("public.updated", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)}`);
document.title = `${page.title} · RustPad`;
setMarkdownFiles(page.files || []);
content.innerHTML = renderMarkdown(page.content);
@@ -191,7 +194,7 @@ async function initialize() {
} catch (error) {
publicAnchorSettleCleanup?.();
if (error.status === 401 || error.status === 403) {
passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password.";
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
if (!passwordDialog.open) passwordDialog.showModal();
passwordInput.focus();
return;
@@ -201,6 +204,7 @@ async function initialize() {
message.className = "error";
message.textContent = String(error.message);
content.append(message);
toast.danger(error.message, { title: "Could not load published page" });
}
}
content.addEventListener("click", async event => {
@@ -216,16 +220,16 @@ content.addEventListener("click", async event => {
await copyText(href);
link.classList.add("is-copied");
setTimeout(() => link.classList.remove("is-copied"), 900);
toast(link.dataset.linkKind === "heading" ? "Heading link copied" : `Link to line ${link.dataset.line} copied`);
} catch (error) { toast(error.message); }
toast.success(link.dataset.linkKind === "heading" ? "Heading link copied to the clipboard." : `Link to line ${link.dataset.line} copied to the clipboard.`, { title: "Link copied" });
} catch (error) { toast.danger(error.message, { title: "Could not copy link" }); }
});
window.addEventListener("hashchange", () => { publicAnchorSettleCleanup?.(); scrollToPublicAnchor(location.hash, "smooth"); });
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`); toast.success(box.checked ? "Task marked as complete." : "Task marked as incomplete.", { title: "Task updated" }); } catch (error) { box.checked = previous; toast.danger(error.message, { title: "Could not update task" }); } finally { box.disabled = false; } });
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
lineLinksToggle.addEventListener("change", () => { document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); });
fullWidthToggle.addEventListener("change", () => setFullWidth(fullWidthToggle.checked));
window.addEventListener("resize", () => requestAnimationFrame(() => alignPreviewLineNumbers(content)));
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); });
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); if (!passwordDialog.open) toast.success("The published page has been unlocked.", { title: "Page unlocked" }); else toast.danger(passwordError.textContent || "Enter the correct password.", { title: "Could not unlock page" }); });
passwordDialog.addEventListener("cancel", event => event.preventDefault());
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast.success("Published page link copied to the clipboard.", { title: "Link copied" }); } catch (error) { toast.danger(error.message, { title: "Could not copy page link" }); } });
initialize();
+1 -1
View File
@@ -11,7 +11,7 @@ const RESOURCE_ACCESS_ERROR_PATTERN = /(?:invalid password|password required|aut
export function isResourceAccessError(error) {
const status = Number(error?.status);
const message = typeof error === "string" ? error : error?.message;
const message = typeof error === "string" ? error : (error?.serverMessage || error?.message);
return status === 401 || RESOURCE_ACCESS_ERROR_PATTERN.test(String(message || ""));
}
+6 -2
View File
@@ -8,6 +8,7 @@
*/
import { applySessionTheme } from "@rustpad/theme";
import { setLanguage } from "@rustpad/i18n";
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
@@ -82,16 +83,19 @@ const AUTH_STATE_KEY = "rustpad:auth-state";
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; }
export function setAuthSession(session) {
export async function setAuthSession(session) {
localStorage.setItem(AUTH_STATE_KEY, "1");
setNickname(session.nickname);
applySessionTheme(session);
await setLanguage(session?.language || "en");
}
export function clearAuthSession() {
export async function clearAuthSession() {
localStorage.removeItem(AUTH_STATE_KEY);
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY);
localStorage.removeItem("rustpad:language");
setNicknameCookie("");
await setLanguage("en", { persist: false });
}
+358 -71
View File
@@ -1,109 +1,386 @@
/*
* 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;
import { formatNumber, t, translateSource } from "@rustpad/i18n";
function toastElement(selector = "#toast") {
return document.querySelector(selector);
const TOAST_TYPES = new Set(["info", "success", "warning", "danger"]);
const DEFAULT_TITLE_KEYS = {
info: "toast.title.info",
success: "toast.title.success",
warning: "toast.title.warning",
danger: "toast.title.danger",
};
function defaultTitle(type) {
const normalized = normalizeType(type);
return t(DEFAULT_TITLE_KEYS[normalized], {}, { info: "Information", success: "Success", warning: "Warning", danger: "Something went wrong" }[normalized]);
}
const DEFAULT_DURATIONS = {
info: 4200,
success: 3800,
warning: 5600,
danger: 6500,
};
const MAX_VISIBLE_TOASTS = 4;
const FLASH_TOAST_STORAGE_KEY = "rustpad:toast:flash";
const MODAL_REGION_CLASS = "toast-region--modal";
const modalRegionBindings = new WeakSet();
function normalizeType(type) {
return TOAST_TYPES.has(type) ? type : "info";
}
function show(element, duration = null) {
element.classList.add("visible");
clearTimeout(hideTimer);
if (duration != null) hideTimer = setTimeout(() => element.classList.remove("visible"), duration);
function normalizeToastCopy(value) {
return String(value ?? "")
.replace(/\s+/gu, " ")
.trim()
.replace(/[.!?…]+$/gu, "")
.trim()
.toLocaleLowerCase();
}
function reset(element) {
element.className = "toast";
element.removeAttribute("aria-busy");
element.removeAttribute("aria-label");
function prepareToastRegion(element) {
if (!element) return null;
element.classList.remove("toast");
element.classList.add("toast-region");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-label", t("toast.region.label", {}, "Notifications"));
element.setAttribute("aria-relevant", "additions removals");
return element;
}
function pageToastContainer(selector = "#toast") {
return prepareToastRegion(document.querySelector(selector));
}
function activeModalDialog() {
const dialogs = [...document.querySelectorAll("dialog[open]")];
for (let index = dialogs.length - 1; index >= 0; index -= 1) {
const dialog = dialogs[index];
try {
if (dialog.matches(":modal")) return dialog;
} catch {
return dialog;
}
}
return null;
}
function rehomeModalToasts(dialog) {
const region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) return;
const pageRegion = pageToastContainer();
if (pageRegion) {
[...region.querySelectorAll(".toast-card")].forEach(card => pageRegion.append(card));
}
try {
if (region.matches(":popover-open")) region.hidePopover();
} catch { /* Popover API fallback. */ }
dialog.classList.remove("has-modal-toast-region");
region.remove();
}
function modalToastContainer(dialog) {
let region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) {
region = document.createElement("div");
region.className = `toast-region ${MODAL_REGION_CLASS}`;
region.setAttribute("popover", "manual");
dialog.append(region);
}
prepareToastRegion(region);
if (typeof region.showPopover === "function") {
try {
if (!region.matches(":popover-open")) region.showPopover();
} catch {
dialog.classList.add("has-modal-toast-region");
}
} else {
dialog.classList.add("has-modal-toast-region");
}
if (!modalRegionBindings.has(dialog)) {
dialog.addEventListener("close", () => rehomeModalToasts(dialog));
modalRegionBindings.add(dialog);
}
return region;
}
function toastContainer(selector = "#toast", { modalAware = true } = {}) {
if (modalAware && selector === "#toast") {
const dialog = activeModalDialog();
if (dialog) return modalToastContainer(dialog);
}
return pageToastContainer(selector);
}
function applyType(card, type) {
const normalized = normalizeType(type);
for (const candidate of TOAST_TYPES) card.classList.remove(`toast-card--${candidate}`);
card.classList.add(`toast-card--${normalized}`);
card.dataset.toastType = normalized;
if (normalized === "danger" || normalized === "warning") card.setAttribute("role", "alert");
else card.removeAttribute("role");
return normalized;
}
function clearAutoDismiss(card) {
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
card._toastRemaining = null;
card._toastStartedAt = null;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer) timer.hidden = true;
if (fill) {
fill.style.animation = "none";
fill.style.animationPlayState = "running";
}
}
function pauseAutoDismiss(card) {
if (!card._toastHideTimer || !Number.isFinite(card._toastRemaining)) return;
const elapsed = Date.now() - card._toastStartedAt;
card._toastRemaining = Math.max(0, card._toastRemaining - elapsed);
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "paused";
}
function resumeAutoDismiss(card) {
if (card.dataset.dismissed === "true" || !Number.isFinite(card._toastRemaining) || card._toastRemaining <= 0) return;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "running";
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), card._toastRemaining);
}
function dismissCard(card) {
if (!card || card.dataset.dismissed === "true") return;
card.dataset.dismissed = "true";
clearAutoDismiss(card);
card.classList.remove("is-visible");
card.classList.add("is-leaving");
window.setTimeout(() => card.remove(), 180);
}
function armAutoDismiss(card, duration) {
clearAutoDismiss(card);
const timeout = Number(duration);
if (!Number.isFinite(timeout) || timeout <= 0) return;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer && fill) {
timer.hidden = false;
fill.style.animation = "none";
void fill.offsetWidth;
fill.style.animation = `toast-countdown ${timeout}ms linear forwards`;
}
card._toastRemaining = timeout;
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), timeout);
if (card._toastHovering || card._toastFocused) pauseAutoDismiss(card);
}
function createCard(container, { type = "info", title, dismissible = true, persistent = false } = {}) {
const card = document.createElement("section");
card.className = "toast-card";
card.dataset.persistent = String(Boolean(persistent));
card.setAttribute("aria-atomic", "true");
card.innerHTML = `
<div class="toast-card__content">
<strong class="toast-card__title"></strong>
</div>
<button class="toast-card__close" type="button" aria-label="${t("toast.close", {}, "Close notification")}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10"></path><path d="m17 7-10 10"></path></svg>
</button>
<div class="toast-card__timer" aria-hidden="true" hidden><span></span></div>`;
const normalized = applyType(card, type);
card.querySelector(".toast-card__title").textContent = title ? translateSource(title) : defaultTitle(normalized);
const closeButton = card.querySelector(".toast-card__close");
closeButton.hidden = !dismissible;
closeButton.addEventListener("click", () => dismissCard(card));
card.addEventListener("mouseenter", () => {
card._toastHovering = true;
pauseAutoDismiss(card);
});
card.addEventListener("mouseleave", () => {
card._toastHovering = false;
if (!card._toastFocused) resumeAutoDismiss(card);
});
card.addEventListener("focusin", () => {
card._toastFocused = true;
pauseAutoDismiss(card);
});
card.addEventListener("focusout", event => {
if (card.contains(event.relatedTarget)) return;
card._toastFocused = false;
if (!card._toastHovering) resumeAutoDismiss(card);
});
if (!persistent) {
const transient = [...container.querySelectorAll('.toast-card[data-persistent="false"]')];
while (transient.length >= MAX_VISIBLE_TOASTS) dismissCard(transient.shift());
}
container.append(card);
requestAnimationFrame(() => card.classList.add("is-visible"));
return card;
}
function setCardTitle(card, title, fallbackType = "info") {
const titleElement = card.querySelector(".toast-card__title");
if (titleElement) titleElement.textContent = title ? translateSource(title) : defaultTitle(fallbackType);
}
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`;
if (bytes < 1024) return `${formatNumber(Math.round(bytes))} B`;
if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: bytes >= 10240 ? 0 : 1 })} KB`;
if (bytes < 1024 * 1024 * 1024) return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: bytes >= 10 * 1024 * 1024 ? 1 : 2 })} MB`;
return `${formatNumber(bytes / (1024 * 1024 * 1024), { maximumFractionDigits: 2 })} GB`;
}
export function queueToast(message, options = {}) {
const payload = {
message: String(message ?? ""),
type: normalizeType(options.type),
title: options.title == null ? null : String(options.title),
duration: Number.isFinite(Number(options.duration)) ? Number(options.duration) : null,
};
try {
sessionStorage.setItem(FLASH_TOAST_STORAGE_KEY, JSON.stringify(payload));
return true;
} catch {
return false;
}
}
export function consumeQueuedToast() {
let raw = null;
try {
raw = sessionStorage.getItem(FLASH_TOAST_STORAGE_KEY);
sessionStorage.removeItem(FLASH_TOAST_STORAGE_KEY);
} catch {
return false;
}
if (!raw) return false;
try {
const payload = JSON.parse(raw);
if (!payload || typeof payload.message !== "string") return false;
const options = { type: normalizeType(payload.type) };
if (typeof payload.title === "string" && payload.title) options.title = payload.title;
if (Number.isFinite(payload.duration) && payload.duration > 0) options.duration = payload.duration;
toast(payload.message, options);
return true;
} catch {
return false;
}
}
export function toast(message, options = {}) {
const element = toastElement(options.selector);
if (!element) return;
reset(element);
element.textContent = String(message ?? "");
show(element, options.duration ?? 1800);
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) return { dismiss() {} };
const type = normalizeType(options.type);
const card = createCard(container, {
type,
title: options.title,
dismissible: options.dismissible !== false,
});
const translatedMessage = translateSource(String(message ?? ""));
const renderedTitle = card.querySelector(".toast-card__title")?.textContent || "";
const normalizedMessage = normalizeToastCopy(translatedMessage);
if (normalizedMessage && normalizedMessage !== normalizeToastCopy(renderedTitle)) {
const body = document.createElement("p");
body.className = "toast-card__message";
body.textContent = translatedMessage;
card.querySelector(".toast-card__content").append(body);
}
armAutoDismiss(card, options.duration ?? DEFAULT_DURATIONS[type]);
return { dismiss: () => dismissCard(card), element: card };
}
toast.info = (message, options = {}) => toast(message, { ...options, type: "info" });
toast.success = (message, options = {}) => toast(message, { ...options, type: "success" });
toast.warning = (message, options = {}) => toast(message, { ...options, type: "warning" });
toast.danger = (message, options = {}) => toast(message, { ...options, type: "danger" });
export function createUploadToast(filename, options = {}) {
const element = toastElement(options.selector);
if (!element) {
return { start() { }, update() { }, fail() { }, success() { }, dismiss() { } };
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) {
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>
const card = createCard(container, {
type: "info",
title: t("upload.title.uploading", {}, "Uploading file"),
dismissible: true,
persistent: true,
});
card.classList.add("toast-card--upload");
card.setAttribute("aria-busy", "true");
const content = card.querySelector(".toast-card__content");
content.insertAdjacentHTML("beforeend", `
<span class="upload-toast__filename"></span>
<div class="upload-toast__progress" role="progressbar" aria-label="${t("upload.progress.label", {}, "File upload progress")}" data-i18n-aria-label="upload.progress.label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>${t("upload.status.preparing", {}, "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>`;
<button type="button" class="upload-toast__retry" data-i18n="common.retry">${t("common.retry", {}, "Retry")}</button>
<button type="button" class="upload-toast__dismiss" data-i18n="common.dismiss">${t("common.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 filenameElement = card.querySelector(".upload-toast__filename");
const progress = card.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");
const amount = card.querySelector("[data-upload-amount]");
const speedElement = card.querySelector("[data-upload-speed]");
const errorElement = card.querySelector("[data-upload-error]");
const actions = card.querySelector("[data-upload-actions]");
const retryButton = card.querySelector(".upload-toast__retry");
const dismissButton = card.querySelector(".upload-toast__dismiss");
let retryHandler = null;
filenameElement.textContent = String(filename || "file");
function dismiss() {
clearTimeout(hideTimer);
element.classList.remove("visible");
dismissCard(card);
}
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";
clearAutoDismiss(card);
card.dataset.dismissed = "false";
card.dataset.persistent = "true";
card.classList.remove("is-leaving");
card.classList.add("is-visible");
card.setAttribute("aria-busy", "true");
applyType(card, "info");
setCardTitle(card, t("upload.title.uploading", {}, "Uploading file"), "info");
filenameElement.textContent = String(filename || "file");
progress.classList.remove("is-error", "is-complete");
progress.classList.remove("is-error", "is-complete", "is-indeterminate");
progress.setAttribute("aria-valuenow", "0");
progressFill.style.width = "0%";
amount.textContent = "Preparing…";
speedElement.textContent = "—";
amount.textContent = t("upload.status.preparing", {}, "Preparing...");
delete speedElement.dataset.i18n;
speedElement.textContent = "-";
errorElement.hidden = true;
errorElement.textContent = "";
actions.hidden = true;
retryButton.hidden = false;
retryButton.disabled = false;
clearTimeout(hideTimer);
}
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
progress.classList.remove("is-indeterminate");
const normalizedPercent = Number.isFinite(percent)
? Math.max(0, Math.min(100, percent))
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
@@ -116,42 +393,52 @@ export function createUploadToast(filename, options = {}) {
progress.classList.add("is-indeterminate");
}
amount.textContent = total > 0
? `${Math.round(normalizedPercent || 0)}% · ${formatBytes(loaded)} / ${formatBytes(total)}`
? `${Math.round(normalizedPercent || 0)}% - ${formatBytes(loaded)} / ${formatBytes(total)}`
: formatBytes(loaded);
speedElement.textContent = phase === "processing" ? "Processing…" : speed > 0 ? `${formatBytes(speed)}/s` : "Starting…";
speedElement.textContent = phase === "processing" ? t("upload.status.processing", {}, "Processing...") : speed > 0 ? `${formatBytes(speed)}/s` : t("upload.status.starting", {}, "Starting...");
}
function fail(message, { retryable = true, onRetry = null } = {}) {
element.removeAttribute("aria-busy");
title.textContent = "Upload failed";
clearAutoDismiss(card);
card.removeAttribute("aria-busy");
card.dataset.persistent = "true";
applyType(card, "danger");
setCardTitle(card, t("upload.title.failed", {}, "Upload failed"), "danger");
progress.classList.remove("is-indeterminate", "is-complete");
progress.classList.add("is-error");
errorElement.textContent = String(message || "Upload failed.");
errorElement.textContent = translateSource(String(message || t("upload.error.generic", {}, "The file could not be uploaded.")));
errorElement.hidden = false;
actions.hidden = false;
retryButton.hidden = !retryable;
retryButton.disabled = false;
retryHandler = typeof onRetry === "function" ? onRetry : null;
show(element);
}
function success(message = "File uploaded") {
element.removeAttribute("aria-busy");
title.textContent = message;
function success(message = t("upload.title.complete", {}, "File uploaded")) {
card.removeAttribute("aria-busy");
card.dataset.persistent = "false";
applyType(card, "success");
setCardTitle(card, message, "success");
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";
speedElement.dataset.i18n = "upload.status.complete";
speedElement.textContent = t("upload.status.complete", {}, "Complete");
errorElement.hidden = true;
actions.hidden = true;
show(element, 1800);
armAutoDismiss(card, 3200);
}
retryButton.addEventListener("click", async () => {
if (!retryHandler) return;
retryButton.disabled = true;
await retryHandler();
try {
await retryHandler();
} finally {
if (card.isConnected && !card.hasAttribute("aria-busy")) retryButton.disabled = false;
}
});
dismissButton.addEventListener("click", dismiss);
+4 -2
View File
@@ -3,6 +3,8 @@
* Source-Available Code / Dual-Licensed.
*/
import { t } from "@rustpad/i18n";
const config = window.__RUSTPAD_CONFIG__ || {};
const assetVersion = encodeURIComponent(String(config.assetVersion || "dev"));
const promiseCache = new Map();
@@ -30,9 +32,9 @@ function loadClassicScript(path, globalName) {
script.dataset.rustpadLib = globalName;
script.addEventListener("load", () => {
if (window[globalName]) resolve(window[globalName]);
else reject(new Error(`${globalName} did not register a browser global.`));
else reject(new Error(t("vendor.globalMissing", { name: globalName }, `${globalName} did not register a browser global.`)));
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Failed to load ${path}.`)), { once: true });
script.addEventListener("error", () => reject(new Error(t("vendor.loadFailed", { path }, `Failed to load ${path}.`))), { once: true });
if (!existing) document.head.append(script);
}));
}
+18 -11
View File
@@ -16,7 +16,10 @@ import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken }
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { askConfirm } from "@rustpad/modal";
import { isResourceAccessError, safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { consumeQueuedToast, toast } from "@rustpad/toast";
import { formatDateTime, formatNumber } from "@rustpad/i18n";
consumeQueuedToast();
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
@@ -135,12 +138,12 @@ function connectWorkspaceWatch() {
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024) return `${formatNumber(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes;
let unit = -1;
do { amount /= 1024; unit++; } while (amount >= 1024 && unit < units.length - 1);
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
return `${formatNumber(amount, { maximumFractionDigits: amount >= 10 ? 0 : 1 })} ${units[unit]}`;
}
function noteStats(note) {
return `<span>Participants: ${Number(note.participant_count) || 0}</span><span>Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})</span><span>Revisions: ${Number(note.revision_count) || 0}</span>`;
@@ -151,7 +154,7 @@ function formatDate(value) {
if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; }
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) raw = raw.replace(" ", "T") + "Z";
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pl-PL");
return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date);
}
function setNotesView(view) {
notesView = view === "table" ? "table" : "grid";
@@ -226,6 +229,7 @@ async function openWorkspace() {
const params = new URLSearchParams({ q: notesSearch.value.trim(), page: String(notesPage), per_page: notesPerPage.value });
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open?${params}`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
info = data.workspace;
document.querySelector("#workspace-title").removeAttribute("data-i18n");
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
document.title = `${info.title} · RustPad`;
@@ -250,6 +254,7 @@ async function init() {
try {
const headers = accessToken && accessToken !== "cookie" ? { Authorization: `Bearer ${accessToken}` } : {};
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
document.querySelector("#workspace-title").removeAttribute("data-i18n");
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
updateWorkspacePasswordControl();
@@ -270,7 +275,8 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
document.querySelector("#open-password").value = "";
document.querySelector("#password-error").textContent = "";
await openWorkspace();
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
toast.success("Workspace access has been unlocked.", { title: "Workspace unlocked" });
} catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock workspace" }); }
});
workspacePasswordForm.addEventListener("submit", async event => {
event.preventDefault();
@@ -294,10 +300,11 @@ workspacePasswordForm.addEventListener("submit", async event => {
setAccessToken("workspace", slug, result.granted);
accessToken = getAccessToken("workspace", slug);
workspacePasswordInput.value = "";
toast("Workspace password set");
toast.success("Password protection is now enabled for this workspace.", { title: "Workspace protected" });
await openWorkspace();
} catch (error) {
workspacePasswordError.textContent = error.message;
toast.danger(error.message, { title: "Could not set workspace password" });
} finally {
submit.disabled = false;
}
@@ -313,7 +320,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(note.url));
} catch (err) { error.textContent = err.message; }
} catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); }
});
notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]");
@@ -325,9 +332,9 @@ notesList.addEventListener("click", async event => {
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
method: "DELETE", body: JSON.stringify({ access_token: accessToken || null })
});
toast("Note deleted");
toast.success(`The note "${title}" was deleted.`, { title: "Note deleted" });
await openWorkspace();
} catch (error) { toast(error.message); button.disabled = false; }
} catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; }
});
document.querySelectorAll("[data-notes-view]").forEach(button => button.addEventListener("click", () => {
if (button.dataset.notesView === notesView) return;
@@ -338,8 +345,8 @@ notesSearch.addEventListener("input", () => { clearTimeout(notesSearchTimer); no
notesPerPage.addEventListener("change", () => { notesPage = 1; openWorkspace(); });
notesPagination.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; notesPage = Number(button.dataset.page) || 1; openWorkspace(); });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => {
try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); }
try { await copyText(new URL(location.pathname, location.origin).href); toast.success("Workspace link copied to the clipboard.", { title: "Link copied" }); }
catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
});
async function startAuthorizedWorkspace() {
const session = await validateCurrentSession();
+1 -1
View File
@@ -38,7 +38,7 @@
<a class="text-button" href="/">Back to home</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>
+3 -3
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>__WORKSPACE_TITLE__ · RustPad</title>
<title data-i18n-ignore>__WORKSPACE_TITLE__ · RustPad</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
@@ -21,7 +21,7 @@
</div>
<span class="header-divider"></span>
<div class="document-heading">
<h1 id="workspace-title">__WORKSPACE_TITLE__</h1>
<h1 id="workspace-title" __WORKSPACE_TITLE_I18N__>__WORKSPACE_TITLE__</h1>
<p id="workspace-url" class="document-url"></p>
</div>
</div>
@@ -108,7 +108,7 @@
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
</form>
</dialog>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>