big features
This commit is contained in:
+59
-1
@@ -51,7 +51,10 @@ input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; }
|
||||
.password-input { position: relative; }
|
||||
.password-input input { padding-right: 72px; }
|
||||
.text-button { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); border: 0; background: transparent; color: #a99ef8; padding: 7px; font-size: .78rem; }
|
||||
.text-button { position: static; transform: none; border: 0; background: transparent; color: #a99ef8; padding: 4px 2px; font-size: .78rem; }
|
||||
.text-button:hover { color: #c3bbff; }
|
||||
.password-toggle { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); padding: 7px; }
|
||||
[hidden] { display: none !important; }
|
||||
.form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; }
|
||||
.error { color: var(--danger); }
|
||||
.primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; }
|
||||
@@ -247,6 +250,9 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||
.home-page { display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.home-page .home-layout { flex: 1 0 auto; }
|
||||
.home-footer { flex: 0 0 auto; width: min(1040px, calc(100% - 32px)); margin: auto auto 28px; padding-top: 24px; color: var(--muted-2); font-size: .76rem; text-align: center; }
|
||||
.home-footer__inner { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
|
||||
.home-footer__separator { color: var(--border-strong); }
|
||||
.home-footer .text-button { font-size: inherit; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.home-footer a { color: var(--muted); text-decoration: none; }
|
||||
.home-footer a:hover { color: white; }
|
||||
.home-header { border-bottom-color: rgba(195, 91, 54, .28); background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); }
|
||||
@@ -466,3 +472,55 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||
.public-page .task-checkbox:not(:disabled) { cursor:pointer; }
|
||||
.public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; }
|
||||
|
||||
.identity-actions, .identity-links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.identity-actions .primary-button { flex: 1 0 100%; }
|
||||
.identity-panel { position: relative; }
|
||||
.identity-panel__close { position: absolute; top: 14px; right: 14px; }
|
||||
.identity-links { justify-content: space-between; }
|
||||
.identity-links .text-button { padding: 3px 0; }
|
||||
.auth-panel { display: grid; gap: 12px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.auth-panel h3 { margin: 0; }
|
||||
.auth-panel label { display: grid; gap: 6px; }
|
||||
|
||||
/* Account footer and authentication dialog. */
|
||||
.home-footer { width: min(1040px, calc(100% - 32px)); margin: auto auto 24px; padding-top: 24px; color: var(--muted); font-size: .78rem; }
|
||||
.home-footer__inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.home-footer__account { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.home-footer__user { color: var(--text); font-weight: 600; }
|
||||
.home-footer__author { margin-left: auto; }
|
||||
.footer-action { min-height: 34px; padding: 0 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; }
|
||||
.footer-action:hover { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); }
|
||||
.footer-action--primary { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: #d9d2ff; }
|
||||
.app-dialog { width: min(440px, calc(100% - 28px)); padding: 0; border: 0; background: transparent; }
|
||||
.app-dialog::backdrop { background: rgba(4, 6, 10, .72); backdrop-filter: blur(3px); }
|
||||
.identity-panel { position: relative; display: grid; gap: 18px; width: 100%; padding: 28px; }
|
||||
.identity-panel__header { padding-right: 38px; }
|
||||
.identity-panel__header h2 { margin: 0 0 7px; }
|
||||
.identity-panel__header p { margin: 0; }
|
||||
.identity-fields { display: grid; gap: 14px; }
|
||||
.identity-fields label { display: grid; gap: 7px; color: var(--muted); font-size: .78rem; }
|
||||
.modal-close { position: absolute; top: 12px; right: 12px; display: grid; width: 34px; height: 34px; padding: 0; place-items: center; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font: 400 1.45rem/1 system-ui; cursor: pointer; }
|
||||
.modal-close:hover { border-color: var(--border); background: var(--surface-2); color: white; }
|
||||
.identity-links { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.identity-links .text-button { padding: 2px 0; }
|
||||
.form-message.success { color: #8ed9a4; }
|
||||
@media (max-width: 600px) {
|
||||
.home-footer__inner { align-items: stretch; flex-direction: column; text-align: center; }
|
||||
.home-footer__account { justify-content: center; }
|
||||
.home-footer__author { margin-left: 0; }
|
||||
.identity-panel { padding: 24px 20px 20px; }
|
||||
.identity-links { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
.home-footer__account[hidden], .footer-action[hidden], .identity-links [hidden], .identity-fields [hidden], .auth-panel[hidden] { display: none !important; }
|
||||
|
||||
.system-dialog-panel { position: relative; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.secondary-button, .danger-button { min-height: 42px; padding: 0 16px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); color: var(--text); font: inherit; cursor: pointer; }
|
||||
.secondary-button:hover { border-color: var(--muted); }
|
||||
.danger-button { border-color: #7f3340; background: #6e2935; color: white; }
|
||||
.danger-button:hover { background: #7c3040; }
|
||||
|
||||
/* Native hidden must win over component display declarations. */
|
||||
[hidden] { display: none !important; }
|
||||
.identity-panel #auth-panel:not([hidden]) { margin-top: 4px; }
|
||||
.identity-panel #nickname:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
+37
-2
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
|
||||
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body class="home-page">
|
||||
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header>
|
||||
<main class="home-layout home-layout--wide">
|
||||
<section class="home-intro">
|
||||
@@ -58,6 +58,41 @@
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="home-footer">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></footer>
|
||||
|
||||
<footer class="home-footer">
|
||||
<div class="home-footer__inner">
|
||||
<div id="footer-account-guest" class="home-footer__account">
|
||||
<button id="footer-login" class="footer-action" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register nickname</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user"></span>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
</div>
|
||||
<span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<dialog id="identity-dialog" class="app-dialog">
|
||||
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
|
||||
<button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button>
|
||||
<header class="identity-panel__header">
|
||||
<h2 id="identity-title">Log in</h2>
|
||||
<p id="identity-copy" class="dialog-copy"></p>
|
||||
</header>
|
||||
<div class="identity-fields">
|
||||
<label>Nickname<input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label>
|
||||
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
||||
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
||||
</div>
|
||||
<button id="auth-submit" class="primary-button" type="submit">Log in</button>
|
||||
<div class="identity-links">
|
||||
<button id="show-reset" class="text-button" type="button">Forgot password?</button>
|
||||
<button id="auth-switch-mode" class="text-button" type="button">Create an account</button>
|
||||
<button id="reset-back" class="text-button" type="button" hidden>Back to log in</button>
|
||||
</div>
|
||||
<p id="identity-error" class="form-message" role="status"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+6
-2
@@ -5,8 +5,12 @@ export async function api(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Error ${response.status}`);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
|
||||
if (!response.ok) {
|
||||
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
|
||||
throw new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") throw new Error("Timed out");
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { api } from "@rustpad/api";
|
||||
import * as sessionStore from "@rustpad/session";
|
||||
import { askInput, showMessage } from "./modal.js";
|
||||
|
||||
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
|
||||
const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:nickname");
|
||||
});
|
||||
|
||||
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
|
||||
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
|
||||
const form = dialog.querySelector("#identity-form");
|
||||
const nickname = dialog.querySelector("#nickname");
|
||||
const message = dialog.querySelector("#identity-error");
|
||||
const title = dialog.querySelector("#identity-title");
|
||||
const copy = dialog.querySelector("#identity-copy");
|
||||
const emailField = dialog.querySelector("#auth-email-field");
|
||||
const email = dialog.querySelector("#auth-email");
|
||||
const password = dialog.querySelector("#auth-password");
|
||||
const submit = dialog.querySelector("#auth-submit");
|
||||
const switchMode = dialog.querySelector("#auth-switch-mode");
|
||||
const resetButton = dialog.querySelector("#show-reset");
|
||||
const backButton = dialog.querySelector("#reset-back");
|
||||
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
|
||||
let mode = initialMode;
|
||||
|
||||
const setMode = (nextMode) => {
|
||||
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
|
||||
const resetting = mode === "reset";
|
||||
const registering = mode === "register";
|
||||
|
||||
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
|
||||
copy.textContent = resetting
|
||||
? "Enter the e-mail address assigned to your account."
|
||||
: registering
|
||||
? "Reserve your nickname with an e-mail address and password."
|
||||
: "Use the e-mail address and password assigned to your account.";
|
||||
|
||||
nickname.closest("label").hidden = !registering;
|
||||
nickname.disabled = !registering;
|
||||
emailField.hidden = false;
|
||||
email.disabled = false;
|
||||
password.closest("label").hidden = resetting;
|
||||
password.disabled = resetting;
|
||||
submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in";
|
||||
switchMode.hidden = resetting || !registrationEnabled;
|
||||
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
|
||||
resetButton.hidden = resetting || registering;
|
||||
backButton.hidden = !resetting;
|
||||
password.autocomplete = registering ? "new-password" : "current-password";
|
||||
message.textContent = "";
|
||||
|
||||
queueMicrotask(() => {
|
||||
email.focus();
|
||||
});
|
||||
};
|
||||
|
||||
switchMode?.addEventListener("click", () => setMode(mode === "register" ? "login" : "register"));
|
||||
resetButton?.addEventListener("click", () => setMode("reset"));
|
||||
backButton?.addEventListener("click", () => setMode("login"));
|
||||
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
form.reset();
|
||||
message.textContent = "";
|
||||
setMode(initialMode);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
message.textContent = "";
|
||||
submit.disabled = true;
|
||||
|
||||
try {
|
||||
if (mode === "reset") {
|
||||
const result = await api("/api/auth/password-reset", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value.trim() }),
|
||||
});
|
||||
message.classList.remove("error");
|
||||
message.classList.add("success");
|
||||
message.textContent = result.message;
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
|
||||
const payload = { email: email.value.trim(), password: password.value };
|
||||
if (mode === "register") payload.nickname = nickname.value.trim();
|
||||
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
||||
setAuthSession(session);
|
||||
await onIdentity(session.nickname, session);
|
||||
dialog.close();
|
||||
} catch (error) {
|
||||
message.classList.remove("success");
|
||||
message.classList.add("error");
|
||||
message.textContent = error.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
setMode(initialMode);
|
||||
return { setMode };
|
||||
}
|
||||
|
||||
|
||||
function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
const form = dialog.querySelector("#identity-form");
|
||||
const nickname = dialog.querySelector("#nickname");
|
||||
const authPanel = dialog.querySelector("#auth-panel");
|
||||
const message = dialog.querySelector("#identity-error");
|
||||
const modeTitle = dialog.querySelector("#auth-mode-title");
|
||||
const emailField = dialog.querySelector("#auth-email-field");
|
||||
const email = dialog.querySelector("#auth-email");
|
||||
const password = dialog.querySelector("#auth-password");
|
||||
const authSubmit = dialog.querySelector("#auth-submit");
|
||||
const guestSubmit = dialog.querySelector("#guest-continue");
|
||||
const registerButton = dialog.querySelector("#show-register");
|
||||
const loginButton = dialog.querySelector("#show-login");
|
||||
const backButton = dialog.querySelector("#auth-back");
|
||||
const logoutButton = dialog.querySelector("#logout-account");
|
||||
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
|
||||
let mode = "login";
|
||||
|
||||
const updateActions = () => {
|
||||
if (logoutButton) logoutButton.hidden = !getAuthToken();
|
||||
if (registerButton) registerButton.hidden = !registrationEnabled;
|
||||
};
|
||||
|
||||
const collapse = () => {
|
||||
authPanel.hidden = true;
|
||||
nickname.disabled = false;
|
||||
nickname.required = true;
|
||||
password.value = "";
|
||||
message.textContent = "";
|
||||
guestSubmit?.focus();
|
||||
};
|
||||
|
||||
const showAuth = (nextMode) => {
|
||||
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
|
||||
authPanel.hidden = false;
|
||||
// Keep the guest nickname form visible so the user can still continue anonymously.
|
||||
nickname.disabled = mode === "login";
|
||||
nickname.required = mode === "register";
|
||||
emailField.hidden = false;
|
||||
email.disabled = false;
|
||||
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
|
||||
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
||||
password.autocomplete = mode === "register" ? "new-password" : "current-password";
|
||||
message.textContent = "";
|
||||
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
|
||||
};
|
||||
|
||||
registerButton?.addEventListener("click", () => showAuth("register"));
|
||||
loginButton?.addEventListener("click", () => showAuth("login"));
|
||||
backButton?.addEventListener("click", collapse);
|
||||
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
|
||||
dialog.addEventListener("close", collapse);
|
||||
dialog.addEventListener("cancel", collapse);
|
||||
|
||||
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
|
||||
const value = email.value.trim() || await askInput({
|
||||
title: "Reset password",
|
||||
message: "Enter the e-mail address assigned to your account.",
|
||||
label: "E-mail",
|
||||
type: "email",
|
||||
autocomplete: "off",
|
||||
confirmText: "Send reset link",
|
||||
bitwardenIgnore: true,
|
||||
}) || "";
|
||||
if (!value) return;
|
||||
try {
|
||||
const result = await api("/api/auth/password-reset", { method: "POST", body: JSON.stringify({ email: value }) });
|
||||
message.classList.remove("error");
|
||||
message.classList.add("success");
|
||||
message.textContent = result.message;
|
||||
} catch (error) {
|
||||
message.classList.remove("success");
|
||||
message.classList.add("error");
|
||||
message.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener("click", async () => {
|
||||
await logoutCurrentSession();
|
||||
updateActions();
|
||||
collapse();
|
||||
nickname.value = "";
|
||||
message.textContent = "Logged out.";
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
message.textContent = "";
|
||||
const name = nickname.value.trim();
|
||||
const authRequested = event.submitter === authSubmit;
|
||||
try {
|
||||
if (authRequested) {
|
||||
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
|
||||
const payload = { email: email.value.trim(), password: password.value };
|
||||
if (mode === "register") payload.nickname = name;
|
||||
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
||||
setAuthSession(session);
|
||||
await onIdentity(session.nickname, session);
|
||||
return;
|
||||
}
|
||||
nickname.disabled = false;
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
|
||||
setNickname(result.nickname);
|
||||
await onIdentity(result.nickname, null);
|
||||
} catch (error) {
|
||||
message.classList.remove("success");
|
||||
message.classList.add("error");
|
||||
message.textContent = error.message;
|
||||
if (/registered|session|account/i.test(error.message)) showAuth("login");
|
||||
}
|
||||
});
|
||||
|
||||
updateActions();
|
||||
collapse();
|
||||
return { setMode: showAuth };
|
||||
}
|
||||
|
||||
export async function validateCurrentSession() {
|
||||
const token = getAuthToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch {
|
||||
clearAuthSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutCurrentSession() {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch {}
|
||||
}
|
||||
clearAuthSession();
|
||||
}
|
||||
|
||||
export async function handleResetToken() {
|
||||
const url = new URL(location.href);
|
||||
const token = url.searchParams.get("reset_token");
|
||||
if (!token) return;
|
||||
|
||||
// Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog.
|
||||
url.searchParams.delete("reset_token");
|
||||
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
|
||||
const password = await askInput({
|
||||
title: "Set a new password",
|
||||
message: "The password must contain at least 8 characters.",
|
||||
label: "New password",
|
||||
type: "password",
|
||||
autocomplete: "off",
|
||||
minLength: 8,
|
||||
confirmText: "Change password",
|
||||
bitwardenIgnore: true,
|
||||
});
|
||||
if (!password) return;
|
||||
try {
|
||||
await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) });
|
||||
await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" });
|
||||
} catch (error) {
|
||||
await showMessage(error.message, { title: "Password reset failed" });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
||||
import { api } from "@rustpad/api";
|
||||
|
||||
function slugify(value, fallback) {
|
||||
@@ -74,3 +75,42 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
setBusy(button, false, "Create workspace", "Creating…");
|
||||
}
|
||||
});
|
||||
|
||||
handleResetToken();
|
||||
|
||||
const identityDialog = document.querySelector("#identity-dialog");
|
||||
const guestAccount = document.querySelector("#footer-account-guest");
|
||||
const userAccount = document.querySelector("#footer-account-user");
|
||||
const userLabel = document.querySelector("#footer-user-label");
|
||||
const registerLink = document.querySelector("#footer-register");
|
||||
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
|
||||
|
||||
function renderAccount(session) {
|
||||
guestAccount.hidden = Boolean(session);
|
||||
userAccount.hidden = !session;
|
||||
if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
|
||||
registerLink.hidden = !registrationEnabled;
|
||||
}
|
||||
|
||||
if (identityDialog) {
|
||||
const authDialog = bindIdentityDialog({
|
||||
dialog: identityDialog,
|
||||
onIdentity: async (_nickname, session) => renderAccount(session),
|
||||
});
|
||||
|
||||
document.querySelector("#footer-login")?.addEventListener("click", () => {
|
||||
authDialog.setMode("login");
|
||||
identityDialog.showModal();
|
||||
});
|
||||
registerLink?.addEventListener("click", () => {
|
||||
authDialog.setMode("register");
|
||||
identityDialog.showModal();
|
||||
});
|
||||
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
|
||||
await logoutCurrentSession();
|
||||
renderAccount(null);
|
||||
});
|
||||
|
||||
renderAccount(null);
|
||||
validateCurrentSession().then(renderAccount);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
function ensureDialog() {
|
||||
let dialog = document.querySelector("#system-dialog");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("dialog");
|
||||
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>
|
||||
<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>
|
||||
</form>`;
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
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-input-wrap]").hidden = true;
|
||||
dialog.querySelector("[data-cancel]").hidden = true;
|
||||
dialog.querySelector("[data-confirm]").textContent = 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-input-wrap]").hidden = true;
|
||||
dialog.querySelector("[data-cancel]").hidden = false;
|
||||
const confirm = dialog.querySelector("[data-confirm]");
|
||||
confirm.textContent = confirmText;
|
||||
confirm.classList.toggle("danger-button", danger);
|
||||
dialog.showModal();
|
||||
return new Promise(resolve => dialog.addEventListener("close", () => {
|
||||
confirm.classList.remove("danger-button");
|
||||
resolve(dialog.returnValue === "confirm");
|
||||
}, { once: true }));
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
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.showModal();
|
||||
queueMicrotask(() => input.focus());
|
||||
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true }));
|
||||
}
|
||||
+9
-5
@@ -3,8 +3,10 @@ import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
import { prepareImageFile } from "./image-upload.js";
|
||||
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session";
|
||||
import { getNickname, getPassword, getAuthToken, setPassword } from "@rustpad/session";
|
||||
import { bindIdentityDialog } from "./auth-ui.js";
|
||||
import { NoteSocket } from "@rustpad/socket";
|
||||
import { askConfirm } from "./modal.js";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3];
|
||||
@@ -69,7 +71,7 @@ function replaceTableCell(line,index,value){
|
||||
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
|
||||
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
|
||||
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
|
||||
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
||||
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
||||
|
||||
function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
|
||||
async function loadFiles({open=false}={}){
|
||||
@@ -81,8 +83,10 @@ async function loadFiles({open=false}={}){
|
||||
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
|
||||
}catch(error){toast(error.message);}
|
||||
}
|
||||
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
|
||||
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
|
||||
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
|
||||
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
|
||||
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
|
||||
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});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);}});
|
||||
@@ -107,11 +111,11 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
|
||||
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
if(deleteButton){
|
||||
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
|
||||
if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
|
||||
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||
}
|
||||
});
|
||||
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
|
||||
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
|
||||
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();
|
||||
|
||||
+6
-3
@@ -3,7 +3,8 @@ import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
import { prepareImageFile } from "./image-upload.js";
|
||||
import { getNickname, setNickname } from "@rustpad/session";
|
||||
import { getNickname, getAuthToken } from "@rustpad/session";
|
||||
import { bindIdentityDialog } from "./auth-ui.js";
|
||||
import { PadSocket } from "@rustpad/socket";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
@@ -78,9 +79,11 @@ async function loadFiles({open=false}={}){
|
||||
if(open)document.querySelector("#files-dialog").showModal();
|
||||
}catch(error){if(open)toast(error.message);}
|
||||
}
|
||||
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
||||
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
||||
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
|
||||
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
|
||||
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
|
||||
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
|
||||
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
|
||||
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});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);}});
|
||||
|
||||
+27
-2
@@ -1,5 +1,30 @@
|
||||
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
|
||||
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
|
||||
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
|
||||
export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; }
|
||||
export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); }
|
||||
const NICKNAME_KEY = "rustpad:nickname";
|
||||
// Nicknames belong to the current browser session. Remove the old persistent
|
||||
// value so deleting/logging out of a session cannot leave an identity behind.
|
||||
localStorage.removeItem(NICKNAME_KEY);
|
||||
export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; }
|
||||
export function setNickname(value) {
|
||||
const nickname = value.trim();
|
||||
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
|
||||
else sessionStorage.removeItem(NICKNAME_KEY);
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = "rustpad:auth-token";
|
||||
const legacyAuthToken = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
if (legacyAuthToken && !sessionStorage.getItem(AUTH_TOKEN_KEY)) sessionStorage.setItem(AUTH_TOKEN_KEY, legacyAuthToken);
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
export function getAuthToken() { return sessionStorage.getItem(AUTH_TOKEN_KEY) || ""; }
|
||||
export function setAuthSession(session) { sessionStorage.setItem(AUTH_TOKEN_KEY, session.token); setNickname(session.nickname); }
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(NICKNAME_KEY);
|
||||
sessionStorage.removeItem(NICKNAME_KEY);
|
||||
}
|
||||
export async function resolveIdentity(api, nickname) {
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
|
||||
setNickname(result.nickname); return result;
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
export class NoteSocket {
|
||||
constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
|
||||
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
|
||||
constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
|
||||
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
|
||||
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
|
||||
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||
}
|
||||
export class PadSocket {
|
||||
constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
|
||||
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
|
||||
constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
|
||||
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
|
||||
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { getNickname, getPassword, setPassword } from "@rustpad/session";
|
||||
import { askConfirm } from "./modal.js";
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const slug = parts[1];
|
||||
@@ -116,7 +117,7 @@ notesList.addEventListener("click", async event => {
|
||||
const button = event.target.closest("[data-delete-note]");
|
||||
if (!button) return;
|
||||
const title = button.dataset.noteTitle;
|
||||
if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return;
|
||||
if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head>
|
||||
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header>
|
||||
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header>
|
||||
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
|
||||
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
|
||||
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
|
||||
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head>
|
||||
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
|
||||
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
|
||||
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
|
||||
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
|
||||
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
|
||||
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
|
||||
|
||||
Reference in New Issue
Block a user