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
+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));