feat: add profile language preferences and refine toast, dropdown and history UI
This commit is contained in:
+104
-17
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user